From 80e29bf57c76a7ad0002faf7b937b261b7ce247a Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:52:01 +0000 Subject: [PATCH] refactor(core): Simplify agent service functions (no-changelog) (#39331) --- .../src/agents/agent-json-config.schema.ts | 1 + packages/@n8n/api-types/src/agents/types.ts | 4 +- packages/@n8n/utils/src/run-serially.ts | 15 + ...agent-execution-update-broadcaster.test.ts | 4 +- .../agent-runtime-cache.service.test.ts | 9 +- ...e-reconstruction-integration-tools.test.ts | 4 +- .../__tests__/agent-task.service.test.ts | 3 +- .../agent-update-broadcaster.test.ts | 4 +- .../adapters/agents-credential-provider.ts | 37 +- .../agents/agent-change-publisher.service.ts | 44 + .../agents/agent-chat-attachment.service.ts | 8 - .../agents/agent-chat-execution.service.ts | 12 +- .../modules/agents/agent-chat.controller.ts | 6 +- .../modules/agents/agent-config.service.ts | 406 ++-- .../agents/agent-custom-tools.service.ts | 93 +- .../agent-default-model-resolver.service.ts | 15 +- .../agent-execution-orchestrator.service.ts | 1010 +++++----- .../agent-execution-update-broadcaster.ts | 16 +- .../modules/agents/agent-execution.service.ts | 283 +-- .../agent-integration-management.service.ts | 248 ++- .../agent-integration-persistence.service.ts | 151 +- .../agents/agent-knowledge-commands.ts | 5 +- .../agents/agent-knowledge-mirror.service.ts | 228 ++- .../agents/agent-knowledge-retrieval.ts | 25 +- .../modules/agents/agent-knowledge.service.ts | 18 +- .../agents/agent-mcp-access.service.ts | 7 +- .../agents/agent-model-catalog.service.ts | 149 +- .../agent-modification-telemetry.service.ts | 177 +- .../modules/agents/agent-publish.service.ts | 326 ++- .../agents/agent-push-recipients.service.ts | 24 + .../agents/agent-run-tracing.service.ts | 19 +- .../agents/agent-runtime-cache.service.ts | 143 +- .../agents/agent-runtime-instrumentation.ts | 4 +- .../agent-runtime-reconstruction.service.ts | 910 +++++---- .../agents/agent-sandbox-runtime.service.ts | 7 +- .../agent-session-langsmith-export.service.ts | 213 +- .../modules/agents/agent-skills.service.ts | 134 +- .../src/modules/agents/agent-task.service.ts | 199 +- .../modules/agents/agent-tasks.controller.ts | 20 +- .../cli/src/modules/agents/agent-telemetry.ts | 26 +- .../modules/agents/agent-test-run.service.ts | 167 +- .../agents/agent-turn-execution.service.ts | 158 +- .../agents/agent-update-broadcaster.ts | 17 +- .../agents/agent-validation.service.ts | 201 +- .../agents/agent-vector-stores.service.ts | 71 +- .../agent-workflow-execution.service.ts | 570 +++--- .../agent-workflow-tool-resume.service.ts | 90 +- .../modules/agents/agents-tools.service.ts | 23 +- .../cli/src/modules/agents/agents.service.ts | 238 ++- .../agent-background-job.service.ts | 130 +- .../agents/background/agent-wake.service.ts | 97 +- .../agents/background/background-job-tools.ts | 20 +- .../background/sub-agent-background-runner.ts | 20 +- .../builder/agents-builder-tools.service.ts | 1794 +++++++++-------- .../agents/builder/agents-builder.service.ts | 195 +- .../builder-model-live-lookup.service.ts | 52 +- .../__tests__/ask-credential.tool.test.ts | 6 +- .../interactive/ask-credential.tool.ts | 27 +- .../interactive/configure-channel.tool.ts | 12 +- .../builder/interactive/finish-setup.tool.ts | 21 +- .../builder/interactive/resolve-llm.tool.ts | 16 +- .../builder/interactive/setup-tool.types.ts | 22 + .../agents/builder/model-lookup.types.ts | 4 + .../entities/agent-background-job.entity.ts | 5 +- .../agents/entities/agent-execution.entity.ts | 11 +- .../agents/entities/agent-history.entity.ts | 9 +- .../entities/agent-memory-entry.entity.ts | 6 +- .../entities/agent-observation-lock.entity.ts | 5 +- .../entities/agent-observation.entity.ts | 8 +- .../src/modules/agents/execution-recorder.ts | 6 +- .../agent-channel-reconciler.service.test.ts | 6 +- ...nt-chat-subscription-state.service.test.ts | 7 +- .../chat-integration.service.test.ts | 5 +- .../__tests__/helpers/replay-test-helpers.ts | 2 +- .../integration-action-executor.test.ts | 58 +- ...integration-context-query-executor.test.ts | 31 +- ...ntegration-message-context.service.test.ts | 1 - .../__tests__/n8n-chat-integration.test.ts | 10 +- .../integrations/__tests__/n8n-memory.test.ts | 5 +- .../__tests__/slack-app-setup.service.test.ts | 5 +- .../slack-manual-setup.service.test.ts | 39 +- .../agent-channel-reconciler.service.ts | 164 +- .../agent-channel-status-reporter.ts | 6 +- .../agents/integrations/agent-chat-bridge.ts | 205 +- .../integrations/agent-chat-integration.ts | 17 +- .../agent-chat-subscription-state.adapter.ts | 14 +- .../agent-chat-subscription-state.service.ts | 68 +- .../integrations/chat-integration.service.ts | 516 ++--- .../integration-action-executor.ts | 40 +- .../integration-context-query-executor.ts | 16 +- .../integrations/integration-helpers.ts | 9 +- .../integration-message-context.service.ts | 15 +- .../integrations/integration-tool-types.ts | 34 +- .../modules/agents/integrations/n8n-memory.ts | 20 +- .../slack/slack-managed-setup.service.ts | 450 +++-- .../slack/slack-manual-setup.service.ts | 119 +- .../platforms/slack/slack-methods.service.ts | 19 +- .../platforms/slack/slack-setup.types.ts | 31 + .../agents/json-config/from-json-config.ts | 24 +- .../agents/json-config/mcp-client-factory.ts | 12 +- .../agent-channel-status.repository.ts | 7 +- .../agent-chat-subscription.repository.ts | 16 +- .../agent-execution.repository.ts | 3 +- .../repositories/agent-resource.repository.ts | 10 + .../agents/repositories/agent.repository.ts | 7 +- .../sub-agents/delegate-sub-agent-tool.ts | 3 +- .../sub-agents/sub-agent-source-resolver.ts | 37 +- .../cli/src/modules/agents/tool-registry.ts | 2 + .../agents/types/agent-chat-attachment.ts | 7 + .../src/modules/agents/types/components.ts | 66 - .../cli/src/modules/agents/types/index.ts | 7 - .../src/modules/agents/utils/agent-channel.ts | 20 + .../agents/utils/agent-runtime-assets.ts | 18 + .../agents/utils/decrypt-agent-credential.ts | 22 + .../agents/utils/execution-failure-summary.ts | 3 +- .../agents/utils/get-agent-or-throw.ts | 15 + .../agents/utils/inbound-attachments.ts | 2 +- .../modules/agents/workflow-agent-stream.ts | 2 +- .../agent-channel-status.repository.test.ts | 6 +- 119 files changed, 5935 insertions(+), 5274 deletions(-) create mode 100644 packages/@n8n/utils/src/run-serially.ts create mode 100644 packages/cli/src/modules/agents/agent-change-publisher.service.ts create mode 100644 packages/cli/src/modules/agents/agent-push-recipients.service.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/setup-tool.types.ts create mode 100644 packages/cli/src/modules/agents/builder/model-lookup.types.ts create mode 100644 packages/cli/src/modules/agents/types/agent-chat-attachment.ts delete mode 100644 packages/cli/src/modules/agents/types/components.ts delete mode 100644 packages/cli/src/modules/agents/types/index.ts create mode 100644 packages/cli/src/modules/agents/utils/agent-channel.ts create mode 100644 packages/cli/src/modules/agents/utils/agent-runtime-assets.ts create mode 100644 packages/cli/src/modules/agents/utils/decrypt-agent-credential.ts create mode 100644 packages/cli/src/modules/agents/utils/get-agent-or-throw.ts diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index e5101e3659b..b97c50c843b 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -537,6 +537,7 @@ export const RunnableAgentJsonConfigSchema = AgentJsonConfigBaseSchema.extend({ }); export type AgentJsonConfig = z.infer; +export type AgentModelCredentialConfig = Required>; export type RunnableAgentJsonConfig = z.infer; export type AgentJsonToolConfig = z.infer; export type AgentJsonWorkflowToolConfig = Extract; diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index d6bd22ec447..4ac8e3f0e1f 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -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. diff --git a/packages/@n8n/utils/src/run-serially.ts b/packages/@n8n/utils/src/run-serially.ts new file mode 100644 index 00000000000..764f73fdaf8 --- /dev/null +++ b/packages/@n8n/utils/src/run-serially.ts @@ -0,0 +1,15 @@ +/** Serialize work per key. A failed operation does not block later work. */ +export async function runSerially( + pending: Map>, + key: string, + work: () => Promise, +): Promise { + 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); + } +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts index 270b2448a80..eaa8ba58ce9 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-update-broadcaster.test.ts @@ -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, ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts index 31193ee6628..d6a766e6479 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts @@ -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]); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index 0a32443282b..fb4103857a9 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -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, diff --git a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts index c4d46f33399..57682982933 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts @@ -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({ isLeader }), agentTaskScheduler, - publisher, + new AgentChangePublisher(publisher, globalConfig, logger), modificationTelemetry, durableJobRegistrar, mock(), diff --git a/packages/cli/src/modules/agents/__tests__/agent-update-broadcaster.test.ts b/packages/cli/src/modules/agents/__tests__/agent-update-broadcaster.test.ts index 104d987ac5f..3e506130991 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-update-broadcaster.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-update-broadcaster.test.ts @@ -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, diff --git a/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts b/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts index cfabcf3d033..500f7e0a9b4 100644 --- a/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts +++ b/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts @@ -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 { - // 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); } /** diff --git a/packages/cli/src/modules/agents/agent-change-publisher.service.ts b/packages/cli/src/modules/agents/agent-change-publisher.service.ts new file mode 100644 index 00000000000..833d192c41f --- /dev/null +++ b/packages/cli/src/modules/agents/agent-change-publisher.service.ts @@ -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 { + 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), + }); + } + } +} diff --git a/packages/cli/src/modules/agents/agent-chat-attachment.service.ts b/packages/cli/src/modules/agents/agent-chat-attachment.service.ts index 1dacfa208fa..d82a4ce9342 100644 --- a/packages/cli/src/modules/agents/agent-chat-attachment.service.ts +++ b/packages/cli/src/modules/agents/agent-chat-attachment.service.ts @@ -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 diff --git a/packages/cli/src/modules/agents/agent-chat-execution.service.ts b/packages/cli/src/modules/agents/agent-chat-execution.service.ts index b43e79a35fe..4dc78c48c70 100644 --- a/packages/cli/src/modules/agents/agent-chat-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-chat-execution.service.ts @@ -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 { + async cancelSuspended(params: CancelSuspendedRunParams): Promise { const checkpointStatus = await this.checkpointStorage.getStatus(params.runId, params.agentId); if (checkpointStatus.status === 'not-found' || checkpointStatus.checkpoint === undefined) return false; diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index a61cedb4fa7..c9534e334dd 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index 7429dceafb9..e02451e30b5 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -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; + previousSchema: AgentJsonConfig | null; + previousIntegrations: NonNullable; + 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 { - 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 { - 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 { + 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 { + 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 => + 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 => 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( diff --git a/packages/cli/src/modules/agents/agent-custom-tools.service.ts b/packages/cli/src/modules/agents/agent-custom-tools.service.ts index 8c0f924b724..3767466f261 100644 --- a/packages/cli/src/modules/agents/agent-custom-tools.service.ts +++ b/packages/cli/src/modules/agents/agent-custom-tools.service.ts @@ -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 { - 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 { + 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; + } } diff --git a/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts b/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts index 882ad218615..c543908ae3f 100644 --- a/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts +++ b/packages/cli/src/modules/agents/agent-default-model-resolver.service.ts @@ -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 { + async resolve(user: User, projectId: string): Promise { 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 { + ): Promise { 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 { + ): Promise { if (!credential) return null; const defaults = LLM_PROVIDER_DEFAULTS[credential.type]; diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index 8d3d1783ed9..ad7d0b08663 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -20,7 +20,10 @@ import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } fr import { Telemetry } from '@/telemetry'; import { AgentExecutionService, type StartExecutionParams } from './agent-execution.service'; -import { AgentChatExecutionService } from './agent-chat-execution.service'; +import { + AgentChatExecutionService, + type CancelSuspendedRunParams, +} from './agent-chat-execution.service'; import type { AgentThreadAccess } from './entities/agent-execution-thread.entity'; import type { AgentSessionMode } from './utils/agent-thread-access'; import { @@ -42,7 +45,7 @@ import { } from './agent-sandbox-principal'; import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service'; import { buildAgentConfigurationTelemetry } from './agent-telemetry'; -import { AgentTurnExecutionService } from './agent-turn-execution.service'; +import { AgentTurnExecutionService, type AgentTurnRequest } from './agent-turn-execution.service'; import { AgentExecutionRecordingError } from './agent-execution-recording.error'; import type { AgentChatBridge } from './integrations/agent-chat-bridge'; import { @@ -58,7 +61,7 @@ import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; import { modelStreamStallOptions } from './model-stream-stall-options'; import { AgentRepository } from './repositories/agent.repository'; import type { ToolRegistry } from './tool-registry'; -import type { StoredAttachmentRef } from './agent-chat-attachment.service'; +import type { StoredAttachmentRef } from './types/agent-chat-attachment'; import { createAgentExecutionCounter } from './utils/agent-execution-counter'; import { getPublishedAgentSnapshot } from './utils/agent-published-snapshot'; import { buildInboundUserMessage } from './utils/inbound-attachments'; @@ -69,10 +72,28 @@ export interface AgentMemoryScope { resourceId: string; } -export interface ExecuteForChatConfig { +interface AgentExecutionInput { agentId: string; projectId: string; + /** User message recorded in the execution transcript. */ message: string; + /** Memory scope for this execution. Task runs use a separate resource ID. */ + memory: AgentMemoryScope; +} + +interface ChatExecutionInput extends AgentExecutionInput { + sessionMode?: AgentSessionMode; + /** Stored attachments for the user turn. */ + attachments?: StoredAttachmentRef[]; +} + +interface ChatExecutionCallbacks { + onExecutionStarted?: (executionId: string, sessionId: string) => void; + /** Runs after the turn is stored. Adds the execution ID to the SSE done event. */ + onExecutionRecorded?: (executionId: string) => void; +} + +export interface ExecuteForChatConfig extends ChatExecutionInput, ChatExecutionCallbacks { /** * The calling n8n user — used to gate node/workflow tools by their access, * and for RBAC / credential resolution and telemetry attribution. Always @@ -80,11 +101,6 @@ export interface ExecuteForChatConfig { * (`AgentChatController.chat` always has `req.user`). */ user: User; - /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ - memory: AgentMemoryScope; - sessionMode?: AgentSessionMode; - /** Stored attachments to include as file parts on the user turn. */ - attachments?: StoredAttachmentRef[]; /** Identifies the surface that started the draft test run. */ source?: string; /** @@ -93,28 +109,17 @@ export interface ExecuteForChatConfig { * callers (AI Assistant test calls, MCP, "Run now") leave it unset. */ previewChat?: boolean; - onExecutionStarted?: (executionId: string, sessionId: string) => void; - /** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */ - onExecutionRecorded?: (executionId: string) => void; abortSignal?: AbortSignal; } -export interface ExecuteForChatPublishedConfig { +export interface ExecuteForChatPublishedConfig extends ChatExecutionInput { messageContext?: IntegrationMessageContext | null; /** Platform conversation metadata scope. Execution memory can belong to a task. */ contextConversation?: SessionBinding; - agentId: string; - projectId: string; - /** What the user wrote; recorded in the execution transcript. */ - message: string; /** What the model receives when it differs from `message`, e.g. with an author label or thread history. */ modelMessage?: string; /** Chat platform user who wrote the turn; shown as the sender in the sessions view. */ author?: AgentMessageAuthor; - /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ - memory: AgentMemoryScope; - sessionMode?: AgentSessionMode; - attachments?: StoredAttachmentRef[]; integrationType?: string; sandboxPrincipalHash: AgentSandboxPrincipalHash; // No `user` field here: a published chat integration (Slack, Telegram, …) @@ -125,7 +130,7 @@ export interface ExecuteForChatPublishedConfig { // applies regardless. } -export interface ResumeForChatConfig { +export interface ResumeForChatConfig extends ChatExecutionCallbacks { messageContext?: IntegrationMessageContext | null; /** Platform conversation metadata scope. Execution memory can belong to a task. */ contextConversation?: SessionBinding; @@ -161,27 +166,17 @@ export interface ResumeForChatConfig { previewChat?: boolean; /** Allows an automatic preview resume to overlap its predecessor's finalization. */ automaticPreviewContinuation?: boolean; - onExecutionStarted?: (executionId: string, sessionId: string) => void; - /** Fired after the resumed turn is persisted; used to attach `executionId` to SSE `done`. */ - onExecutionRecorded?: (executionId: string) => void; abortSignal?: AbortSignal; } -export interface ExecuteForTaskPublishedConfig { - agentId: string; - projectId: string; - message: string; - /** Memory scope — resourceId isolates per-run memory. */ - memory: AgentMemoryScope; +export interface ExecuteForTaskPublishedConfig extends AgentExecutionInput { /** The scheduled task this run belongs to; stamped on the session for traceability. */ taskId: string; /** Published agent_history version that supplied the scheduled task snapshot. */ taskVersionId: string; } -export interface ExecuteForTaskNowConfig { - agentId: string; - projectId: string; +export interface ExecuteForTaskNowConfig extends AgentExecutionInput { /** * The calling n8n user — used to gate node/workflow tools by their * access, and for RBAC / credential resolution and recorded on the @@ -190,19 +185,12 @@ export interface ExecuteForTaskNowConfig { * `AgentTaskService.runNow(agentId, taskId, user)`. */ user: User; - message: string; - /** Memory scope — resourceId isolates per-run memory. */ - memory: AgentMemoryScope; /** The task this manual run belongs to; stamped on the session for traceability. */ taskId: string; } -export interface ExecuteForWakeConfig { +export interface ExecuteForWakeConfig extends AgentExecutionInput { backgroundJobSignal: AgentBackgroundJobSignal; - agentId: string; - projectId: string; - message: string; - memory: AgentMemoryScope; abortSignal: AbortSignal; identity: | { type: 'draft'; user: User; principalHash: AgentSandboxPrincipalHash } @@ -213,24 +201,18 @@ export interface ExecuteForWakeConfig { }; } -export interface StreamChatResponseConfig { +export interface StreamChatResponseConfig extends ChatExecutionInput, ChatExecutionCallbacks { access: AgentThreadAccess; messageContext?: IntegrationMessageContext | null; agentInstance: RuntimeAgent; toolRegistry: ToolRegistry; /** See `AgentRuntime.mcpServerAttributions`. */ mcpServerAttributions: Map; - agentId: string; userId?: string; - /** What the user wrote; recorded in the execution transcript. */ - message: string; /** What the model receives when it differs from `message`. */ modelMessage?: string; /** Chat platform user who wrote the turn; shown as the sender in the sessions view. */ author?: AgentMessageAuthor; - attachments?: StoredAttachmentRef[]; - memory: AgentMemoryScope; - projectId: string; source?: string; taskId?: string; taskVersionId?: string; @@ -239,9 +221,6 @@ export interface StreamChatResponseConfig { configuration: IAgentConfigurationTelemetryProperties; }; previewChat?: boolean; - onExecutionStarted?: (executionId: string, sessionId: string) => void; - /** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */ - onExecutionRecorded?: (executionId: string) => void; abortSignal?: AbortSignal; /** Add full sanitized tool configuration to approval cards in preview chat. */ includeHitlToolDetails?: boolean; @@ -251,7 +230,6 @@ export interface StreamChatResponseConfig { /** Prevent this wake run from triggering another wake. */ isWakeRun?: boolean; backgroundJobSignal?: AgentBackgroundJobSignal; - sessionMode?: AgentSessionMode; } /** @@ -261,6 +239,15 @@ export interface StreamChatResponseConfig { * checkpoints. Workflow-invoked runs (AI Agent node, "Message an Agent") * live in `AgentWorkflowExecutionService`. */ +interface ResumeCheckpoint { + memoryScope: NonNullable; + sandboxPrincipalHash: AgentSandboxPrincipalHash | undefined; + access: AgentThreadAccess; +} + +type ResumeChatConfig = ResumeForChatConfig & { usePublishedVersion: boolean }; +type DraftChatConfig = ExecuteForChatConfig & { sessionMode: AgentSessionMode }; + @Service() export class AgentExecutionOrchestratorService { constructor( @@ -311,29 +298,18 @@ export class AgentExecutionOrchestratorService { }; } - async cancelChatRun(params: { - agentId: string; - runId: string; - resourceId: string; - }): Promise { + async cancelChatRun(params: CancelSuspendedRunParams): Promise { return await this.chatExecutionService.cancelSuspended(params); } - private async loadResumeCheckpoint(params: { - agentId: string; - projectId: string; - runId: string; - expectedMemory: Partial | undefined; - user: User | undefined; - usePublishedVersion: boolean; - previewChat: boolean | undefined; - }): Promise<{ - memoryScope: NonNullable; - sandboxPrincipalHash: AgentSandboxPrincipalHash | undefined; - access: AgentThreadAccess; - }> { - const { agentId, projectId, runId, expectedMemory, user, usePublishedVersion, previewChat } = - params; + private async loadResumeCheckpoint(config: ResumeChatConfig): Promise { + const memoryScope = await this.loadResumeMemory(config); + const access = await this.resolveResumeAccess(config, memoryScope); + const sandboxPrincipalHash = this.validateResumeSandbox(config, memoryScope); + return { memoryScope, sandboxPrincipalHash, access }; + } + + private async loadResumeMemory({ agentId, runId, expectedMemory }: ResumeChatConfig) { const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId, agentId); if (checkpointStatus.status === 'expired') { throw new UserError(`Checkpoint ${runId} is expired and cannot be resumed`); @@ -358,6 +334,13 @@ export class AgentExecutionOrchestratorService { ) { throw new UserError(`Checkpoint ${runId} does not belong to this chat`); } + return memoryScope; + } + + private async resolveResumeAccess( + { agentId, projectId, runId, user, usePublishedVersion, previewChat }: ResumeChatConfig, + memoryScope: ResumeCheckpoint['memoryScope'], + ): Promise { const isPreview = !usePublishedVersion && !isTaskRunMemoryResourceId(memoryScope.resourceId); let access: AgentThreadAccess = { accessScope: 'project', ownerId: null }; if (isPreview) { @@ -387,7 +370,13 @@ export class AgentExecutionOrchestratorService { throw new UserError(`Checkpoint ${runId} does not belong to this chat`); } } + return access; + } + private validateResumeSandbox( + { projectId, runId, user, usePublishedVersion }: ResumeChatConfig, + memoryScope: ResumeCheckpoint['memoryScope'], + ): AgentSandboxPrincipalHash | undefined { const sandboxScope = decodeAgentSandboxHostMetadata(memoryScope.hostMetadata); const sandboxPrincipalHash = sandboxScope?.principalHash; if ( @@ -402,8 +391,7 @@ export class AgentExecutionOrchestratorService { ) { throw new UserError(`Checkpoint ${runId} is unavailable and cannot be resumed`); } - - return { memoryScope, sandboxPrincipalHash, access }; + return sandboxPrincipalHash; } /** @@ -412,144 +400,11 @@ export class AgentExecutionOrchestratorService { * a human-in-the-loop action (button click, modal submission). */ async *resumeForChat(config: ResumeForChatConfig): AsyncGenerator { - const { - agentId, - projectId, - runId, - toolCallId, - resumeData, - expectedMemory, - source, - integrationType, - user, - usePublishedVersion = true, - onExecutionRecorded, - abortSignal, - } = config; - const { memoryScope, sandboxPrincipalHash, access } = await this.loadResumeCheckpoint({ - agentId, - projectId, - runId, - expectedMemory, - user, - usePublishedVersion, - previewChat: config.previewChat, - }); - - const threadId = memoryScope.threadId; - + const resume = { ...config, usePublishedVersion: config.usePublishedVersion ?? true }; + const checkpoint = await this.loadResumeCheckpoint(resume); yield* this.withRuntimeLease( - async () => - await this.getRuntimeOrRecordFailure( - { - agentId, - projectId, - usePublishedVersion, - integrationType, - // Published integrations retain their project-scoped tool access. - user: usePublishedVersion ? undefined : user, - ...(sandboxPrincipalHash ? { sandboxPrincipalHash } : {}), - previewChat: config.previewChat, - }, - { - threadId, - userMessage: null, - source, - onExecutionRecorded, - abortSignal, - access, - automaticContinuationRunId: - config.previewChat && config.automaticPreviewContinuation ? runId : undefined, - sessionMode: 'existing', - }, - ), - (runtime) => - this.turnExecutionService.execute({ - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - mcpServerAttributions: runtime.mcpServerAttributions, - context: { projectId, agentId, threadId }, - includeHitlToolDetails: !usePublishedVersion, - previewChat: config.previewChat, - automaticPreviewContinuation: config.automaticPreviewContinuation, - onExecutionStarted: config.onExecutionStarted, - onExecutionRecorded, - onSettled: async (suspended) => { - if (!suspended) await this.requestPendingBackgroundWake(threadId); - }, - prepare: async () => { - // Recover the original source only when tracing needs it. - const suspendedExecution = - this.agentRunTracingService.enabled && source === undefined - ? await this.agentExecutionService.findLatestSuspendedRun(threadId) - : undefined; - const executionSource = source ?? suspendedExecution?.source ?? undefined; - const runType = usePublishedVersion ? 'production' : 'test'; - const tracing = await this.agentRunTracingService.build({ - agentId, - projectId, - threadId, - userId: user?.id, - source: executionSource ?? 'unknown', - modelId: modelIdFromSnapshot(runtime.agent.snapshot.model), - }); - let messageContext = config.messageContext; - if (messageContext === undefined) { - messageContext = - await this.integrationMessageContextService.getForResume(memoryScope); - } else if (messageContext && config.contextConversation) { - messageContext = inheritIntegrationMessageContext( - messageContext, - await this.integrationMessageContextService.getForResume(memoryScope), - ); - } - const selectedContext = messageContext; - const conversation = config.contextConversation; - - return { - type: 'resume', - resumeData, - options: { - runId, - toolCallId, - hostMetadata: encodeIntegrationMessageContext(selectedContext), - ...(selectedContext && conversation - ? { - onResumeClaimed: async () => - await this.integrationMessageContextService.installIncoming( - selectedContext, - memoryScope, - conversation, - ), - } - : {}), - executionCounter: createAgentExecutionCounter(this.telemetry, { - agentId, - userId: user?.id, - runType, - }), - ...modelStreamStallOptions(this.aiConfig), - ...(tracing ? { telemetry: tracing } : {}), - ...(abortSignal ? { abortSignal } : {}), - }, - recording: { - access, - threadId, - agentId, - agentName: runtime.agent.name, - projectId, - userMessage: null, - sessionMode: 'existing', - ...(executionSource !== undefined ? { source: executionSource } : {}), - telemetry: { - userId: user?.id, - runType, - configuration: runtime.telemetryConfiguration, - }, - }, - }; - }, - }), + async () => await this.getResumeRuntime(resume, checkpoint), + (runtime) => this.resumeRuntimeTurn(resume, checkpoint, runtime), ); } @@ -557,101 +412,17 @@ export class AgentExecutionOrchestratorService { * Execute an agent for the in-app test chat and yield stream chunks. */ async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator { - const { - agentId, - projectId, - message, - user, - memory, - attachments, - source, - previewChat, - sessionMode = 'new', - onExecutionRecorded, - abortSignal, - } = config; - - // `user` is always set (see ExecuteForChatConfig) — this builds/reuses a - // runtime scoped to this specific user's tool access. + const draft = { ...config, sessionMode: config.sessionMode ?? 'new' }; const sandboxPrincipalHash = hashAgentSandboxPrincipal({ type: 'n8n-user', - userId: user.id, + userId: config.user.id, }); - if ( - memory.resourceId !== draftChatMemoryResourceId(user.id) || - !(await this.agentExecutionService.canUseDraftThread( - memory.threadId, - projectId, - agentId, - user.id, - { previewChat, sessionMode }, - )) - ) { - throw new UserError('Session not found'); - } - const access: AgentThreadAccess = { accessScope: 'user', ownerId: user.id }; + await this.assertDraftChatAccess(draft); + const access: AgentThreadAccess = { accessScope: 'user', ownerId: config.user.id }; yield* this.withRuntimeLease( - async () => - await this.getRuntimeOrRecordFailure( - { - agentId, - projectId, - integrationType: N8N_CHAT_INTEGRATION_TYPE, - user, - sandboxPrincipalHash, - previewChat, - }, - { - threadId: memory.threadId, - access, - userMessage: message, - attachments, - source, - onExecutionRecorded, - abortSignal, - sessionMode, - }, - ), - async (runtime) => { - const messageContext: IntegrationMessageContext = { - integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE, - platform: N8N_CHAT_INTEGRATION_TYPE, - target: { type: 'dm', userId: user.id, threadId: memory.threadId }, - interactingUserId: user.id, - updatedAt: new Date().toISOString(), - }; - await this.integrationMessageContextService.setLatest( - memory.threadId, - memory.resourceId, - messageContext, - ); - - return this.streamChatResponse({ - access, - messageContext, - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - mcpServerAttributions: runtime.mcpServerAttributions, - agentId, - userId: user.id, - message, - attachments, - memory, - projectId: runtime.projectId, - source, - telemetry: { - runType: 'test', - configuration: runtime.telemetryConfiguration, - }, - onExecutionRecorded, - abortSignal, - includeHitlToolDetails: true, - previewChat, - onExecutionStarted: config.onExecutionStarted, - sandboxPrincipalHash, - sessionMode, - }); - }, + async () => await this.getDraftChatRuntime(draft, access, sandboxPrincipalHash), + async (runtime) => + await this.streamDraftChatResponse(draft, runtime, access, sandboxPrincipalHash), ); } @@ -846,79 +617,26 @@ export class AgentExecutionOrchestratorService { } async executeForWake(config: ExecuteForWakeConfig): Promise { - const { agentId, projectId, message, memory, identity, abortSignal } = config; + const { agentId, memory, identity, abortSignal } = config; const isDraft = identity.type === 'draft'; const access: AgentThreadAccess = isDraft ? { accessScope: 'user', ownerId: identity.user.id } : { accessScope: 'project', ownerId: null }; - if ( - isDraft && - (memory.resourceId !== draftChatMemoryResourceId(identity.user.id) || - !(await this.agentExecutionService.canUseDraftThread( - memory.threadId, - projectId, - agentId, - identity.user.id, - { sessionMode: 'existing' }, - ))) - ) { - throw new UserError('Session not found'); + if (isDraft) { + await this.assertDraftChatAccess({ ...config, user: identity.user, sessionMode: 'existing' }); + } else { + await this.externalHooks.run('agent.preExecute', [agentId]); } - - // Draft wakes skip the quota hook, like other test chat runs. - if (!isDraft) await this.externalHooks.run('agent.preExecute', [agentId]); - const integrationType = isDraft ? N8N_CHAT_INTEGRATION_TYPE : identity.integrationType; const messageContext = await this.integrationMessageContextService.getLatest(memory.threadId); const delivery = isDraft ? undefined : await this.getWakeDelivery(agentId, integrationType, messageContext); const stream = this.withRuntimeLease( - async () => - await this.getRuntimeOrRecordFailure( - { - agentId, - projectId, - integrationType, - usePublishedVersion: !isDraft, - ...(isDraft ? { user: identity.user } : {}), - sandboxPrincipalHash: identity.principalHash, - }, - { - threadId: memory.threadId, - userMessage: null, - source: integrationType, - abortSignal, - access, - sessionMode: 'existing', - }, - ), + async () => await this.getWakeRuntime(config, access, integrationType), (runtime) => this.streamWakeResponse( - this.streamChatResponse({ - access, - messageContext, - agentInstance: runtime.agent, - toolRegistry: runtime.toolRegistry, - mcpServerAttributions: runtime.mcpServerAttributions, - agentId, - ...(isDraft ? { userId: identity.user.id } : {}), - message, - memory, - projectId: runtime.projectId, - source: integrationType, - telemetry: { - runType: isDraft ? 'test' : 'production', - configuration: runtime.telemetryConfiguration, - }, - abortSignal, - includeHitlToolDetails: isDraft, - sandboxPrincipalHash: identity.principalHash, - hideUserMessageFromTranscript: true, - isWakeRun: true, - sessionMode: 'existing', - backgroundJobSignal: config.backgroundJobSignal, - }), + this.streamWakeTurn(config, runtime, access, integrationType, messageContext), abortSignal, delivery, ), @@ -982,101 +700,24 @@ export class AgentExecutionOrchestratorService { * Stream an agent response, record it, and yield each chunk. */ async *streamChatResponse(config: StreamChatResponseConfig): AsyncGenerator { - const { - agentInstance, - toolRegistry, - mcpServerAttributions, - agentId, - userId, - message, - modelMessage = message, - author, - attachments, - memory, - projectId, - source, - taskId, - taskVersionId, - telemetry, - onExecutionRecorded, - abortSignal, - includeHitlToolDetails, - sandboxPrincipalHash, - hideUserMessageFromTranscript, - isWakeRun, - backgroundJobSignal, - sessionMode, - } = config; - const { threadId, resourceId } = memory; - yield* this.turnExecutionService.execute({ - agentInstance, - toolRegistry, - mcpServerAttributions, - context: { projectId, agentId, threadId }, - backgroundJobSignal, - includeHitlToolDetails, - onExecutionRecorded, + agentInstance: config.agentInstance, + toolRegistry: config.toolRegistry, + mcpServerAttributions: config.mcpServerAttributions, + context: { + projectId: config.projectId, + agentId: config.agentId, + threadId: config.memory.threadId, + }, + backgroundJobSignal: config.backgroundJobSignal, + includeHitlToolDetails: config.includeHitlToolDetails, + onExecutionRecorded: config.onExecutionRecorded, previewChat: config.previewChat, onExecutionStarted: config.onExecutionStarted, - onSettled: isWakeRun + onSettled: config.isWakeRun ? undefined - : async () => await this.requestPendingBackgroundWake(threadId), - prepare: async () => { - const tracing = await this.agentRunTracingService.build({ - agentId, - projectId, - threadId, - userId, - source: source ?? 'test', - modelId: modelIdFromSnapshot(agentInstance.snapshot.model), - }); - const input = attachments?.length - ? buildInboundUserMessage(modelMessage, attachments) - : modelMessage; - const messageContext = - config.messageContext === undefined - ? await this.integrationMessageContextService.getLatest(threadId) - : config.messageContext; - const hostMetadata = { - ...encodeAgentSandboxHostMetadata({ - projectId, - principalHash: sandboxPrincipalHash, - }), - ...encodeIntegrationMessageContext(messageContext), - }; - - return { - type: 'start', - input, - options: { - persistence: { threadId, resourceId, hostMetadata }, - executionCounter: createAgentExecutionCounter(this.telemetry, { - agentId, - userId, - runType: telemetry.runType, - }), - ...modelStreamStallOptions(this.aiConfig), - ...(tracing ? { telemetry: tracing } : {}), - ...(abortSignal ? { abortSignal } : {}), - }, - recording: { - threadId, - access: config.access, - agentId, - agentName: agentInstance.name, - projectId, - userMessage: hideUserMessageFromTranscript ? null : message, - sessionMode, - author, - attachments, - source, - taskId, - taskVersionId, - telemetry: { ...telemetry, userId }, - }, - }; - }, + : async () => await this.requestPendingBackgroundWake(config.memory.threadId), + prepare: async () => await this.prepareChatTurn(config), }); } @@ -1162,4 +803,465 @@ export class AgentExecutionOrchestratorService { throw error; } } + + private async getResumeRuntime(config: ResumeChatConfig, checkpoint: ResumeCheckpoint) { + const { + agentId, + projectId, + runId, + source, + integrationType, + user, + usePublishedVersion, + onExecutionRecorded, + abortSignal, + previewChat, + } = config; + const { memoryScope, sandboxPrincipalHash, access } = checkpoint; + return await this.getRuntimeOrRecordFailure( + { + agentId, + projectId, + usePublishedVersion, + integrationType, + user: usePublishedVersion ? undefined : user, + ...(sandboxPrincipalHash ? { sandboxPrincipalHash } : {}), + previewChat, + }, + { + threadId: memoryScope.threadId, + userMessage: null, + source, + onExecutionRecorded, + abortSignal, + access, + automaticContinuationRunId: + previewChat && config.automaticPreviewContinuation ? runId : undefined, + sessionMode: 'existing', + }, + ); + } + + private resumeRuntimeTurn( + config: ResumeChatConfig, + checkpoint: ResumeCheckpoint, + runtime: AgentRuntime, + ) { + const threadId = checkpoint.memoryScope.threadId; + return this.turnExecutionService.execute({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + mcpServerAttributions: runtime.mcpServerAttributions, + context: { projectId: config.projectId, agentId: config.agentId, threadId }, + includeHitlToolDetails: !config.usePublishedVersion, + previewChat: config.previewChat, + automaticPreviewContinuation: config.automaticPreviewContinuation, + onExecutionStarted: config.onExecutionStarted, + onExecutionRecorded: config.onExecutionRecorded, + onSettled: async (suspended) => { + if (!suspended) await this.requestPendingBackgroundWake(threadId); + }, + prepare: async () => await this.prepareChatResume(config, checkpoint, runtime), + }); + } + + private async prepareChatResume( + config: ResumeChatConfig, + checkpoint: ResumeCheckpoint, + runtime: AgentRuntime, + ): Promise { + const { memoryScope } = checkpoint; + const { executionSource, tracing } = await this.getResumeTracing( + config, + memoryScope.threadId, + runtime, + ); + const messageContext = await this.resolveResumeMessageContext(config, memoryScope); + return { + type: 'resume', + resumeData: config.resumeData, + options: this.createResumeOptions(config, memoryScope, messageContext, tracing), + recording: this.createResumeRecording(config, checkpoint, runtime, executionSource), + }; + } + + private async getResumeTracing( + config: ResumeChatConfig, + threadId: string, + runtime: AgentRuntime, + ) { + const { agentId, projectId, source, user } = config; + // Recover the original source only when tracing needs it. + const suspendedExecution = + this.agentRunTracingService.enabled && source === undefined + ? await this.agentExecutionService.findLatestSuspendedRun(threadId) + : undefined; + const executionSource = source ?? suspendedExecution?.source ?? undefined; + const tracing = await this.agentRunTracingService.build({ + agentId, + projectId, + threadId, + userId: user?.id, + source: executionSource ?? 'unknown', + modelId: modelIdFromSnapshot(runtime.agent.snapshot.model), + }); + return { executionSource, tracing }; + } + + private createResumeOptions( + config: ResumeChatConfig, + memoryScope: ResumeCheckpoint['memoryScope'], + selectedContext: IntegrationMessageContext | null, + tracing: Awaited>, + ): Extract['options'] { + const { runId, toolCallId, agentId, user, usePublishedVersion, abortSignal } = config; + const runType = usePublishedVersion ? 'production' : 'test'; + const conversation = config.contextConversation; + return { + runId, + toolCallId, + hostMetadata: encodeIntegrationMessageContext(selectedContext), + ...(selectedContext && conversation + ? { + onResumeClaimed: async () => + await this.integrationMessageContextService.installIncoming( + selectedContext, + memoryScope, + conversation, + ), + } + : {}), + executionCounter: createAgentExecutionCounter(this.telemetry, { + agentId, + userId: user?.id, + runType, + }), + ...modelStreamStallOptions(this.aiConfig), + ...(tracing ? { telemetry: tracing } : {}), + ...(abortSignal ? { abortSignal } : {}), + }; + } + + private createResumeRecording( + config: ResumeChatConfig, + { memoryScope, access }: ResumeCheckpoint, + runtime: AgentRuntime, + executionSource: string | undefined, + ): StartExecutionParams { + const { agentId, projectId, user, usePublishedVersion } = config; + const threadId = memoryScope.threadId; + const runType = usePublishedVersion ? 'production' : 'test'; + return { + access, + threadId, + agentId, + agentName: runtime.agent.name, + projectId, + userMessage: null, + sessionMode: 'existing', + ...(executionSource !== undefined ? { source: executionSource } : {}), + telemetry: { + userId: user?.id, + runType, + configuration: runtime.telemetryConfiguration, + }, + }; + } + + private async resolveResumeMessageContext( + config: ResumeChatConfig, + memoryScope: ResumeCheckpoint['memoryScope'], + ) { + let messageContext = config.messageContext; + if (messageContext === undefined) { + messageContext = await this.integrationMessageContextService.getForResume(memoryScope); + } else if (messageContext && config.contextConversation) { + messageContext = inheritIntegrationMessageContext( + messageContext, + await this.integrationMessageContextService.getForResume(memoryScope), + ); + } + return messageContext; + } + + private async assertDraftChatAccess({ + agentId, + projectId, + memory, + user, + previewChat, + sessionMode, + }: Pick< + DraftChatConfig, + 'agentId' | 'projectId' | 'memory' | 'user' | 'previewChat' | 'sessionMode' + >): Promise { + if ( + memory.resourceId !== draftChatMemoryResourceId(user.id) || + !(await this.agentExecutionService.canUseDraftThread( + memory.threadId, + projectId, + agentId, + user.id, + { previewChat, sessionMode }, + )) + ) { + throw new UserError('Session not found'); + } + } + + private async getDraftChatRuntime( + config: DraftChatConfig, + access: AgentThreadAccess, + sandboxPrincipalHash: AgentSandboxPrincipalHash, + ) { + const { + agentId, + projectId, + user, + previewChat, + memory, + message, + attachments, + source, + onExecutionRecorded, + abortSignal, + sessionMode, + } = config; + return await this.getRuntimeOrRecordFailure( + { + agentId, + projectId, + integrationType: N8N_CHAT_INTEGRATION_TYPE, + user, + sandboxPrincipalHash, + previewChat, + }, + { + threadId: memory.threadId, + access, + userMessage: message, + attachments, + source, + onExecutionRecorded, + abortSignal, + sessionMode, + }, + ); + } + + private async streamDraftChatResponse( + config: DraftChatConfig, + runtime: AgentRuntime, + access: AgentThreadAccess, + sandboxPrincipalHash: AgentSandboxPrincipalHash, + ) { + const { + agentId, + user, + message, + attachments, + memory, + source, + onExecutionRecorded, + abortSignal, + previewChat, + sessionMode, + } = config; + const messageContext = await this.installDraftMessageContext(memory, user.id); + return this.streamChatResponse({ + access, + messageContext, + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + mcpServerAttributions: runtime.mcpServerAttributions, + agentId, + userId: user.id, + message, + attachments, + memory, + projectId: runtime.projectId, + source, + telemetry: { runType: 'test', configuration: runtime.telemetryConfiguration }, + onExecutionRecorded, + abortSignal, + includeHitlToolDetails: true, + previewChat, + onExecutionStarted: config.onExecutionStarted, + sandboxPrincipalHash, + sessionMode, + }); + } + + private async installDraftMessageContext( + memory: AgentMemoryScope, + userId: string, + ): Promise { + const context: IntegrationMessageContext = { + integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE, + platform: N8N_CHAT_INTEGRATION_TYPE, + target: { type: 'dm', userId, threadId: memory.threadId }, + interactingUserId: userId, + updatedAt: new Date().toISOString(), + }; + await this.integrationMessageContextService.setLatest( + memory.threadId, + memory.resourceId, + context, + ); + return context; + } + + private async prepareChatTurn(config: StreamChatResponseConfig): Promise { + const { + agentInstance, + agentId, + userId, + message, + modelMessage = message, + attachments, + memory, + projectId, + source, + telemetry, + abortSignal, + sandboxPrincipalHash, + } = config; + const { threadId, resourceId } = memory; + const tracing = await this.agentRunTracingService.build({ + agentId, + projectId, + threadId, + userId, + source: source ?? 'test', + modelId: modelIdFromSnapshot(agentInstance.snapshot.model), + }); + const input = attachments?.length + ? buildInboundUserMessage(modelMessage, attachments) + : modelMessage; + const messageContext = + config.messageContext === undefined + ? await this.integrationMessageContextService.getLatest(threadId) + : config.messageContext; + const hostMetadata = { + ...encodeAgentSandboxHostMetadata({ + projectId, + principalHash: sandboxPrincipalHash, + }), + ...encodeIntegrationMessageContext(messageContext), + }; + + return { + type: 'start', + input, + options: { + persistence: { threadId, resourceId, hostMetadata }, + executionCounter: createAgentExecutionCounter(this.telemetry, { + agentId, + userId, + runType: telemetry.runType, + }), + ...modelStreamStallOptions(this.aiConfig), + ...(tracing ? { telemetry: tracing } : {}), + ...(abortSignal ? { abortSignal } : {}), + }, + recording: this.createChatRecording(config), + }; + } + + private createChatRecording(config: StreamChatResponseConfig): StartExecutionParams { + const { + agentInstance, + agentId, + userId, + message, + author, + attachments, + memory, + projectId, + source, + taskId, + taskVersionId, + telemetry, + hideUserMessageFromTranscript, + sessionMode, + } = config; + const { threadId } = memory; + return { + threadId, + access: config.access, + agentId, + agentName: agentInstance.name, + projectId, + userMessage: hideUserMessageFromTranscript ? null : message, + sessionMode, + author, + attachments, + source, + taskId, + taskVersionId, + telemetry: { ...telemetry, userId }, + }; + } + + private async getWakeRuntime( + config: ExecuteForWakeConfig, + access: AgentThreadAccess, + integrationType: string, + ) { + const { agentId, projectId, memory, identity, abortSignal } = config; + const isDraft = identity.type === 'draft'; + return await this.getRuntimeOrRecordFailure( + { + agentId, + projectId, + integrationType, + usePublishedVersion: !isDraft, + ...(isDraft ? { user: identity.user } : {}), + sandboxPrincipalHash: identity.principalHash, + }, + { + threadId: memory.threadId, + userMessage: null, + source: integrationType, + abortSignal, + access, + sessionMode: 'existing', + }, + ); + } + + private streamWakeTurn( + config: ExecuteForWakeConfig, + runtime: AgentRuntime, + access: AgentThreadAccess, + integrationType: string, + messageContext: IntegrationMessageContext | null, + ) { + const { agentId, message, memory, identity, abortSignal } = config; + const isDraft = identity.type === 'draft'; + return this.streamChatResponse({ + access, + messageContext, + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + mcpServerAttributions: runtime.mcpServerAttributions, + agentId, + ...(isDraft ? { userId: identity.user.id } : {}), + message, + memory, + projectId: runtime.projectId, + source: integrationType, + telemetry: { + runType: isDraft ? 'test' : 'production', + configuration: runtime.telemetryConfiguration, + }, + abortSignal, + includeHitlToolDetails: isDraft, + sandboxPrincipalHash: identity.principalHash, + hideUserMessageFromTranscript: true, + isWakeRun: true, + sessionMode: 'existing', + backgroundJobSignal: config.backgroundJobSignal, + }); + } } diff --git a/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts b/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts index af1991cf2ce..b17db6e3dd4 100644 --- a/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts +++ b/packages/cli/src/modules/agents/agent-execution-update-broadcaster.ts @@ -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 { - 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)); } diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts index 8481daf4ff6..187b4a4dfc8 100644 --- a/packages/cli/src/modules/agents/agent-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -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 { 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 { 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((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 { - const { threadId, agentId, record, hitlStatus } = params; - const updates: Array> = []; - 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(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, + ): Promise { + 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 { + 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, + ): Promise { + 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((resolve) => { + const timer = setTimeout(resolve, TIMELINE_SNAPSHOT_RETRY_DELAY_MS); + timer.unref(); + }); + } + return true; + } + + private async updateThreadAfterExecution( + params: RecordMessageParams, + executionId: string, + ): Promise { + const { threadId, record, hitlStatus } = params; + const updates: Array> = []; + 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( diff --git a/packages/cli/src/modules/agents/agent-integration-management.service.ts b/packages/cli/src/modules/agents/agent-integration-management.service.ts index 0f8e7de351a..cab9f97cb73 100644 --- a/packages/cli/src/modules/agents/agent-integration-management.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-management.service.ts @@ -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>(); 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 { - return await this.serializePerAgent( + private async applyChange( + options: IntegrationChangeOptions, + ): Promise { + // 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(agentId: string, work: () => Promise): Promise { - 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 { + private async runChange( + options: IntegrationChangeOptions, + ): Promise { 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 { + 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 { + 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 { + 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 { + // 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. * diff --git a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts index 059b418d675..ba4be718416 100644 --- a/packages/cli/src/modules/agents/agent-integration-persistence.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-persistence.service.ts @@ -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, + ): Promise { + 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, + context: CredentialIntegrationMutationContext, + credentialProvider: ReturnType, + ) { + // 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 }; + } } diff --git a/packages/cli/src/modules/agents/agent-knowledge-commands.ts b/packages/cli/src/modules/agents/agent-knowledge-commands.ts index d4b29a70a0f..fc3c07ef830 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-commands.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-commands.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts b/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts index 55c4fdab3eb..cb22df59dd5 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-mirror.service.ts @@ -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>(); + private readonly pendingMirrorSyncs = new Map>(); constructor( private readonly agentsConfig: AgentsConfig, @@ -144,15 +145,7 @@ export class AgentKnowledgeMirrorService { return emptySearchKnowledgeResult(outputMode, limit); } - const scopedFilesByPath = new Map(); - 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 { - 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(); + 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, + 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, + stagingId: string, + ): Promise { + 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 { + 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; } diff --git a/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts b/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts index 11e845aca97..29e691e11ab 100644 --- a/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts +++ b/packages/cli/src/modules/agents/agent-knowledge-retrieval.ts @@ -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; } diff --git a/packages/cli/src/modules/agents/agent-knowledge.service.ts b/packages/cli/src/modules/agents/agent-knowledge.service.ts index 97dee534feb..519be983492 100644 --- a/packages/cli/src/modules/agents/agent-knowledge.service.ts +++ b/packages/cli/src/modules/agents/agent-knowledge.service.ts @@ -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 { 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 { - 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 { - 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 { - 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))); } diff --git a/packages/cli/src/modules/agents/agent-mcp-access.service.ts b/packages/cli/src/modules/agents/agent-mcp-access.service.ts index fdff07bad95..dba6aafb1cb 100644 --- a/packages/cli/src/modules/agents/agent-mcp-access.service.ts +++ b/packages/cli/src/modules/agents/agent-mcp-access.service.ts @@ -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 { const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:update']); return await this.agentRepository.findByProjectIdsPaginated( projectIds, diff --git a/packages/cli/src/modules/agents/agent-model-catalog.service.ts b/packages/cli/src/modules/agents/agent-model-catalog.service.ts index 128bcbbc9b1..00f835c8e6f 100644 --- a/packages/cli/src/modules/agents/agent-model-catalog.service.ts +++ b/packages/cli/src/modules/agents/agent-model-catalog.service.ts @@ -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 { 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 { + 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 { + 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 { + 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, + ): 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, + ): 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, + }; }); } diff --git a/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts b/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts index 56be8d0662b..606a779fffa 100644 --- a/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts +++ b/packages/cli/src/modules/agents/agent-modification-telemetry.service.ts @@ -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> = {}, + 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; + +export type AgentSidecarChanges = Partial>; + +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, + ): 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; + } + } } diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index d6e1dadbf55..c27d995721c 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -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 { - 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() @@ -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 { - 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 { - 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 { - 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>, + sidecarChanges: AgentSidecarChanges, ): Promise { 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 { - 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 { + 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 { + 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 { + 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, + targetHistory?: AgentHistory, + ): Promise { + 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, + 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) { + const { name, objective, cronExpression, timezone } = task; + return { name, objective, cronExpression, timezone }; } diff --git a/packages/cli/src/modules/agents/agent-push-recipients.service.ts b/packages/cli/src/modules/agents/agent-push-recipients.service.ts new file mode 100644 index 00000000000..ba97eac55e1 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-push-recipients.service.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/cli/src/modules/agents/agent-run-tracing.service.ts b/packages/cli/src/modules/agents/agent-run-tracing.service.ts index 7b0c28df1f3..058d0140c4e 100644 --- a/packages/cli/src/modules/agents/agent-run-tracing.service.ts +++ b/packages/cli/src/modules/agents/agent-run-tracing.service.ts @@ -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; } diff --git a/packages/cli/src/modules/agents/agent-runtime-cache.service.ts b/packages/cli/src/modules/agents/agent-runtime-cache.service.ts index 166fa30f4e3..43184d76c58 100644 --- a/packages/cli/src/modules/agents/agent-runtime-cache.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-cache.service.ts @@ -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; 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 { + 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; + } } diff --git a/packages/cli/src/modules/agents/agent-runtime-instrumentation.ts b/packages/cli/src/modules/agents/agent-runtime-instrumentation.ts index 134637a6b32..fa918c4b732 100644 --- a/packages/cli/src/modules/agents/agent-runtime-instrumentation.ts +++ b/packages/cli/src/modules/agents/agent-runtime-instrumentation.ts @@ -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 { diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index e2a757c6900..e377b058bd4 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -1,10 +1,10 @@ import { createWriteTodosTool, type Agent as RuntimeAgent, + type CreateDelegateSubAgentToolOptions, BuiltTool, CredentialProvider, ModelConfig, - ToolDescriptor, } from '@n8n/agents'; import { getProviderPrefix } from '@n8n/ai-utilities/agent-config'; import { @@ -19,7 +19,6 @@ import { type AgentJsonMcpServerConfig, type AgentJsonMemoryConfig, type AgentJsonToolConfig, - type AgentSkill, type SubAgentRunPolicy, type SubAgentSource, type SubAgentTaskDifficulty, @@ -82,12 +81,13 @@ import { AgentRepository } from './repositories/agent.repository'; import { AgentSecureRuntime } from './runtime/agent-secure-runtime'; import { createN8nDelegateSubAgentTool } from './sub-agents/delegate-sub-agent-tool'; import { SubAgentRunner } from './sub-agents/sub-agent-runner'; -import { buildToolRegistry, type ToolRegistry } from './tool-registry'; +import { buildToolRegistry, type ReferencedToolKind, type ToolRegistry } from './tool-registry'; import { createGetEnvironmentTool } from './tools/environment-tool'; import type { WorkflowToolExecutionMode } from './tools/workflow-tool-factory'; import { WorkflowToolUnavailableError } from './tools/workflow-tool-unavailable-error'; import { findWorkflowToolWorkflow } from './tools/workflow-tool-workflow-resolver'; import { WorkflowToolWorkflowLoader } from './tools/workflow-tool-workflow-loader.service'; +import { getAgentRuntimeAssets, type AgentRuntimeAssets } from './utils/agent-runtime-assets'; import { resolveUniqueSubAgents } from './utils/sub-agent-resolver'; /** * `inline` runs an agent defined in a workflow node's parameters: no entity @@ -96,19 +96,23 @@ import { resolveUniqueSubAgents } from './utils/sub-agent-resolver'; */ export type AgentRuntimeProfile = 'top-level' | 'sub-agent' | 'inline'; -export interface SubAgentDelegationConfig { - sourcesById: Record; - availableSubAgents: Array<{ id: string; name: string; useWhen?: string }>; +export interface ReconstructedAgentRuntime { + agent: RuntimeAgent; + toolRegistry: ToolRegistry; + /** Maps MCP server names to attribution for replies that use their tools. */ + mcpServerAttributions: Map; } -export interface ReconstructAgentRuntimeParams { +export interface SubAgentDelegationConfig { + sourcesById: Record; + availableSubAgents: NonNullable; +} + +export interface ReconstructAgentRuntimeParams extends AgentRuntimeAssets { config: AgentJsonConfig; memoryOwnerAgentId: string; projectId: string; credentialProvider: CredentialProvider; - toolDescriptors: Record; - toolCodeByName: Record; - skills: Record; runtimeProfile: AgentRuntimeProfile; /** * Telemetry classification of the run this runtime serves. Baked in at build @@ -148,6 +152,50 @@ export interface ReconstructAgentRuntimeParams { parentWorkspace?: { handle: AgentSandboxRuntime; delegationThreadId: string }; } +interface RuntimeReconstructionOptions extends ReconstructAgentRuntimeParams { + /** + * Whether the caller can resume a suspended tool. False for workflow-driven + * runs, where HITL tools report status instead of parking forever. + */ + supportsHitl?: boolean; + credentialIntegrations: AgentIntegrationConfig[]; + subAgentDelegation: SubAgentDelegationConfig; + allowBackgroundTasks?: boolean; + /** Tools the access filter already dropped; reported together with build-time stubs. */ + unavailableTools?: UnavailableTool[]; + /** Set by the in-app preview chat only — see `BuildFromJsonOptions.previewChat`. */ + previewChat?: boolean; +} + +interface RuntimeDependencies + extends Omit< + RuntimeReconstructionOptions, + | keyof AgentRuntimeAssets + | 'memoryOwnerAgentId' + | 'supportsHitl' + | 'allowBackgroundTasks' + | 'unavailableTools' + | 'previewChat' + > { + agent: RuntimeAgent; + agentId: string; + workflowToolExecutionMode: WorkflowToolExecutionMode; + parentAgentIdForDelegation: string; + backgroundTasksEnabled: boolean; +} + +interface ToolRunIdentity { + projectId: string; + workflowToolExecutionMode: WorkflowToolExecutionMode; + usePublishedWorkflowVersion: boolean; + agentId?: string; + integrationType?: string; + userId?: string; + previewChat?: boolean; + supportsHitl: boolean; + backgroundTasksEnabled: boolean; +} + async function getChatIntegrationToolServices() { const { IntegrationMessageContextService } = await import( './integrations/integration-message-context.service.js' @@ -192,7 +240,7 @@ export interface UserToolAccessSnapshot { */ export interface UnavailableTool { toolName: string; - toolType: 'workflow' | 'node'; + toolType: ReferencedToolKind; reason: 'not_found' | 'not_published' | 'incompatible' | 'no_access'; message: string; } @@ -247,12 +295,7 @@ export class AgentRuntimeReconstructionService { /** Disable background jobs for task-triggered runtimes. */ allowBackgroundTasks?: boolean; } = {}, - ): Promise<{ - agent: RuntimeAgent; - toolRegistry: ToolRegistry; - mcpServerAttributions: Map; - userToolAccessSnapshot?: UserToolAccessSnapshot; - }> { + ): Promise { let config = agentEntity.schema; if (!config) { throw new UserError('Agent has no JSON config.'); @@ -272,13 +315,7 @@ export class AgentRuntimeReconstructionService { unavailableTools = filtered.unavailable; } - const toolsByName: Record = {}; - const toolDescriptors: Record = {}; - for (const [_toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) { - toolsByName[toolEntry.descriptor.name] = toolEntry.code; - toolDescriptors[_toolId] = toolEntry.descriptor; - } - + const { toolDescriptors, toolCodeByName } = getAgentRuntimeAssets(agentEntity); const subAgentDelegation = await this.createSubAgentDelegationConfig( config, agentEntity.projectId, @@ -290,7 +327,7 @@ export class AgentRuntimeReconstructionService { projectId: agentEntity.projectId, credentialProvider, toolDescriptors, - toolCodeByName: toolsByName, + toolCodeByName, skills: agentEntity.skills ?? {}, runtimeProfile: 'top-level', supportsHitl, @@ -333,73 +370,27 @@ export class AgentRuntimeReconstructionService { unavailable: UnavailableTool[]; }> { const canExecute = await userHasScopes(user, ['workflow:execute'], false, { projectId }); - const filtered: AgentJsonToolConfig[] = []; const unavailable: UnavailableTool[] = []; const grantedCredentialIds = new Set(); const grantedWorkflowIds = new Set(); let keptGatedTool = false; - const drop = ( - ref: Extract, - reason: UnavailableTool['reason'], - message: string, - ) => unavailable.push({ toolName: toolRefName(ref), toolType: ref.type, reason, message }); + for (const ref of tools) { if (ref.type === 'custom') { filtered.push(ref); continue; } - - if (!canExecute) { - drop(ref, 'no_access', 'The user lacks workflow:execute on the project'); + const access = await this.checkToolAccess(ref, projectId, user, canExecute); + if (access && 'reason' in access) { + unavailable.push(access); continue; } - - if (ref.type === 'node') { - const credentialIds = Object.values(ref.node.credentials ?? {}) - .map((credential) => credential.id) - .filter((id): id is string => Boolean(id)); - - const accessibleCredentials = await Promise.all( - credentialIds.map( - async (id) => - await this.credentialsFinderService.findCredentialForUser(id, user, [ - 'credential:read', - ]), - ), - ); - if (accessibleCredentials.some((credential) => credential === null)) { - drop(ref, 'no_access', 'The user cannot read a credential the tool uses'); - continue; - } - - for (const id of credentialIds) grantedCredentialIds.add(id); - keptGatedTool = true; - filtered.push(ref); - continue; - } - - // ref.type === 'workflow' - const workflow = await findWorkflowToolWorkflow(this.workflowRepository, ref, projectId); - if (!workflow) { - // Nothing to gate: the tool factory turns a missing workflow into a stub. - filtered.push(ref); - continue; - } - - const accessibleWorkflow = await this.workflowFinderService.findWorkflowForUser( - workflow.id, - user, - ['workflow:execute'], - ); - if (!accessibleWorkflow) { - drop(ref, 'no_access', `The user cannot execute workflow "${workflow.name}"`); - continue; - } - - grantedWorkflowIds.add(workflow.id); - keptGatedTool = true; filtered.push(ref); + if (!access) continue; + keptGatedTool = true; + for (const id of access.credentialIds) grantedCredentialIds.add(id); + for (const id of access.workflowIds) grantedWorkflowIds.add(id); } return { @@ -416,6 +407,52 @@ export class AgentRuntimeReconstructionService { }; } + private async checkToolAccess( + ref: Exclude, + projectId: string, + user: User, + canExecute: boolean, + ): Promise { + const denied = (message: string): UnavailableTool => ({ + toolName: toolRefName(ref), + toolType: ref.type, + reason: 'no_access', + message, + }); + if (!canExecute) return denied('The user lacks workflow:execute on the project'); + + if (ref.type === 'node') { + const credentialIds = Object.values(ref.node.credentials ?? {}) + .map((credential) => credential.id) + .filter((id): id is string => Boolean(id)); + const credentials = await Promise.all( + credentialIds.map( + async (id) => + await this.credentialsFinderService.findCredentialForUser(id, user, [ + 'credential:read', + ]), + ), + ); + if (credentials.some((credential) => credential === null)) { + return denied('The user cannot read a credential the tool uses'); + } + return { credentialIds, workflowIds: [] }; + } + + const workflow = await findWorkflowToolWorkflow(this.workflowRepository, ref, projectId); + // Missing workflows stay in the list so the tool factory can return a stub. + if (!workflow) return undefined; + const accessibleWorkflow = await this.workflowFinderService.findWorkflowForUser( + workflow.id, + user, + ['workflow:execute'], + ); + if (!accessibleWorkflow) { + return denied(`The user cannot execute workflow "${workflow.name}"`); + } + return { credentialIds: [], workflowIds: [workflow.id] }; + } + /** * Re-run the access checks recorded in `snapshot` against current DB state. * Returns false when any grant no longer holds — a runtime built from that @@ -457,11 +494,9 @@ export class AgentRuntimeReconstructionService { * when the parent had a user), so raw credential access stays gated there * regardless. */ - async reconstructFromResolvedSource(params: ReconstructAgentRuntimeParams): Promise<{ - agent: RuntimeAgent; - toolRegistry: ToolRegistry; - mcpServerAttributions: Map; - }> { + async reconstructFromResolvedSource( + params: ReconstructAgentRuntimeParams, + ): Promise { let config = params.config; let unavailableTools: UnavailableTool[] = []; if (params.user && config.tools?.length) { @@ -483,40 +518,42 @@ export class AgentRuntimeReconstructionService { }); } - private async reconstructRuntime(options: { - config: AgentJsonConfig; - memoryOwnerAgentId: string; - projectId: string; - credentialProvider: CredentialProvider; - toolDescriptors: Record; - toolCodeByName: Record; - skills: Record; - runtimeProfile: AgentRuntimeProfile; - /** - * Whether the caller can resume a suspended tool. False for workflow-driven - * runs, where HITL tools report status instead of parking forever. - */ - supportsHitl?: boolean; - runType: AgentRunTelemetryType; - workflowToolExecutionMode?: WorkflowToolExecutionMode; - parentAgentIdForDelegation?: string; - integrationType?: string; - credentialIntegrations: AgentIntegrationConfig[]; - subAgentDelegation: SubAgentDelegationConfig; - user?: User; - instrumentation?: AgentRuntimeInstrumentation; - sandboxPrincipalHash?: AgentSandboxPrincipalHash; - allowBackgroundTasks?: boolean; - parentWorkspace?: { handle: AgentSandboxRuntime; delegationThreadId: string }; - /** Tools the access filter already dropped; reported together with build-time stubs. */ - unavailableTools?: UnavailableTool[]; - /** Set by the in-app preview chat only — see `BuildFromJsonOptions.previewChat`. */ - previewChat?: boolean; - }): Promise<{ - agent: RuntimeAgent; - toolRegistry: ToolRegistry; - mcpServerAttributions: Map; - }> { + private async reconstructRuntime( + options: RuntimeReconstructionOptions, + ): Promise { + const unavailable = [...(options.unavailableTools ?? [])]; + const backgroundTasksEnabled = + options.runtimeProfile === 'top-level' && + (options.allowBackgroundTasks ?? true) && + Container.get(AgentsConfig).backgroundTasksEnabled; + const runtime = await this.buildConfiguredRuntime(options, backgroundTasksEnabled, unavailable); + if (unavailable.length > 0) { + this.logger.warn('Agent runtime built with unavailable tools', { + agentId: options.memoryOwnerAgentId, + runType: options.runType, + tools: unavailable, + }); + } + await this.injectRuntimeDependencies({ + ...options, + agent: runtime.agent, + agentId: options.memoryOwnerAgentId, + workflowToolExecutionMode: options.workflowToolExecutionMode ?? 'manual', + parentAgentIdForDelegation: options.parentAgentIdForDelegation ?? options.memoryOwnerAgentId, + backgroundTasksEnabled, + }); + return { + agent: runtime.agent, + toolRegistry: buildToolRegistry(runtime.resolvedTools), + mcpServerAttributions: runtime.mcpServerAttributions, + }; + } + + private async buildConfiguredRuntime( + options: RuntimeReconstructionOptions, + backgroundTasksEnabled: boolean, + unavailable: UnavailableTool[], + ) { const { config, memoryOwnerAgentId, @@ -525,34 +562,63 @@ export class AgentRuntimeReconstructionService { toolDescriptors, toolCodeByName, skills, - runtimeProfile, - runType, - workflowToolExecutionMode = 'manual', - supportsHitl, - parentAgentIdForDelegation, - integrationType, - credentialIntegrations, - subAgentDelegation, - user, instrumentation, - sandboxPrincipalHash, - parentWorkspace, - allowBackgroundTasks = true, previewChat, } = options; - const unavailable = [...(options.unavailableTools ?? [])]; - const backgroundTasksEnabled = - runtimeProfile === 'top-level' && - allowBackgroundTasks && - Container.get(AgentsConfig).backgroundTasksEnabled; - const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName); - // Callers that cannot resume a suspended run (agents invoked as workflow - // steps) pass supportsHitl false explicitly; otherwise only the top-level - // profile can be woken again. - const canResume = supportsHitl ?? runtimeProfile === 'top-level'; + const toolResolver = this.createRuntimeToolResolver( + options, + backgroundTasksEnabled, + unavailable, + ); + const resolvedTools: BuiltTool[] = []; + const mcpServerAttributions = new Map(); + const aiProxyFetch = createAiProxyFetch(this.outboundHttp); + const aiMcpFetch = instrumentation?.mcpFetch ?? createAiMcpFetch(this.outboundHttp); + const webSearchFetch = createWebSearchFetch(this.outboundHttp); + const buildMcpClient = this.makeMcpClientFactory(options, aiMcpFetch, mcpServerAttributions); + const reconstructed = await buildFromJson(config, toolDescriptors, { + toolExecutor, + credentialProvider, + resolveTool: async (ref) => { + const resolved = await toolResolver(ref); + if (resolved) resolvedTools.push(resolved); + return resolved; + }, + skills, + memoryFactory: this.getMemoryFactory(memoryOwnerAgentId), + buildMcpClient, + resolveManagedEmbeddingProviderOptions: async () => + await this.resolveManagedEmbeddingProviderOptions(projectId), + modelFetch: instrumentation?.modelFetch ?? aiProxyFetch, + fallbackWebSearch: instrumentation?.webSearch, + // Only the mock MCP transport makes attaching auth-pending servers safe. + attachAuthPendingMcpServers: instrumentation?.mcpFetch !== undefined, + webSearchFetch, + previewChat, + }); + return { agent: reconstructed, resolvedTools, mcpServerAttributions }; + } - const toolResolver = this.makeToolResolver( + private createRuntimeToolResolver( + options: RuntimeReconstructionOptions, + backgroundTasksEnabled: boolean, + unavailable: UnavailableTool[], + ): ToolResolver { + const { + projectId, + workflowToolExecutionMode = 'manual', + runType, + memoryOwnerAgentId, + integrationType, + user, + previewChat, + supportsHitl, + runtimeProfile, + instrumentation, + } = options; + const canResume = supportsHitl ?? runtimeProfile === 'top-level'; + return this.makeToolResolver( { projectId, workflowToolExecutionMode, @@ -576,19 +642,15 @@ export class AgentRuntimeReconstructionService { instrumentation, unavailable, ); - const resolvedTools: BuiltTool[] = []; - // See AgentRuntime.mcpServerAttributions - const mcpServerAttributions = new Map(); + } - // Transport for LLM calls - const aiProxyFetch = createAiProxyFetch(this.outboundHttp); - // Transport for MCP calls - const aiMcpFetch = instrumentation?.mcpFetch ?? createAiMcpFetch(this.outboundHttp); - - // Transport for fallback web-search calls - const webSearchFetch = createWebSearchFetch(this.outboundHttp); - - const buildMcpClient = async (server: AgentJsonMcpServerConfig) => + private makeMcpClientFactory( + options: RuntimeReconstructionOptions, + aiMcpFetch: ReturnType, + mcpServerAttributions: Map, + ) { + const { credentialProvider, projectId, memoryOwnerAgentId, instrumentation } = options; + return async (server: AgentJsonMcpServerConfig) => await buildMcpClientForServer(server, { credentialProvider, oauthService: this.oauthService, @@ -616,61 +678,6 @@ export class AgentRuntimeReconstructionService { }), }), }); - - const reconstructed = await buildFromJson(config, toolDescriptors, { - toolExecutor, - credentialProvider, - resolveTool: async (ref) => { - const resolved = await toolResolver(ref); - if (resolved) resolvedTools.push(resolved); - return resolved; - }, - skills, - memoryFactory: this.getMemoryFactory(memoryOwnerAgentId), - buildMcpClient, - resolveManagedEmbeddingProviderOptions: async () => - await this.resolveManagedEmbeddingProviderOptions(projectId), - modelFetch: instrumentation?.modelFetch ?? aiProxyFetch, - fallbackWebSearch: instrumentation?.webSearch, - // Only the mock MCP transport makes attaching auth-pending servers safe. - attachAuthPendingMcpServers: instrumentation?.mcpFetch !== undefined, - webSearchFetch, - previewChat, - }); - - if (unavailable.length > 0) { - this.logger.warn('Agent runtime built with unavailable tools', { - agentId: memoryOwnerAgentId, - runType, - tools: unavailable, - }); - } - - await this.injectRuntimeDependencies({ - agent: reconstructed, - agentId: memoryOwnerAgentId, - projectId, - credentialProvider, - runtimeProfile, - runType, - workflowToolExecutionMode, - config, - subAgentDelegation, - parentAgentIdForDelegation: parentAgentIdForDelegation ?? memoryOwnerAgentId, - integrationType, - credentialIntegrations, - parentWorkspace, - user, - instrumentation, - sandboxPrincipalHash, - backgroundTasksEnabled, - }); - - return { - agent: reconstructed, - toolRegistry: buildToolRegistry(resolvedTools), - mcpServerAttributions, - }; } async createSubAgentDelegationConfig( @@ -748,21 +755,38 @@ export class AgentRuntimeReconstructionService { }; } private makeToolResolver( - runIdentity: { - projectId: string; - workflowToolExecutionMode: WorkflowToolExecutionMode; - usePublishedWorkflowVersion: boolean; - agentId?: string; - integrationType?: string; - userId?: string; - previewChat?: boolean; - supportsHitl: boolean; - backgroundTasksEnabled: boolean; - }, + runIdentity: ToolRunIdentity, instrumentation: AgentRuntimeInstrumentation | undefined, - /** Receives every workflow tool that had to be stubbed. */ unavailable: UnavailableTool[], ): ToolResolver { + const instrumentToolAdditionalData = instrumentation?.configureToolAdditionalData; + return async (ref) => { + if (ref.type === 'workflow') { + return await this.resolveWorkflowTool( + ref, + runIdentity, + instrumentToolAdditionalData, + unavailable, + ); + } + if (ref.type === 'node') { + const { resolveNodeTool } = await import('./tools/node-tool-factory.js'); + return await resolveNodeTool(ref, { + executor: this.ephemeralNodeExecutor, + projectId: runIdentity.projectId, + instrumentToolAdditionalData, + }); + } + return null; + }; + } + + private async resolveWorkflowTool( + ref: Extract, + runIdentity: ToolRunIdentity, + instrumentToolAdditionalData: AgentRuntimeInstrumentation['configureToolAdditionalData'], + unavailable: UnavailableTool[], + ) { const { projectId, workflowToolExecutionMode, @@ -773,264 +797,234 @@ export class AgentRuntimeReconstructionService { supportsHitl, backgroundTasksEnabled, } = runIdentity; - const instrumentToolAdditionalData = instrumentation?.configureToolAdditionalData; - return async (ref: AgentJsonToolConfig) => { - if (ref.type === 'workflow') { - const { resolveWorkflowTool, buildUnavailableWorkflowTool } = await import( - './tools/workflow-tool-factory.js' - ); - const context = { - workflowLoader: Container.get(WorkflowToolWorkflowLoader), - workflowRunner: await getWorkflowRunner(), - subworkflowPolicyChecker: Container.get(SubworkflowPolicyChecker), - activeExecutions: this.activeExecutions, + const { resolveWorkflowTool, buildUnavailableWorkflowTool } = await import( + './tools/workflow-tool-factory.js' + ); + const context = { + workflowLoader: Container.get(WorkflowToolWorkflowLoader), + workflowRunner: await getWorkflowRunner(), + subworkflowPolicyChecker: Container.get(SubworkflowPolicyChecker), + activeExecutions: this.activeExecutions, + projectId, + executionMode: workflowToolExecutionMode, + usePublishedWorkflowVersion, + instrumentToolAdditionalData, + agentId, + integrationType, + userId, + supportsHitl, + backgroundTasksEnabled, + }; + try { + return await resolveWorkflowTool(ref, context); + } catch (error) { + // A missing or incompatible workflow costs the agent one tool call, not + // the whole run: the stub keeps the tool listed and reports the reason. + if (!(error instanceof WorkflowToolUnavailableError)) throw error; + unavailable.push({ + toolName: toolRefName(ref), + toolType: 'workflow', + reason: error.reason, + message: error.message, + }); + return buildUnavailableWorkflowTool(ref, context); + } + } + + private async injectRuntimeDependencies(params: RuntimeDependencies): Promise { + const { agent, agentId, projectId, runtimeProfile, config } = params; + agent.tool(createGetEnvironmentTool()); + const parentWorkspaceHandle = await this.attachWorkspaceAndKnowledge(params); + if (runtimeProfile === 'top-level') { + await this.attachIntegrationTools(params); + await this.attachTopLevelTools(params, parentWorkspaceHandle); + } + // Inline agents have no entity for checkpoint or attachment rows. + if (runtimeProfile === 'inline') return; + if (!agent.hasCheckpointStorage()) { + agent.checkpoint(this.n8nCheckpointStorage.getStorage(agentId)); + } + agent.fileStore( + this.agentChatAttachmentService.getFileStore( + { agentId, projectId }, + getProviderPrefix(config.model), + ), + ); + } + + private async attachWorkspaceAndKnowledge(params: RuntimeDependencies) { + if (params.runtimeProfile === 'inline' || !this.agentSandboxRuntimeService.isEnabled()) + return undefined; + const parentWorkspaceHandle = await this.attachWorkspace(params); + const { agent, agentId, projectId } = params; + if (await this.agentFileRepository.hasFilesForAgent(agentId)) { + const { createKnowledgeRetrievalTools } = await import( + './tools/knowledge/search-knowledge.tool.js' + ); + agent.tool( + createKnowledgeRetrievalTools({ projectId, - executionMode: workflowToolExecutionMode, - usePublishedWorkflowVersion, - instrumentToolAdditionalData, agentId, - integrationType, - userId, - supportsHitl, - backgroundTasksEnabled, + knowledgeMirrorService: this.agentKnowledgeMirrorService, + }), + ); + } + return parentWorkspaceHandle; + } + + private async attachWorkspace( + params: RuntimeDependencies, + ): Promise { + const { agent, agentId, projectId, runtimeProfile, parentWorkspace, sandboxPrincipalHash } = + params; + if (runtimeProfile === 'sub-agent') { + // Delegated runs use the parent's sandbox only. + if (parentWorkspace) { + agent.workspace( + this.agentWorkspaceService.getDelegatedAgentWorkspace( + parentWorkspace.handle, + parentWorkspace.delegationThreadId, + ), + ); + } + return undefined; + } + if (!sandboxPrincipalHash) { + throw new UserError( + 'Agent workspace scope is missing and the runtime cannot be reconstructed', + ); + } + try { + const { workspace, handle } = await this.agentWorkspaceService.getAgentWorkspace( + projectId, + agentId, + sandboxPrincipalHash, + ); + agent.workspace(workspace); + return handle; + } catch (error) { + this.logger.warn('Failed to attach agent workspace', { + projectId, + agentId, + error: sanitizeSandboxErrorDetail(error instanceof Error ? error.message : String(error)), + }); + } + return undefined; + } + + private async attachIntegrationTools(params: RuntimeDependencies): Promise { + const { agent, agentId, integrationType, credentialIntegrations } = params; + const includeN8nChat = integrationType === N8N_CHAT_INTEGRATION_TYPE; + if (credentialIntegrations.length === 0 && !includeN8nChat) return; + const integrationRegistry = Container.get(ChatIntegrationRegistry); + const { messageContextStore, actionExecutor, queryExecutor } = + await getChatIntegrationToolServices(); + + const descriptors = this.createIntegrationDescriptors( + agentId, + credentialIntegrations, + integrationRegistry, + ); + + if (includeN8nChat) { + descriptors.push(this.createN8nChatDescriptor(agentId, integrationRegistry)); + } + + for (const descriptor of descriptors) { + agent.tool(createIntegrationContextTool({ descriptor, queryExecutor })); + agent.tool(createIntegrationActionTool({ descriptor, messageContextStore, actionExecutor })); + } + } + + private createIntegrationDescriptors( + agentId: string, + credentialIntegrations: AgentIntegrationConfig[], + integrationRegistry: ChatIntegrationRegistry, + ): IntegrationToolConnectionDescriptor[] { + return getIntegrationToolConnectionDescriptors( + credentialIntegrations, + agentId, + (integrationConfig) => { + const integrationDef = integrationRegistry.get(integrationConfig.type); + return { + contextToolDefinitions: integrationDef?.contextToolDefinitions, + actionToolDefinitions: integrationDef?.actionToolDefinitions, + contextQueries: integrationDef?.contextQueries, + actions: integrationDef?.actions, + contextToolGuidance: integrationDef?.contextToolGuidance, + actionToolGuidance: integrationDef?.actionToolGuidance, }; - try { - return await resolveWorkflowTool(ref, context); - } catch (error) { - // A missing or incompatible workflow costs the agent one tool call, not - // the whole run: the stub keeps the tool listed and reports the reason. - if (!(error instanceof WorkflowToolUnavailableError)) throw error; - unavailable.push({ - toolName: toolRefName(ref), - toolType: 'workflow', - reason: error.reason, - message: error.message, - }); - return buildUnavailableWorkflowTool(ref, context); - } - } + }, + ); + } - if (ref.type === 'node') { - const { resolveNodeTool } = await import('./tools/node-tool-factory.js'); - return await resolveNodeTool(ref, { - executor: this.ephemeralNodeExecutor, - projectId, - instrumentToolAdditionalData, - }); - } - - return null; + private createN8nChatDescriptor( + agentId: string, + integrationRegistry: ChatIntegrationRegistry, + ): IntegrationToolConnectionDescriptor { + const n8nChat = integrationRegistry.require(N8N_CHAT_INTEGRATION_TYPE); + const n8nChatIntegration = { + type: N8N_CHAT_INTEGRATION_TYPE, + } as unknown as IntegrationToolConnectionDescriptor['integration']; + return { + agentId, + integration: n8nChatIntegration, + integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE, + contextToolName: N8N_CHAT_CONTEXT_TOOL_NAME, + actionToolName: N8N_CHAT_ACTION_TOOL_NAME, + contextQueries: [...n8nChat.contextQueries], + actions: [...n8nChat.actions], + contextToolDefinitions: [...n8nChat.contextToolDefinitions], + actionToolDefinitions: [...n8nChat.actionToolDefinitions], + contextToolGuidance: n8nChat.contextToolGuidance, + actionToolGuidance: n8nChat.actionToolGuidance, }; } - private async injectRuntimeDependencies(params: { - agent: RuntimeAgent; - agentId: string; - projectId: string; - credentialProvider: CredentialProvider; - runtimeProfile: AgentRuntimeProfile; - runType: AgentRunTelemetryType; - workflowToolExecutionMode: WorkflowToolExecutionMode; - config: AgentJsonConfig; - subAgentDelegation: SubAgentDelegationConfig; - parentAgentIdForDelegation: string; - integrationType?: string; - credentialIntegrations: AgentIntegrationConfig[]; - user?: User; - instrumentation?: AgentRuntimeInstrumentation; - sandboxPrincipalHash?: AgentSandboxPrincipalHash; - backgroundTasksEnabled: boolean; - parentWorkspace?: { handle: AgentSandboxRuntime; delegationThreadId: string }; - }): Promise { + private async attachTopLevelTools( + params: RuntimeDependencies, + parentWorkspaceHandle: AgentSandboxRuntime | undefined, + ): Promise { const { agent, agentId, + config, + parentAgentIdForDelegation, projectId, credentialProvider, - runtimeProfile, runType, workflowToolExecutionMode, - config, subAgentDelegation, - parentAgentIdForDelegation, - integrationType, - credentialIntegrations, user, instrumentation, - sandboxPrincipalHash, backgroundTasksEnabled, - parentWorkspace, } = params; - - agent.tool(createGetEnvironmentTool()); - - let parentWorkspaceHandle: AgentSandboxRuntime | undefined; - - if (runtimeProfile !== 'inline' && this.agentSandboxRuntimeService.isEnabled()) { - if (runtimeProfile === 'sub-agent') { - // Delegated runs share the parent's sandbox, scoped to a per-delegation - // subdirectory. No parent workspace → no workspace tools (no own-sandbox fallback). - if (parentWorkspace) { - agent.workspace( - this.agentWorkspaceService.getDelegatedAgentWorkspace( - parentWorkspace.handle, - parentWorkspace.delegationThreadId, - ), - ); - } - } else { - if (!sandboxPrincipalHash) { - throw new UserError( - 'Agent workspace scope is missing and the runtime cannot be reconstructed', - ); - } - try { - const { workspace, handle } = await this.agentWorkspaceService.getAgentWorkspace( - projectId, - agentId, - sandboxPrincipalHash, - ); - agent.workspace(workspace); - parentWorkspaceHandle = handle; - } catch (error) { - this.logger.warn('Failed to attach agent workspace', { - projectId, - agentId, - error: sanitizeSandboxErrorDetail( - error instanceof Error ? error.message : String(error), - ), - }); - } - } - - if (await this.agentFileRepository.hasFilesForAgent(agentId)) { - const { createKnowledgeRetrievalTools } = await import( - './tools/knowledge/search-knowledge.tool.js' - ); - agent.tool( - createKnowledgeRetrievalTools({ - projectId, - agentId, - knowledgeMirrorService: this.agentKnowledgeMirrorService, - }), - ); - } - } - - if (runtimeProfile === 'top-level') { - const includeN8nChat = integrationType === N8N_CHAT_INTEGRATION_TYPE; - - if (credentialIntegrations.length > 0 || includeN8nChat) { - const integrationRegistry = Container.get(ChatIntegrationRegistry); - const { messageContextStore, actionExecutor, queryExecutor } = - await getChatIntegrationToolServices(); - - const descriptors: IntegrationToolConnectionDescriptor[] = - getIntegrationToolConnectionDescriptors( - credentialIntegrations, - agentId, - (integrationConfig) => { - const integrationDef = integrationRegistry.get(integrationConfig.type); - return { - contextToolDefinitions: integrationDef?.contextToolDefinitions, - actionToolDefinitions: integrationDef?.actionToolDefinitions, - contextQueries: integrationDef?.contextQueries, - actions: integrationDef?.actions, - contextToolGuidance: integrationDef?.contextToolGuidance, - actionToolGuidance: integrationDef?.actionToolGuidance, - }; - }, - ); - - if (includeN8nChat) { - // Implicit in-app chat channel: credential-less, per-run, fixed - // tool names (exactly one n8n_chat per run — no suffixing). - const n8nChat = integrationRegistry.require(N8N_CHAT_INTEGRATION_TYPE); - const n8nChatIntegration = { - type: N8N_CHAT_INTEGRATION_TYPE, - } as unknown as IntegrationToolConnectionDescriptor['integration']; - descriptors.push({ - agentId, - integration: n8nChatIntegration, - integrationConnectionId: N8N_CHAT_INTEGRATION_TYPE, - contextToolName: N8N_CHAT_CONTEXT_TOOL_NAME, - actionToolName: N8N_CHAT_ACTION_TOOL_NAME, - contextQueries: [...n8nChat.contextQueries], - actions: [...n8nChat.actions], - contextToolDefinitions: [...n8nChat.contextToolDefinitions], - actionToolDefinitions: [...n8nChat.actionToolDefinitions], - contextToolGuidance: n8nChat.contextToolGuidance, - actionToolGuidance: n8nChat.actionToolGuidance, - }); - } - - for (const descriptor of descriptors) { - agent.tool(createIntegrationContextTool({ descriptor, queryExecutor })); - agent.tool( - createIntegrationActionTool({ descriptor, messageContextStore, actionExecutor }), - ); - } - } - } - - if (runtimeProfile === 'top-level') { - await this.attachSubAgentDelegationTool({ - agent, - config, - parentAgentId: parentAgentIdForDelegation, - projectId, - credentialProvider, - runType, - workflowToolExecutionMode, - delegation: subAgentDelegation, - parentWorkspaceHandle, - user, - instrumentation, - }); - this.attachWriteTodosTool(agent, agentId); - - if (backgroundTasksEnabled) { - await this.attachBackgroundJobTools({ - agent, - parentAgentId: parentAgentIdForDelegation, - projectId, - credentialProvider, - runType, - workflowToolExecutionMode, - delegation: subAgentDelegation, - user, - instrumentation, - ...(parentWorkspaceHandle !== undefined ? { parentWorkspaceHandle } : {}), - }); - - agent.volatileInstructionsProvider(async ({ persistence }) => { - if (!persistence?.threadId) return undefined; - const { AgentWakeService } = await import('./background/agent-wake.service.js'); - return await Container.get(AgentWakeService).getBackgroundUpdates( - persistence.threadId, - persistence.resourceId, - ); - }); - } - } - - // Inline agents get no checkpoint storage: `agent_checkpoints.agentId` - // is an FK to `agents`, and a synthetic inline id has no entity row. - if (runtimeProfile !== 'inline' && !agent.hasCheckpointStorage()) { - agent.checkpoint(this.n8nCheckpointStorage.getStorage(agentId)); - } - - // Attachment lookups are agent-scoped, so a synthetic inline id would - // never match a row — inline agents get their file input via workflow - // items instead. - if (runtimeProfile !== 'inline') { - agent.fileStore( - this.agentChatAttachmentService.getFileStore( - { agentId, projectId }, - getProviderPrefix(config.model), - ), + const delegationParams = { + agent, + parentAgentId: parentAgentIdForDelegation, + projectId, + credentialProvider, + runType, + workflowToolExecutionMode, + delegation: subAgentDelegation, + user, + instrumentation, + }; + await this.attachSubAgentDelegationTool({ ...delegationParams, config, parentWorkspaceHandle }); + this.attachWriteTodosTool(agent, agentId); + if (!backgroundTasksEnabled) return; + await this.attachBackgroundJobTools({ + ...delegationParams, + ...(parentWorkspaceHandle !== undefined ? { parentWorkspaceHandle } : {}), + }); + agent.volatileInstructionsProvider(async ({ persistence }) => { + if (!persistence?.threadId) return undefined; + const { AgentWakeService } = await import('./background/agent-wake.service.js'); + return await Container.get(AgentWakeService).getBackgroundUpdates( + persistence.threadId, + persistence.resourceId, ); - } + }); } private async attachSubAgentDelegationTool(params: { diff --git a/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts b/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts index 87c3ea5e191..a2ff5a0f382 100644 --- a/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts +++ b/packages/cli/src/modules/agents/agent-sandbox-runtime.service.ts @@ -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 { - 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' diff --git a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts index ba6e5d384a8..0d9b974965a 100644 --- a/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts +++ b/packages/cli/src/modules/agents/agent-session-langsmith-export.service.ts @@ -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, + 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, + 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, + 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, + 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, + 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, + 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 { return { executionId: execution.id, diff --git a/packages/cli/src/modules/agents/agent-skills.service.ts b/packages/cli/src/modules/agents/agent-skills.service.ts index 22a2a9ef7e6..fce6bb50c39 100644 --- a/packages/cli/src/modules/agents/agent-skills.service.ts +++ b/packages/cli/src/modules/agents/agent-skills.service.ts @@ -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> { - 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 { - 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 { - 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 { + 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; + } } diff --git a/packages/cli/src/modules/agents/agent-task.service.ts b/packages/cli/src/modules/agents/agent-task.service.ts index 87663f9778f..d4a1a34d0bc 100644 --- a/packages/cli/src/modules/agents/agent-task.service.ts +++ b/packages/cli/src/modules/agents/agent-task.service.ts @@ -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 { 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 { 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 { + 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), + }); + }); + } + } } diff --git a/packages/cli/src/modules/agents/agent-tasks.controller.ts b/packages/cli/src/modules/agents/agent-tasks.controller.ts index bcb4d05ae71..8892fda7cd3 100644 --- a/packages/cli/src/modules/agents/agent-tasks.controller.ts +++ b/packages/cli/src/modules/agents/agent-tasks.controller.ts @@ -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 { - 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 { - 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 { 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 { 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 }; } diff --git a/packages/cli/src/modules/agents/agent-telemetry.ts b/packages/cli/src/modules/agents/agent-telemetry.ts index cdb2e4920a3..f8298e95aaf 100644 --- a/packages/cli/src/modules/agents/agent-telemetry.ts +++ b/packages/cli/src/modules/agents/agent-telemetry.ts @@ -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, diff --git a/packages/cli/src/modules/agents/agent-test-run.service.ts b/packages/cli/src/modules/agents/agent-test-run.service.ts index 7137f850ae1..cf513759571 100644 --- a/packages/cli/src/modules/agents/agent-test-run.service.ts +++ b/packages/cli/src/modules/agents/agent-test-run.service.ts @@ -43,6 +43,14 @@ export type PrepareDraftRunResult = | { status: 'session_not_found' } | { status: 'agent_misconfigured'; missing: string[] }; +interface DraftRunState { + response: string; + suspensions: AgentTestRunSuspension[]; + errorChunk?: Extract; + 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 { - let response = initialResponse; - const suspensions: AgentTestRunSuspension[] = []; - let errorChunk: Extract | 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 { + 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 } : {}), + }); + } } } diff --git a/packages/cli/src/modules/agents/agent-turn-execution.service.ts b/packages/cli/src/modules/agents/agent-turn-execution.service.ts index a07092863f2..014295f696a 100644 --- a/packages/cli/src/modules/agents/agent-turn-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-turn-execution.service.ts @@ -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; -type AgentTurnRequest = { recording: StartExecutionParams } & ( +export type AgentTurnRequest = { recording: StartExecutionParams } & ( | { type: 'start'; input: Parameters[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 { return await this.agentExecutionService.getSessionMode(threadId); } async *execute(config: ExecuteTurnConfig): AsyncGenerator { - 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; - 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> { + 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 { + 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> { + 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 { + 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; + } + } } diff --git a/packages/cli/src/modules/agents/agent-update-broadcaster.ts b/packages/cli/src/modules/agents/agent-update-broadcaster.ts index 34ea6bdefe8..5a9b78e254e 100644 --- a/packages/cli/src/modules/agents/agent-update-broadcaster.ts +++ b/packages/cli/src/modules/agents/agent-update-broadcaster.ts @@ -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 { - 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 }); diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index e92fd41cf89..fc13e77fb5d 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -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>[number] | undefined>; -type CustomToolEntries = Record; +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 { + 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 { + 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, + ) + ); + }); + } } diff --git a/packages/cli/src/modules/agents/agent-vector-stores.service.ts b/packages/cli/src/modules/agents/agent-vector-stores.service.ts index 087e3b0fe54..f574f96dd50 100644 --- a/packages/cli/src/modules/agents/agent-vector-stores.service.ts +++ b/packages/cli/src/modules/agents/agent-vector-stores.service.ts @@ -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 { + 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, + backend: BuiltVectorStoreBackend, + ): Promise { + 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 } : {}) }; + } } diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts index bf844409b5d..b85abaa91be 100644 --- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts @@ -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; +} + +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 { - const { - agentInstance, - message, - threadId, - telemetryAgentId, - telemetryUserId, - runType, - outputSchema, - tracing, - recordingParams, - sandboxScope, - } = params; - let structuredOutput: unknown = null; - const toolCalls: ExecuteAgentData['toolCalls'] = []; - const toolInputs = new Map(); - 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 => { - // 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 { + 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 { - 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 { + 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 { + 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 { + 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 }); } /** diff --git a/packages/cli/src/modules/agents/agent-workflow-tool-resume.service.ts b/packages/cli/src/modules/agents/agent-workflow-tool-resume.service.ts index cac179ee017..1f723a87f26 100644 --- a/packages/cli/src/modules/agents/agent-workflow-tool-resume.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-tool-resume.service.ts @@ -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, + ) { + 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; + } + } } diff --git a/packages/cli/src/modules/agents/agents-tools.service.ts b/packages/cli/src/modules/agents/agents-tools.service.ts index 411c6db5310..881fa0296ba 100644 --- a/packages/cli/src/modules/agents/agents-tools.service.ts +++ b/packages/cli/src/modules/agents/agents-tools.service.ts @@ -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, '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) }; diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index d6d622cdb44..e9741f72d48 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -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 { return await this.agentRepository.findByProjectIdsPaginated([projectId], options); } @@ -248,8 +224,12 @@ export class AgentsService { * the full `AgentJsonConfig`. */ async getCapabilitySummary(agentId: string, projectId: string): Promise { - 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((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 = {}; - 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 { 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> { + 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 { + 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((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 { + const taskRefs = entity.schema?.tasks ?? []; + let taskNamesById: Record = {}; + 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 { + 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 { + 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 { 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 { 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; } } diff --git a/packages/cli/src/modules/agents/background/agent-background-job.service.ts b/packages/cli/src/modules/agents/background/agent-background-job.service.ts index 8edaa533552..91d22e57973 100644 --- a/packages/cli/src/modules/agents/background/agent-background-job.service.ts +++ b/packages/cli/src/modules/agents/background/agent-background-job.service.ts @@ -194,14 +194,12 @@ export class AgentBackgroundJobService { async settle(jobId: string, settlement: AgentBackgroundJobSettlement): Promise { 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 { + 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 { + 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; + } + } } diff --git a/packages/cli/src/modules/agents/background/agent-wake.service.ts b/packages/cli/src/modules/agents/background/agent-wake.service.ts index 03187ff8d48..274bfda33d2 100644 --- a/packages/cli/src/modules/agents/background/agent-wake.service.ts +++ b/packages/cli/src/modules/agents/background/agent-wake.service.ts @@ -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 { + 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); + } + } } diff --git a/packages/cli/src/modules/agents/background/background-job-tools.ts b/packages/cli/src/modules/agents/background/background-job-tools.ts index 261cca560a2..df837e6e568 100644 --- a/packages/cli/src/modules/agents/background/background-job-tools.ts +++ b/packages/cli/src/modules/agents/background/background-job-tools.ts @@ -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; - availableSubAgents: Array<{ id: string; name: string; useWhen?: string }>; + availableSubAgents: NonNullable; 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; } /** diff --git a/packages/cli/src/modules/agents/background/sub-agent-background-runner.ts b/packages/cli/src/modules/agents/background/sub-agent-background-runner.ts index da36e24b4ef..1789dbb177e 100644 --- a/packages/cli/src/modules/agents/background/sub-agent-background-runner.ts +++ b/packages/cli/src/modules/agents/background/sub-agent-background-runner.ts @@ -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 { // 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. diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index c769b27d80b..34833cc2226 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -200,6 +200,12 @@ const updateTaskInputSchema = z type UpdateTaskInput = z.infer; +type BuilderConfigFailure = { + ok: false; + stage?: 'parse' | 'stale' | 'patch' | 'schema'; + errors: ConfigValidationError[]; +}; + interface AgentConfigSnapshot { config: AgentJsonConfig | null; configHash: string | null; @@ -370,275 +376,314 @@ export class AgentsBuilderToolsService { ...(options?.runId ? { run_id: options.runId } : {}), ...properties, }); - const readConfigTool = new Tool(BUILDER_TOOLS.READ_CONFIG) + const readConfigTool = this.buildReadConfigTool(agentId, projectId); + const writeConfigTool = this.buildWriteConfigTool(agentId, projectId, user); + const patchConfigTool = this.buildPatchConfigTool(agentId, projectId, user); + const listIntegrationTypesTool = this.buildListIntegrationTypesTool(); + const listSubAgentsTool = this.buildListSubAgentsTool(projectId, agentId); + const publishAgentTool = this.buildPublishAgentTool(user, projectId, agentId); + const unpublishAgentTool = this.buildUnpublishAgentTool(user, projectId, agentId); + const callAgentTool = this.buildCallAgentTool(user, projectId, agentId, credentialProvider); + const modelLookup = this.createModelLookup(user, projectId, options); + const tools: BuiltTool[] = [ + readConfigTool, + this.withConfigMutationMarker(writeConfigTool, agentId), + this.withConfigMutationMarker(patchConfigTool, agentId), + listIntegrationTypesTool, + listSubAgentsTool, + this.withConfigMutationMarker(publishAgentTool, agentId), + this.withConfigMutationMarker(unpublishAgentTool, agentId), + callAgentTool, + this.createLlmTool(credentialProvider, modelLookup, user, projectId), + this.createCredentialTool(credentialService, projectId, agentId, track), + this.createEmbeddingCredentialTool(credentialService, projectId, track), + buildAskQuestionsTool({ track }), + this.withConfigMutationMarker( + this.createConfigureChannelTool(agentId, projectId, track), + agentId, + ), + this.withConfigMutationMarker( + this.createFinishSetupTool(credentialService, agentId, projectId, track), + agentId, + ), + this.createVerifyMcpServerTool(agentId, credentialProvider, projectId, user), + buildSearchMcpServersTool({ mcpRegistryService: this.mcpRegistryService }), + buildResolveIntegrationTool({ + mcpRegistryService: this.mcpRegistryService, + agentsToolsService: this.agentsToolsService, + }), + ]; + + return tools; + } + + private createVerifyMcpServerTool( + agentId: string, + credentialProvider: CredentialProvider, + projectId: string, + user: User, + ): BuiltTool { + return buildVerifyMcpServerTool({ + agentId, + credentialProvider, + oauthService: this.oauthService, + projectId, + proxyFetch: createAiMcpFetch(this.outboundHttp), + resolveRegistryConnection: async (nodeTypeName) => + await this.mcpRegistryService.getConnection(nodeTypeName), + applyCredentialToMcpServer: async (serverName, credentialId) => + await this.applyCredentialToMcpServer(agentId, projectId, serverName, credentialId, user), + }); + } + + private createFinishSetupTool( + credentialService: InstanceAiCredentialService, + agentId: string, + projectId: string, + track: BuilderTrackFn, + ): BuiltTool { + return buildFinishSetupTool({ + credentialService, + agentId, + projectId, + track, + isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), + listIntegrationCredentialIds: async () => { + const agent = await this.agentsService.findById(agentId, projectId); + return (agent?.integrations ?? []) + .filter((integration) => !isDraftIntegration(integration)) + .map((integration) => integration.credentialId); + }, + listChatIntegrationTypes: () => + this.agentIntegrationPersistenceService + .listChatIntegrations() + .map((integration) => integration.type), + listAiGatewayManagedCredentialTypes: async () => { + const agent = await this.agentsService.findById(agentId, projectId); + return listAiGatewayManagedCredentialTypes(agent?.schema?.tools, this.nodeTypes); + }, + }); + } + + private createConfigureChannelTool( + agentId: string, + projectId: string, + track: BuilderTrackFn, + ): BuiltTool { + return buildConfigureChannelTool({ + agentId, + projectId, + listChatIntegrationTypes: () => + this.agentIntegrationPersistenceService + .listChatIntegrations() + .map((integration) => integration.type), + track, + }); + } + + private createEmbeddingCredentialTool( + credentialService: InstanceAiCredentialService, + projectId: string, + track: BuilderTrackFn, + ): BuiltTool { + return buildAskEmbeddingCredentialTool({ + credentialService, + projectId, + isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), + isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(), + track, + }); + } + + private createCredentialTool( + credentialService: InstanceAiCredentialService, + projectId: string, + agentId: string, + track: BuilderTrackFn, + ): BuiltTool { + return buildAskCredentialTool({ + credentialService, + projectId, + isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), + listIntegrationCredentialIds: async () => { + const agent = await this.agentsService.findById(agentId, projectId); + return (agent?.integrations ?? []) + .filter((integration) => !isDraftIntegration(integration)) + .map((integration) => integration.credentialId); + }, + track, + }); + } + + private createLlmTool( + credentialProvider: CredentialProvider, + modelLookup: ModelLookup, + user: User, + projectId: string, + ): BuiltTool { + return buildResolveLlmTool({ + credentialProvider, + modelLookup, + isProviderServedByGateway: async (provider) => { + try { + return (await this.aiGatewayService.getCredentialTypeForProvider(provider)) !== undefined; + } catch { + return false; + } + }, + freeCredits: { + isEligible: () => this.freeAiCreditsService.isEligible(user), + claim: async () => { + const credential = await this.freeAiCreditsService.claim(user, projectId); + this.telemetry.track('User claimed OpenAI credits', { + user_id: user.id, + source: 'agentBuilderResolveLlm', + }); + return { credentialId: credential.id, credentialName: credential.name }; + }, + }, + }); + } + + private buildCallAgentTool( + user: User, + projectId: string, + agentId: string, + credentialProvider: CredentialProvider, + ) { + return new Tool(BUILDER_TOOLS.CALL_AGENT) .description( - 'Read the latest persisted agent configuration and its freshness token. ' + - 'Returns { ok: true, config, configHash }. This is the only tool that returns the full config — ' + - 'write_config, patch_config, and stale responses never echo it back. ' + - 'Call this before every write_config or patch_config and use configHash as baseConfigHash.', + 'Tests the draft agent through built-in Preview chat. It does not test configured channel integrations, including their triggers, platform context, message delivery, or replies. ' + + 'Pass the returned sessionId on later calls to continue the same conversation; omit it to start a new one. ' + + 'The draft uses its real configured tools and credentials, so external side effects are possible. ' + + 'Standard tool approvals pause this test until the user approves or rejects them in chat. ' + + 'Unsupported interactive requests return approval_required with a Preview path.', + ) + .input( + z.object({ + message: z.string().trim().min(1).describe('Message to send to the target agent'), + sessionId: z + .string() + .trim() + .min(1) + .optional() + .describe('Session ID from a previous call_agent result'), + }), + ) + .suspend(APPROVAL_SUSPEND_SCHEMA) + .resume(APPROVAL_RESUME_SCHEMA) + .handler( + async ( + { message, sessionId }: { message: string; sessionId?: string }, + ctx: InterruptibleToolContext, + ) => { + if (!(await userHasScopes(user, ['agent:execute'], false, { projectId }))) { + return { + status: 'error', + code: 'forbidden', + message: 'You do not have permission to run agents in this project.', + }; + } + + const previewPath = buildAgentPreviewPath(projectId, agentId); + try { + const result = await this.runDraftTest( + ctx, + agentId, + projectId, + message, + sessionId, + credentialProvider, + user, + ); + + return await this.formatDraftTestResult(result, ctx, agentId, user, previewPath); + } catch (error) { + if (ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error)) throw error; + if (error instanceof InvalidAgentTestRunCheckpointError) { + return { + status: 'error', + code: error.code, + message: error.message, + }; + } + const { code, message } = describeCallAgentFailure( + error instanceof Error ? error.message : 'Agent test run failed.', + ); + return { status: 'error', code, message }; + } + }, + ) + .build(); + } + + private async runDraftTest( + ctx: InterruptibleToolContext, + agentId: string, + projectId: string, + message: string, + sessionId: string | undefined, + credentialProvider: CredentialProvider, + user: User, + ): Promise { + let result: AgentTestRunResult; + if (ctx.resumeData === undefined) { + result = await this.agentTestRunService.executeDraftRun({ + agentId, + projectId, + message, + sessionId, + credentialProvider, + user, + source: 'instance-ai', + ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}), + }); + } else { + result = await this.agentTestRunService.resumeDraftApproval({ + agentId, + projectId, + continuation: ctx.continuation, + approved: ctx.resumeData.approved, + user, + source: 'instance-ai', + ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}), + }); + } + return result; + } + + private buildUnpublishAgentTool(user: User, projectId: string, agentId: string) { + return new Tool(BUILDER_TOOLS.UNPUBLISH_AGENT) + .description( + 'Unpublish this target agent: clears the live version while preserving the draft, disconnects chat ' + + 'integrations, and stops scheduled tasks. Call when the user asks to unpublish or take the agent offline. ' + + 'Returns { ok: true, configMutated: true, agentId, activeVersionId: null } or { ok: false, errors }.', ) .input(z.object({})) .handler(async () => { + if (!(await userHasScopes(user, ['agent:unpublish'], false, { projectId }))) { + return { + ok: false, + errors: [ + { message: 'You do not have permission to unpublish agents in this project.' }, + ], + }; + } try { - const snapshot = await this.getConfigSnapshot(agentId, projectId); - return { ok: true, ...snapshot }; + await this.agentPublishService.unpublishAgent(agentId, projectId, user, 'builder'); + return { ok: true, agentId, activeVersionId: null }; } catch (e) { return { ok: false, - errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], + errors: [{ message: e instanceof Error ? e.message : String(e) }], }; } }) .build(); + } - const writeConfigTool = new Tool(BUILDER_TOOLS.WRITE_CONFIG) - .description( - 'Create or replace the agent configuration by writing a complete JSON string. ' + - 'Requires baseConfigHash from the immediately preceding read_config result — never from a prior ' + - 'write_config/patch_config success or from a stale response. ' + - 'Returns { ok: true, configMutated: true, agentId } on success — no config, hash, or timestamps are returned; call ' + - 'read_config again before any later inspection or mutation — or ' + - '{ ok: false, stage, errors } with path, message, expected, received fields on failure. ' + - 'On stage: "stale", call read_config and retry once using its fresh config and configHash.', - ) - .input( - z.object({ - json: z.string().describe('Complete agent configuration as a JSON string'), - baseConfigHash: z - .string() - .nullable() - .describe( - 'configHash from the immediately preceding read_config result; null only if no config exists', - ), - }), - ) - .handler( - async ({ json, baseConfigHash }: { json: string; baseConfigHash: string | null }) => { - const parsed = tryParseConfigJson(json); - if (!parsed.ok) { - return { ok: false, errors: parsed.errors }; - } - let snapshot: AgentConfigSnapshot; - try { - snapshot = await this.getConfigSnapshot(agentId, projectId); - } catch (e) { - return { - ok: false, - stage: 'stale', - errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], - }; - } - if (baseConfigHash !== snapshot.configHash) { - return { ok: false, stage: 'stale', errors: [STALE_CONFIG_ERROR] }; - } - const zodResult = parseBuilderWriteConfig(parsed.data, snapshot.config); - if (!zodResult.success) { - return { ok: false, errors: formatZodErrors(zodResult.error) }; - } - const emptyInstructions = rejectIfEmptyInstructions( - zodResult.data, - CLI_AGENT_CONFIG_MESSAGES, - ); - if (emptyInstructions) { - return { ok: false, errors: emptyInstructions }; - } - const unsupportedNativeWebSearch = rejectIfUnsupportedNativeWebSearch(zodResult.data); - if (unsupportedNativeWebSearch) { - return { ok: false, errors: unsupportedNativeWebSearch }; - } - const dynamicSelectorFromAi = rejectIfDynamicSelectorUsesFromAi( - zodResult.data, - snapshot.config, - this.nodeTypes, - CLI_AGENT_CONFIG_MESSAGES, - ); - if (dynamicSelectorFromAi) { - return { ok: false, errors: dynamicSelectorFromAi }; - } - // Seed the builder's "native model gets web search by default" ergonomic - // as an explicit flag; updateConfig owns the actual provider-tool - // reconciliation so the write and read paths can't disagree. - const configWithDefaults = applyPromptCachingBuilderDefaults( - applyNativeWebSearchDefaultOn(zodResult.data), - ); - try { - await this.agentConfigService.updateConfig( - agentId, - projectId, - configWithDefaults, - user, - { baseConfigHash: snapshot.configHash, modifiedBy: 'builder' }, - ); - return { ok: true }; - } catch (e) { - return { - ok: false, - stage: e instanceof ConflictError ? 'stale' : 'schema', - errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], - }; - } - }, - ) - .build(); - - const patchConfigTool = new Tool(BUILDER_TOOLS.PATCH_CONFIG) - .description( - 'Apply RFC 6902 JSON Patch operations to the current agent configuration. ' + - 'Pass an array of patch operations as a JSON string. ' + - 'Requires baseConfigHash from the immediately preceding read_config result — never from a prior ' + - 'write_config/patch_config success or from a stale response. ' + - 'Supported ops: add, remove, replace, move, copy, test. ' + - 'Returns { ok: true, configMutated: true, agentId } on success — no config, hash, or timestamps are returned; call ' + - 'read_config again before any later inspection or mutation — or ' + - '{ ok: false, stage, errors } on failure. ' + - 'stage is "parse", "stale", "patch", or "schema". On stage: "stale", call read_config and retry ' + - 'once using its fresh config and configHash.', - ) - .input( - z.object({ - operations: z.string().describe('RFC 6902 JSON Patch operations array as a JSON string'), - baseConfigHash: z - .string() - .nullable() - .describe( - 'configHash from the immediately preceding read_config result; null only if no config exists', - ), - }), - ) - .handler( - async ({ - operations, - baseConfigHash, - }: { - operations: string; - baseConfigHash: string | null; - }) => { - const parsedOps = tryParseConfigJson(operations); - if (!parsedOps.ok) { - return { ok: false, stage: 'parse', errors: parsedOps.errors }; - } - - let snapshot: AgentConfigSnapshot; - try { - snapshot = await this.getConfigSnapshot(agentId, projectId); - } catch (e) { - return { - ok: false, - stage: 'stale', - errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], - }; - } - if (baseConfigHash !== snapshot.configHash) { - return { ok: false, stage: 'stale', errors: [STALE_CONFIG_ERROR] }; - } - if (!snapshot.config) { - return { - ok: false, - stage: 'patch', - errors: [{ path: '(root)', message: 'Agent has no JSON config yet.' }], - }; - } - - const jsonpatch = (await import('fast-json-patch')).default; - const ops = parsedOps.data as Operation[]; - const patchError = jsonpatch.validate(ops, snapshot.config); - if (patchError) { - const opPath = - (patchError.operation as { path?: string } | undefined)?.path ?? '(root)'; - return { - ok: false, - stage: 'patch', - errors: [{ path: opPath, message: patchError.message ?? 'Invalid patch operation' }], - }; - } - - const patched = jsonpatch.applyPatch(jsonpatch.deepClone(snapshot.config), ops) - .newDocument as unknown as AgentJsonConfig; - - const zodResult = parseBuilderWriteConfig(patched, snapshot.config); - if (!zodResult.success) { - return { ok: false, stage: 'schema', errors: formatZodErrors(zodResult.error) }; - } - const emptyInstructions = rejectIfEmptyInstructions( - zodResult.data, - CLI_AGENT_CONFIG_MESSAGES, - ); - if (emptyInstructions) { - return { ok: false, stage: 'schema', errors: emptyInstructions }; - } - const unsupportedNativeWebSearch = rejectIfUnsupportedNativeWebSearch(zodResult.data); - if (unsupportedNativeWebSearch) { - return { ok: false, stage: 'schema', errors: unsupportedNativeWebSearch }; - } - const dynamicSelectorFromAi = rejectIfDynamicSelectorUsesFromAi( - zodResult.data, - snapshot.config, - this.nodeTypes, - CLI_AGENT_CONFIG_MESSAGES, - ); - if (dynamicSelectorFromAi) { - return { ok: false, stage: 'schema', errors: dynamicSelectorFromAi }; - } - const configWithDefaults = applyPromptCachingBuilderDefaults( - applyNativeWebSearchDefaultOn(zodResult.data), - ); - - try { - await this.agentConfigService.updateConfig( - agentId, - projectId, - configWithDefaults, - user, - { baseConfigHash: snapshot.configHash, modifiedBy: 'builder' }, - ); - return { ok: true }; - } catch (e) { - return { - ok: false, - stage: e instanceof ConflictError ? 'stale' : 'schema', - errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], - }; - } - }, - ) - .build(); - - const listIntegrationTypesTool = new Tool(BUILDER_TOOLS.LIST_INTEGRATION_TYPES) - .description( - "List integration types that can be added to the agent's `integrations` array. " + - 'Returns every available chat platform with the list of ' + - 'credential types it supports (`credentialTypes: string[]`) and builder guidance ' + - '(`capabilities`, `useIntegrationWhen`, `useNodeToolWhen`). ' + - 'Use that guidance to decide whether the user needs a chat integration or a node tool. ' + - 'For a chat integration, pass the selected integration `type` to `configure_channel`; ' + - 'never use `ask_credential` for chat-channel credentials.', - ) - .input(z.object({})) - .handler(async () => this.agentIntegrationPersistenceService.listChatIntegrations()) - .build(); - - const listSubAgentsTool = new Tool(BUILDER_TOOLS.LIST_SUB_AGENTS) - .description( - 'List agents in the same project that can be added to the target agent as subagents. ' + - 'Excludes the target agent itself. Use before asking the user which subagents to add. ' + - 'Returned `agentId` values are the only valid values to write into `subAgents.agents[].agentId`; ' + - 'write parent-owned routing guidance into `subAgents.agents[].useWhen`; ask a follow-up first when it is unclear when that parent should use the subagent.', - ) - .input(z.object({})) - .handler(async () => { - const agents = await this.agentsService.findByProjectId(projectId); - return { - agents: agents - .filter((agent) => agent.id !== agentId) - .map((agent) => ({ - agentId: agent.id, - name: agent.name, - })), - }; - }) - .build(); - - const publishAgentTool = new Tool(BUILDER_TOOLS.PUBLISH_AGENT) + private buildPublishAgentTool(user: User, projectId: string, agentId: string) { + return new Tool(BUILDER_TOOLS.PUBLISH_AGENT) .description( 'Publish this target agent so it becomes live: integrations sync and scheduled tasks start running. ' + 'Idempotent when the draft is already the active published version. Pass optional `versionId` to ' + @@ -686,288 +731,132 @@ export class AgentsBuilderToolsService { } }) .build(); + } - const unpublishAgentTool = new Tool(BUILDER_TOOLS.UNPUBLISH_AGENT) + private buildListSubAgentsTool(projectId: string, agentId: string) { + return new Tool(BUILDER_TOOLS.LIST_SUB_AGENTS) .description( - 'Unpublish this target agent: clears the live version while preserving the draft, disconnects chat ' + - 'integrations, and stops scheduled tasks. Call when the user asks to unpublish or take the agent offline. ' + - 'Returns { ok: true, configMutated: true, agentId, activeVersionId: null } or { ok: false, errors }.', + 'List agents in the same project that can be added to the target agent as subagents. ' + + 'Excludes the target agent itself. Use before asking the user which subagents to add. ' + + 'Returned `agentId` values are the only valid values to write into `subAgents.agents[].agentId`; ' + + 'write parent-owned routing guidance into `subAgents.agents[].useWhen`; ask a follow-up first when it is unclear when that parent should use the subagent.', + ) + .input(z.object({})) + .handler(async () => { + const agents = await this.agentsService.findByProjectId(projectId); + return { + agents: agents + .filter((agent) => agent.id !== agentId) + .map((agent) => ({ + agentId: agent.id, + name: agent.name, + })), + }; + }) + .build(); + } + + private buildListIntegrationTypesTool() { + return new Tool(BUILDER_TOOLS.LIST_INTEGRATION_TYPES) + .description( + "List integration types that can be added to the agent's `integrations` array. " + + 'Returns every available chat platform with the list of ' + + 'credential types it supports (`credentialTypes: string[]`) and builder guidance ' + + '(`capabilities`, `useIntegrationWhen`, `useNodeToolWhen`). ' + + 'Use that guidance to decide whether the user needs a chat integration or a node tool. ' + + 'For a chat integration, pass the selected integration `type` to `configure_channel`; ' + + 'never use `ask_credential` for chat-channel credentials.', + ) + .input(z.object({})) + .handler(async () => this.agentIntegrationPersistenceService.listChatIntegrations()) + .build(); + } + + private buildPatchConfigTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.PATCH_CONFIG) + .description( + 'Apply RFC 6902 JSON Patch operations to the current agent configuration. ' + + 'Pass an array of patch operations as a JSON string. ' + + 'Requires baseConfigHash from the immediately preceding read_config result — never from a prior ' + + 'write_config/patch_config success or from a stale response. ' + + 'Supported ops: add, remove, replace, move, copy, test. ' + + 'Returns { ok: true, configMutated: true, agentId } on success — no config, hash, or timestamps are returned; call ' + + 'read_config again before any later inspection or mutation — or ' + + '{ ok: false, stage, errors } on failure. ' + + 'stage is "parse", "stale", "patch", or "schema". On stage: "stale", call read_config and retry ' + + 'once using its fresh config and configHash.', + ) + .input( + z.object({ + operations: z.string().describe('RFC 6902 JSON Patch operations array as a JSON string'), + baseConfigHash: z + .string() + .nullable() + .describe( + 'configHash from the immediately preceding read_config result; null only if no config exists', + ), + }), + ) + .handler( + async ({ + operations, + baseConfigHash, + }: { operations: string; baseConfigHash: string | null }) => + await this.patchBuilderConfig(agentId, projectId, user, operations, baseConfigHash), + ) + .build(); + } + + private buildWriteConfigTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.WRITE_CONFIG) + .description( + 'Create or replace the agent configuration by writing a complete JSON string. ' + + 'Requires baseConfigHash from the immediately preceding read_config result — never from a prior ' + + 'write_config/patch_config success or from a stale response. ' + + 'Returns { ok: true, configMutated: true, agentId } on success — no config, hash, or timestamps are returned; call ' + + 'read_config again before any later inspection or mutation — or ' + + '{ ok: false, stage, errors } with path, message, expected, received fields on failure. ' + + 'On stage: "stale", call read_config and retry once using its fresh config and configHash.', + ) + .input( + z.object({ + json: z.string().describe('Complete agent configuration as a JSON string'), + baseConfigHash: z + .string() + .nullable() + .describe( + 'configHash from the immediately preceding read_config result; null only if no config exists', + ), + }), + ) + .handler( + async ({ json, baseConfigHash }: { json: string; baseConfigHash: string | null }) => + await this.writeBuilderConfig(agentId, projectId, user, json, baseConfigHash), + ) + .build(); + } + + private buildReadConfigTool(agentId: string, projectId: string) { + return new Tool(BUILDER_TOOLS.READ_CONFIG) + .description( + 'Read the latest persisted agent configuration and its freshness token. ' + + 'Returns { ok: true, config, configHash }. This is the only tool that returns the full config — ' + + 'write_config, patch_config, and stale responses never echo it back. ' + + 'Call this before every write_config or patch_config and use configHash as baseConfigHash.', ) .input(z.object({})) .handler(async () => { - if (!(await userHasScopes(user, ['agent:unpublish'], false, { projectId }))) { - return { - ok: false, - errors: [ - { message: 'You do not have permission to unpublish agents in this project.' }, - ], - }; - } try { - await this.agentPublishService.unpublishAgent(agentId, projectId, user, 'builder'); - return { ok: true, agentId, activeVersionId: null }; + const snapshot = await this.getConfigSnapshot(agentId, projectId); + return { ok: true, ...snapshot }; } catch (e) { return { ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], + errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], }; } }) .build(); - - const callAgentTool = new Tool(BUILDER_TOOLS.CALL_AGENT) - .description( - 'Tests the draft agent through built-in Preview chat. It does not test configured channel integrations, including their triggers, platform context, message delivery, or replies. ' + - 'Pass the returned sessionId on later calls to continue the same conversation; omit it to start a new one. ' + - 'The draft uses its real configured tools and credentials, so external side effects are possible. ' + - 'Standard tool approvals pause this test until the user approves or rejects them in chat. ' + - 'Unsupported interactive requests return approval_required with a Preview path.', - ) - .input( - z.object({ - message: z.string().trim().min(1).describe('Message to send to the target agent'), - sessionId: z - .string() - .trim() - .min(1) - .optional() - .describe('Session ID from a previous call_agent result'), - }), - ) - .suspend(APPROVAL_SUSPEND_SCHEMA) - .resume(APPROVAL_RESUME_SCHEMA) - .handler( - async ( - { message, sessionId }: { message: string; sessionId?: string }, - ctx: InterruptibleToolContext, - ) => { - if (!(await userHasScopes(user, ['agent:execute'], false, { projectId }))) { - return { - status: 'error', - code: 'forbidden', - message: 'You do not have permission to run agents in this project.', - }; - } - - const previewPath = buildAgentPreviewPath(projectId, agentId); - try { - let result: AgentTestRunResult; - if (ctx.resumeData === undefined) { - result = await this.agentTestRunService.executeDraftRun({ - agentId, - projectId, - message, - sessionId, - credentialProvider, - user, - source: 'instance-ai', - ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}), - }); - } else { - result = await this.agentTestRunService.resumeDraftApproval({ - agentId, - projectId, - continuation: ctx.continuation, - approved: ctx.resumeData.approved, - user, - source: 'instance-ai', - ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}), - }); - } - - if (result.status === 'session_not_found') { - return { - status: 'error', - code: 'session_not_found', - message: 'Session not found.', - }; - } - if (result.status === 'agent_misconfigured') { - return { - status: 'error', - code: 'agent_misconfigured', - message: "This agent isn't ready to run yet. Finish configuring it and try again.", - missing: result.missing, - }; - } - if (result.status === 'completed') return result; - - const approvals = collectStandardApprovals(result); - const firstApproval = approvals?.[0]; - if (firstApproval) { - const { continuation, ...approval } = firstApproval; - return await ctx.suspend(approval, { continuation }); - } - - const cancelled = await this.agentTestRunService.cancelSuspendedRuns({ - agentId, - suspensions: result.suspensions, - userId: user.id, - }); - if (!cancelled) { - return { - status: 'error', - code: 'cancellation_failed', - message: - 'This test needs approval, but its suspended run could not be cancelled. Open Preview before continuing this session.', - sessionId: result.sessionId, - previewPath, - }; - } - - return { - status: 'approval_required', - response: result.response, - sessionId: result.sessionId, - ...(result.executionId ? { executionId: result.executionId } : {}), - suspensions: result.suspensions.map(({ runId, toolCallId, toolName }) => ({ - runId, - toolCallId, - toolName, - })), - previewPath, - }; - } catch (error) { - if (ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error)) throw error; - if (error instanceof InvalidAgentTestRunCheckpointError) { - return { - status: 'error', - code: error.code, - message: error.message, - }; - } - const { code, message } = describeCallAgentFailure( - error instanceof Error ? error.message : 'Agent test run failed.', - ); - return { status: 'error', code, message }; - } - }, - ) - .build(); - - const modelLookup: ModelLookup = { - // `list` resolves the n8n Connect managed tag to the synthetic gateway - // credential internally, so no managed branch is needed here. - list: async (credentialId, credentialType, provider) => - await this.builderModelLiveLookupService.list( - user, - projectId, - credentialId, - credentialType, - provider, - { useEvalModelCatalog: options?.useEvalModelCatalog === true }, - ), - }; - - const tools: BuiltTool[] = [ - readConfigTool, - this.withConfigMutationMarker(writeConfigTool, agentId), - this.withConfigMutationMarker(patchConfigTool, agentId), - listIntegrationTypesTool, - listSubAgentsTool, - this.withConfigMutationMarker(publishAgentTool, agentId), - this.withConfigMutationMarker(unpublishAgentTool, agentId), - callAgentTool, - buildResolveLlmTool({ - credentialProvider, - modelLookup, - isProviderServedByGateway: async (provider) => { - try { - return ( - (await this.aiGatewayService.getCredentialTypeForProvider(provider)) !== undefined - ); - } catch { - return false; - } - }, - freeCredits: { - isEligible: () => this.freeAiCreditsService.isEligible(user), - claim: async () => { - const credential = await this.freeAiCreditsService.claim(user, projectId); - this.telemetry.track('User claimed OpenAI credits', { - user_id: user.id, - source: 'agentBuilderResolveLlm', - }); - return { credentialId: credential.id, credentialName: credential.name }; - }, - }, - }), - buildAskCredentialTool({ - credentialService, - projectId, - isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), - listIntegrationCredentialIds: async () => { - const agent = await this.agentsService.findById(agentId, projectId); - return (agent?.integrations ?? []) - .filter((integration) => !isDraftIntegration(integration)) - .map((integration) => integration.credentialId); - }, - track, - }), - buildAskEmbeddingCredentialTool({ - credentialService, - projectId, - isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), - isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(), - track, - }), - buildAskQuestionsTool({ track }), - this.withConfigMutationMarker( - buildConfigureChannelTool({ - agentId, - projectId, - listChatIntegrationTypes: () => - this.agentIntegrationPersistenceService - .listChatIntegrations() - .map((integration) => integration.type), - track, - }), - agentId, - ), - this.withConfigMutationMarker( - buildFinishSetupTool({ - credentialService, - agentId, - projectId, - track, - isCredentialTypeKnown: (credentialType) => - this.credentialTypes.recognizes(credentialType), - listIntegrationCredentialIds: async () => { - const agent = await this.agentsService.findById(agentId, projectId); - return (agent?.integrations ?? []) - .filter((integration) => !isDraftIntegration(integration)) - .map((integration) => integration.credentialId); - }, - listChatIntegrationTypes: () => - this.agentIntegrationPersistenceService - .listChatIntegrations() - .map((integration) => integration.type), - listAiGatewayManagedCredentialTypes: async () => { - const agent = await this.agentsService.findById(agentId, projectId); - return listAiGatewayManagedCredentialTypes(agent?.schema?.tools, this.nodeTypes); - }, - }), - agentId, - ), - buildVerifyMcpServerTool({ - agentId, - credentialProvider, - oauthService: this.oauthService, - projectId, - proxyFetch: createAiMcpFetch(this.outboundHttp), - resolveRegistryConnection: async (nodeTypeName) => - await this.mcpRegistryService.getConnection(nodeTypeName), - applyCredentialToMcpServer: async (serverName, credentialId) => - await this.applyCredentialToMcpServer(agentId, projectId, serverName, credentialId, user), - }), - buildSearchMcpServersTool({ mcpRegistryService: this.mcpRegistryService }), - buildResolveIntegrationTool({ - mcpRegistryService: this.mcpRegistryService, - agentsToolsService: this.agentsToolsService, - }), - ]; - - return tools; } private getSharedTools( @@ -976,284 +865,68 @@ export class AgentsBuilderToolsService { credentialProvider: CredentialProvider, user: User, ): BuiltTool[] { - const buildCustomToolTool = new Tool(BUILDER_TOOLS.BUILD_CUSTOM_TOOL) + const buildCustomToolTool = this.buildCustomToolTool(agentId, projectId, user); + const createSkillsTool = this.buildCreateSkillsTool(agentId, projectId, user); + const readSkillTool = this.buildReadSkillTool(agentId, projectId); + const listSkillsTool = this.buildListSkillsTool(agentId, projectId); + const updateSkillTool = this.buildUpdateSkillTool(agentId, projectId, user); + const listTasksTool = this.buildListTasksTool(agentId, projectId); + const updateTaskTool = this.buildUpdateTaskTool(agentId, projectId, user); + const createTasksTool = this.buildCreateTasksTool(agentId, projectId, user); + const listWorkflowsTool = this.buildListWorkflowsTool(user, projectId); + + return [ + buildCustomToolTool, + createSkillsTool, + listSkillsTool, + readSkillTool, + this.withConfigMutationMarker(updateSkillTool, agentId), + this.withConfigMutationMarker(createTasksTool, agentId), + listTasksTool, + this.withConfigMutationMarker(updateTaskTool, agentId), + listWorkflowsTool, + buildGetResourceLocatorOptionsTool({ + dynamicNodeParametersService: this.dynamicNodeParametersService, + nodeTypes: this.nodeTypes, + user, + projectId, + }), + ...this.agentsToolsService.getSharedTools( + credentialProvider, + 'Read-only inspection of available credentials. Use ask_credential to let the user ' + + 'pick the credential to wire into a node tool — never copy ids from this list directly ' + + 'into the config.', + ), + ]; + } + + private buildListWorkflowsTool(user: User, projectId: string) { + return new Tool(BUILDER_TOOLS.LIST_WORKFLOWS) .description( - 'Compile and store a custom tool. Pass the complete TypeScript source ' + - 'using `export default new Tool(...)` builder chain. The code is validated in a ' + - 'sandbox and saved against the agent. The returned `id` equals the tool name ' + - 'declared in the code (e.g. `new Tool("my_tool")` → id `"my_tool"`). ' + - 'This does NOT register the tool in the agent config — follow up with ' + - 'patch_config (or write_config) to add `{ type: "custom", id: "" }` ' + - 'to `tools`.' + - 'Returns { ok: true, id, name } or { ok: false, errors }.', + 'List the n8n workflows that can be attached as tools via `type: "workflow"` in the agent config. ' + + `Only returns workflows that start with a '${WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME}' trigger. ` + + 'The published agent cannot call a workflow with `published: false` until the user publishes it. ' + + 'Pass `searchTerm` to narrow by workflow name; ' + + 'omitting it returns the 10 most recently updated attachable workflows.', ) .input( z.object({ - code: z + searchTerm: z .string() - .describe('Complete TypeScript source using export default new Tool(...)'), + .optional() + .describe('Optional workflow-name search term. Omit to return the first 10 results.'), }), ) - .handler(async ({ code }: { code: string }, ctx) => { - try { - const descriptor = await this.secureRuntime.describeToolSecurely(code); - const built = await this.agentCustomToolsService.buildCustomTool( - agentId, - projectId, - code, - descriptor, - { user, modifiedBy: 'builder' }, - ); - return { ok: true, id: built.id, name: descriptor.name }; - } catch (e) { - // Unlike its sibling handlers, this one runs long isolate work, so an - // abort can land mid-call and must not be reported as a build error. - // When a signal is present it is the authority: the isolate compiles - // model-authored code, so a generated tool throwing `Aborted` must not - // be mistaken for a cancellation and kill the whole builder run. - if (ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(e)) throw e; - return { - ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], - }; - } - }) - .build(); - - const createSkillsTool = new Tool(BUILDER_TOOLS.CREATE_SKILLS) - .description( - 'Create and store one or more agent skills (reusable, load-on-demand capabilities) in a ' + - 'single call. Pass every skill you currently know how to write in one `skills` array — do ' + - "not spread multiple fully-specified skills across separate calls; each skill's instructions " + - 'field carries its own structured template. The whole batch is all-or-nothing: an invalid or ' + - 'duplicate-named skill rejects every skill in the call. This does NOT attach the skills to the ' + - 'agent config; follow up with read_config and patch_config (or write_config) to add a ' + - '`{ type: "skill", id }` entry per skill to `skills`. Returns { ok: true, skills: [{ id, name }, ' + - '...] } (same order as input, bodies are not echoed back) or { ok: false, errors }.', - ) - .systemInstruction( - 'Never create a vague or placeholder skill. The description field is the routing contract the ' + - 'runtime uses to decide when to load the skill; the instructions must follow the required ' + - 'structured Markdown template (Overview, Inputs, Steps, Rules, Example, Gotchas) with each ' + - 'applicable section filled in with concrete, specific content. If you do not have enough domain ' + - "detail to write a genuinely useful skill, derive it from the user's goal as stated assumptions " + - 'listed in your summary; ask the user clarifying questions only when even a reasonable ' + - 'assumption is impossible. Use allowedTools only with exact target-agent tool names. Use references ' + - 'only for markdown supporting files under the references/ directory — references are not ' + - 'automatically loaded, so instructions must say exactly when to load each one by path; scripts and ' + - 'non-markdown linked files are not supported. Do not invent tool names or reference paths. Batch ' + - 'every skill you currently know how to write into one call.', - ) - .input( - z.object({ - skills: z - .array(createSkillInputSchema) - .min(1) - .max(20) - .describe('Every skill to create, in the order they should be created.'), - }), - ) - .handler(async ({ skills }: { skills: CreateSkillInput[] }) => { - // Each skill is already validated against `.input()` (agentSkillSchema - // shapes) by the tool runtime before the handler runs. - try { - const created = await this.agentSkillsService.createSkills(agentId, projectId, skills, { - user, - modifiedBy: 'builder', - }); - return { - ok: true, - skills: created.map(({ id, skill }) => ({ id, name: skill.name })), - }; - } catch (e) { - return { - ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], - }; - } - }) - .build(); - - const readSkillTool = new Tool(BUILDER_TOOLS.READ_SKILL) - .description( - 'Read an existing target-agent skill by id. The response includes its instructions, but ' + - 'references are returned as { path, characterCount } metadata by default to keep context small. ' + - 'Pass only the referencePaths whose content you need. Returns { ok: true, id, skill, skillHash } or ' + - '{ ok: false, errors }. Call this before every update_skill and use skillHash as baseSkillHash.', - ) - .input(readSkillInputSchema) - .handler(async ({ skillId, referencePaths = [] }: ReadSkillInput) => { - try { - const skill = await this.agentSkillsService.getSkill(agentId, projectId, skillId); - const skillHash = getAgentSkillHash(skill); - const { references, ...body } = skill; - const requestedPaths = new Set(referencePaths); - const knownPaths = new Set(references?.map((reference) => reference.path) ?? []); - const missingPaths = referencePaths.filter((path) => !knownPaths.has(path)); - if (missingPaths.length > 0) { - return { - ok: false, - errors: [ - { - message: `Reference path${missingPaths.length === 1 ? '' : 's'} not found: ${missingPaths.join(', ')}`, - }, - ], - }; - } - - return { - ok: true, - id: skillId, - skillHash, - skill: { - ...body, - ...(references - ? { - references: references.map((reference) => ({ - path: reference.path, - characterCount: reference.content.length, - ...(requestedPaths.has(reference.path) ? { content: reference.content } : {}), - })), - } - : {}), - }, - }; - } catch (e) { - return { - ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], - }; - } - }) - .build(); - - const listSkillsTool = new Tool(BUILDER_TOOLS.LIST_SKILLS) - .description( - 'List lightweight metadata for persisted target-agent skills. Use this to identify which ' + - 'existing skill owns a capability before reading or creating a skill. Returns ' + - '{ ok: true, skills: [{ id, name, description }] } or { ok: false, errors }.', - ) - .input(z.object({}).strict()) - .handler(async () => { - try { - const skills = await this.agentSkillsService.listSkills(agentId, projectId); - return { - ok: true, - skills: Object.entries(skills).map(([id, skill]) => ({ - id, - name: skill.name, - description: skill.description, - })), - }; - } catch (e) { - return { - ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], - }; - } - }) - .build(); - - const updateSkillTool = new Tool(BUILDER_TOOLS.UPDATE_SKILL) - .description( - 'Update selected fields of an existing target-agent skill in place, preserving its id and ' + - 'agent config reference. Requires baseSkillHash from the immediately preceding read_skill ' + - 'result. Pass null for allowedTools to remove the tool restriction, or null ' + - 'for references to remove all references; empty arrays are invalid. Returns ' + - '{ ok: true, id, name, configMutated: true, agentId } or { ok: false, errors }. On a stale ' + - 'skill error, call read_skill and retry once with its fresh skillHash.', - ) - .input(updateSkillInputSchema) - .handler(async ({ skillId, baseSkillHash, updates }: UpdateSkillInput) => { - const { allowedTools, references, ...requiredUpdates } = updates; - const normalizedUpdates = { - ...requiredUpdates, - ...(allowedTools !== undefined ? { allowedTools: allowedTools ?? undefined } : {}), - ...(references !== undefined ? { references: references ?? undefined } : {}), + .handler(async ({ searchTerm }: { searchTerm?: string }) => { + return { + workflows: await this.attachableWorkflowsService.list(user, projectId, searchTerm), }; - - try { - const updated = await this.agentSkillsService.updateSkill( - agentId, - projectId, - skillId, - normalizedUpdates, - { user, modifiedBy: 'builder' }, - baseSkillHash, - ); - return { ok: true, id: updated.id, name: updated.skill.name }; - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return { - ok: false, - errors: [{ message: e instanceof ConflictError ? STALE_SKILL_ERROR_MESSAGE : message }], - }; - } }) .build(); + } - const listTasksTool = new Tool(BUILDER_TOOLS.LIST_TASKS) - .description( - 'List the target agent scheduled tasks, including each persisted body and whether its ' + - 'current config reference is enabled. Use this to identify a task before updating it. Returns ' + - '{ ok: true, tasks: [{ id, name, objective, cronExpression, timezone, enabled }] } or ' + - '{ ok: false, errors }.', - ) - .input(z.object({}).strict()) - .handler(async () => { - try { - const agent = await this.agentsService.findById(agentId, projectId); - if (!agent) throw new Error('Agent not found'); - - const tasks = await this.agentTaskService.list(agentId); - const enabledByTaskId = new Map( - (composeJsonConfig(agent)?.tasks ?? []).map((task) => [task.id, task.enabled]), - ); - return { - ok: true, - tasks: tasks.map(({ id, name, objective, cronExpression, timezone }) => ({ - id, - name, - objective, - cronExpression, - // Null means the task runs on the instance timezone. - timezone, - enabled: enabledByTaskId.get(id) ?? false, - })), - }; - } catch (e) { - return { - ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], - }; - } - }) - .build(); - - const updateTaskTool = new Tool(BUILDER_TOOLS.UPDATE_TASK) - .description( - 'Update selected body fields of an existing target-agent scheduled task in place, preserving ' + - 'its id and config reference. Returns { ok: true, id, name, configMutated: true, agentId } ' + - 'or { ok: false, errors }.', - ) - .input(updateTaskInputSchema) - .handler(async ({ taskId, updates }: UpdateTaskInput) => { - try { - const updated = await this.agentTaskService.update(agentId, projectId, taskId, updates, { - user, - modifiedBy: 'builder', - }); - return { ok: true, id: updated.id, name: updated.name }; - } catch (e) { - return { - ok: false, - errors: [{ message: e instanceof Error ? e.message : String(e) }], - }; - } - }) - .build(); - - const createTasksTool = new Tool(BUILDER_TOOLS.CREATE_TASKS) + private buildCreateTasksTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.CREATE_TASKS) .description( 'Create one or more recurring scheduled tasks for the target agent (name + objective + cron ' + 'schedule per task) in a single call. Pass every task you currently know how to write in one ' + @@ -1336,53 +1009,291 @@ export class AgentsBuilderToolsService { }, ) .build(); + } - const listWorkflowsTool = new Tool(BUILDER_TOOLS.LIST_WORKFLOWS) + private buildUpdateTaskTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.UPDATE_TASK) .description( - 'List the n8n workflows that can be attached as tools via `type: "workflow"` in the agent config. ' + - `Only returns workflows that start with a '${WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME}' trigger. ` + - 'The published agent cannot call a workflow with `published: false` until the user publishes it. ' + - 'Pass `searchTerm` to narrow by workflow name; ' + - 'omitting it returns the 10 most recently updated attachable workflows.', + 'Update selected body fields of an existing target-agent scheduled task in place, preserving ' + + 'its id and config reference. Returns { ok: true, id, name, configMutated: true, agentId } ' + + 'or { ok: false, errors }.', + ) + .input(updateTaskInputSchema) + .handler(async ({ taskId, updates }: UpdateTaskInput) => { + try { + const updated = await this.agentTaskService.update(agentId, projectId, taskId, updates, { + user, + modifiedBy: 'builder', + }); + return { ok: true, id: updated.id, name: updated.name }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }) + .build(); + } + + private buildListTasksTool(agentId: string, projectId: string) { + return new Tool(BUILDER_TOOLS.LIST_TASKS) + .description( + 'List the target agent scheduled tasks, including each persisted body and whether its ' + + 'current config reference is enabled. Use this to identify a task before updating it. Returns ' + + '{ ok: true, tasks: [{ id, name, objective, cronExpression, timezone, enabled }] } or ' + + '{ ok: false, errors }.', + ) + .input(z.object({}).strict()) + .handler(async () => { + try { + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new Error('Agent not found'); + + const tasks = await this.agentTaskService.list(agentId); + const enabledByTaskId = new Map( + (composeJsonConfig(agent)?.tasks ?? []).map((task) => [task.id, task.enabled]), + ); + return { + ok: true, + tasks: tasks.map(({ id, name, objective, cronExpression, timezone }) => ({ + id, + name, + objective, + cronExpression, + // Null means the task runs on the instance timezone. + timezone, + enabled: enabledByTaskId.get(id) ?? false, + })), + }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }) + .build(); + } + + private buildUpdateSkillTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.UPDATE_SKILL) + .description( + 'Update selected fields of an existing target-agent skill in place, preserving its id and ' + + 'agent config reference. Requires baseSkillHash from the immediately preceding read_skill ' + + 'result. Pass null for allowedTools to remove the tool restriction, or null ' + + 'for references to remove all references; empty arrays are invalid. Returns ' + + '{ ok: true, id, name, configMutated: true, agentId } or { ok: false, errors }. On a stale ' + + 'skill error, call read_skill and retry once with its fresh skillHash.', + ) + .input(updateSkillInputSchema) + .handler(async ({ skillId, baseSkillHash, updates }: UpdateSkillInput) => { + const { allowedTools, references, ...requiredUpdates } = updates; + const normalizedUpdates = { + ...requiredUpdates, + ...(allowedTools !== undefined ? { allowedTools: allowedTools ?? undefined } : {}), + ...(references !== undefined ? { references: references ?? undefined } : {}), + }; + + try { + const updated = await this.agentSkillsService.updateSkill( + agentId, + projectId, + skillId, + normalizedUpdates, + { user, modifiedBy: 'builder' }, + baseSkillHash, + ); + return { ok: true, id: updated.id, name: updated.skill.name }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + ok: false, + errors: [{ message: e instanceof ConflictError ? STALE_SKILL_ERROR_MESSAGE : message }], + }; + } + }) + .build(); + } + + private buildListSkillsTool(agentId: string, projectId: string) { + return new Tool(BUILDER_TOOLS.LIST_SKILLS) + .description( + 'List lightweight metadata for persisted target-agent skills. Use this to identify which ' + + 'existing skill owns a capability before reading or creating a skill. Returns ' + + '{ ok: true, skills: [{ id, name, description }] } or { ok: false, errors }.', + ) + .input(z.object({}).strict()) + .handler(async () => { + try { + const skills = await this.agentSkillsService.listSkills(agentId, projectId); + return { + ok: true, + skills: Object.entries(skills).map(([id, skill]) => ({ + id, + name: skill.name, + description: skill.description, + })), + }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }) + .build(); + } + + private buildReadSkillTool(agentId: string, projectId: string) { + return new Tool(BUILDER_TOOLS.READ_SKILL) + .description( + 'Read an existing target-agent skill by id. The response includes its instructions, but ' + + 'references are returned as { path, characterCount } metadata by default to keep context small. ' + + 'Pass only the referencePaths whose content you need. Returns { ok: true, id, skill, skillHash } or ' + + '{ ok: false, errors }. Call this before every update_skill and use skillHash as baseSkillHash.', + ) + .input(readSkillInputSchema) + .handler(async ({ skillId, referencePaths = [] }: ReadSkillInput) => { + try { + const skill = await this.agentSkillsService.getSkill(agentId, projectId, skillId); + const skillHash = getAgentSkillHash(skill); + const { references, ...body } = skill; + const requestedPaths = new Set(referencePaths); + const knownPaths = new Set(references?.map((reference) => reference.path) ?? []); + const missingPaths = referencePaths.filter((path) => !knownPaths.has(path)); + if (missingPaths.length > 0) { + return { + ok: false, + errors: [ + { + message: `Reference path${missingPaths.length === 1 ? '' : 's'} not found: ${missingPaths.join(', ')}`, + }, + ], + }; + } + + const visibleReferences = references?.map((reference) => ({ + path: reference.path, + characterCount: reference.content.length, + ...(requestedPaths.has(reference.path) ? { content: reference.content } : {}), + })); + return { + ok: true, + id: skillId, + skillHash, + skill: { ...body, ...(visibleReferences ? { references: visibleReferences } : {}) }, + }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }) + .build(); + } + + private buildCreateSkillsTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.CREATE_SKILLS) + .description( + 'Create and store one or more agent skills (reusable, load-on-demand capabilities) in a ' + + 'single call. Pass every skill you currently know how to write in one `skills` array — do ' + + "not spread multiple fully-specified skills across separate calls; each skill's instructions " + + 'field carries its own structured template. The whole batch is all-or-nothing: an invalid or ' + + 'duplicate-named skill rejects every skill in the call. This does NOT attach the skills to the ' + + 'agent config; follow up with read_config and patch_config (or write_config) to add a ' + + '`{ type: "skill", id }` entry per skill to `skills`. Returns { ok: true, skills: [{ id, name }, ' + + '...] } (same order as input, bodies are not echoed back) or { ok: false, errors }.', + ) + .systemInstruction( + 'Never create a vague or placeholder skill. The description field is the routing contract the ' + + 'runtime uses to decide when to load the skill; the instructions must follow the required ' + + 'structured Markdown template (Overview, Inputs, Steps, Rules, Example, Gotchas) with each ' + + 'applicable section filled in with concrete, specific content. If you do not have enough domain ' + + "detail to write a genuinely useful skill, derive it from the user's goal as stated assumptions " + + 'listed in your summary; ask the user clarifying questions only when even a reasonable ' + + 'assumption is impossible. Use allowedTools only with exact target-agent tool names. Use references ' + + 'only for markdown supporting files under the references/ directory — references are not ' + + 'automatically loaded, so instructions must say exactly when to load each one by path; scripts and ' + + 'non-markdown linked files are not supported. Do not invent tool names or reference paths. Batch ' + + 'every skill you currently know how to write into one call.', ) .input( z.object({ - searchTerm: z - .string() - .optional() - .describe('Optional workflow-name search term. Omit to return the first 10 results.'), + skills: z + .array(createSkillInputSchema) + .min(1) + .max(20) + .describe('Every skill to create, in the order they should be created.'), }), ) - .handler(async ({ searchTerm }: { searchTerm?: string }) => { - return { - workflows: await this.attachableWorkflowsService.list(user, projectId, searchTerm), - }; + .handler(async ({ skills }: { skills: CreateSkillInput[] }) => { + // Each skill is already validated against `.input()` (agentSkillSchema + // shapes) by the tool runtime before the handler runs. + try { + const created = await this.agentSkillsService.createSkills(agentId, projectId, skills, { + user, + modifiedBy: 'builder', + }); + return { + ok: true, + skills: created.map(({ id, skill }) => ({ id, name: skill.name })), + }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } }) .build(); + } - return [ - buildCustomToolTool, - createSkillsTool, - listSkillsTool, - readSkillTool, - this.withConfigMutationMarker(updateSkillTool, agentId), - this.withConfigMutationMarker(createTasksTool, agentId), - listTasksTool, - this.withConfigMutationMarker(updateTaskTool, agentId), - listWorkflowsTool, - buildGetResourceLocatorOptionsTool({ - dynamicNodeParametersService: this.dynamicNodeParametersService, - nodeTypes: this.nodeTypes, - user, - projectId, - }), - ...this.agentsToolsService.getSharedTools( - credentialProvider, - 'Read-only inspection of available credentials. Use ask_credential to let the user ' + - 'pick the credential to wire into a node tool — never copy ids from this list directly ' + - 'into the config.', - ), - ]; + private buildCustomToolTool(agentId: string, projectId: string, user: User) { + return new Tool(BUILDER_TOOLS.BUILD_CUSTOM_TOOL) + .description( + 'Compile and store a custom tool. Pass the complete TypeScript source ' + + 'using `export default new Tool(...)` builder chain. The code is validated in a ' + + 'sandbox and saved against the agent. The returned `id` equals the tool name ' + + 'declared in the code (e.g. `new Tool("my_tool")` → id `"my_tool"`). ' + + 'This does NOT register the tool in the agent config — follow up with ' + + 'patch_config (or write_config) to add `{ type: "custom", id: "" }` ' + + 'to `tools`.' + + 'Returns { ok: true, id, name } or { ok: false, errors }.', + ) + .input( + z.object({ + code: z + .string() + .describe('Complete TypeScript source using export default new Tool(...)'), + }), + ) + .handler(async ({ code }: { code: string }, ctx) => { + try { + const descriptor = await this.secureRuntime.describeToolSecurely(code); + const built = await this.agentCustomToolsService.buildCustomTool( + agentId, + projectId, + code, + descriptor, + { user, modifiedBy: 'builder' }, + ); + return { ok: true, id: built.id, name: descriptor.name }; + } catch (e) { + // Unlike its sibling handlers, this one runs long isolate work, so an + // abort can land mid-call and must not be reported as a build error. + // When a signal is present it is the authority: the isolate compiles + // model-authored code, so a generated tool throwing `Aborted` must not + // be mistaken for a cancellation and kill the whole builder run. + if (ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(e)) throw e; + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }) + .build(); } private async getConfigSnapshot( @@ -1442,4 +1353,229 @@ export class AgentsBuilderToolsService { }); return { applied: true }; } + + private async getFreshConfigSnapshot( + agentId: string, + projectId: string, + baseConfigHash: string | null, + ): Promise<{ ok: true; snapshot: AgentConfigSnapshot } | BuilderConfigFailure> { + try { + const snapshot = await this.getConfigSnapshot(agentId, projectId); + if (baseConfigHash !== snapshot.configHash) { + return { ok: false, stage: 'stale', errors: [STALE_CONFIG_ERROR] }; + } + return { ok: true, snapshot }; + } catch (e) { + return { + ok: false, + stage: 'stale', + errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], + }; + } + } + + private validateBuilderConfig( + incoming: unknown, + currentConfig: AgentJsonConfig | null, + ): { ok: true; config: AgentJsonConfig } | BuilderConfigFailure { + const parsed = parseBuilderWriteConfig(incoming, currentConfig); + if (!parsed.success) return { ok: false, errors: formatZodErrors(parsed.error) }; + const emptyInstructions = rejectIfEmptyInstructions(parsed.data, CLI_AGENT_CONFIG_MESSAGES); + if (emptyInstructions) return { ok: false, errors: emptyInstructions }; + const unsupportedWebSearch = rejectIfUnsupportedNativeWebSearch(parsed.data); + if (unsupportedWebSearch) return { ok: false, errors: unsupportedWebSearch }; + const dynamicSelectorFromAi = rejectIfDynamicSelectorUsesFromAi( + parsed.data, + currentConfig, + this.nodeTypes, + CLI_AGENT_CONFIG_MESSAGES, + ); + if (dynamicSelectorFromAi) return { ok: false, errors: dynamicSelectorFromAi }; + // Save defaults explicitly so the write and read paths agree. + const config = applyPromptCachingBuilderDefaults(applyNativeWebSearchDefaultOn(parsed.data)); + return { ok: true, config }; + } + + private async saveBuilderConfig( + agentId: string, + projectId: string, + user: User, + config: AgentJsonConfig, + baseConfigHash: string | null, + ): Promise<{ ok: true } | BuilderConfigFailure> { + try { + await this.agentConfigService.updateConfig(agentId, projectId, config, user, { + baseConfigHash, + modifiedBy: 'builder', + }); + return { ok: true }; + } catch (e) { + return { + ok: false, + stage: e instanceof ConflictError ? 'stale' : 'schema', + errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], + }; + } + } + + private async writeBuilderConfig( + agentId: string, + projectId: string, + user: User, + json: string, + baseConfigHash: string | null, + ) { + const parsed = tryParseConfigJson(json); + if (!parsed.ok) return { ok: false, errors: parsed.errors }; + const fresh = await this.getFreshConfigSnapshot(agentId, projectId, baseConfigHash); + if (!fresh.ok) return fresh; + const validated = this.validateBuilderConfig(parsed.data, fresh.snapshot.config); + if (!validated.ok) return validated; + return await this.saveBuilderConfig( + agentId, + projectId, + user, + validated.config, + fresh.snapshot.configHash, + ); + } + + private async patchBuilderConfig( + agentId: string, + projectId: string, + user: User, + operations: string, + baseConfigHash: string | null, + ) { + const parsed = tryParseConfigJson(operations); + if (!parsed.ok) return { ok: false, stage: 'parse', errors: parsed.errors }; + const fresh = await this.getFreshConfigSnapshot(agentId, projectId, baseConfigHash); + if (!fresh.ok) return fresh; + if (!fresh.snapshot.config) { + return { + ok: false, + stage: 'patch', + errors: [{ path: '(root)', message: 'Agent has no JSON config yet.' }], + }; + } + const patched = await this.applyConfigPatch(parsed.data, fresh.snapshot.config); + if (!patched.ok) return patched; + const validated = this.validateBuilderConfig(patched.config, fresh.snapshot.config); + if (!validated.ok) return { ...validated, stage: 'schema' }; + return await this.saveBuilderConfig( + agentId, + projectId, + user, + validated.config, + fresh.snapshot.configHash, + ); + } + + private async applyConfigPatch( + operations: unknown, + config: AgentJsonConfig, + ): Promise<{ ok: true; config: unknown } | BuilderConfigFailure> { + const jsonpatch = (await import('fast-json-patch')).default; + const ops = operations as Operation[]; + const patchError = jsonpatch.validate(ops, config); + if (patchError) { + const opPath = (patchError.operation as { path?: string } | undefined)?.path ?? '(root)'; + return { + ok: false, + stage: 'patch', + errors: [{ path: opPath, message: patchError.message ?? 'Invalid patch operation' }], + }; + } + return { ok: true, config: jsonpatch.applyPatch(jsonpatch.deepClone(config), ops).newDocument }; + } + + private async formatDraftTestResult( + result: AgentTestRunResult, + ctx: InterruptibleToolContext, + agentId: string, + user: User, + previewPath: string, + ) { + if (result.status === 'session_not_found') { + return { + status: 'error', + code: 'session_not_found', + message: 'Session not found.', + }; + } + if (result.status === 'agent_misconfigured') { + return { + status: 'error', + code: 'agent_misconfigured', + message: "This agent isn't ready to run yet. Finish configuring it and try again.", + missing: result.missing, + }; + } + if (result.status === 'completed') return result; + + const approvals = collectStandardApprovals(result); + const firstApproval = approvals?.[0]; + if (firstApproval) { + const { continuation, ...approval } = firstApproval; + return await ctx.suspend(approval, { continuation }); + } + + return await this.cancelUnsupportedDraftTest(result, agentId, user, previewPath); + } + + private async cancelUnsupportedDraftTest( + result: Extract, + agentId: string, + user: User, + previewPath: string, + ) { + const cancelled = await this.agentTestRunService.cancelSuspendedRuns({ + agentId, + suspensions: result.suspensions, + userId: user.id, + }); + if (!cancelled) { + return { + status: 'error', + code: 'cancellation_failed', + message: + 'This test needs approval, but its suspended run could not be cancelled. Open Preview before continuing this session.', + sessionId: result.sessionId, + previewPath, + }; + } + + return { + status: 'approval_required', + response: result.response, + sessionId: result.sessionId, + ...(result.executionId ? { executionId: result.executionId } : {}), + suspensions: result.suspensions.map(({ runId, toolCallId, toolName }) => ({ + runId, + toolCallId, + toolName, + })), + previewPath, + }; + } + + private createModelLookup( + user: User, + projectId: string, + options?: BuilderToolsOptions, + ): ModelLookup { + return { + // `list` resolves the n8n Connect managed tag to the synthetic gateway + // credential internally, so no managed branch is needed here. + list: async (credentialId, credentialType, provider) => + await this.builderModelLiveLookupService.list( + user, + projectId, + credentialId, + credentialType, + provider, + { useEvalModelCatalog: options?.useEvalModelCatalog === true }, + ), + }; + } } diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index b3c4e4e7b3c..9cfb9cbac04 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -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 { return await this.n8nCheckpointStorage.findSuspendedForThread(agentId, threadId); } + + private async claimMemoryUsage( + report: MemoryTaskUsageReport, + agentId: string, + user: User, + session: InstanceAiBuilderSessionOptions, + ): Promise { + 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 { + 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 { + 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(); + } } diff --git a/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts b/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts index c7d0a968b64..51b768d58e8 100644 --- a/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts +++ b/packages/cli/src/modules/agents/builder/builder-model-live-lookup.service.ts @@ -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> { + ): Promise { 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 { - // 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; } diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts index 92b1e302e9d..a6d67daf069 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts @@ -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) { - const merged: AskCredentialToolDeps = { projectId: 'project-1', ...deps, track }; +function askCredentialTool(deps: Omit) { + const merged: CredentialSetupDeps = { projectId: 'project-1', ...deps, track }; return buildAskCredentialTool(merged); } diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts index 7adacb0bb9c..822c4dceda8 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts @@ -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; - 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; + credentials: Record>; }; function withNodeCredentialMap( @@ -63,7 +50,7 @@ async function listExistingCredentials( credentialService: InstanceAiCredentialService, projectId: string, credentialType: string, -): Promise> { +): Promise>> { 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, - deps: AskCredentialToolDeps, + deps: CredentialSetupDeps, ): Promise { 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 ' + diff --git a/packages/cli/src/modules/agents/builder/interactive/configure-channel.tool.ts b/packages/cli/src/modules/agents/builder/interactive/configure-channel.tool.ts index 905ce6a327a..175259ca59f 100644 --- a/packages/cli/src/modules/agents/builder/interactive/configure-channel.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/configure-channel.tool.ts @@ -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; -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 ' + diff --git a/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts b/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts index e2170978104..1e711e6d3bb 100644 --- a/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts @@ -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> { 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; - /** 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>; }> = []; for (const slot of phase.slots) { if (seenTypes.has(slot.credentialType)) continue; diff --git a/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts b/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts index 57695d54ab7..f8bd16707ef 100644 --- a/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts @@ -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>; + list(credentialId: string, credentialType: string, provider: string): Promise; } /** 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 { diff --git a/packages/cli/src/modules/agents/builder/interactive/setup-tool.types.ts b/packages/cli/src/modules/agents/builder/interactive/setup-tool.types.ts new file mode 100644 index 00000000000..3bea6316bc3 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/setup-tool.types.ts @@ -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; +} + +export interface ChannelSetupDeps extends SetupToolDeps { + agentId: string; + /** Lists the channel types that setup can offer. */ + listChatIntegrationTypes: () => string[]; +} diff --git a/packages/cli/src/modules/agents/builder/model-lookup.types.ts b/packages/cli/src/modules/agents/builder/model-lookup.types.ts new file mode 100644 index 00000000000..2909c140bed --- /dev/null +++ b/packages/cli/src/modules/agents/builder/model-lookup.types.ts @@ -0,0 +1,4 @@ +export interface ModelChoice { + name: string; + value: string; +} diff --git a/packages/cli/src/modules/agents/entities/agent-background-job.entity.ts b/packages/cli/src/modules/agents/entities/agent-background-job.entity.ts index 2dbc513bbc5..cf7582542d8 100644 --- a/packages/cli/src/modules/agents/entities/agent-background-job.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-background-job.entity.ts @@ -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 diff --git a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts index 667c7df88b2..c0f67884144 100644 --- a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts @@ -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; diff --git a/packages/cli/src/modules/agents/entities/agent-history.entity.ts b/packages/cli/src/modules/agents/entities/agent-history.entity.ts index eb12f1d0b9c..11df4feb0fc 100644 --- a/packages/cli/src/modules/agents/entities/agent-history.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-history.entity.ts @@ -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 | null; diff --git a/packages/cli/src/modules/agents/entities/agent-memory-entry.entity.ts b/packages/cli/src/modules/agents/entities/agent-memory-entry.entity.ts index b52f1c24ac7..d325b3e0f49 100644 --- a/packages/cli/src/modules/agents/entities/agent-memory-entry.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-memory-entry.entity.ts @@ -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; diff --git a/packages/cli/src/modules/agents/entities/agent-observation-lock.entity.ts b/packages/cli/src/modules/agents/entities/agent-observation-lock.entity.ts index ba2794972c5..3528da35e8b 100644 --- a/packages/cli/src/modules/agents/entities/agent-observation-lock.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-observation-lock.entity.ts @@ -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; diff --git a/packages/cli/src/modules/agents/entities/agent-observation.entity.ts b/packages/cli/src/modules/agents/entities/agent-observation.entity.ts index f9422a76c2b..db06143f700 100644 --- a/packages/cli/src/modules/agents/entities/agent-observation.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-observation.entity.ts @@ -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; diff --git a/packages/cli/src/modules/agents/execution-recorder.ts b/packages/cli/src/modules/agents/execution-recorder.ts index f39a261cbe9..df96267a95e 100644 --- a/packages/cli/src/modules/agents/execution-recorder.ts +++ b/packages/cli/src/modules/agents/execution-recorder.ts @@ -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 | 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; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts index a652c5c6f2c..c95198a07ee 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-channel-reconciler.service.test.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription-state.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription-state.service.test.ts index 4fcc9177678..af0dd016dcb 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription-state.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription-state.service.test.ts @@ -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); 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' }), ); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts index 2890cc65c5a..408d66b3ad5 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts @@ -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({ isLeader: true }), - mock(), + mock(), mock({ multiMainSetup: { enabled: false } } as Partial), mock(), mock(), diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts index b2d9aca714e..f4047316fc6 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/replay-test-helpers.ts @@ -238,7 +238,7 @@ export function createReplayContextSetup(params: { ); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(params.chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(params.chat); const actionExecutor = new ChatIntegrationActionExecutor( chatIntegrationService, registry, diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts index 9663b9b0f5e..33b1e445c81 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts @@ -135,7 +135,7 @@ describe('ChatIntegrationActionExecutor', () => { chat.thread.mockReturnValue(sentThread as never); const chatIntegrationService = mock(); - 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.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -233,7 +233,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.openDM.mockResolvedValue(thread as never); const chatIntegrationService = mock(); - 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.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.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -358,7 +358,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.thread.mockReturnValue(thread as never); const chatIntegrationService = mock(); - 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(); chat.thread.mockReturnValue(thread as never); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue({ editMessage }); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue({ editMessage }); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue({}); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue({ editMessage }); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -760,7 +760,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -810,7 +810,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ addReaction }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -880,7 +880,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ addReaction }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -943,7 +943,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - 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.getChatInstance.mockReturnValue(undefined); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(undefined); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -1026,7 +1026,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ client: linearClient }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -1130,7 +1130,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ client: linearClient }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -1201,7 +1201,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ client: linearClient }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, buildRegistry(), @@ -1284,7 +1284,7 @@ describe('ChatIntegrationActionExecutor', () => { const chat = mock(); chat.thread.mockReturnValue(thread as never); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const guard = new ChannelRateLimitGuard(); const executor = new ChatIntegrationActionExecutor( chatIntegrationService, diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts index a84f4f08901..08f8324a39f 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts @@ -63,7 +63,6 @@ describe('ChatIntegrationContextQueryExecutor', () => { chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - 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.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.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.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.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationContextQueryExecutor( chatIntegrationService, buildRegistry(), @@ -414,7 +409,7 @@ describe('ChatIntegrationContextQueryExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ client: linearClient }); const chatIntegrationService = mock(); - 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.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.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationContextQueryExecutor( chatIntegrationService, buildRegistry(), @@ -697,7 +692,7 @@ describe('ChatIntegrationContextQueryExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ client: linearClient }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationContextQueryExecutor( chatIntegrationService, buildRegistry(), @@ -742,7 +737,7 @@ describe('ChatIntegrationContextQueryExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue({ client: linearClient }); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationContextQueryExecutor( chatIntegrationService, buildRegistry(), @@ -778,7 +773,7 @@ describe('ChatIntegrationContextQueryExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(chat); + chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const executor = new ChatIntegrationContextQueryExecutor( chatIntegrationService, buildRegistry(), @@ -816,7 +811,7 @@ describe('ChatIntegrationContextQueryExecutor', () => { const chat = mock(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - 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(); chat.getAdapter.mockReturnValue(slackAdapter); const chatIntegrationService = mock(); - chatIntegrationService.getChatInstance.mockReturnValue(undefined); chatIntegrationService.getChatInstanceForTools.mockResolvedValue(chat); const guard = new ChannelRateLimitGuard(); const executor = new ChatIntegrationContextQueryExecutor( diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-message-context.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-message-context.service.test.ts index b2ee594ebd9..04ce95cb9a2 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-message-context.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-message-context.service.test.ts @@ -26,7 +26,6 @@ describe('IntegrationMessageContextService', () => { } const threadRepository = mock(); const resourceRepository = mock(); - resourceRepository.existsBy.mockResolvedValue(true); threadRepository.findOneBy.mockImplementation(async ({ id }: { id: string }) => threads.has(id) ? (threads.get(id) as never) : (null as never), ); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/n8n-chat-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/n8n-chat-integration.test.ts index 0bad264a702..4e35fe603e8 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/n8n-chat-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/n8n-chat-integration.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/n8n-memory.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/n8n-memory.test.ts index 550e82122fa..563a5d8ce1d 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/n8n-memory.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/n8n-memory.test.ts @@ -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([]); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts index 6ae90a6b572..ade9de9844e 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts @@ -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; deleteManagedAppForCredential: ( - options: DeleteManagedSlackAppOptions, + options: AgentIntegrationRemovalContext, ) => ReturnType; }; @@ -190,7 +190,6 @@ describe('Slack setup services', () => { methods, userRepository, cacheService, - cipher, projectService, ); const managedService = new SlackManagedSetupService( diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts index 1cf2603cce4..1b5e9a1f5d0 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts @@ -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(); const userRepository = mock(); const cacheService = mock(); - const cipher = mock(); const projectService = mock(); 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({ diff --git a/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts b/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts index 9a79b4ef314..e091ad15294 100644 --- a/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts +++ b/packages/cli/src/modules/agents/integrations/agent-channel-reconciler.service.ts @@ -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; /** @@ -31,10 +29,6 @@ type WantedChannels = Map { - 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 { 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 { 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 { + 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 { + 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)), + }); + } } } diff --git a/packages/cli/src/modules/agents/integrations/agent-channel-status-reporter.ts b/packages/cli/src/modules/agents/integrations/agent-channel-status-reporter.ts index bbea9acf96a..aa1625e871a 100644 --- a/packages/cli/src/modules/agents/integrations/agent-channel-status-reporter.ts +++ b/packages/cli/src/modules/agents/integrations/agent-channel-status-reporter.ts @@ -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 diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts index c7f09451311..d2194cd666e 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -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 { - 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 { - 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 { 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 { + 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 { + 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 { 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', diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index 0bf043016c8..f9b0b44510d 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -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 { /** `undefined` only for integrations with `requiresChatInstance === false`. */ chat: ChatInstance | undefined; - descriptor: IntegrationToolConnectionDescriptor; - query: IntegrationContextQuery; - input: Record; } /** Per-platform action-execution params. */ -export interface PlatformActionParams { +export interface PlatformActionParams + extends Omit { /** `undefined` only for integrations with `requiresChatInstance === false`. */ chat: ChatInstance | undefined; - descriptor: IntegrationToolConnectionDescriptor; - action: IntegrationAction; - input: Record; - currentMessageContext?: IntegrationMessageContext; } /** diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.adapter.ts b/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.adapter.ts index 802b79c7e09..2d43e044751 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.adapter.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.adapter.ts @@ -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(); 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 { diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.service.ts b/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.service.ts index 86cde4823fb..ed03723741e 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.service.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-subscription-state.service.ts @@ -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(); 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 { - 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 { - 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 { - 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), - }, - ); - } - } } diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts index 73f9f20add4..d9a6c13e4f8 100644 --- a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -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 { - 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 { - 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, - 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 { 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 { + async releaseChannelLocally(agentId: string, integration: IntegrationRef): Promise { 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 { 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 { 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 { - 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 { + // 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, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)}`, + ); + } + } } diff --git a/packages/cli/src/modules/agents/integrations/integration-action-executor.ts b/packages/cli/src/modules/agents/integrations/integration-action-executor.ts index cfff01329f0..4e79bfccf43 100644 --- a/packages/cli/src/modules/agents/integrations/integration-action-executor.ts +++ b/packages/cli/src/modules/agents/integrations/integration-action-executor.ts @@ -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; - awaitResponse: boolean; - runId?: string; - toolCallId?: string; - currentMessageContext?: IntegrationMessageContext; - }): Promise { + async execute(params: IntegrationActionParams): Promise { 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 { 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 { 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 { 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 { 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 { 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; - awaitResponse: boolean; - runId?: string; - toolCallId?: string; - currentMessageContext?: IntegrationMessageContext; -} - function buildMessageContextFromSentMessage(params: { descriptor: IntegrationToolConnectionDescriptor; sent: SentMessage; diff --git a/packages/cli/src/modules/agents/integrations/integration-context-query-executor.ts b/packages/cli/src/modules/agents/integrations/integration-context-query-executor.ts index 56d525f03d3..cff1d4268a0 100644 --- a/packages/cli/src/modules/agents/integrations/integration-context-query-executor.ts +++ b/packages/cli/src/modules/agents/integrations/integration-context-query-executor.ts @@ -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; - persistence?: { threadId: string; resourceId: string }; - }): Promise { + async execute(params: IntegrationContextQueryParams): Promise { 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, ); diff --git a/packages/cli/src/modules/agents/integrations/integration-helpers.ts b/packages/cli/src/modules/agents/integrations/integration-helpers.ts index bf1c2d682da..28671bd0980 100644 --- a/packages/cli/src/modules/agents/integrations/integration-helpers.ts +++ b/packages/cli/src/modules/agents/integrations/integration-helpers.ts @@ -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; export function integrationError( code: IntegrationErrorCode, diff --git a/packages/cli/src/modules/agents/integrations/integration-message-context.service.ts b/packages/cli/src/modules/agents/integrations/integration-message-context.service.ts index 775ca8a7ac1..7f5f6ec2a4f 100644 --- a/packages/cli/src/modules/agents/integrations/integration-message-context.service.ts +++ b/packages/cli/src/modules/agents/integrations/integration-message-context.service.ts @@ -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 { - 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 { if (!value) return {}; try { diff --git a/packages/cli/src/modules/agents/integrations/integration-tool-types.ts b/packages/cli/src/modules/agents/integrations/integration-tool-types.ts index 0b21e91de4b..4cfa6462d03 100644 --- a/packages/cli/src/modules/agents/integrations/integration-tool-types.ts +++ b/packages/cli/src/modules/agents/integrations/integration-tool-types.ts @@ -170,25 +170,29 @@ export interface SessionBinding { resourceId: string; } +export interface IntegrationContextQueryParams { + descriptor: IntegrationToolConnectionDescriptor; + query: IntegrationContextQuery; + input: Record; + persistence?: { threadId: string; resourceId: string }; +} + export interface IntegrationContextQueryExecutor { - execute(params: { - descriptor: IntegrationToolConnectionDescriptor; - query: IntegrationContextQuery; - input: Record; - persistence?: { threadId: string; resourceId: string }; - }): Promise; + execute(params: IntegrationContextQueryParams): Promise; +} + +export interface IntegrationActionParams { + descriptor: IntegrationToolConnectionDescriptor; + action: IntegrationAction; + input: Record; + awaitResponse: boolean; + runId?: string; + toolCallId?: string; + currentMessageContext?: IntegrationMessageContext; } export interface IntegrationActionExecutor { - execute(params: { - descriptor: IntegrationToolConnectionDescriptor; - action: IntegrationAction; - input: Record; - awaitResponse: boolean; - runId?: string; - toolCallId?: string; - currentMessageContext?: IntegrationMessageContext; - }): Promise; + execute(params: IntegrationActionParams): Promise; } export type IntegrationActionResult = diff --git a/packages/cli/src/modules/agents/integrations/n8n-memory.ts b/packages/cli/src/modules/agents/integrations/n8n-memory.ts index 7a9e55a7a7f..cbc1d0a83bc 100644 --- a/packages/cli/src/modules/agents/integrations/n8n-memory.ts +++ b/packages/cli/src/modules/agents/integrations/n8n-memory.ts @@ -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): Promise { - 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 { - // 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 { await this.threadRepository.runInTransaction(ctx, async (trx) => { await this.dropEpisodicEntriesWithoutSources(trx, threadId); @@ -602,7 +590,7 @@ export class N8nMemoryImpl candidate: NewEpisodicMemoryCaptureCandidate, ): Promise { 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 { 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); diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts index 4b4f3b34e4e..15d68f4339a 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts @@ -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 { return new Set(value.split(/[\s,]+/).filter(Boolean)); } -function hasSessionShape(value: unknown): value is SlackAppSetupSession { - const keys: Array = [ - '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 { 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 { - const team = childRecord(manager.oauthTokenData, 'team'); const enterprise = childRecord(manager.oauthTokenData, 'enterprise'); - const isEnterpriseInstall = manager.oauthTokenData.is_enterprise_install === true; - const workspaceRecords: Array> = []; - - 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(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( + manager: ManagerCredentialContext, + method: string, + params: Record | 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 { + 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 { + 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> = []; + 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, + ): Promise { + 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, + responseOauthAuthorizeUrl: string | undefined, + ): Promise { + 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 { + 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 { - try { - const decrypted = await this.cipher.decryptV2(value); - const session = jsonParse(decrypted, { fallbackValue: null }); - if (hasManagedSessionShape(session)) return session; - } catch { - // Ignore stale or undecryptable managed setup state. - } - return undefined; - } - - private async callManagerSlackApi( + private async refreshManagerAccessToken( manager: ManagerCredentialContext, - method: string, - params: Record | 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 { 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( @@ -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 | undefined, + refreshResponse: RefreshTokenResponse, + refreshedAccessToken: string, + ): Promise { const refreshedAuthedUser: Record = { ...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 { - 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); - } } } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts index 67479ab44eb..4952e30fec2 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts @@ -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 = [ - '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 { + 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 { + 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 { + const cached = await this.cacheService.take(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, + ): Promise { + 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 { - 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 { - 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 { 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 { - const cached = await this.cacheService.take(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(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 }; } } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts index 6366326edb4..4404874a702 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -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 { - 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 { + try { + const decrypted = await this.cipher.decryptV2(value); + const session = jsonParse(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, diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts index d90b2b5646a..6a1a7ffc82b 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts @@ -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 = [ + '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) { + 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 }; +} diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index 8fffde1617b..eebdda703d9 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -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; -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 { // Mirrors `resolveModelConfig`: an empty credential means "not configured", diff --git a/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts b/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts index 13daa3caa64..56860584c25 100644 --- a/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts +++ b/packages/cli/src/modules/agents/json-config/mcp-client-factory.ts @@ -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), } : {}), }; diff --git a/packages/cli/src/modules/agents/repositories/agent-channel-status.repository.ts b/packages/cli/src/modules/agents/repositories/agent-channel-status.repository.ts index 44ed84ef17e..a078c685a5e 100644 --- a/packages/cli/src/modules/agents/repositories/agent-channel-status.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-channel-status.repository.ts @@ -5,12 +5,7 @@ import { InstanceSettings } from 'n8n-core'; import type { AgentChannelStatusValue } from '../entities/agent-channel-status.entity'; import { AgentChannelStatus } from '../entities/agent-channel-status.entity'; -/** Identifies one channel of one agent, across all processes running it. */ -export interface AgentChannelRef { - agentId: string; - integrationType: string; - credentialId: string; -} +import type { AgentChannelRef } from '../utils/agent-channel'; /** What one process observed, as its own row records it. */ export interface AgentChannelObservation { diff --git a/packages/cli/src/modules/agents/repositories/agent-chat-subscription.repository.ts b/packages/cli/src/modules/agents/repositories/agent-chat-subscription.repository.ts index 569355b5e26..110e45f6885 100644 --- a/packages/cli/src/modules/agents/repositories/agent-chat-subscription.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-chat-subscription.repository.ts @@ -3,11 +3,7 @@ import { DataSource, Repository } from '@n8n/typeorm'; import { AgentChatSubscription } from '../entities/agent-chat-subscription.entity'; -export interface AgentChatSubscriptionScope { - agentId: string; - integrationType: string; - credentialId: string; -} +import type { AgentChannelRef } from '../utils/agent-channel'; @Service() export class AgentChatSubscriptionRepository extends Repository { @@ -15,7 +11,7 @@ export class AgentChatSubscriptionRepository extends Repository { + async subscribe(scope: AgentChannelRef, threadId: string): Promise { await this.createQueryBuilder() .insert() .into(AgentChatSubscription) @@ -24,19 +20,19 @@ export class AgentChatSubscriptionRepository extends Repository { + async unsubscribe(scope: AgentChannelRef, threadId: string): Promise { await this.delete({ ...scope, threadId }); } - async deleteForConnection(scope: AgentChatSubscriptionScope): Promise { + async deleteForConnection(scope: AgentChannelRef): Promise { await this.delete(scope); } - async isSubscribed(scope: AgentChatSubscriptionScope, threadId: string): Promise { + async isSubscribed(scope: AgentChannelRef, threadId: string): Promise { return await this.existsBy({ ...scope, threadId }); } - async listThreadIdsForConnection(scope: AgentChatSubscriptionScope): Promise { + async listThreadIdsForConnection(scope: AgentChannelRef): Promise { const rows = await this.find({ select: { threadId: true }, where: scope, diff --git a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts index 2c24ae5df67..0cdaf40b5d8 100644 --- a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -1,9 +1,10 @@ +import type { AgentExecutionStatus } from '@n8n/api-types'; import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; import { DataSource, IsNull, Not } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; -import { AgentExecution, type AgentExecutionStatus } from '../entities/agent-execution.entity'; +import { AgentExecution } from '../entities/agent-execution.entity'; import type { ThreadFailureSummary } from '../utils/execution-failure-summary'; export type RunningAgentExecution = Pick< diff --git a/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts b/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts index 5d41fd25d9a..c7cc85cd7ab 100644 --- a/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts @@ -8,4 +8,14 @@ export class AgentResourceRepository extends Repository { constructor(dataSource: DataSource) { super(AgentResourceEntity, dataSource.manager); } + + async ensureExists(resourceId: string): Promise { + // Concurrent callers can create the same resource. Keep the first row. + await this.createQueryBuilder() + .insert() + .into(AgentResourceEntity) + .values({ id: resourceId, metadata: null }) + .orIgnore() + .execute(); + } } diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 047ec8cba81..08327be7fa8 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -12,6 +12,11 @@ import { import { Agent } from '../entities/agent.entity'; +export interface AgentListResult { + count: number; + data: Agent[]; +} + export type AgentSummary = Pick< Agent, 'id' | 'name' | 'projectId' | 'activeVersionId' | 'availableInMCP' | 'updatedAt' @@ -86,7 +91,7 @@ export class AgentRepository extends Repository { projectIds: string[] | null, options: ListAgentsQueryDto, { withProject = false }: { withProject?: boolean } = {}, - ): Promise<{ count: number; data: Agent[] }> { + ): Promise { if (projectIds?.length === 0) return { count: 0, data: [] }; const query = this.createQueryBuilder('agent').leftJoinAndSelect( diff --git a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts index c22c19a18c4..7f11fc5adc7 100644 --- a/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts +++ b/packages/cli/src/modules/agents/sub-agents/delegate-sub-agent-tool.ts @@ -1,5 +1,6 @@ import { createDelegateSubAgentTool, + type CreateDelegateSubAgentToolOptions, INLINE_SUB_AGENT_ID, type InlineSubAgentProviderToolsResolver, type ModelConfig, @@ -18,7 +19,7 @@ import type { SubAgentRunContext, SubAgentRunner } from './sub-agent-runner'; export interface CreateN8nDelegateSubAgentToolOptions extends SubAgentRunContext { runner: SubAgentRunner; sourcesById: Record; - availableSubAgents?: Array<{ id: string; name: string; useWhen?: string }>; + availableSubAgents?: NonNullable; policy?: SubAgentRunPolicy; inlineSubAgentModelsByDifficulty?: Partial>; resolveInlineSubAgentProviderTools?: InlineSubAgentProviderToolsResolver; diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts index 67e80a43492..108d7fc554e 100644 --- a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts @@ -1,6 +1,4 @@ -import type { ToolDescriptor } from '@n8n/agents'; import { - type AgentSkill, RunnableAgentJsonConfigSchema, type AgentJsonConfig, type ResolvedSubAgentSource, @@ -11,10 +9,10 @@ import { UserError } from 'n8n-workflow'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { AgentHistory } from '../entities/agent-history.entity'; -import type { Agent } from '../entities/agent.entity'; +import { getAgentOrThrow } from '../utils/get-agent-or-throw'; import { AgentHistoryRepository } from '../repositories/agent-history.repository'; import { AgentRepository } from '../repositories/agent.repository'; +import { getAgentRuntimeAssets, type AgentRuntimeAssets } from '../utils/agent-runtime-assets'; export interface ResolveSubAgentSourceContext { projectId: string; @@ -26,11 +24,8 @@ export interface ResolveSubAgentSourceContext { usePublishedVersion?: boolean; } -export interface ResolvedSubAgentRuntimeSource { +export interface ResolvedSubAgentRuntimeSource extends AgentRuntimeAssets { source: ResolvedSubAgentSource; - toolDescriptors: Record; - toolCodeByName: Record; - skills: Record; } @Service() @@ -49,13 +44,7 @@ export class SubAgentSourceResolver { source: SubAgentSource, context: ResolveSubAgentSourceContext, ): Promise { - const agent = await this.agentRepository.findByIdAndProjectId( - source.agentId, - context.projectId, - ); - if (!agent) { - throw new NotFoundError(`Agent "${source.agentId}" not found`); - } + const agent = await getAgentOrThrow(this.agentRepository, source.agentId, context.projectId); if (source.versionId) { const version = await this.agentHistoryRepository.findByVersionAndAgentId( @@ -125,21 +114,3 @@ export class SubAgentSourceResolver { return result.data; } } - -function getAgentRuntimeAssets( - agent: Pick, -): Omit { - const toolDescriptors: Record = {}; - const toolCodeByName: Record = {}; - - for (const [toolId, toolEntry] of Object.entries(agent.tools ?? {})) { - toolDescriptors[toolId] = toolEntry.descriptor; - toolCodeByName[toolEntry.descriptor.name] = toolEntry.code; - } - - return { - toolDescriptors, - toolCodeByName, - skills: agent.skills ?? {}, - }; -} diff --git a/packages/cli/src/modules/agents/tool-registry.ts b/packages/cli/src/modules/agents/tool-registry.ts index d5adbe6515f..aeac1468972 100644 --- a/packages/cli/src/modules/agents/tool-registry.ts +++ b/packages/cli/src/modules/agents/tool-registry.ts @@ -19,6 +19,8 @@ export interface ToolRegistryEntry { export type ToolRegistry = Map; +export type ReferencedToolKind = Exclude; + /** * Build a registry mapping tool name -> { kind, workflowId?, workflowName?, triggerType? }. * Reads metadata attached to each BuiltTool (populated by workflow-tool-factory for workflow tools). diff --git a/packages/cli/src/modules/agents/types/agent-chat-attachment.ts b/packages/cli/src/modules/agents/types/agent-chat-attachment.ts new file mode 100644 index 00000000000..4d35370c12b --- /dev/null +++ b/packages/cli/src/modules/agents/types/agent-chat-attachment.ts @@ -0,0 +1,7 @@ +/** Attachment metadata passed to an execution and stored with its transcript. */ +export interface StoredAttachmentRef { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; +} diff --git a/packages/cli/src/modules/agents/types/components.ts b/packages/cli/src/modules/agents/types/components.ts deleted file mode 100644 index 56b96eb5abf..00000000000 --- a/packages/cli/src/modules/agents/types/components.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Inline components — rendered directly in the chat message */ -export type InlineComponent = - | { - type: 'button'; - label: string; - value: string; - style?: 'primary' | 'danger'; - confirm?: { title: string; text: string; confirm: string; deny: string }; - } - | { type: 'select'; label: string; options: Array<{ label: string; value: string }> } - | { - type: 'section'; - text: string; - /** Optional button displayed inline with this section (renders as an accessory) */ - button?: { label: string; value: string; style?: 'primary' | 'danger' }; - /** Optional image displayed inline with this section (renders as an accessory) */ - image?: { url: string; alt: string }; - } - | { type: 'divider' } - | { type: 'image'; url: string; alt: string } - | { - type: 'context'; - /** Array of text and image elements displayed in a smaller, muted row */ - elements: Array<{ type: 'text'; text: string } | { type: 'image'; url: string; alt: string }>; - }; - -/** Form components — rendered in a modal/dialog */ -export type FormComponent = - | { - type: 'text-input'; - id: string; - label: string; - placeholder?: string; - multiline?: boolean; - } - | { - type: 'select-input'; - id: string; - label: string; - options: Array<{ label: string; value: string }>; - }; - -export type SuspendComponent = InlineComponent | FormComponent; - -/** - * Rich suspend payload for HITL tool interactions. - * Platform integrations (Slack, Discord, etc.) translate these - * abstract components into platform-specific UI. - */ -export interface RichSuspendPayload { - /** Header text displayed above components */ - message?: string; - /** UI components to render */ - components: SuspendComponent[]; - /** Modal title when form components are present */ - formTitle?: string; -} - -/** - * Normalized resume data from any platform interaction. - * All platforms normalize their responses into this shape - * before calling agent.resume(). - */ -export interface ComponentResumeData { - values: Record; -} diff --git a/packages/cli/src/modules/agents/types/index.ts b/packages/cli/src/modules/agents/types/index.ts deleted file mode 100644 index b0efc188be5..00000000000 --- a/packages/cli/src/modules/agents/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { - InlineComponent, - FormComponent, - SuspendComponent, - RichSuspendPayload, - ComponentResumeData, -} from './components'; diff --git a/packages/cli/src/modules/agents/utils/agent-channel.ts b/packages/cli/src/modules/agents/utils/agent-channel.ts new file mode 100644 index 00000000000..3f363d9c187 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-channel.ts @@ -0,0 +1,20 @@ +/** Identifies a persisted integration. An empty credential ID identifies a draft. */ +export interface IntegrationRef { + type: string; + credentialId: string; +} + +/** Identifies one channel of one agent across all processes. */ +export interface AgentChannelRef { + agentId: string; + integrationType: string; + credentialId: string; +} + +export function agentChannelRef(agentId: string, integration: IntegrationRef): AgentChannelRef { + return { agentId, integrationType: integration.type, credentialId: integration.credentialId }; +} + +export function agentChannelKey(ref: AgentChannelRef): string { + return `${ref.agentId}:${ref.integrationType}:${ref.credentialId}`; +} diff --git a/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts b/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts new file mode 100644 index 00000000000..eb6133752cb --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts @@ -0,0 +1,18 @@ +import type { ToolDescriptor } from '@n8n/agents'; + +import type { AgentHistory } from '../entities/agent-history.entity'; +import type { Agent } from '../entities/agent.entity'; + +export type AgentRuntimeAssets = ReturnType; + +export function getAgentRuntimeAssets(agent: Pick) { + const toolDescriptors: Record = {}; + const toolCodeByName: Record = {}; + + for (const [toolId, toolEntry] of Object.entries(agent.tools ?? {})) { + toolDescriptors[toolId] = toolEntry.descriptor; + toolCodeByName[toolEntry.descriptor.name] = toolEntry.code; + } + + return { toolDescriptors, toolCodeByName, skills: agent.skills ?? {} }; +} diff --git a/packages/cli/src/modules/agents/utils/decrypt-agent-credential.ts b/packages/cli/src/modules/agents/utils/decrypt-agent-credential.ts new file mode 100644 index 00000000000..e783da9cb01 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/decrypt-agent-credential.ts @@ -0,0 +1,22 @@ +import type { CredentialsEntity } from '@n8n/db'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import type { CredentialsHelper } from '@/credentials-helper'; + +export async function decryptAgentCredential( + credentialsHelper: CredentialsHelper, + credential: Pick, + scope: { projectId: string; userId?: string }, +): Promise { + // Load lazily because the execution stack imports the agent module. + const { getBase } = await import('@/workflow-execute-additional-data.js'); + const additionalData = await getBase(scope); + + // Agents have no workflow execution context for dynamic credential resolution. + return await credentialsHelper.getDecrypted( + additionalData, + { id: credential.id, name: credential.name }, + credential.type, + 'internal', + ); +} diff --git a/packages/cli/src/modules/agents/utils/execution-failure-summary.ts b/packages/cli/src/modules/agents/utils/execution-failure-summary.ts index a1e9267ff66..c5aaa310554 100644 --- a/packages/cli/src/modules/agents/utils/execution-failure-summary.ts +++ b/packages/cli/src/modules/agents/utils/execution-failure-summary.ts @@ -1,3 +1,4 @@ +import type { AgentExecutionStatus } from '@n8n/api-types'; import { isRecord } from '@n8n/utils/is-record'; import type { TimelineEvent } from '../execution-recorder'; @@ -47,7 +48,7 @@ export function computeExecutionFailureSummary({ stoppedAt, }: { timeline: TimelineEvent[]; - status: 'running' | 'success' | 'error' | 'cancelled' | 'interrupted'; + status: AgentExecutionStatus; error: string | null; stoppedAt: number; }): AgentExecutionFailureSummary | null { diff --git a/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts b/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts new file mode 100644 index 00000000000..d4b3fb34f80 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/get-agent-or-throw.ts @@ -0,0 +1,15 @@ +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import type { Agent } from '../entities/agent.entity'; +import type { AgentRepository } from '../repositories/agent.repository'; + +export async function getAgentOrThrow( + repository: AgentRepository, + agentId: string, + projectId: string, + message = `Agent "${agentId}" not found`, +): Promise { + const agent = await repository.findByIdAndProjectId(agentId, projectId); + if (!agent) throw new NotFoundError(message); + return agent; +} diff --git a/packages/cli/src/modules/agents/utils/inbound-attachments.ts b/packages/cli/src/modules/agents/utils/inbound-attachments.ts index 24a3670b05c..cbb203b6502 100644 --- a/packages/cli/src/modules/agents/utils/inbound-attachments.ts +++ b/packages/cli/src/modules/agents/utils/inbound-attachments.ts @@ -1,7 +1,7 @@ import type { AgentMessage, Message } from '@n8n/agents'; import { MAX_AGENT_CHAT_ATTACHMENT_MIMETYPE_LENGTH } from '@n8n/api-types'; -import type { StoredAttachmentRef } from '../agent-chat-attachment.service'; +import type { StoredAttachmentRef } from '../types/agent-chat-attachment'; /** Media-type families that can be promoted to model file parts (see `isAttachmentMediaTypeSupported`). */ function modelEligibleFamily(mimeType: string): 'image' | 'pdf' | 'audio' | null { diff --git a/packages/cli/src/modules/agents/workflow-agent-stream.ts b/packages/cli/src/modules/agents/workflow-agent-stream.ts index 9b16dda1189..046e77335a1 100644 --- a/packages/cli/src/modules/agents/workflow-agent-stream.ts +++ b/packages/cli/src/modules/agents/workflow-agent-stream.ts @@ -17,7 +17,7 @@ type WorkflowAgentStreamEvent = export type WorkflowAgentStreamObserver = (event: WorkflowAgentStreamEvent) => Promise; -type PendingTool = { +export type PendingTool = { toolName: string; input: unknown; }; diff --git a/packages/cli/test/integration/database/repositories/agent-channel-status.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-channel-status.repository.test.ts index ad7044c09be..1c9b2284011 100644 --- a/packages/cli/test/integration/database/repositories/agent-channel-status.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-channel-status.repository.test.ts @@ -1,3 +1,4 @@ +import type { AgentChannelRef } from '@/modules/agents/utils/agent-channel'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; @@ -5,10 +6,7 @@ import { v4 as uuid } from 'uuid'; import type { Agent } from '@/modules/agents/entities/agent.entity'; import type { AgentChannelObservation } from '@/modules/agents/repositories/agent-channel-status.repository'; -import { - AgentChannelStatusRepository, - type AgentChannelRef, -} from '@/modules/agents/repositories/agent-channel-status.repository'; +import { AgentChannelStatusRepository } from '@/modules/agents/repositories/agent-channel-status.repository'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; const CONNECTED: AgentChannelObservation = {