From 64079ad98c067098eb6473ecbf7a0da6a69ceda0 Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Wed, 6 May 2026 18:44:44 +0300 Subject: [PATCH] feat(core): Agents as first class entities support (no-changelog) (#28017) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Michael Drury Co-authored-by: Arvin A <51036481+DeveloperTheExplorer@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Arvin Ansari Co-authored-by: bjorger <50590409+bjorger@users.noreply.github.com> Co-authored-by: Eugene Co-authored-by: Michael Drury Co-authored-by: Robin Braumann Co-authored-by: Rob Hough --- .gitignore | 1 + .../agents/docs/agent-runtime-architecture.md | 4 +- packages/@n8n/agents/package.json | 20 +- .../src/__tests__/agent-runtime.test.ts | 477 ++++- .../@n8n/agents/src/__tests__/agent.test.ts | 445 ----- .../agents/src/__tests__/describe.test.ts | 405 ----- .../src/__tests__/from-json-config.test.ts | 150 ++ .../agents/src/__tests__/from-schema.test.ts | 606 ------- .../__tests__/inmemory-working-memory.test.ts | 58 +- .../agent-runtime-conversion.test.ts | 327 ++++ .../batched-tool-execution.test.ts | 2 +- .../concurrent-tool-execution.test.ts | 22 +- .../integration/events-and-abort.test.ts | 47 +- .../src/__tests__/integration/helpers.ts | 9 +- ...nterim-user-message-during-suspend.test.ts | 214 +++ .../json-schema-validation.test.ts | 16 +- .../__tests__/integration/mcp-runtime.test.ts | 13 +- .../memory/memory-custom-backend.test.ts | 5 +- .../memory/memory-postgres.test.ts | 65 +- .../integration/multi-tool-calls.test.ts | 18 +- .../orphaned-tool-messages.test.ts | 93 +- .../integration/provider-options.test.ts | 2 +- .../integration/provider-tools.test.ts | 15 +- .../state-restore-after-suspension.test.ts | 12 +- .../integration/stream-timing.test.ts | 11 +- .../__tests__/integration/sub-agent.test.ts | 14 +- .../integration/to-model-output.test.ts | 26 +- .../integration/tool-call-upsert.test.ts | 222 +++ .../integration/tool-error-handling.test.ts | 9 +- .../integration/tool-interrupt.test.ts | 37 +- .../workspace/workspace-agent.test.ts | 19 +- .../integration/zod-validation-error.test.ts | 8 +- .../agents/src/__tests__/message-list.test.ts | 117 +- .../src/__tests__/model-factory.test.ts | 230 ++- .../@n8n/agents/src/__tests__/parse.test.ts | 146 ++ .../src/__tests__/sqlite-memory.test.ts | 2 +- .../strip-orphaned-tool-messages.test.ts | 123 +- .../src/__tests__/title-generation.test.ts | 35 - .../@n8n/agents/src/__tests__/tool.test.ts | 31 + .../agents/src/codegen/generate-agent-code.ts | 217 --- packages/@n8n/agents/src/index.ts | 35 +- .../@n8n/agents/src/runtime/agent-runtime.ts | 702 ++++---- packages/@n8n/agents/src/runtime/event-bus.ts | 4 + .../@n8n/agents/src/runtime/memory-store.ts | 18 +- .../@n8n/agents/src/runtime/message-list.ts | 74 +- packages/@n8n/agents/src/runtime/messages.ts | 273 +-- .../@n8n/agents/src/runtime/model-factory.ts | 209 ++- .../src/runtime/provider-credentials.ts | 42 + .../agents/src/runtime/runtime-helpers.ts | 54 +- packages/@n8n/agents/src/runtime/stream.ts | 88 +- .../runtime/strip-orphaned-tool-messages.ts | 47 +- .../agents/src/runtime/title-generation.ts | 83 +- .../@n8n/agents/src/runtime/working-memory.ts | 6 +- packages/@n8n/agents/src/sdk/agent.ts | 573 +----- packages/@n8n/agents/src/sdk/eval.ts | 21 +- packages/@n8n/agents/src/sdk/from-schema.ts | 364 ---- .../agents/src/sdk/provider-capabilities.ts | 6 +- .../@n8n/agents/src/sdk/provider-tools.ts | 74 +- packages/@n8n/agents/src/sdk/tool.ts | 98 +- packages/@n8n/agents/src/sdk/verify.ts | 10 +- .../@n8n/agents/src/storage/base-memory.ts | 93 + .../agents/src/storage/postgres-memory.ts | 134 +- .../@n8n/agents/src/storage/sqlite-memory.ts | 35 +- packages/@n8n/agents/src/types/index.ts | 2 +- .../@n8n/agents/src/types/runtime/event.ts | 14 +- .../agents/src/types/sdk/agent-builder.ts | 3 - packages/@n8n/agents/src/types/sdk/agent.ts | 103 +- .../src/types/sdk/credential-provider.ts | 7 +- packages/@n8n/agents/src/types/sdk/memory.ts | 18 + packages/@n8n/agents/src/types/sdk/message.ts | 33 +- .../agents/src/types/sdk/tool-descriptor.ts | 21 + packages/@n8n/agents/src/types/sdk/tool.ts | 25 +- packages/@n8n/agents/src/utils/parse.ts | 40 + packages/@n8n/agents/src/utils/zod.ts | 6 +- .../code-builder-node-search-engine.ts | 18 +- .../code-builder-node-search-engine.test.ts | 17 + .../src/code-builder/index.ts | 1 + .../tools/code-builder-get.tool.ts | 3 +- .../tools/code-builder-search.tool.ts | 241 ++- .../code-builder/utils/node-type-parser.ts | 8 +- .../@n8n/ai-workflow-builder.ee/src/index.ts | 1 + .../agent-builder-admin-settings.test.ts | 95 + .../src/agent-builder-interactive.ts | 114 ++ packages/@n8n/api-types/src/agent-sse.ts | 96 + packages/@n8n/api-types/src/agents.ts | 311 ++++ .../agents/__tests__/agent-skill.dto.test.ts | 19 + .../src/dto/agents/agent-build-resume.dto.ts | 17 + .../src/dto/agents/agent-chat-message.dto.ts | 8 + .../src/dto/agents/agent-integration.dto.ts | 8 + .../src/dto/agents/create-agent-skill.dto.ts | 17 + .../src/dto/agents/create-agent.dto.ts | 7 + .../src/dto/agents/update-agent-config.dto.ts | 7 + .../dto/agents/update-agent-schedule.dto.ts | 8 + .../src/dto/agents/update-agent-skill.dto.ts | 8 + .../src/dto/agents/update-agent.dto.ts | 9 + packages/@n8n/api-types/src/dto/index.ts | 14 + .../@n8n/api-types/src/frontend-settings.ts | 13 + packages/@n8n/api-types/src/index.ts | 25 + .../src/modules/modules.config.ts | 1 + .../@n8n/config/src/configs/agents.config.ts | 44 + .../@n8n/config/src/configs/logging.config.ts | 1 + packages/@n8n/config/src/index.ts | 5 + packages/@n8n/config/test/config.test.ts | 4 + .../common/1783000000000-CreateAgentTables.ts | 111 ++ ...783000000001-CreateAgentExecutionTables.ts | 78 + .../db/src/migrations/postgresdb/index.ts | 8 +- .../@n8n/db/src/migrations/sqlite/index.ts | 8 +- .../@n8n/decorators/src/controller/route.ts | 3 + .../@n8n/decorators/src/controller/types.ts | 2 + .../VectorStoreRedis.node.test.ts | 13 +- .../scope-information.test.ts.snap | 10 + packages/@n8n/permissions/src/constants.ee.ts | 3 +- .../src/roles/scopes/global-scopes.ee.ts | 9 + .../src/roles/scopes/project-scopes.ee.ts | 28 +- .../@n8n/permissions/src/scope-information.ts | 32 + .../get-resource-permissions.test.ts | 2 + .../src/generate-types/generate-types.ts | 6 +- .../generate-zod-schemas.test.ts | 420 +++-- .../generate-types/generate-zod-schemas.ts | 574 +++--- packages/@n8n/workflow-sdk/src/index.ts | 2 + packages/@n8n/workflow-sdk/src/validation.ts | 2 + .../@n8n/workflow-sdk/src/validation/index.ts | 6 +- packages/cli/package.json | 15 +- packages/cli/scripts/build.mjs | 2 + packages/cli/scripts/bundle-agent-library.mjs | 91 + packages/cli/src/abstract-server.ts | 24 +- packages/cli/src/commands/start.ts | 2 + packages/cli/src/controller.registry.ts | 11 + .../agent-config-composition.test.ts | 68 + .../__tests__/agent-json-config.test.ts | 92 + ...agent-published-version.repository.test.ts | 94 + .../__tests__/agent-secure-runtime.test.ts | 378 ++++ .../__tests__/agent-skills.service.test.ts | 135 ++ .../agents/__tests__/agent.repository.test.ts | 165 ++ .../agents-builder-tools.service.test.ts | 329 ++++ .../agents-service-reconstruct-gating.test.ts | 136 ++ .../__tests__/agents-service-sync.test.ts | 156 ++ .../__tests__/agents-tools.service.test.ts | 330 ++++ .../__tests__/agents.controller.test.ts | 46 + .../agents/__tests__/agents.service.test.ts | 965 ++++++++++ .../__tests__/execution-recorder.test.ts | 474 +++++ .../agents/__tests__/from-json-config.test.ts | 761 ++++++++ .../__tests__/integration-config.test.ts | 72 + .../__tests__/schema-text-serializer.test.ts | 590 +++++++ .../agents/__tests__/tool-registry.test.ts | 76 + .../__tests__/workflow-tool-factory.test.ts | 150 ++ .../adapters/agents-credential-provider.ts | 59 + .../modules/agents/agent-execution.service.ts | 259 +++ .../modules/agents/agent-message-mapper.ts | 34 + .../modules/agents/agent-skills.service.ts | 202 +++ .../src/modules/agents/agent-sse-stream.ts | 304 ++++ .../modules/agents/agents-tools.service.ts | 267 +++ .../src/modules/agents/agents.controller.ts | 865 +++++++++ .../cli/src/modules/agents/agents.module.ts | 114 ++ .../cli/src/modules/agents/agents.service.ts | 1572 +++++++++++++++++ .../agents-builder-settings.service.test.ts | 374 ++++ .../agents/builder/agents-builder-prompts.ts | 589 ++++++ .../agents-builder-settings.controller.ts | 46 + .../agents-builder-settings.service.ts | 240 +++ .../builder/agents-builder-tools.service.ts | 499 ++++++ .../agents/builder/agents-builder.service.ts | 237 +++ .../agents/builder/builder-tool-names.ts | 21 + .../cli/src/modules/agents/builder/errors.ts | 18 + .../__tests__/ask-credential.tool.test.ts | 80 + .../__tests__/ask-llm.tool.test.ts | 39 + .../__tests__/ask-question.tool.test.ts | 58 + .../__tests__/resolve-llm.tool.test.ts | 118 ++ .../interactive/ask-credential.tool.ts | 50 + .../builder/interactive/ask-llm.tool.ts | 31 + .../builder/interactive/ask-question.tool.ts | 37 + .../agents/builder/interactive/index.ts | 4 + .../interactive/llm-provider-defaults.ts | 29 + .../builder/interactive/resolve-llm.tool.ts | 124 ++ .../entities/agent-checkpoint.entity.ts | 23 + .../entities/agent-execution-thread.entity.ts | 65 + .../agents/entities/agent-execution.entity.ts | 87 + .../agents/entities/agent-message.entity.ts | 26 + .../agent-published-version.entity.ts | 63 + .../agents/entities/agent-resource.entity.ts | 11 + .../agents/entities/agent-thread.entity.ts | 14 + .../modules/agents/entities/agent.entity.ts | 57 + .../src/modules/agents/execution-recorder.ts | 410 +++++ .../__tests__/agent-chat-bridge.test.ts | 230 +++ .../__tests__/agent-schedule.service.test.ts | 268 +++ .../chat-integration.service.test.ts | 149 ++ .../__tests__/component-mapper.test.ts | 438 +++++ .../__tests__/linear-integration.test.ts | 101 ++ .../integrations/__tests__/n8n-memory.test.ts | 308 ++++ .../__tests__/rich-interaction-tool.test.ts | 125 ++ .../agents/integrations/agent-chat-bridge.ts | 889 ++++++++++ .../integrations/agent-chat-integration.ts | 122 ++ .../integrations/agent-schedule.service.ts | 379 ++++ .../agents/integrations/callback-store.ts | 47 + .../integrations/chat-integration.service.ts | 461 +++++ .../agents/integrations/component-mapper.ts | 369 ++++ .../agents/integrations/cron-validation.ts | 11 + .../modules/agents/integrations/esm-loader.ts | 43 + .../agents/integrations/integrations-sync.ts | 74 + .../integrations/n8n-checkpoint-storage.ts | 143 ++ .../modules/agents/integrations/n8n-memory.ts | 274 +++ .../__tests__/telegram-integration.test.ts | 148 ++ .../platforms/linear-integration.ts | 118 ++ .../platforms/slack-integration.ts | 90 + .../platforms/telegram-integration.ts | 186 ++ .../integrations/rich-interaction-tool.ts | 164 ++ .../src/modules/agents/integrations/types.ts | 21 + .../json-config/agent-config-composition.ts | 45 + .../agents/json-config/agent-json-config.ts | 165 ++ .../json-config/credential-field-mapping.ts | 84 + .../agents/json-config/from-json-config.ts | 320 ++++ .../agents/json-config/integration-config.ts | 37 + .../json-config/provider-tool-aliases.ts | 47 + .../json-config/schema-text-serializer.ts | 496 ++++++ .../agent-checkpoint.repository.ts | 22 + .../agent-execution-thread.repository.ts | 115 ++ .../agent-execution.repository.ts | 68 + .../repositories/agent-message.repository.ts | 11 + .../agent-published-version.repository.ts | 53 + .../repositories/agent-resource.repository.ts | 11 + .../repositories/agent-thread.repository.ts | 11 + .../agents/repositories/agent.repository.ts | 71 + .../agents/runtime/agent-isolate-pool.ts | 385 ++++ .../agents/runtime/agent-secure-runtime.ts | 373 ++++ .../agents/runtime/sandbox-polyfills.ts | 80 + .../cli/src/modules/agents/tool-registry.ts | 61 + .../tools/__tests__/node-tool-factory.test.ts | 160 ++ .../modules/agents/tools/node-tool-factory.ts | 174 ++ .../agents/tools/workflow-tool-factory.ts | 645 +++++++ .../src/modules/agents/types/components.ts | 66 + .../cli/src/modules/agents/types/index.ts | 7 + .../modules/agents/utils/agent-draft.utils.ts | 17 + .../modules/agents/utils/agent-resource-id.ts | 17 + ...hts-collection.service.integration.test.ts | 1 + .../insights/insights-collection.service.ts | 3 + .../instance-ai/instance-ai.service.ts | 2 +- .../modules/mcp/__tests__/mcp.service.test.ts | 18 +- packages/cli/src/modules/mcp/mcp.service.ts | 12 +- .../get-suggested-workflow-nodes.tool.ts | 6 +- .../get-workflow-node-types.tool.ts | 6 +- .../search-workflow-nodes.tool.ts | 6 +- .../__tests__/node-catalog.service.test.ts} | 94 +- packages/cli/src/node-catalog/index.ts | 5 + .../node-catalog.service.ts} | 85 +- .../__tests__/ephemeral-node-executor.test.ts | 620 +++++++ .../node-execution/ephemeral-node-executor.ts | 534 ++++++ packages/cli/src/node-execution/index.ts | 10 + packages/cli/src/server.ts | 1 + .../__tests__/auth.roles.service.test.ts | 1 + .../__tests__/proxy-token-manager.test.ts | 0 .../proxy-token-manager.ts | 0 .../services/workflow-statistics.service.ts | 6 +- packages/cli/src/utils/ttl-map.ts | 110 ++ .../src/workflow-execute-additional-data.ts | 53 + packages/core/package.json | 2 +- .../base-execute-context.ts | 24 + .../node-execution-context.ts | 7 + .../components/N8nBreadcrumbs/Breadcrumbs.vue | 4 +- .../src/components/N8nIcon/custom/linear.svg | 1 + .../src/components/N8nIcon/custom/slack.svg | 1 + .../components/N8nIcon/custom/telegram.svg | 1 + .../src/components/N8nIcon/icons.ts | 11 + .../components/N8nIconPicker/IconPicker.vue | 41 +- .../src/components/N8nTabs/Tabs.vue | 17 +- .../@n8n/design-system/src/css/_tokens.scss | 2 + .../design-system/src/css/mixins/index.scss | 1 + .../src/css/mixins/markdown.scss | 313 ++++ .../@n8n/design-system/src/types/tabs.ts | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 380 ++++ packages/frontend/editor-ui/src/Interface.ts | 1 + .../components/MainHeader/MainHeader.test.ts | 3 +- .../MainHeader/WorkflowDetails.test.ts | 3 +- .../src/app/components/MainSidebarHeader.vue | 1 + .../editor-ui/src/app/components/Modals.vue | 12 + .../WorkflowProductionChecklist.test.ts | 2 - .../composables/useCanvasOperations.test.ts | 4 +- .../src/app/composables/useN8nLocalStorage.ts | 2 +- .../src/app/composables/useNodeHelpers.ts | 1 + .../src/app/constants/localStorage.ts | 1 + .../editor-ui/src/app/constants/modals.ts | 1 + .../editor-ui/src/app/constants/nodeTypes.ts | 3 + .../moduleInitializer/moduleInitializer.ts | 2 + .../src/app/stores/rbac.store.test.ts | 1 - .../editor-ui/src/app/stores/rbac.store.ts | 1 + .../src/app/stores/settings.store.test.ts | 3 +- .../src/app/stores/settings.store.ts | 24 +- .../editor-ui/src/app/stores/ui.store.ts | 7 +- .../__tests__/AgentAdvancedPanel.test.ts | 131 ++ .../__tests__/AgentBuilderHeader.test.ts | 225 +++ .../agents/__tests__/AgentBuilderView.test.ts | 818 +++++++++ .../AgentCapabilitiesSection.test.ts | 92 + .../agents/__tests__/AgentChatPanel.test.ts | 147 ++ .../__tests__/AgentChatQuickActions.test.ts | 146 ++ .../agents/__tests__/AgentConfigTree.test.ts | 72 + .../__tests__/AgentPublishButton.test.ts | 338 ++++ .../AgentScheduleTriggerCard.test.ts | 312 ++++ .../__tests__/AgentSectionEditor.test.ts | 46 + .../agents/__tests__/AgentSkillModal.test.ts | 96 + .../agents/__tests__/AgentSkillViewer.test.ts | 97 + .../__tests__/AgentToolConfigModal.test.ts | 262 +++ .../agents/__tests__/AgentToolsModal.test.ts | 722 ++++++++ .../__tests__/AskCredentialCard.test.ts | 186 ++ .../agents/__tests__/AskLlmCard.test.ts | 183 ++ .../agents/__tests__/AskQuestionCard.test.ts | 105 ++ .../agents/__tests__/NewAgentView.test.ts | 25 + .../__tests__/SessionDetailPanel.spec.ts | 174 ++ .../__tests__/SessionEventFilter.spec.ts | 94 + .../__tests__/SessionTimelineChart.spec.ts | 101 ++ .../__tests__/SessionTimelineTable.spec.ts | 122 ++ .../WorkflowExecutionLogViewer.spec.ts | 141 ++ .../__tests__/agentChatMessages.test.ts | 365 ++++ .../agentSectionEditor.utils.test.ts | 93 + .../__tests__/agentTelemetry.utils.test.ts | 113 ++ .../agents/__tests__/constants.test.ts | 10 + .../__tests__/interactive-summary.test.ts | 68 + .../agents/__tests__/model-string.test.ts | 71 + .../__tests__/provider-capabilities.test.ts | 41 + .../agents/__tests__/relative-time.test.ts | 52 + .../__tests__/session-timeline.utils.spec.ts | 314 ++++ .../agents/__tests__/thread-title.test.ts | 43 + .../agents/__tests__/toolDisplayName.test.ts | 26 + .../__tests__/useAgentChatStream.test.ts | 310 ++++ .../__tests__/useAgentConfigAutosave.test.ts | 78 + .../useAgentIntegrationsCatalog.test.ts | 94 + .../__tests__/useAgentTelemetry.test.ts | 140 ++ .../__tests__/useAgentToolRefAdapter.test.ts | 366 ++++ .../__tests__/useAgentToolTelemetry.test.ts | 98 + .../__tests__/useCodeMirrorEditor.test.ts | 99 ++ .../__tests__/useProjectAgentsList.test.ts | 78 + .../src/features/agents/agent.types.ts | 38 + .../agents/agentBuilderSettings.store.ts | 124 ++ .../features/agents/agentSessions.store.ts | 183 ++ .../src/features/agents/agents.eventBus.ts | 8 + .../components/AgentAddTriggerModal.vue | 823 +++++++++ .../agents/components/AgentAdvancedPanel.vue | 293 +++ .../components/AgentBuilderChatColumn.vue | 250 +++ .../components/AgentBuilderChatModeToggle.vue | 76 + .../components/AgentBuilderEditorColumn.vue | 240 +++ .../agents/components/AgentBuilderHeader.vue | 207 +++ .../components/AgentBuilderProgress.vue | 371 ++++ .../AgentBuilderUnconfiguredEmptyState.vue | 70 + .../components/AgentCapabilitiesSection.vue | 301 ++++ .../features/agents/components/AgentCard.vue | 189 ++ .../agents/components/AgentChatEmptyState.vue | 37 + .../components/AgentChatMessageList.vue | 351 ++++ .../agents/components/AgentChatPanel.vue | 310 ++++ .../components/AgentChatQuickActions.vue | 94 + .../agents/components/AgentChatToolSteps.vue | 146 ++ .../agents/components/AgentChipButton.vue | 164 ++ .../agents/components/AgentConfigTree.vue | 286 +++ .../components/AgentConfirmationModal.vue | 99 ++ .../components/AgentCustomToolViewer.vue | 79 + .../agents/components/AgentEvalsPanel.vue | 135 ++ .../agents/components/AgentIdentityHeader.vue | 82 + .../agents/components/AgentInfoPanel.vue | 222 +++ .../components/AgentIntegrationsPanel.vue | 670 +++++++ .../agents/components/AgentJsonCopyButton.vue | 69 + .../agents/components/AgentJsonEditor.vue | 162 ++ .../agents/components/AgentMemoryPanel.vue | 157 ++ .../agents/components/AgentMiniEditor.vue | 66 + .../agents/components/AgentPanelHeader.vue | 25 + .../agents/components/AgentPublishButton.vue | 209 +++ .../components/AgentScheduleTriggerCard.vue | 430 +++++ .../agents/components/AgentSectionEditor.vue | 233 +++ .../agents/components/AgentSkillModal.vue | 178 ++ .../agents/components/AgentSkillViewer.vue | 345 ++++ .../components/AgentSkillsListPanel.vue | 174 ++ .../components/AgentToolConfigModal.vue | 361 ++++ .../agents/components/AgentToolItem.vue | 144 ++ .../agents/components/AgentToolsListPanel.vue | 401 +++++ .../agents/components/AgentToolsModal.vue | 578 ++++++ .../agents/components/RichInteractionCard.vue | 235 +++ .../agents/components/SessionDetailPanel.vue | 557 ++++++ .../agents/components/SessionEventFilter.vue | 100 ++ .../components/SessionTimelineChart.vue | 432 +++++ .../agents/components/SessionTimelinePill.vue | 73 + .../agents/components/SessionTimelineRow.vue | 153 ++ .../components/SessionTimelineTable.vue | 298 ++++ .../agents/components/ToolConnectedBadge.vue | 29 + .../components/ToolCredsMissingChip.vue | 61 + .../features/agents/components/ToolIoView.vue | 303 ++++ .../components/WorkflowExecutionLogViewer.vue | 373 ++++ .../components/WorkflowToolConfigContent.vue | 123 ++ .../agents/components/WorkflowToolRow.vue | 128 ++ .../interactive/AskCredentialCard.vue | 180 ++ .../components/interactive/AskLlmCard.vue | 153 ++ .../interactive/AskQuestionCard.vue | 228 +++ .../interactive/InteractiveCard.vue | 73 + .../settings/AgentBuilderModelSection.vue | 265 +++ .../agents/composables/agentChatMessages.ts | 363 ++++ .../composables/agentTelemetry.utils.ts | 82 + .../agents/composables/useAgentApi.ts | 404 +++++ .../composables/useAgentBuilderMainTabs.ts | 93 + .../composables/useAgentBuilderSession.ts | 137 ++ .../composables/useAgentBuilderSettingsApi.ts | 30 + .../composables/useAgentBuilderStatus.ts | 16 + .../composables/useAgentBuilderTelemetry.ts | 308 ++++ .../agents/composables/useAgentChatMode.ts | 40 + .../agents/composables/useAgentChatStream.ts | 593 +++++++ .../agents/composables/useAgentConfig.ts | 47 + .../composables/useAgentConfigAutosave.ts | 147 ++ .../composables/useAgentConfirmationModal.ts | 43 + .../composables/useAgentIntegrationStatus.ts | 156 ++ .../useAgentIntegrationsCatalog.ts | 33 + .../agents/composables/useAgentPublish.ts | 111 ++ .../agents/composables/useAgentSectionNav.ts | 88 + .../agents/composables/useAgentTelemetry.ts | 146 ++ .../agents/composables/useAgentThreadsApi.ts | 114 ++ .../composables/useAgentToolRefAdapter.ts | 154 ++ .../composables/useAgentToolTelemetry.ts | 74 + .../agents/composables/useCodeMirrorEditor.ts | 80 + .../agents/composables/useModelCatalog.ts | 32 + .../composables/useProjectAgentsList.ts | 78 + .../src/features/agents/constants.ts | 54 + .../src/features/agents/module.descriptor.ts | 203 +++ .../features/agents/provider-capabilities.ts | 25 + .../src/features/agents/provider-mapping.ts | 41 + .../agents/session-timeline.styles.ts | 42 + .../features/agents/session-timeline.types.ts | 48 + .../features/agents/session-timeline.utils.ts | 302 ++++ .../agents/styles/agent-panel.module.scss | 4 + .../editor-ui/src/features/agents/types.ts} | 74 +- .../agents/utils/agentSectionEditor.utils.ts | 92 + .../agents/utils/interactive-summary.ts | 57 + .../src/features/agents/utils/model-string.ts | 45 + .../features/agents/utils/relative-time.ts | 81 + .../src/features/agents/utils/thread-title.ts | 40 + .../features/agents/utils/toolDisplayName.ts | 23 + .../agents/views/AgentBuilderView.vue | 963 ++++++++++ .../agents/views/AgentSessionTimelineView.vue | 644 +++++++ .../agents/views/AgentSessionsListView.vue | 209 +++ .../src/features/agents/views/AgentView.vue | 17 + .../features/agents/views/AgentsListView.vue | 118 ++ .../features/agents/views/NewAgentView.vue | 564 ++++++ .../agents/views/SettingsAgentBuilderView.vue | 75 + .../ai/assistant/builder.store.test.ts | 1 - .../src/features/ai/chatHub/ChatView.test.ts | 1 - .../components/AgentEditorModal.test.ts | 1 - .../chatHub/components/ChatMarkdownChunk.vue | 328 +--- .../ai/chatHub/components/ModelSelector.vue | 68 +- .../components/ToolSettingsModal.test.ts | 2 +- .../chatHub/components/ToolSettingsModal.vue | 6 +- .../components/ToolsManagerModal.test.ts | 2 +- .../chatHub/components/ToolsManagerModal.vue | 8 +- .../composables/useChatHubMarkdownOptions.ts | 12 +- .../ai/chatHub/model-selector.utils.ts | 3 + .../components/AgentActivityTree.vue | 3 +- .../ai/instanceAi/components/ArtifactCard.vue | 3 +- .../InstanceAiPromptSuggestions.vue | 57 +- .../components/InstanceAiQuestions.vue | 73 +- .../ai/shared/components/ChatInputBase.vue | 9 +- .../styles/_prompt-suggestion-buttons.scss | 62 + .../shared/styles/_question-option-rows.scss | 79 + .../projects/components/ProjectHeader.test.ts | 37 +- .../projects/components/ProjectHeader.vue | 48 +- .../projects/components/ProjectTabs.test.ts | 12 + .../components/RoleHoverPopover.test.ts | 2 +- .../composables/useFavoriteNavItems.test.ts | 1 - .../components/FolderBreadcrumbs.test.ts | 3 +- .../CredentialEdit/CredentialConfig.vue | 2 + .../CredentialEdit/CredentialEdit.vue | 6 + .../components/NodeCredentials.test.ts | 4 + .../components/NodeCredentials.vue | 7 +- .../components/global/AgentSessionsList.vue | 227 +++ .../global/GlobalExecutionsList.vue | 102 +- .../executions/views/ExecutionsView.vue | 23 +- .../runData/components/RunDataJson.test.ts | 3 +- .../project-roles/projectRoleScopes.ts | 2 + .../toolConfig/NodeToolSettingsContent.vue} | 18 +- .../NodeToolSettingsContent.test.ts} | 6 +- .../components/ReadyToRunButton.test.ts | 3 +- .../credentials/SlackOAuth2Api.credentials.ts | 9 + .../MessageAnAgent/MessageAnAgent.node.json | 13 + .../MessageAnAgent/MessageAnAgent.node.ts | 154 ++ .../__tests__/MessageAnAgent.node.test.ts | 176 ++ .../nodes/N8n/n8n-api-coverage.json | 3 + packages/nodes-base/package.json | 3 +- .../playwright/pages/ChatHubChatPage.ts | 2 +- .../testing/playwright/pages/SidebarPage.ts | 48 +- .../e2e/ai/assistant-credential-help.spec.ts | 4 +- packages/workflow/src/execution-context.ts | 1 + packages/workflow/src/interfaces.ts | 48 + packages/workflow/src/node-helpers.ts | 22 + pnpm-lock.yaml | 810 ++++++--- pnpm-workspace.yaml | 10 + 484 files changed, 57825 insertions(+), 5393 deletions(-) delete mode 100644 packages/@n8n/agents/src/__tests__/agent.test.ts delete mode 100644 packages/@n8n/agents/src/__tests__/describe.test.ts create mode 100644 packages/@n8n/agents/src/__tests__/from-json-config.test.ts delete mode 100644 packages/@n8n/agents/src/__tests__/from-schema.test.ts create mode 100644 packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts create mode 100644 packages/@n8n/agents/src/__tests__/integration/interim-user-message-during-suspend.test.ts create mode 100644 packages/@n8n/agents/src/__tests__/integration/tool-call-upsert.test.ts create mode 100644 packages/@n8n/agents/src/__tests__/parse.test.ts delete mode 100644 packages/@n8n/agents/src/codegen/generate-agent-code.ts create mode 100644 packages/@n8n/agents/src/runtime/provider-credentials.ts delete mode 100644 packages/@n8n/agents/src/sdk/from-schema.ts create mode 100644 packages/@n8n/agents/src/storage/base-memory.ts create mode 100644 packages/@n8n/agents/src/types/sdk/tool-descriptor.ts create mode 100644 packages/@n8n/agents/src/utils/parse.ts create mode 100644 packages/@n8n/api-types/src/__tests__/agent-builder-admin-settings.test.ts create mode 100644 packages/@n8n/api-types/src/agent-builder-interactive.ts create mode 100644 packages/@n8n/api-types/src/agent-sse.ts create mode 100644 packages/@n8n/api-types/src/agents.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts create mode 100644 packages/@n8n/config/src/configs/agents.config.ts create mode 100644 packages/@n8n/db/src/migrations/common/1783000000000-CreateAgentTables.ts create mode 100644 packages/@n8n/db/src/migrations/common/1783000000001-CreateAgentExecutionTables.ts create mode 100644 packages/cli/scripts/bundle-agent-library.mjs create mode 100644 packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-published-version.repository.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-secure-runtime.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent.repository.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agents-tools.service.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agents.controller.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agents.service.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/from-json-config.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/integration-config.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/tool-registry.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts create mode 100644 packages/cli/src/modules/agents/adapters/agents-credential-provider.ts create mode 100644 packages/cli/src/modules/agents/agent-execution.service.ts create mode 100644 packages/cli/src/modules/agents/agent-message-mapper.ts create mode 100644 packages/cli/src/modules/agents/agent-skills.service.ts create mode 100644 packages/cli/src/modules/agents/agent-sse-stream.ts create mode 100644 packages/cli/src/modules/agents/agents-tools.service.ts create mode 100644 packages/cli/src/modules/agents/agents.controller.ts create mode 100644 packages/cli/src/modules/agents/agents.module.ts create mode 100644 packages/cli/src/modules/agents/agents.service.ts create mode 100644 packages/cli/src/modules/agents/builder/__tests__/agents-builder-settings.service.test.ts create mode 100644 packages/cli/src/modules/agents/builder/agents-builder-prompts.ts create mode 100644 packages/cli/src/modules/agents/builder/agents-builder-settings.controller.ts create mode 100644 packages/cli/src/modules/agents/builder/agents-builder-settings.service.ts create mode 100644 packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts create mode 100644 packages/cli/src/modules/agents/builder/agents-builder.service.ts create mode 100644 packages/cli/src/modules/agents/builder/builder-tool-names.ts create mode 100644 packages/cli/src/modules/agents/builder/errors.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/index.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/llm-provider-defaults.ts create mode 100644 packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-execution.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-message.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-published-version.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-resource.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent-thread.entity.ts create mode 100644 packages/cli/src/modules/agents/entities/agent.entity.ts create mode 100644 packages/cli/src/modules/agents/execution-recorder.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/n8n-memory.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/__tests__/rich-interaction-tool.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts create mode 100644 packages/cli/src/modules/agents/integrations/agent-chat-integration.ts create mode 100644 packages/cli/src/modules/agents/integrations/agent-schedule.service.ts create mode 100644 packages/cli/src/modules/agents/integrations/callback-store.ts create mode 100644 packages/cli/src/modules/agents/integrations/chat-integration.service.ts create mode 100644 packages/cli/src/modules/agents/integrations/component-mapper.ts create mode 100644 packages/cli/src/modules/agents/integrations/cron-validation.ts create mode 100644 packages/cli/src/modules/agents/integrations/esm-loader.ts create mode 100644 packages/cli/src/modules/agents/integrations/integrations-sync.ts create mode 100644 packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts create mode 100644 packages/cli/src/modules/agents/integrations/n8n-memory.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts create mode 100644 packages/cli/src/modules/agents/integrations/rich-interaction-tool.ts create mode 100644 packages/cli/src/modules/agents/integrations/types.ts create mode 100644 packages/cli/src/modules/agents/json-config/agent-config-composition.ts create mode 100644 packages/cli/src/modules/agents/json-config/agent-json-config.ts create mode 100644 packages/cli/src/modules/agents/json-config/credential-field-mapping.ts create mode 100644 packages/cli/src/modules/agents/json-config/from-json-config.ts create mode 100644 packages/cli/src/modules/agents/json-config/integration-config.ts create mode 100644 packages/cli/src/modules/agents/json-config/provider-tool-aliases.ts create mode 100644 packages/cli/src/modules/agents/json-config/schema-text-serializer.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-execution.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-message.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-resource.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent-thread.repository.ts create mode 100644 packages/cli/src/modules/agents/repositories/agent.repository.ts create mode 100644 packages/cli/src/modules/agents/runtime/agent-isolate-pool.ts create mode 100644 packages/cli/src/modules/agents/runtime/agent-secure-runtime.ts create mode 100644 packages/cli/src/modules/agents/runtime/sandbox-polyfills.ts create mode 100644 packages/cli/src/modules/agents/tool-registry.ts create mode 100644 packages/cli/src/modules/agents/tools/__tests__/node-tool-factory.test.ts create mode 100644 packages/cli/src/modules/agents/tools/node-tool-factory.ts create mode 100644 packages/cli/src/modules/agents/tools/workflow-tool-factory.ts create mode 100644 packages/cli/src/modules/agents/types/components.ts create mode 100644 packages/cli/src/modules/agents/types/index.ts create mode 100644 packages/cli/src/modules/agents/utils/agent-draft.utils.ts create mode 100644 packages/cli/src/modules/agents/utils/agent-resource-id.ts rename packages/cli/src/{modules/mcp/__tests__/workflow-builder-tools.service.test.ts => node-catalog/__tests__/node-catalog.service.test.ts} (77%) create mode 100644 packages/cli/src/node-catalog/index.ts rename packages/cli/src/{modules/mcp/tools/workflow-builder/workflow-builder-tools.service.ts => node-catalog/node-catalog.service.ts} (65%) create mode 100644 packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts create mode 100644 packages/cli/src/node-execution/ephemeral-node-executor.ts create mode 100644 packages/cli/src/node-execution/index.ts rename packages/cli/src/{modules/instance-ai => services}/__tests__/proxy-token-manager.test.ts (100%) rename packages/cli/src/{modules/instance-ai => services}/proxy-token-manager.ts (100%) create mode 100644 packages/cli/src/utils/ttl-map.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/linear.svg create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/slack.svg create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/telegram.svg create mode 100644 packages/frontend/@n8n/design-system/src/css/mixins/markdown.scss create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatQuickActions.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentConfigTree.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentScheduleTriggerCard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentSectionEditor.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolConfigModal.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentToolsModal.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AskCredentialCard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AskLlmCard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AskQuestionCard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/NewAgentView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/SessionEventFilter.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineTable.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/WorkflowExecutionLogViewer.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/agentSectionEditor.utils.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/agentTelemetry.utils.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/constants.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/interactive-summary.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/model-string.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/provider-capabilities.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/relative-time.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/thread-title.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/toolDisplayName.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useAgentConfigAutosave.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useAgentIntegrationsCatalog.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useAgentTelemetry.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolRefAdapter.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useAgentToolTelemetry.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useCodeMirrorEditor.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/useProjectAgentsList.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/agent.types.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/agentBuilderSettings.store.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/agentSessions.store.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/agents.eventBus.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentAddTriggerModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentAdvancedPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatModeToggle.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentBuilderProgress.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentBuilderUnconfiguredEmptyState.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChatEmptyState.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChatQuickActions.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentConfigTree.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentConfirmationModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentCustomToolViewer.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentEvalsPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentIdentityHeader.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentIntegrationsPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentJsonCopyButton.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentJsonEditor.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentMiniEditor.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentPanelHeader.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentPublishButton.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentScheduleTriggerCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentSectionEditor.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentSkillModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentSkillViewer.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentSkillsListPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentToolConfigModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentToolItem.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentToolsListPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/AgentToolsModal.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/RichInteractionCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/SessionEventFilter.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/SessionTimelineChart.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/SessionTimelinePill.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/SessionTimelineRow.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/SessionTimelineTable.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/ToolConnectedBadge.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/ToolCredsMissingChip.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/ToolIoView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/WorkflowExecutionLogViewer.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/WorkflowToolConfigContent.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/WorkflowToolRow.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/interactive/AskCredentialCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/interactive/AskLlmCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/interactive/AskQuestionCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/interactive/InteractiveCard.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/components/settings/AgentBuilderModelSection.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/agentChatMessages.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/agentTelemetry.utils.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderMainTabs.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderSession.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderSettingsApi.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderStatus.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentBuilderTelemetry.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentChatMode.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentConfig.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentConfigAutosave.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentConfirmationModal.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationStatus.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentIntegrationsCatalog.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentPublish.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentSectionNav.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentThreadsApi.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentToolRefAdapter.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentToolTelemetry.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useCodeMirrorEditor.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useModelCatalog.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useProjectAgentsList.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/constants.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/module.descriptor.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/provider-capabilities.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/provider-mapping.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/session-timeline.styles.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/session-timeline.types.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/session-timeline.utils.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/styles/agent-panel.module.scss rename packages/{@n8n/agents/src/types/sdk/schema.ts => frontend/editor-ui/src/features/agents/types.ts} (58%) create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/agentSectionEditor.utils.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/interactive-summary.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/model-string.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/relative-time.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/thread-title.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/utils/toolDisplayName.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/AgentSessionTimelineView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/AgentView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/NewAgentView.vue create mode 100644 packages/frontend/editor-ui/src/features/agents/views/SettingsAgentBuilderView.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/shared/styles/_prompt-suggestion-buttons.scss create mode 100644 packages/frontend/editor-ui/src/features/ai/shared/styles/_question-option-rows.scss create mode 100644 packages/frontend/editor-ui/src/features/execution/executions/components/global/AgentSessionsList.vue rename packages/frontend/editor-ui/src/features/{ai/chatHub/components/ToolSettingsContent.vue => shared/toolConfig/NodeToolSettingsContent.vue} (94%) rename packages/frontend/editor-ui/src/features/{ai/chatHub/components/ToolSettingsContent.test.ts => shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts} (98%) create mode 100644 packages/nodes-base/nodes/MessageAnAgent/MessageAnAgent.node.json create mode 100644 packages/nodes-base/nodes/MessageAnAgent/MessageAnAgent.node.ts create mode 100644 packages/nodes-base/nodes/MessageAnAgent/__tests__/MessageAnAgent.node.test.ts diff --git a/.gitignore b/.gitignore index 0b3ab9669f3..cd516ca0873 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ packages/cli/src/commands/export/outputs .claude/settings.local.json .claude/plans/ .claude/worktrees/ +.claude/specs/ .cursor/plans/ .superset .conductor diff --git a/packages/@n8n/agents/docs/agent-runtime-architecture.md b/packages/@n8n/agents/docs/agent-runtime-architecture.md index 746a447e25d..2f52d98950e 100644 --- a/packages/@n8n/agents/docs/agent-runtime-architecture.md +++ b/packages/@n8n/agents/docs/agent-runtime-architecture.md @@ -367,7 +367,7 @@ At end of turn, `saveToMemory()` uses `list.turnDelta()` and `saveMessagesToThread`. If **semantic recall** is configured with an embedder and `memory.saveEmbeddings`, new messages are embedded and stored. -**Working memory:** when configured, the runtime injects an `updateWorkingMemory` +**Working memory:** when configured, the runtime injects an `update_working_memory` tool into the agent's tool set. The current state is included in the system prompt so the model can read it; when new information should be persisted the model calls the tool, which validates the input and asynchronously persists via @@ -415,7 +415,7 @@ src/ tool-adapter.ts — buildToolMap, executeTool, toAiSdkTools, suspend / agent-result guards stream.ts — convertChunk, toTokenUsage runtime-helpers.ts — normalizeInput, usage merge, stream error helpers, … - working-memory.ts — instruction text, updateWorkingMemory tool builder + working-memory.ts — instruction text, update_working_memory tool builder strip-orphaned-tool-messages.ts title-generation.ts logger.ts diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index 5507767354e..78ed6f76a5e 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -24,23 +24,31 @@ "test:integration": "vitest run --config vitest.integration.config.mjs" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "catalog:", "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/azure": "catalog:", + "@ai-sdk/cohere": "catalog:", + "@ai-sdk/deepseek": "catalog:", + "@ai-sdk/gateway": "catalog:", "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "catalog:", + "@ai-sdk/mistral": "catalog:", "@ai-sdk/openai": "^3.0.41", - "@ai-sdk/xai": "^3.0.67", "@ai-sdk/provider-utils": "^4.0.21", - "@modelcontextprotocol/sdk": "1.26.0", - "ajv": "^8.18.0", + "@ai-sdk/xai": "^3.0.67", "@libsql/client": "^0.17.0", + "@modelcontextprotocol/sdk": "1.26.0", + "@openrouter/ai-sdk-provider": "catalog:", "ai": "^6.0.116", + "ajv": "^8.18.0", "pg": "catalog:", "zod": "catalog:" }, "peerDependencies": { - "langsmith": ">=0.3.0", - "@opentelemetry/sdk-trace-node": ">=1.0.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.50.0", "@opentelemetry/sdk-trace-base": ">=1.0.0", - "@opentelemetry/exporter-trace-otlp-http": ">=0.50.0" + "@opentelemetry/sdk-trace-node": ">=1.0.0", + "langsmith": ">=0.3.0" }, "peerDependenciesMeta": { "langsmith": { diff --git a/packages/@n8n/agents/src/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/__tests__/agent-runtime.test.ts index f0e80c7b8a3..8af622fff54 100644 --- a/packages/@n8n/agents/src/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/__tests__/agent-runtime.test.ts @@ -6,7 +6,8 @@ import { isLlmMessage } from '../sdk/message'; import { Tool, Tool as ToolBuilder } from '../sdk/tool'; import { AgentEvent } from '../types/runtime/event'; import type { StreamChunk } from '../types/sdk/agent'; -import type { ContentToolResult, Message } from '../types/sdk/message'; +import type { BuiltMemory } from '../types/sdk/memory'; +import type { ContentToolCall, Message } from '../types/sdk/message'; import type { BuiltTool, InterruptibleToolContext } from '../types/sdk/tool'; import type { BuiltTelemetry } from '../types/telemetry'; @@ -236,9 +237,9 @@ describe('AgentRuntime.generate() — graceful error contract', () => { generateText.mockRejectedValue(new Error('API failure')); const { runtime } = createRuntime(); - const result = await runtime.generate('hello'); + await runtime.generate('hello'); - expect(result.getState().status).toBe('failed'); + expect(runtime.getState().status).toBe('failed'); }); it('emits AgentEvent.Error (not AgentEnd) when the LLM call throws', async () => { @@ -266,10 +267,10 @@ describe('AgentRuntime.generate() — graceful error contract', () => { // Abort during AgentStart so the loop's first abort-check fires before generateText is called bus.on(AgentEvent.AgentStart, () => bus.abort()); - const result = await runtime.generate('hello'); + await runtime.generate('hello'); expect(errorEvents.length).toBe(0); - expect(result.getState().status).toBe('cancelled'); + expect(runtime.getState().status).toBe('cancelled'); }); it('returns finishReason "error" and sets cancelled status on abort', async () => { @@ -282,7 +283,7 @@ describe('AgentRuntime.generate() — graceful error contract', () => { const result = await runtime.generate('hello'); expect(result.finishReason).toBe('error'); - expect(result.getState().status).toBe('cancelled'); + expect(runtime.getState().status).toBe('cancelled'); }); it('is reusable after an error — subsequent call with a good LLM response succeeds', async () => { @@ -400,10 +401,10 @@ describe('AgentRuntime.stream() — graceful error contract', () => { }); const { runtime } = createRuntime(); - const { stream: readableStream, getState } = await runtime.stream('hello'); + const { stream: readableStream } = await runtime.stream('hello'); await collectChunks(readableStream); - expect(getState().status).toBe('failed'); + expect(runtime.getState().status).toBe('failed'); }); it('yields error chunk and finishes cleanly on abort', async () => { @@ -412,13 +413,13 @@ describe('AgentRuntime.stream() — graceful error contract', () => { const { runtime, bus } = createRuntime(); bus.on(AgentEvent.TurnStart, () => bus.abort()); - const { stream: readableStream, getState } = await runtime.stream('hello'); + const { stream: readableStream } = await runtime.stream('hello'); const chunks = await collectChunks(readableStream); const errorChunks = chunks.filter((c) => c.type === 'error'); expect(errorChunks.length).toBeGreaterThan(0); - expect(getState().status).toBe('cancelled'); + expect(runtime.getState().status).toBe('cancelled'); }); it('stream is reusable after an error', async () => { @@ -466,6 +467,120 @@ describe('AgentRuntime.stream() — graceful error contract', () => { }); }); +// --------------------------------------------------------------------------- +// stream() — working memory +// --------------------------------------------------------------------------- + +describe('AgentRuntime.stream() — working memory', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function makeMemory(savedWorkingMemory: string[]): BuiltMemory { + return { + getThread: jest.fn().mockResolvedValue(null), + saveThread: jest.fn(async (thread) => { + await Promise.resolve(); + return { + ...thread, + createdAt: new Date(), + updatedAt: new Date(), + }; + }), + deleteThread: jest.fn(), + getMessages: jest.fn().mockResolvedValue([]), + saveMessages: jest.fn(), + deleteMessages: jest.fn(), + getWorkingMemory: jest.fn().mockResolvedValue(null), + saveWorkingMemory: jest.fn(async (_params, content: string) => { + await Promise.resolve(); + savedWorkingMemory.push(content); + }), + describe: jest + .fn() + .mockReturnValue({ name: 'test', constructorName: 'TestMemory', connectionParams: {} }), + }; + } + + it('persists working memory and streams the tool chunks unfiltered', async () => { + const savedWorkingMemory: string[] = []; + const memoryContent = '# Thread memory\n- User facts: Alice likes concise answers'; + const memory = makeMemory(savedWorkingMemory); + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'You are a test assistant.', + memory, + lastMessages: 5, + workingMemory: { + template: '# Thread memory\n- User facts:', + structured: false, + scope: 'thread', + }, + }); + + streamText + .mockReturnValueOnce({ + fullStream: makeChunkStream([ + { type: 'tool-input-start', id: 'wm-1', toolName: 'update_working_memory' }, + { type: 'tool-input-delta', id: 'wm-1', delta: memoryContent }, + { + type: 'tool-call', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + input: { memory: memoryContent }, + }, + ]), + finishReason: Promise.resolve('tool-calls'), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), + response: Promise.resolve({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + args: { memory: memoryContent }, + }, + ], + }, + ], + }), + toolCalls: Promise.resolve([ + { + toolCallId: 'wm-1', + toolName: 'update_working_memory', + input: { memory: memoryContent }, + }, + ]), + }) + .mockReturnValueOnce(makeStreamSuccess('Done')); + + const { stream } = await runtime.stream('remember this', { + persistence: { threadId: 'thread-1', resourceId: 'user-1' }, + }); + const chunks = await collectChunks(stream); + + expect(savedWorkingMemory).toEqual([memoryContent]); + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'tool-call', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + }), + ); + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'tool-result', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + }), + ); + }); +}); + // --------------------------------------------------------------------------- // resume() — graceful error contract // --------------------------------------------------------------------------- @@ -497,37 +612,35 @@ describe('AgentRuntime — state transitions on error', () => { jest.clearAllMocks(); }); - it('starts idle before first run', () => { + it('starts idle, then reflects running→failed after a generate error', async () => { const { runtime } = createRuntime(); + expect(runtime.getState().status).toBe('idle'); - }); - it('result.getState() reflects failed after a generate error', async () => { generateText.mockRejectedValue(new Error('oops')); + const runDone = runtime.generate('hi'); - const { runtime } = createRuntime(); - const result = await runtime.generate('hi'); - - expect(result.getState().status).toBe('failed'); + await runDone; + expect(runtime.getState().status).toBe('failed'); }); - it('result.getState() reflects cancelled on abort', async () => { + it('starts idle, then reflects running→cancelled on abort', async () => { generateText.mockResolvedValue(makeGenerateSuccess()); const { runtime, bus } = createRuntime(); bus.on(AgentEvent.AgentStart, () => bus.abort()); - const result = await runtime.generate('hi'); - expect(result.getState().status).toBe('cancelled'); + await runtime.generate('hi'); + expect(runtime.getState().status).toBe('cancelled'); }); - it('result.getState() transitions to success on a clean run', async () => { + it('transitions to success on a clean run', async () => { generateText.mockResolvedValue(makeGenerateSuccess()); const { runtime } = createRuntime(); - const result = await runtime.generate('hi'); + await runtime.generate('hi'); - expect(result.getState().status).toBe('success'); + expect(runtime.getState().status).toBe('success'); }); }); @@ -675,7 +788,7 @@ describe('AgentRuntime — concurrent tool execution', () => { expect(result.pendingSuspend![0].toolCallId).toBe('tc-1'); // Verify tc-3 is in the persisted state as a pending tool call (without suspendPayload) - const state = result.getState(); + const state = runtime.getState(); expect(state.pendingToolCalls['tc-3']).toBeDefined(); expect(state.pendingToolCalls['tc-3'].suspended).toBe(false); }); @@ -905,7 +1018,7 @@ describe('AgentRuntime — concurrent tool execution', () => { it('tool error produces an error tool-result in the message list and loop continues', async () => { type ToolOutputContent = { type: string; - output?: { type: string; value?: { error?: string } }; + output?: { type: string; value?: unknown }; }; type ToolMessage = { role: string; content: ToolOutputContent[] }; const receivedMessages: unknown[] = []; @@ -932,13 +1045,15 @@ describe('AgentRuntime — concurrent tool execution', () => { expect(result.finishReason).toBe('stop'); // LLM was called a second time — it saw the error tool result and continued expect(generateText).toHaveBeenCalledTimes(2); - // The second LLM call received a tool message whose output carries the error description + // The second LLM call received a tool message whose output carries the error description. const toolMsg = receivedMessages.find( (m): m is ToolMessage => typeof m === 'object' && m !== null && (m as ToolMessage).role === 'tool', ); expect(toolMsg).toBeDefined(); - const hasErrorOutput = toolMsg!.content.some((c) => !!c.output?.value?.error); + const hasErrorOutput = toolMsg!.content.some( + (c) => c.output?.type === 'error-text' || c.output?.type === 'error-json', + ); expect(hasErrorOutput).toBe(true); }); @@ -982,9 +1097,9 @@ describe('AgentRuntime — concurrent tool execution', () => { ]), ); - const result = await runtime.generate('run tools'); + await runtime.generate('run tools'); - const state = result.getState(); + const state = runtime.getState(); expect(state.pendingToolCalls['tc-1']).toBeDefined(); expect(state.pendingToolCalls['tc-1'].toolName).toBe('suspend_tool'); }); @@ -1007,9 +1122,9 @@ describe('AgentRuntime — concurrent tool execution', () => { ]), ); - const result = await runtime.generate('run tools'); + await runtime.generate('run tools'); - const state = result.getState(); + const state = runtime.getState(); expect(state.pendingToolCalls['tc-2']).toBeDefined(); expect(state.pendingToolCalls['tc-2'].toolName).toBe('normal_tool'); expect(state.pendingToolCalls['tc-2'].suspended).toBe(false); @@ -1554,17 +1669,14 @@ describe('AgentRuntime — runtime input schema validation', () => { // the LLM responds with 'done' on the next turn. expect(result.finishReason).toBe('stop'); - const toolErrorMessage = result.messages.find( - (m) => isLlmMessage(m) && m.role === 'tool' && m.content[0].type === 'tool-result', + const assistantMsg = result.messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), ) as Message; - expect(toolErrorMessage).toBeDefined(); - const content = toolErrorMessage.content[0] as ContentToolResult; - expect(content.result).toEqual( - expect.objectContaining({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - error: expect.stringContaining('Expected string, received number'), - }), - ); + expect(assistantMsg).toBeDefined(); + const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(call.state).toBe('rejected'); + expect(call.state === 'rejected' && call.error).toContain('Expected string, received number'); }); }); @@ -1603,13 +1715,14 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => { const result = await runtime.generate('go'); expect(result.finishReason).toBe('stop'); - // No tool-result error — the tool ran successfully - const toolResultMsg = result.messages.find( - (m) => isLlmMessage(m) && m.role === 'tool', + // No error — the tool ran successfully + const assistantMsg = result.messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), ) as Message; - expect(toolResultMsg).toBeDefined(); - const content = toolResultMsg.content[0] as ContentToolResult; - expect(content.isError).toBeFalsy(); + expect(assistantMsg).toBeDefined(); + const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(call.state).toBe('resolved'); }); it('surfaces a validation error as a tool error outcome when LLM provides the wrong type', async () => { @@ -1639,14 +1752,14 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => { const result = await runtime.generate('go'); expect(result.finishReason).toBe('stop'); - const toolResultMsg = result.messages.find( - (m) => isLlmMessage(m) && m.role === 'tool', + const assistantMsg = result.messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), ) as Message; - expect(toolResultMsg).toBeDefined(); - console.log('ToolResultMsg', toolResultMsg); - const content = toolResultMsg.content[0] as ContentToolResult; - expect(content.isError).toBe(true); - expect(JSON.stringify(content.result)).toContain('Invalid tool input'); + expect(assistantMsg).toBeDefined(); + const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(call.state).toBe('rejected'); + expect(call.state === 'rejected' && call.error).toContain('Invalid tool input'); }); it('surfaces a validation error when a required property is missing', async () => { @@ -1677,15 +1790,15 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => { }); const result = await runtime.generate('go'); - console.log('Result', result.error); expect(result.finishReason).toBe('stop'); - const toolResultMsg = result.messages.find( - (m) => isLlmMessage(m) && m.role === 'tool', + const assistantMsg = result.messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), ) as Message; - const content = toolResultMsg.content[0] as ContentToolResult; - expect(content.isError).toBe(true); - expect(JSON.stringify(content.result)).toContain('Invalid tool input'); + const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(call.state).toBe('rejected'); + expect(call.state === 'rejected' && call.error).toContain('Invalid tool input'); }); it('does not invoke the handler when JSON Schema validation fails', async () => { @@ -1718,6 +1831,142 @@ describe('AgentRuntime — runtime JSON Schema input validation', () => { }); }); +// --------------------------------------------------------------------------- +// Tool builder — JSON Schema input integration +// +// Mirrors the resolveNodeTool() code path in node-tool-factory.ts where the +// input schema is a raw JSON Schema object (converted from Zod by ToolFromNode). +// --------------------------------------------------------------------------- + +describe('AgentRuntime — Tool builder with JSON Schema input', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('passes valid input to the handler when built via Tool builder', async () => { + const handlerFn = jest.fn().mockResolvedValue({ found: true }); + + const tool = new Tool('lookup') + .description('Look up a record by id') + .input({ + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }) + .handler(handlerFn) + .build(); + + generateText + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'lookup', { id: 'abc-123' })) + .mockResolvedValueOnce(makeGenerateSuccess('done')); + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'test', + tools: [tool], + }); + + const result = await runtime.generate('go'); + + expect(result.finishReason).toBe('stop'); + expect(handlerFn).toHaveBeenCalledWith( + expect.objectContaining({ id: 'abc-123' }), + expect.anything(), + ); + + const assistantMsg = result.messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), + ) as Message; + const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(call.state).toBe('resolved'); + }); + + it('produces a tool error when the LLM sends input that fails JSON Schema validation', async () => { + const handlerFn = jest.fn().mockResolvedValue({ found: true }); + + const tool = new Tool('lookup') + .description('Look up a record by id') + .input({ + type: 'object', + properties: { + id: { type: 'string' }, + count: { type: 'integer', minimum: 1 }, + }, + required: ['id', 'count'], + }) + .handler(handlerFn) + .build(); + + generateText + // LLM sends count: 0 (violates minimum: 1) and id as a number (wrong type) + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'lookup', { id: 42, count: 0 })) + .mockResolvedValueOnce(makeGenerateSuccess('corrected')); + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'test', + tools: [tool], + }); + + const result = await runtime.generate('go'); + + expect(result.finishReason).toBe('stop'); + // Handler must not be called — validation should block execution + expect(handlerFn).not.toHaveBeenCalled(); + + const assistantMsg = result.messages.find( + (m) => + isLlmMessage(m) && m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), + ) as Message; + const call = assistantMsg.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(call.state).toBe('rejected'); + expect(call.state === 'rejected' && call.error).toContain('Invalid tool input'); + }); + + it('validates enum and pattern constraints defined in JSON Schema', async () => { + const handlerFn = jest.fn().mockResolvedValue({ ok: true }); + + const tool = new Tool('set_status') + .description('Set the status of a record') + .input({ + type: 'object', + properties: { + status: { type: 'string', enum: ['active', 'inactive', 'pending'] }, + }, + required: ['status'], + }) + .handler(handlerFn) + .build(); + + // First call: invalid enum value + generateText + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-1', 'set_status', { status: 'deleted' })) + // Second call: valid enum value after self-correction + .mockResolvedValueOnce(makeGenerateWithToolCall('tc-2', 'set_status', { status: 'inactive' })) + .mockResolvedValueOnce(makeGenerateSuccess('done')); + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'test', + tools: [tool], + }); + + const result = await runtime.generate('go'); + + expect(result.finishReason).toBe('stop'); + // Handler called exactly once — only for the valid input + expect(handlerFn).toHaveBeenCalledTimes(1); + expect(handlerFn).toHaveBeenCalledWith( + expect.objectContaining({ status: 'inactive' }), + expect.anything(), + ); + }); +}); + // --------------------------------------------------------------------------- // Runtime validation — resume data schema // --------------------------------------------------------------------------- @@ -1953,6 +2202,114 @@ describe('provider options merging', () => { // Instruction providerOptions // --------------------------------------------------------------------------- +describe('tool systemInstruction merging', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function getSystemMessageText(): string { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const callArgs = generateText.mock.calls[0][0] as Record; + const messages = callArgs.messages as Array>; + const systemMsg = messages[0]; + expect(systemMsg.role).toBe('system'); + return String(systemMsg.content); + } + + it("wraps a tool's systemInstruction in a built_in_rules block above user instructions", async () => { + generateText.mockResolvedValue(makeGenerateSuccess()); + + const toolWithDirective: BuiltTool = { + name: 'show_card', + description: 'show a card', + systemInstruction: 'Prefer this tool over plain text when posting images.', + inputSchema: z.object({ value: z.string().optional() }), + handler: async () => await Promise.resolve('ok'), + }; + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'You are a helpful assistant.', + tools: [toolWithDirective], + }); + + await runtime.generate('hello'); + + const text = getSystemMessageText(); + expect(text).toContain(''); + expect(text).toContain('- Prefer this tool over plain text when posting images.'); + expect(text).toContain(''); + expect(text).toContain('You are a helpful assistant.'); + expect(text.indexOf('')).toBeLessThan(text.indexOf('You are a helpful')); + }); + + it('joins multiple tools systemInstructions into a single block', async () => { + generateText.mockResolvedValue(makeGenerateSuccess()); + + const toolA: BuiltTool = { + name: 'a', + description: 'a', + systemInstruction: 'Rule A.', + inputSchema: z.object({}), + handler: async () => await Promise.resolve('ok'), + }; + const toolB: BuiltTool = { + name: 'b', + description: 'b', + systemInstruction: 'Rule B.', + inputSchema: z.object({}), + handler: async () => await Promise.resolve('ok'), + }; + const toolC: BuiltTool = { + name: 'c', + description: 'c', + inputSchema: z.object({}), + handler: async () => await Promise.resolve('ok'), + }; + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'base', + tools: [toolA, toolB, toolC], + }); + + await runtime.generate('hello'); + + const text = getSystemMessageText(); + const block = text.match(/([\s\S]*?)<\/built_in_rules>/); + expect(block).not.toBeNull(); + expect(block![1]).toContain('- Rule A.'); + expect(block![1]).toContain('- Rule B.'); + expect(block![1]).not.toContain('Rule C'); + }); + + it('does not add a built_in_rules block when no tool sets a systemInstruction', async () => { + generateText.mockResolvedValue(makeGenerateSuccess()); + + const plainTool: BuiltTool = { + name: 'plain', + description: 'plain', + inputSchema: z.object({}), + handler: async () => await Promise.resolve('ok'), + }; + + const runtime = new AgentRuntime({ + name: 'test', + model: 'openai/gpt-4o-mini', + instructions: 'You are a helpful assistant.', + tools: [plainTool], + }); + + await runtime.generate('hello'); + + const text = getSystemMessageText(); + expect(text).not.toContain(''); + expect(text).toContain('You are a helpful assistant.'); + }); +}); + describe('instruction providerOptions', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/@n8n/agents/src/__tests__/agent.test.ts b/packages/@n8n/agents/src/__tests__/agent.test.ts deleted file mode 100644 index 905fdf03802..00000000000 --- a/packages/@n8n/agents/src/__tests__/agent.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -/** - * Tests for the Agent builder focusing on per-run isolation guarantees introduced - * by the "shared config, per-run runtime" refactor. - */ - -import { Agent } from '../sdk/agent'; -import { AgentEvent } from '../types/runtime/event'; - -// --------------------------------------------------------------------------- -// Module mocks (same as agent-runtime.test.ts) -// --------------------------------------------------------------------------- - -jest.mock('@ai-sdk/openai', () => ({ - createOpenAI: () => () => ({ provider: 'openai', modelId: 'mock', specificationVersion: 'v3' }), -})); - -jest.mock('@ai-sdk/anthropic', () => ({ - createAnthropic: () => () => ({ - provider: 'anthropic', - modelId: 'mock', - specificationVersion: 'v3', - }), -})); - -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -type AiImport = typeof import('ai'); - -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); - return { - ...actual, - generateText: jest.fn(), - streamText: jest.fn(), - tool: jest.fn((config: unknown) => config), - Output: { - object: jest.fn(({ schema }: { schema: unknown }) => ({ _type: 'object', schema })), - }, - }; -}); - -// Prevent real catalog HTTP calls -jest.mock('../sdk/catalog', () => ({ - getModelCost: jest.fn().mockResolvedValue(undefined), - computeCost: jest.fn(), -})); - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { generateText, streamText } = require('ai') as { - generateText: jest.Mock; - streamText: jest.Mock; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeGenerateSuccess(text = 'OK') { - return { - finishReason: 'stop', - usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, - response: { - messages: [{ role: 'assistant', content: [{ type: 'text', text }] }], - }, - toolCalls: [], - }; -} - -function* makeChunkStream(chunks: Array>) { - for (const c of chunks) yield c; -} - -function makeStreamSuccess(text = 'Hello') { - return { - fullStream: makeChunkStream([{ type: 'text-delta', textDelta: text }]), - finishReason: Promise.resolve('stop'), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5, totalTokens: 15 }), - response: Promise.resolve({ - messages: [{ role: 'assistant', content: [{ type: 'text', text }] }], - }), - toolCalls: Promise.resolve([]), - }; -} - -async function drainStream(stream: ReadableStream): Promise { - const reader = stream.getReader(); - - while (true) { - const { done } = await reader.read(); - if (done) break; - } -} - -function buildAgent() { - return new Agent('test').model('openai/gpt-4o-mini').instructions('You are a test assistant.'); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('Agent — per-run isolation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('concurrent generate() calls', () => { - it('returns independent results for each call', async () => { - generateText - .mockResolvedValueOnce(makeGenerateSuccess('Result A')) - .mockResolvedValueOnce(makeGenerateSuccess('Result B')); - - const agent = buildAgent(); - - const [resultA, resultB] = await Promise.all([ - agent.generate('Prompt A'), - agent.generate('Prompt B'), - ]); - - const textA = resultA.messages - .flatMap((m) => ('content' in m ? m.content : [])) - .filter((c) => c.type === 'text') - .map((c) => ('text' in c ? c.text : '')) - .join(''); - - const textB = resultB.messages - .flatMap((m) => ('content' in m ? m.content : [])) - .filter((c) => c.type === 'text') - .map((c) => ('text' in c ? c.text : '')) - .join(''); - - expect(textA).toBe('Result A'); - expect(textB).toBe('Result B'); - expect(resultA.runId).not.toBe(resultB.runId); - }); - - it('aborting one generate() does not cancel the other', async () => { - const abortControllerA = new AbortController(); - - // Run A resolves only after a delay; we'll abort it via its signal. - // Run B resolves immediately. - let resolveA!: (v: unknown) => void; - const pendingA = new Promise((res) => { - resolveA = res; - }); - - generateText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => { - if (abortSignal === abortControllerA.signal || abortSignal?.aborted) { - // Simulate the AI SDK throwing on abort - await new Promise((_, rej) => - abortSignal.addEventListener('abort', () => rej(new Error('aborted')), { - once: true, - }), - ); - } - // Run B path — return immediately - await pendingA; - return makeGenerateSuccess('Result B'); - }); - - const agent = buildAgent(); - - // Start both runs; abort run A immediately - const runAPromise = agent.generate('Prompt A', { abortSignal: abortControllerA.signal }); - abortControllerA.abort(); - resolveA(undefined); - - const runA = await runAPromise; - expect(runA.finishReason).toBe('error'); - - // Run B separately (no abort) - generateText.mockResolvedValueOnce(makeGenerateSuccess('Result B')); - const runB = await agent.generate('Prompt B'); - const textB = runB.messages - .flatMap((m) => ('content' in m ? m.content : [])) - .filter((c) => c.type === 'text') - .map((c) => ('text' in c ? c.text : '')) - .join(''); - expect(textB).toBe('Result B'); - }); - }); - - describe('concurrent stream() calls', () => { - it('returns independent streams for each call', async () => { - streamText - .mockReturnValueOnce(makeStreamSuccess('Stream A')) - .mockReturnValueOnce(makeStreamSuccess('Stream B')); - - const agent = buildAgent(); - - const [resultA, resultB] = await Promise.all([ - agent.stream('Prompt A'), - agent.stream('Prompt B'), - ]); - - // Both streams should be distinct ReadableStream objects - expect(resultA.stream).not.toBe(resultB.stream); - expect(resultA.runId).not.toBe(resultB.runId); - - // Drain both streams to completion - await Promise.all([drainStream(resultA.stream), drainStream(resultB.stream)]); - }); - - it('aborting one stream does not cancel the other', async () => { - const abortControllerA = new AbortController(); - - streamText.mockImplementation(({ abortSignal }: { abortSignal?: AbortSignal }) => { - if (abortSignal === abortControllerA.signal) { - return { - fullStream: (async function* () { - // Wait until aborted then throw - await new Promise((_, rej) => { - abortSignal.addEventListener('abort', () => rej(new Error('aborted')), { - once: true, - }); - }); - yield 'something'; - })(), - finishReason: Promise.resolve('error'), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }), - response: Promise.resolve({ messages: [] }), - toolCalls: Promise.resolve([]), - }; - } - return makeStreamSuccess('Stream B'); - }); - - const agent = buildAgent(); - - const [resultA, resultB] = await Promise.all([ - agent.stream('Prompt A', { abortSignal: abortControllerA.signal }), - agent.stream('Prompt B'), - ]); - - // Abort run A - abortControllerA.abort(); - - // Drain stream B — it should complete successfully regardless of A being aborted - await drainStream(resultB.stream); - - // Drain stream A — it will error but shouldn't affect B - await drainStream(resultA.stream).catch(() => {}); - }); - }); - - describe('event handlers (on())', () => { - it('fires registered handlers for every concurrent run', async () => { - generateText - .mockResolvedValueOnce(makeGenerateSuccess('A')) - .mockResolvedValueOnce(makeGenerateSuccess('B')); - - const agent = buildAgent(); - const agentStartEvents: string[] = []; - - agent.on(AgentEvent.AgentStart, () => { - agentStartEvents.push('start'); - }); - - await Promise.all([agent.generate('Prompt A'), agent.generate('Prompt B')]); - - // Handler should have fired once per run - expect(agentStartEvents).toHaveLength(2); - }); - - it('handlers registered before first run still fire on every subsequent run', async () => { - generateText - .mockResolvedValueOnce(makeGenerateSuccess('First')) - .mockResolvedValueOnce(makeGenerateSuccess('Second')); - - const agent = buildAgent(); - const events: string[] = []; - - agent.on(AgentEvent.AgentEnd, () => { - events.push('end'); - }); - - await agent.generate('First'); - await agent.generate('Second'); - - expect(events).toHaveLength(2); - }); - }); - - describe('abort() broadcast', () => { - it('aborts all active runs when agent.abort() is called', async () => { - let resolveA!: (v: unknown) => void; - - generateText.mockImplementation(async ({ abortSignal }: { abortSignal?: AbortSignal }) => { - // Each call waits until its resolver is called or the signal fires - await new Promise((res, rej) => { - abortSignal?.addEventListener('abort', () => rej(new Error('aborted')), { - once: true, - }); - resolveA ??= res; - }); - return makeGenerateSuccess(); - }); - - const agent = buildAgent(); - - const runAPromise = agent.generate('A'); - const runBPromise = agent.generate('B'); - - // Give both calls time to reach the mock and register abort listeners - await new Promise((res) => setTimeout(res, 10)); - - // Broadcast abort — both runs should be cancelled - agent.abort(); - - const [runA, runB] = await Promise.all([runAPromise, runBPromise]); - expect(runA.finishReason).toBe('error'); - expect(runB.finishReason).toBe('error'); - }); - }); - - describe('off() — event handler removal', () => { - it('removes a specific handler so it no longer fires', async () => { - generateText - .mockResolvedValueOnce(makeGenerateSuccess('A')) - .mockResolvedValueOnce(makeGenerateSuccess('B')); - - const agent = buildAgent(); - const events: string[] = []; - - const handler = () => events.push('end'); - agent.on(AgentEvent.AgentEnd, handler); - await agent.generate('First'); - - agent.off(AgentEvent.AgentEnd, handler); - await agent.generate('Second'); - - // Handler should have fired only for the first run - expect(events).toHaveLength(1); - }); - - it('removing one handler does not affect other handlers for the same event', async () => { - generateText.mockResolvedValueOnce(makeGenerateSuccess('A')); - - const agent = buildAgent(); - const firedA: string[] = []; - const firedB: string[] = []; - - const handlerA = () => firedA.push('a'); - const handlerB = () => firedB.push('b'); - - agent.on(AgentEvent.AgentEnd, handlerA); - agent.on(AgentEvent.AgentEnd, handlerB); - - agent.off(AgentEvent.AgentEnd, handlerA); - - await agent.generate('Hello'); - - expect(firedA).toHaveLength(0); - expect(firedB).toHaveLength(1); - }); - - it('off() on a handler that was never registered is a no-op', () => { - const agent = buildAgent(); - expect(() => agent.off(AgentEvent.AgentEnd, () => {})).not.toThrow(); - }); - }); - - describe('trackStreamBus — cleanup on stream cancel', () => { - it('removes the bus from active runs when the consumer cancels the stream', async () => { - streamText.mockReturnValueOnce(makeStreamSuccess('Hello')); - - const agent = buildAgent(); - - // Access the private set via casting so we can assert its size - const getActiveBuses = () => - (agent as unknown as { activeEventBuses: Set }).activeEventBuses; - - const { stream } = await agent.stream('Hello'); - - // Bus is registered while the stream is live - expect(getActiveBuses().size).toBe(1); - - // Consumer cancels instead of draining - await stream.cancel(); - - // Bus must be removed immediately after cancel - expect(getActiveBuses().size).toBe(0); - }); - - it('removes the bus from active runs when the consumer drains the stream normally', async () => { - streamText.mockReturnValueOnce(makeStreamSuccess('Hello')); - - const agent = buildAgent(); - const getActiveBuses = () => - (agent as unknown as { activeEventBuses: Set }).activeEventBuses; - - const { stream } = await agent.stream('Hello'); - expect(getActiveBuses().size).toBe(1); - - await drainStream(stream); - - expect(getActiveBuses().size).toBe(0); - }); - - it('abort() after stream cancel does not throw on a disposed bus', async () => { - streamText.mockReturnValueOnce(makeStreamSuccess('Hello')); - - const agent = buildAgent(); - const { stream } = await agent.stream('Hello'); - - await stream.cancel(); - - // agent.abort() should be harmless — no active buses remain - expect(() => agent.abort()).not.toThrow(); - }); - }); - - describe('result.getState()', () => { - it('generate() result.getState() reports success after a clean run', async () => { - generateText.mockResolvedValueOnce(makeGenerateSuccess()); - - const agent = buildAgent(); - const result = await agent.generate('Hello'); - - expect(result.getState().status).toBe('success'); - }); - - it('generate() result.getState() reports failed after an error', async () => { - generateText.mockRejectedValueOnce(new Error('boom')); - - const agent = buildAgent(); - const result = await agent.generate('Hello'); - - expect(result.getState().status).toBe('failed'); - }); - - it('stream() result.getState() reports success after the stream is consumed', async () => { - streamText.mockReturnValueOnce(makeStreamSuccess()); - - const agent = buildAgent(); - const { stream, getState } = await agent.stream('Hello'); - - // State is running while stream is open - expect(getState().status).toBe('running'); - - await drainStream(stream); - - expect(getState().status).toBe('success'); - }); - }); -}); diff --git a/packages/@n8n/agents/src/__tests__/describe.test.ts b/packages/@n8n/agents/src/__tests__/describe.test.ts deleted file mode 100644 index e651c79e9c7..00000000000 --- a/packages/@n8n/agents/src/__tests__/describe.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { z } from 'zod'; - -import { Agent } from '../sdk/agent'; -import { McpClient } from '../sdk/mcp-client'; -import { Telemetry } from '../sdk/telemetry'; -import { Tool } from '../sdk/tool'; -import type { BuiltEval, BuiltGuardrail, BuiltMemory, BuiltProviderTool } from '../types'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeMockMemory(): BuiltMemory { - return { - getThread: jest.fn(), - saveThread: jest.fn(), - deleteThread: jest.fn(), - getMessages: jest.fn(), - saveMessages: jest.fn(), - deleteMessages: jest.fn(), - } as unknown as BuiltMemory; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('Agent.describe()', () => { - it('returns null/empty fields for an unconfigured agent', () => { - const agent = new Agent('test-agent'); - const schema = agent.describe(); - - expect(schema.model).toEqual({ provider: null, name: null }); - expect(schema.credential).toBeNull(); - expect(schema.instructions).toBeNull(); - expect(schema.description).toBeNull(); - expect(schema.tools).toEqual([]); - expect(schema.providerTools).toEqual([]); - expect(schema.memory).toBeNull(); - expect(schema.evaluations).toEqual([]); - expect(schema.guardrails).toEqual([]); - expect(schema.mcp).toBeNull(); - expect(schema.telemetry).toBeNull(); - expect(schema.checkpoint).toBeNull(); - expect(schema.config.structuredOutput).toEqual({ enabled: false, schemaSource: null }); - expect(schema.config.thinking).toBeNull(); - expect(schema.config.toolCallConcurrency).toBeNull(); - expect(schema.config.requireToolApproval).toBe(false); - }); - - // --- Model parsing --- - - it('parses two-arg model (provider, name)', () => { - const agent = new Agent('test-agent').model('anthropic', 'claude-sonnet-4-5'); - const schema = agent.describe(); - - expect(schema.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' }); - }); - - it('parses single-arg model with slash', () => { - const agent = new Agent('test-agent').model('anthropic/claude-sonnet-4-5'); - const schema = agent.describe(); - - expect(schema.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' }); - }); - - it('parses model without slash', () => { - const agent = new Agent('test-agent').model('gpt-4o'); - const schema = agent.describe(); - - expect(schema.model).toEqual({ provider: null, name: 'gpt-4o' }); - }); - - it('handles object model config', () => { - const agent = new Agent('test-agent').model({ - id: 'anthropic/claude-sonnet-4-5', - apiKey: 'sk-test', - }); - const schema = agent.describe(); - - expect(schema.model).toEqual({ provider: null, name: null, raw: 'object' }); - }); - - // --- Credential --- - - it('returns credential name', () => { - const agent = new Agent('test-agent').credential('my-anthropic-key'); - const schema = agent.describe(); - - expect(schema.credential).toBe('my-anthropic-key'); - }); - - // --- Instructions --- - - it('returns instructions text', () => { - const agent = new Agent('test-agent').instructions('You are helpful.'); - const schema = agent.describe(); - - expect(schema.instructions).toBe('You are helpful.'); - }); - - // --- Custom tool --- - - it('describes a custom tool with handler, input schema, and suspend/resume', () => { - const suspendSchema = z.object({ reason: z.string() }); - const resumeSchema = z.object({ approved: z.boolean() }); - - const tool = new Tool('danger') - .description('A dangerous action') - .input(z.object({ target: z.string() })) - .output(z.object({ result: z.string() })) - .suspend(suspendSchema) - .resume(resumeSchema) - .handler(async ({ target }) => await Promise.resolve({ result: target })) - .build(); - - const agent = new Agent('test-agent').tool(tool); - const schema = agent.describe(); - - expect(schema.tools).toHaveLength(1); - const ts = schema.tools[0]; - expect(ts.name).toBe('danger'); - expect(ts.editable).toBe(true); - expect(ts.hasSuspend).toBe(true); - expect(ts.hasResume).toBe(true); - expect(ts.hasToMessage).toBe(false); - expect(ts.inputSchema).toBeTruthy(); - expect(ts.outputSchema).toBeTruthy(); - // handlerSource is a fallback (compiled JS), CLI overrides with real TypeScript - expect(ts.handlerSource).toContain('target'); - // Source string fields are null — CLI patches with original TypeScript - expect(ts.inputSchemaSource).toBeNull(); - expect(ts.outputSchemaSource).toBeNull(); - expect(ts.suspendSchemaSource).toBeNull(); - expect(ts.resumeSchemaSource).toBeNull(); - expect(ts.toMessageSource).toBeNull(); - expect(ts.requireApproval).toBe(false); - expect(ts.needsApprovalFnSource).toBeNull(); - expect(ts.providerOptions).toBeNull(); - }); - - // --- Provider tool --- - - it('describes a provider tool in providerTools array', () => { - const providerTool: BuiltProviderTool = { - name: 'anthropic.web_search_20250305', - args: { maxResults: 5 }, - }; - - const agent = new Agent('test-agent').providerTool(providerTool); - const schema = agent.describe(); - - // Provider tools are now in a separate array - expect(schema.tools).toHaveLength(0); - expect(schema.providerTools).toHaveLength(1); - expect(schema.providerTools[0].name).toBe('anthropic.web_search_20250305'); - expect(schema.providerTools[0].source).toBe(''); - }); - - // --- MCP servers --- - - it('describes MCP servers in mcp field', () => { - const client = new McpClient([ - { name: 'browser', url: 'http://localhost:9222/mcp', transport: 'streamableHttp' }, - { name: 'fs', command: 'echo', args: ['test'] }, - ]); - - const agent = new Agent('test-agent').mcp(client); - const schema = agent.describe(); - - // MCP servers are now in a separate mcp field - expect(schema.tools).toHaveLength(0); - expect(schema.mcp).toHaveLength(2); - expect(schema.mcp![0].name).toBe('browser'); - expect(schema.mcp![0].configSource).toBe(''); - expect(schema.mcp![1].name).toBe('fs'); - expect(schema.mcp![1].configSource).toBe(''); - }); - - it('returns null mcp when no clients are configured', () => { - const agent = new Agent('test-agent'); - const schema = agent.describe(); - - expect(schema.mcp).toBeNull(); - }); - - // --- Guardrails --- - - it('describes input and output guardrails', () => { - const inputGuard: BuiltGuardrail = { - name: 'pii-filter', - guardType: 'pii', - strategy: 'redact', - _config: { types: ['email', 'phone'] }, - }; - const outputGuard: BuiltGuardrail = { - name: 'moderation-check', - guardType: 'moderation', - strategy: 'block', - _config: {}, - }; - - const agent = new Agent('test-agent').inputGuardrail(inputGuard).outputGuardrail(outputGuard); - const schema = agent.describe(); - - expect(schema.guardrails).toHaveLength(2); - expect(schema.guardrails[0]).toEqual({ - name: 'pii-filter', - guardType: 'pii', - strategy: 'redact', - position: 'input', - config: { types: ['email', 'phone'] }, - source: '', - }); - expect(schema.guardrails[1]).toEqual({ - name: 'moderation-check', - guardType: 'moderation', - strategy: 'block', - position: 'output', - config: {}, - source: '', - }); - }); - - // --- Telemetry --- - - it('returns telemetry schema when telemetry builder is set', () => { - const agent = new Agent('test-agent').telemetry(new Telemetry()); - const schema = agent.describe(); - - expect(schema.telemetry).toEqual({ source: '' }); - }); - - it('returns null telemetry when not configured', () => { - const agent = new Agent('test-agent'); - const schema = agent.describe(); - - expect(schema.telemetry).toBeNull(); - }); - - // --- Checkpoint --- - - it('returns memory checkpoint when checkpoint is memory', () => { - const agent = new Agent('test-agent').checkpoint('memory'); - const schema = agent.describe(); - - expect(schema.checkpoint).toBe('memory'); - }); - - it('returns null checkpoint when not configured', () => { - const agent = new Agent('test-agent'); - const schema = agent.describe(); - - expect(schema.checkpoint).toBeNull(); - }); - - // --- Memory --- - - it('describes memory configuration', () => { - const agent = new Agent('test-agent').memory({ - memory: makeMockMemory(), - lastMessages: 20, - semanticRecall: { - topK: 5, - messageRange: { before: 2, after: 2 }, - embedder: 'openai/text-embedding-3-small', - }, - workingMemory: { - template: 'Current state: {{state}}', - structured: false, - scope: 'resource' as const, - }, - }); - const schema = agent.describe(); - - expect(schema.memory).toBeTruthy(); - expect(schema.memory!.source).toBeNull(); - expect(schema.memory!.lastMessages).toBe(20); - expect(schema.memory!.semanticRecall).toEqual({ - topK: 5, - messageRange: { before: 2, after: 2 }, - embedder: 'openai/text-embedding-3-small', - }); - expect(schema.memory!.workingMemory).toEqual({ - type: 'freeform', - template: 'Current state: {{state}}', - }); - }); - - it('describes structured working memory', () => { - const agent = new Agent('test-agent').memory({ - memory: makeMockMemory(), - lastMessages: 10, - workingMemory: { - template: '', - structured: true, - schema: z.object({ notes: z.string() }), - scope: 'resource' as const, - }, - }); - const schema = agent.describe(); - - expect(schema.memory!.workingMemory!.type).toBe('structured'); - expect(schema.memory!.workingMemory!.schema).toBeTruthy(); - }); - - // --- Evaluations --- - - it('describes evaluations with evalType, modelId, and handlerSource', () => { - const checkEval: BuiltEval = { - name: 'has-greeting', - description: 'Checks for greeting', - evalType: 'check', - modelId: null, - credentialName: null, - _run: jest.fn(), - }; - const judgeEval: BuiltEval = { - name: 'quality-judge', - description: undefined, - evalType: 'judge', - modelId: 'anthropic/claude-haiku-4-5', - credentialName: 'anthropic-key', - _run: jest.fn(), - }; - - const agent = new Agent('test-agent').eval(checkEval).eval(judgeEval); - const schema = agent.describe(); - - expect(schema.evaluations).toHaveLength(2); - expect(schema.evaluations[0]).toEqual({ - name: 'has-greeting', - description: 'Checks for greeting', - type: 'check', - modelId: null, - hasCredential: false, - credentialName: null, - handlerSource: null, - }); - expect(schema.evaluations[1]).toEqual({ - name: 'quality-judge', - description: null, - type: 'judge', - modelId: 'anthropic/claude-haiku-4-5', - hasCredential: true, - credentialName: 'anthropic-key', - handlerSource: null, - }); - }); - - // --- Thinking config --- - - it('describes anthropic thinking config', () => { - const agent = new Agent('test-agent') - .model('anthropic', 'claude-sonnet-4-5') - .thinking('anthropic', { budgetTokens: 10000 }); - const schema = agent.describe(); - - expect(schema.config.thinking).toEqual({ - provider: 'anthropic', - budgetTokens: 10000, - }); - }); - - it('describes openai thinking config', () => { - const agent = new Agent('test-agent') - .model('openai', 'o3-mini') - .thinking('openai', { reasoningEffort: 'high' }); - const schema = agent.describe(); - - expect(schema.config.thinking).toEqual({ - provider: 'openai', - reasoningEffort: 'high', - }); - }); - - // --- requireToolApproval --- - - it('reflects requireToolApproval flag', () => { - const agent = new Agent('test-agent').requireToolApproval(); - const schema = agent.describe(); - - expect(schema.config.requireToolApproval).toBe(true); - }); - - // --- toolCallConcurrency --- - - it('reflects toolCallConcurrency', () => { - const agent = new Agent('test-agent').toolCallConcurrency(5); - const schema = agent.describe(); - - expect(schema.config.toolCallConcurrency).toBe(5); - }); - - // --- Structured output --- - - it('describes structured output with schemaSource null', () => { - const outputSchema = z.object({ code: z.string(), explanation: z.string() }); - const agent = new Agent('test-agent').structuredOutput(outputSchema); - const schema = agent.describe(); - - expect(schema.config.structuredOutput.enabled).toBe(true); - expect(schema.config.structuredOutput.schemaSource).toBeNull(); - }); -}); diff --git a/packages/@n8n/agents/src/__tests__/from-json-config.test.ts b/packages/@n8n/agents/src/__tests__/from-json-config.test.ts new file mode 100644 index 00000000000..3c9b45a7194 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/from-json-config.test.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; + +import { Tool } from '../sdk/tool'; +import type { AgentMessage } from '../types/sdk/message'; +import type { InterruptibleToolContext } from '../types/sdk/tool'; + +// --------------------------------------------------------------------------- +// Tool.describe() tests +// --------------------------------------------------------------------------- + +describe('Tool.describe()', () => { + it('returns a ToolDescriptor with name, description, and inputSchema', () => { + const tool = new Tool('search') + .description('Search the web') + .input(z.object({ query: z.string() })) + .handler(async ({ query }) => await Promise.resolve({ result: query })); + + const descriptor = tool.describe(); + + expect(descriptor.name).toBe('search'); + expect(descriptor.description).toBe('Search the web'); + expect(descriptor.inputSchema).toBeDefined(); + expect(descriptor.outputSchema).toBeNull(); + expect(descriptor.hasSuspend).toBe(false); + expect(descriptor.hasResume).toBe(false); + expect(descriptor.hasToMessage).toBe(false); + expect(descriptor.requireApproval).toBe(false); + expect(descriptor.providerOptions).toBeNull(); + expect(descriptor.systemInstruction).toBeNull(); + }); + + it('persists systemInstruction through describe() so it survives JSON-config round-trip', () => { + const directive = 'Always cite a URL when summarising web search results.'; + const tool = new Tool('search') + .description('Search the web') + .systemInstruction(directive) + .input(z.object({ query: z.string() })) + .handler(async ({ query }) => await Promise.resolve({ result: query })); + + const descriptor = tool.describe(); + + expect(descriptor.systemInstruction).toBe(directive); + }); + + it('sets hasSuspend/hasResume when suspend/resume are defined', () => { + const tool = new Tool('approve') + .description('Approve an action') + .input(z.object({ action: z.string() })) + .suspend(z.object({ message: z.string() })) + .resume(z.object({ approved: z.boolean() })) + .handler(async (input, ctx) => { + const interruptCtx = ctx as InterruptibleToolContext; + if (!interruptCtx.resumeData) { + return await interruptCtx.suspend({ message: `Approve: ${input.action}?` }); + } + return { + result: (interruptCtx.resumeData as { approved: boolean }).approved + ? 'approved' + : 'denied', + }; + }); + + const descriptor = tool.describe(); + + expect(descriptor.hasSuspend).toBe(true); + expect(descriptor.hasResume).toBe(true); + }); + + it('sets hasToMessage when toMessage is defined', () => { + const tool = new Tool('get_data') + .description('Get data') + .input(z.object({ id: z.string() })) + .output(z.object({ value: z.string() })) + .toMessage( + (output) => + ({ + type: 'custom', + data: { + components: [{ type: 'section', text: output.value }], + }, + }) as unknown as AgentMessage, + ) + .handler(async ({ id }) => await Promise.resolve({ value: id })); + + const descriptor = tool.describe(); + + expect(descriptor.hasToMessage).toBe(true); + }); + + it('sets requireApproval when .requireApproval() is called', () => { + const tool = new Tool('delete') + .description('Delete a record') + .input(z.object({ id: z.string() })) + .requireApproval() + .handler(async ({ id }) => await Promise.resolve({ deleted: id })); + + const descriptor = tool.describe(); + + expect(descriptor.requireApproval).toBe(true); + }); + + it('sets outputSchema when output schema is defined', () => { + const tool = new Tool('compute') + .description('Compute something') + .input(z.object({ value: z.number() })) + .output(z.object({ result: z.number() })) + .handler(async ({ value }) => await Promise.resolve({ result: value * 2 })); + + const descriptor = tool.describe(); + + expect(descriptor.outputSchema).toBeDefined(); + expect(descriptor.outputSchema).not.toBeNull(); + }); + + it('throws if name is missing', () => { + const tool = new Tool(''); + expect(() => tool.describe()).toThrow('Tool name is required'); + }); + + it('throws if description is missing', () => { + const tool = new Tool('no-desc') + .input(z.object({ x: z.string() })) + .handler(async ({ x }) => await Promise.resolve({ x })); + expect(() => tool.describe()).toThrow('"no-desc" requires a description'); + }); + + it('throws if input schema is missing', () => { + const tool = new Tool('no-input').description('No input'); + expect(() => tool.describe()).toThrow('"no-input" requires an input schema'); + }); + + it('inputSchema conforms to JSON Schema format', () => { + const tool = new Tool('typed') + .description('Typed tool') + .input( + z.object({ + name: z.string().describe('The name'), + count: z.number().int().min(1), + }), + ) + .handler(async ({ name, count }) => await Promise.resolve({ name, count })); + + const descriptor = tool.describe(); + + expect(descriptor.inputSchema).toBeDefined(); + const schema = descriptor.inputSchema as Record; + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/from-schema.test.ts b/packages/@n8n/agents/src/__tests__/from-schema.test.ts deleted file mode 100644 index 589674ef870..00000000000 --- a/packages/@n8n/agents/src/__tests__/from-schema.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { z } from 'zod'; - -import { Agent } from '../sdk/agent'; -import { isSuspendResult } from '../sdk/from-schema'; -import type { HandlerExecutor } from '../types/sdk/handler-executor'; -import type { AgentSchema, ToolSchema } from '../types/sdk/schema'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function mockExecutor(): HandlerExecutor { - return { - executeTool: jest.fn().mockResolvedValue({ result: 'mocked' }), - executeToMessage: jest.fn().mockResolvedValue(undefined), - executeEval: jest.fn().mockResolvedValue({ score: 1 }), - evaluateSchema: jest.fn().mockResolvedValue(undefined), - evaluateExpression: jest.fn().mockResolvedValue(undefined), - }; -} - -function minimalSchema(overrides: Partial = {}): AgentSchema { - return { - model: { provider: 'anthropic', name: 'claude-sonnet-4-5' }, - credential: 'my-credential', - instructions: 'You are helpful.', - description: null, - tools: [], - providerTools: [], - memory: null, - evaluations: [], - guardrails: [], - mcp: null, - telemetry: null, - checkpoint: null, - config: { - structuredOutput: { enabled: false, schemaSource: null }, - thinking: null, - toolCallConcurrency: null, - requireToolApproval: false, - }, - ...overrides, - }; -} - -function makeToolSchema(overrides: Partial = {}): ToolSchema { - return { - name: 'test-tool', - description: 'A test tool', - type: 'custom', - editable: true, - inputSchemaSource: null, - outputSchemaSource: null, - handlerSource: null, - suspendSchemaSource: null, - resumeSchemaSource: null, - toMessageSource: null, - requireApproval: false, - needsApprovalFnSource: null, - providerOptions: null, - inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, - outputSchema: null, - hasSuspend: false, - hasResume: false, - hasToMessage: false, - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('Agent.fromSchema()', () => { - it('reconstructs basic agent config', async () => { - const schema = minimalSchema(); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' }); - expect(described.credential).toBe('my-credential'); - expect(described.instructions).toBe('You are helpful.'); - }); - - it('reconstructs model with only name (no provider)', async () => { - const schema = minimalSchema({ - model: { provider: null, name: 'gpt-4o' }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.model).toEqual({ provider: null, name: 'gpt-4o' }); - }); - - it('reconstructs thinking config with correct provider arg', async () => { - const schema = minimalSchema({ - config: { - structuredOutput: { enabled: false, schemaSource: null }, - thinking: { provider: 'anthropic', budgetTokens: 10000 }, - toolCallConcurrency: null, - requireToolApproval: false, - }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.config.thinking).toEqual({ - provider: 'anthropic', - budgetTokens: 10000, - }); - }); - - it('reconstructs openai thinking config', async () => { - const schema = minimalSchema({ - model: { provider: 'openai', name: 'o3-mini' }, - config: { - structuredOutput: { enabled: false, schemaSource: null }, - thinking: { provider: 'openai', reasoningEffort: 'high' }, - toolCallConcurrency: null, - requireToolApproval: false, - }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.config.thinking).toEqual({ - provider: 'openai', - reasoningEffort: 'high', - }); - }); - - it('creates proxy handlers for custom tools', async () => { - const toolSchema = makeToolSchema({ - name: 'search', - description: 'Search the web', - }); - const schema = minimalSchema({ tools: [toolSchema] }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.tools).toHaveLength(1); - expect(described.tools[0].name).toBe('search'); - expect(described.tools[0].description).toBe('Search the web'); - expect(described.tools[0].editable).toBe(true); - }); - - it('adds WorkflowTool markers for non-editable tools', async () => { - const toolSchema = makeToolSchema({ name: 'Send Email', type: 'workflow', editable: false }); - const schema = minimalSchema({ tools: [toolSchema] }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - // Non-editable tools become WorkflowTool markers in declaredTools - const markers = agent.declaredTools.filter( - (t) => '__workflowTool' in t && (t as Record).__workflowTool === true, - ); - expect(markers).toHaveLength(1); - expect(markers[0].name).toBe('Send Email'); - }); - - it('reconstructs memory from schema fields', async () => { - const schema = minimalSchema({ - memory: { - source: null, - storage: 'memory', - lastMessages: 20, - semanticRecall: null, - workingMemory: null, - }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.memory).toBeTruthy(); - expect(described.memory!.lastMessages).toBe(20); - expect(described.memory!.storage).toBe('memory'); - }); - - it('sets toolCallConcurrency when specified', async () => { - const schema = minimalSchema({ - config: { - structuredOutput: { enabled: false, schemaSource: null }, - thinking: null, - toolCallConcurrency: 5, - requireToolApproval: false, - }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.config.toolCallConcurrency).toBe(5); - }); - - it('sets requireToolApproval when true', async () => { - const schema = minimalSchema({ - config: { - structuredOutput: { enabled: false, schemaSource: null }, - thinking: null, - toolCallConcurrency: null, - requireToolApproval: true, - }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.config.requireToolApproval).toBe(true); - }); - - it('sets checkpoint when specified', async () => { - const schema = minimalSchema({ checkpoint: 'memory' }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - const described = agent.describe(); - - expect(described.checkpoint).toBe('memory'); - }); - - it('delegates tool execution to handlerExecutor', async () => { - const executor = mockExecutor(); - const toolSchema = makeToolSchema({ name: 'my-tool' }); - const schema = minimalSchema({ tools: [toolSchema] }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - // Access the built tool's handler via declaredTools - const tools = agent.declaredTools; - expect(tools).toHaveLength(1); - - const result = await tools[0].handler!({ query: 'test' }, { parentTelemetry: undefined }); - expect(executor.executeTool).toHaveBeenCalledWith( - 'my-tool', - { query: 'test' }, - { parentTelemetry: undefined }, - ); - expect(result).toEqual({ result: 'mocked' }); - }); - - it('reconstructs guardrails with correct position', async () => { - const schema = minimalSchema({ - guardrails: [ - { - name: 'pii-guard', - guardType: 'pii', - strategy: 'redact', - position: 'input', - config: { detectionTypes: ['email', 'phone'] }, - source: '', - }, - { - name: 'mod-guard', - guardType: 'moderation', - strategy: 'block', - position: 'output', - config: {}, - source: '', - }, - ], - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - const described = agent.describe(); - - expect(described.guardrails).toHaveLength(2); - expect(described.guardrails[0].name).toBe('pii-guard'); - expect(described.guardrails[0].position).toBe('input'); - expect(described.guardrails[0].guardType).toBe('pii'); - expect(described.guardrails[1].name).toBe('mod-guard'); - expect(described.guardrails[1].position).toBe('output'); - }); - - it('reconstructs evals with proxy _run', async () => { - const executor = mockExecutor(); - const schema = minimalSchema({ - evaluations: [ - { - name: 'accuracy', - description: 'Check accuracy', - type: 'check', - modelId: null, - credentialName: null, - hasCredential: false, - handlerSource: null, - }, - { - name: 'quality', - description: 'Judge quality', - type: 'judge', - modelId: 'anthropic/claude-sonnet-4-5', - credentialName: 'anthropic', - hasCredential: true, - handlerSource: null, - }, - ], - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - const described = agent.describe(); - - expect(described.evaluations).toHaveLength(2); - expect(described.evaluations[0].name).toBe('accuracy'); - expect(described.evaluations[0].type).toBe('check'); - expect(described.evaluations[1].name).toBe('quality'); - expect(described.evaluations[1].type).toBe('judge'); - }); - - it('reconstructs provider tools', async () => { - const schema = minimalSchema({ - providerTools: [{ name: 'anthropic.web_search_20250305', source: '' }], - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - const described = agent.describe(); - - expect(described.providerTools).toHaveLength(1); - expect(described.providerTools[0].name).toBe('anthropic.web_search_20250305'); - }); - - it('evaluates provider tool source via evaluateExpression', async () => { - const executor = mockExecutor(); - (executor.evaluateExpression as jest.Mock).mockResolvedValue({ - name: 'anthropic.web_search_20250305', - args: { maxUses: 5 }, - }); - const schema = minimalSchema({ - providerTools: [ - { - name: 'anthropic.web_search_20250305', - source: 'providerTools.anthropicWebSearch({ maxUses: 5 })', - }, - ], - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - const described = agent.describe(); - - expect(executor.evaluateExpression).toHaveBeenCalledWith( - 'providerTools.anthropicWebSearch({ maxUses: 5 })', - ); - expect(described.providerTools).toHaveLength(1); - expect(described.providerTools[0].name).toBe('anthropic.web_search_20250305'); - }); - - it('evaluates structuredOutput schema via evaluateSchema', async () => { - const zodSchema = z.object({ answer: z.string() }); - const executor = mockExecutor(); - (executor.evaluateSchema as jest.Mock).mockResolvedValue(zodSchema); - const schema = minimalSchema({ - config: { - structuredOutput: { enabled: true, schemaSource: 'z.object({ answer: z.string() })' }, - thinking: null, - toolCallConcurrency: null, - requireToolApproval: false, - }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - const described = agent.describe(); - - expect(executor.evaluateSchema).toHaveBeenCalledWith('z.object({ answer: z.string() })'); - expect(described.config.structuredOutput.enabled).toBe(true); - }); - - it('handles suspend result detection via isSuspendResult', () => { - const suspendMarker = Symbol.for('n8n.agent.suspend'); - const suspendResult = { [suspendMarker]: true, payload: { message: 'approve?' } }; - const nonSuspend = { result: 42 }; - - expect(isSuspendResult(suspendResult)).toBe(true); - expect(isSuspendResult(nonSuspend)).toBe(false); - expect(isSuspendResult(null)).toBe(false); - expect(isSuspendResult(undefined)).toBe(false); - }); - - it('delegates interruptible tool execution with suspend detection', async () => { - const suspendMarker = Symbol.for('n8n.agent.suspend'); - const executor = { - ...mockExecutor(), - executeTool: jest.fn().mockResolvedValue({ - [suspendMarker]: true, - payload: { message: 'Please approve' }, - }), - }; - - const toolSchema = makeToolSchema({ - name: 'suspend-tool', - hasSuspend: true, - }); - const schema = minimalSchema({ tools: [toolSchema] }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - const tools = agent.declaredTools; - expect(tools).toHaveLength(1); - - // Call with an interruptible context - let suspendedPayload: unknown; - const ctx = { - parentTelemetry: undefined, - resumeData: undefined, - // eslint-disable-next-line @typescript-eslint/require-await - suspend: jest.fn().mockImplementation(async (payload: unknown) => { - suspendedPayload = payload; - return { suspended: true }; - }), - }; - - await tools[0].handler!({ query: 'test' }, ctx); - - expect(ctx.suspend).toHaveBeenCalledWith({ message: 'Please approve' }); - expect(suspendedPayload).toEqual({ message: 'Please approve' }); - }); - - it('reconstructs requireApproval on individual tools', async () => { - const toolSchema = makeToolSchema({ - name: 'danger-tool', - requireApproval: true, - }); - const schema = minimalSchema({ - tools: [toolSchema], - checkpoint: 'memory', - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: mockExecutor(), - }); - - // The tool should be wrapped for approval, which adds suspendSchema - const tools = agent.declaredTools; - expect(tools).toHaveLength(1); - expect(tools[0].suspendSchema).toBeDefined(); - }); - - it('reconstructs MCP servers by evaluating configSource', async () => { - const executor = mockExecutor(); - (executor.evaluateExpression as jest.Mock).mockResolvedValue({ - name: 'browser', - url: 'http://localhost:9222/mcp', - transport: 'streamableHttp', - }); - - const schema = minimalSchema({ - mcp: [ - { - name: 'browser', - configSource: - '({ name: "browser", url: "http://localhost:9222/mcp", transport: "streamableHttp" })', - }, - ], - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - expect(executor.evaluateExpression).toHaveBeenCalledWith( - '({ name: "browser", url: "http://localhost:9222/mcp", transport: "streamableHttp" })', - ); - - const described = agent.describe(); - expect(described.mcp).toHaveLength(1); - expect(described.mcp![0].name).toBe('browser'); - }); - - it('reconstructs multiple MCP servers', async () => { - const executor = mockExecutor(); - (executor.evaluateExpression as jest.Mock) - .mockResolvedValueOnce({ - name: 'browser', - url: 'http://localhost:9222/mcp', - transport: 'streamableHttp', - }) - .mockResolvedValueOnce({ - name: 'fs', - command: 'npx', - args: ['@anthropic/mcp-fs', '/tmp'], - }); - - const schema = minimalSchema({ - mcp: [ - { name: 'browser', configSource: 'browserConfig' }, - { name: 'fs', configSource: 'fsConfig' }, - ], - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - const described = agent.describe(); - expect(described.mcp).toHaveLength(2); - expect(described.mcp![0].name).toBe('browser'); - expect(described.mcp![1].name).toBe('fs'); - }); - - it('skips MCP servers with empty configSource', async () => { - const schema = minimalSchema({ - mcp: [{ name: 'browser', configSource: '' }], - }); - const executor = mockExecutor(); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - expect(executor.evaluateExpression).not.toHaveBeenCalled(); - // No MCP configs evaluated means no client is added - const described = agent.describe(); - expect(described.mcp).toBeNull(); - }); - - it('reconstructs telemetry by evaluating source', async () => { - const executor = mockExecutor(); - (executor.evaluateExpression as jest.Mock).mockResolvedValue({ - enabled: true, - functionId: 'my-agent', - recordInputs: true, - recordOutputs: true, - integrations: [], - }); - - const schema = minimalSchema({ - telemetry: { source: 'new Telemetry().functionId("my-agent").build()' }, - }); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - expect(executor.evaluateExpression).toHaveBeenCalledWith( - 'new Telemetry().functionId("my-agent").build()', - ); - - const described = agent.describe(); - expect(described.telemetry).not.toBeNull(); - }); - - it('does not set telemetry when schema has no telemetry', async () => { - const schema = minimalSchema({ telemetry: null }); - const executor = mockExecutor(); - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - const described = agent.describe(); - expect(described.telemetry).toBeNull(); - expect(executor.evaluateExpression).not.toHaveBeenCalled(); - }); - - it('evaluates suspend/resume schemas via evaluateSchema', async () => { - const suspendSchema = z.object({ reason: z.string() }); - const resumeSchema = z.object({ approved: z.boolean() }); - - const executor = mockExecutor(); - (executor.evaluateSchema as jest.Mock) - .mockResolvedValueOnce(suspendSchema) - .mockResolvedValueOnce(resumeSchema); - - const toolSchema = makeToolSchema({ - name: 'interruptible-tool', - hasSuspend: true, - hasResume: true, - suspendSchemaSource: 'z.object({ reason: z.string() })', - resumeSchemaSource: 'z.object({ approved: z.boolean() })', - }); - const schema = minimalSchema({ tools: [toolSchema] }); - - const agent = await Agent.fromSchema(schema, 'test-agent', { - handlerExecutor: executor, - }); - - const tools = agent.declaredTools; - expect(tools).toHaveLength(1); - expect(tools[0].suspendSchema).toBe(suspendSchema); - expect(tools[0].resumeSchema).toBe(resumeSchema); - }); -}); diff --git a/packages/@n8n/agents/src/__tests__/inmemory-working-memory.test.ts b/packages/@n8n/agents/src/__tests__/inmemory-working-memory.test.ts index e03eab8441d..2a4c5d5fea0 100644 --- a/packages/@n8n/agents/src/__tests__/inmemory-working-memory.test.ts +++ b/packages/@n8n/agents/src/__tests__/inmemory-working-memory.test.ts @@ -1,5 +1,5 @@ import { InMemoryMemory } from '../runtime/memory-store'; -import type { AgentDbMessage } from '../types/sdk/message'; +import type { AgentDbMessage, Message } from '../types/sdk/message'; describe('InMemoryMemory working memory', () => { it('returns null for unknown key', async () => { @@ -117,3 +117,59 @@ describe('InMemoryMemory — message createdAt', () => { expect(loaded[1].createdAt.getTime()).toBe(t2.getTime()); }); }); + +// --------------------------------------------------------------------------- +// Upsert contract +// --------------------------------------------------------------------------- + +describe('InMemoryMemory — saveMessages upsert by id', () => { + it('upserts by id (no duplicate rows after a re-save)', async () => { + const mem = new InMemoryMemory(); + const t1 = new Date('2020-01-01T00:00:01.000Z'); + + await mem.saveMessages({ + threadId: 't1', + messages: [makeDbMsg('msg-1', t1, 'original')], + }); + + const updated = { ...makeDbMsg('msg-1', t1, 'updated content') }; + await mem.saveMessages({ threadId: 't1', messages: [updated] }); + + const result = await mem.getMessages('t1'); + expect(result).toHaveLength(1); + expect(((result[0] as Message).content[0] as { type: string; text: string }).text).toBe( + 'updated content', + ); + }); + + it('preserves insertion order on upsert', async () => { + const mem = new InMemoryMemory(); + const t1 = new Date('2020-01-01T00:00:01.000Z'); + const t2 = new Date('2020-01-01T00:00:02.000Z'); + const t3 = new Date('2020-01-01T00:00:03.000Z'); + + await mem.saveMessages({ + threadId: 't1', + messages: [ + makeDbMsg('m1', t1, 'first'), + makeDbMsg('m2', t2, 'second'), + makeDbMsg('m3', t3, 'third'), + ], + }); + + // Update m2 in place + await mem.saveMessages({ + threadId: 't1', + messages: [makeDbMsg('m2', t2, 'second-updated')], + }); + + const result = await mem.getMessages('t1'); + expect(result).toHaveLength(3); + // Original order preserved + expect(result[0].id).toBe('m1'); + expect(result[1].id).toBe('m2'); + expect(result[2].id).toBe('m3'); + // Updated content + expect(((result[1] as Message).content[0] as { text: string }).text).toBe('second-updated'); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts b/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts new file mode 100644 index 00000000000..ea74565a4ca --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts @@ -0,0 +1,327 @@ +/** + * Round-trip conversion tests: toAiMessages ↔ fromAiMessages + * + * These tests exercise the message split/merge logic without making real LLM + * calls. They lock down the structural invariants that the agent runtime relies + * on, including the key interim-message ordering guarantee described in the + * plan: + * + * input: [assistant{tool-call resolved}, user{x}, assistant{y}] + * output: [assistant{tool-call}, tool{tool-result}, user{x}, assistant{y}] + * + * The tool-result is inserted right after its tool-call, regardless of what + * messages follow it in the n8n list. + */ +import { describe, it, expect } from 'vitest'; + +import { toAiMessages, fromAiMessages } from '../../runtime/messages'; +import type { Message } from '../../types/sdk/message'; + +describe('toAiMessages + fromAiMessages — round-trip', () => { + it('splits a resolved tool-call into assistant + tool ModelMessages', () => { + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'add', + input: { a: 1, b: 2 }, + state: 'resolved', + output: { result: 3 }, + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + + expect(aiMessages).toHaveLength(2); + expect(aiMessages[0].role).toBe('assistant'); + expect(aiMessages[1].role).toBe('tool'); + + const toolCallPart = ( + aiMessages[0] as { role: string; content: Array<{ type: string; toolCallId: string }> } + ).content[0]; + expect(toolCallPart.type).toBe('tool-call'); + expect(toolCallPart.toolCallId).toBe('tc-1'); + + const toolResultPart = ( + aiMessages[1] as { + role: string; + content: Array<{ + type: string; + toolCallId: string; + output: { type: string; value: unknown }; + }>; + } + ).content[0]; + expect(toolResultPart.type).toBe('tool-result'); + expect(toolResultPart.toolCallId).toBe('tc-1'); + expect(toolResultPart.output.type).toBe('json'); + expect(toolResultPart.output.value).toEqual({ result: 3 }); + }); + + it('encodes rejected tool-call as error-text in the tool ModelMessage', () => { + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'do_it', + input: {}, + state: 'rejected', + error: 'Error: something went wrong', + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + expect(aiMessages).toHaveLength(2); + + const toolResultPart = ( + aiMessages[1] as { role: string; content: Array<{ output: { type: string; value: string } }> } + ).content[0]; + expect(toolResultPart.output.type).toBe('error-text'); + expect(toolResultPart.output.value).toBe('Error: something went wrong'); + }); + + it('drops pending tool-call blocks from both assistant and tool ModelMessages', () => { + const input: Message[] = [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Thinking...' }, + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'do_it', + input: {}, + state: 'pending', + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + + // Only the assistant text part remains; no tool-result emitted for pending + expect(aiMessages).toHaveLength(1); + expect(aiMessages[0].role).toBe('assistant'); + const content = (aiMessages[0] as { role: string; content: Array<{ type: string }> }).content; + expect(content).toHaveLength(1); + expect(content[0].type).toBe('text'); + }); + + it('emits nothing for an assistant message whose only blocks are all pending', () => { + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'do_it', + input: {}, + state: 'pending', + }, + { + type: 'tool-call', + toolCallId: 'tc-2', + toolName: 'do_more', + input: {}, + state: 'pending', + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + + // No empty-content assistant message — the whole message is suppressed + expect(aiMessages).toHaveLength(0); + }); + + it('skips legacy tool-call blocks that have no state field and emits nothing when they are the only content', () => { + const input: Message[] = [ + { + role: 'assistant', + content: [ + // Simulate a DB row written before the state field was introduced + { + type: 'tool-call', + toolCallId: 'tc-legacy', + toolName: 'old_tool', + input: {}, + } as unknown as Message['content'][number], + ], + }, + ]; + + const aiMessages = toAiMessages(input); + + // No empty-content assistant message and no spurious error-json tool message + expect(aiMessages).toHaveLength(0); + }); + + it('emits one tool ModelMessage per settled block in the same assistant turn', () => { + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'add', + input: { a: 1, b: 2 }, + state: 'resolved', + output: { result: 3 }, + }, + { + type: 'tool-call', + toolCallId: 'tc-2', + toolName: 'mul', + input: { a: 4, b: 5 }, + state: 'resolved', + output: { result: 20 }, + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + + // assistant{tc-1, tc-2} + tool{tc-1} + tool{tc-2} + expect(aiMessages).toHaveLength(3); + expect(aiMessages[0].role).toBe('assistant'); + const assistantContent = ( + aiMessages[0] as { content: Array<{ type: string; toolCallId: string }> } + ).content; + expect(assistantContent).toHaveLength(2); + expect(assistantContent[0].toolCallId).toBe('tc-1'); + expect(assistantContent[1].toolCallId).toBe('tc-2'); + + expect(aiMessages[1].role).toBe('tool'); + expect(aiMessages[2].role).toBe('tool'); + }); + + it('merges role:tool ModelMessages into the preceding assistant tool-call block', () => { + // Simulate AI SDK output: [assistant{tool-call}, tool{tool-result}] + const aiMessages = [ + { + role: 'assistant' as const, + content: [ + { + type: 'tool-call' as const, + toolCallId: 'tc-1', + toolName: 'add', + input: { a: 1, b: 2 }, + providerExecuted: undefined, + }, + ], + }, + { + role: 'tool' as const, + content: [ + { + type: 'tool-result' as const, + toolCallId: 'tc-1', + toolName: 'add', + output: { type: 'json' as const, value: { result: 3 } }, + }, + ], + }, + ]; + + const n8nMessages = fromAiMessages(aiMessages); + + // Should produce a single assistant message with the resolved block + expect(n8nMessages).toHaveLength(1); + expect((n8nMessages[0] as Message).role).toBe('assistant'); + const block = (n8nMessages[0] as Message).content[0]; + expect(block.type).toBe('tool-call'); + expect((block as { state: string }).state).toBe('resolved'); + expect((block as { output: unknown }).output).toEqual({ result: 3 }); + }); + + it('round-trip is structurally equivalent for a resolved tool-call', () => { + const original: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'echo', + input: { text: 'hello' }, + state: 'resolved', + output: { echoed: 'hello' }, + }, + ], + }, + ]; + + const aiMessages = toAiMessages(original); + const roundTripped = fromAiMessages(aiMessages); + + expect(roundTripped).toHaveLength(1); + expect((roundTripped[0] as Message).role).toBe('assistant'); + const block = (roundTripped[0] as Message).content[0]; + expect(block.type).toBe('tool-call'); + expect((block as { state: string }).state).toBe('resolved'); + expect((block as { output: unknown }).output).toEqual({ echoed: 'hello' }); + expect((block as { toolCallId: string }).toolCallId).toBe('tc-1'); + }); + + it('interim-message ordering: tool-result is inserted right after its tool-call', () => { + // This is the key regression test for the interim-message scenario. + // Input n8n list: [assistant{tool-call resolved}, user{x}, assistant{y}] + // Expected AI SDK output: [assistant{tc}, tool{tr}, user{x}, assistant{y}] + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'delete_file', + input: { path: 'foo.txt' }, + state: 'resolved', + output: { deleted: true }, + }, + ], + }, + { + role: 'user', + content: [{ type: 'text', text: 'Actually, what is 2+2?' }], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'It is 4.' }], + }, + ]; + + const aiMessages = toAiMessages(input); + + // 4 messages: assistant{tool-call}, tool{tool-result}, user, assistant + expect(aiMessages).toHaveLength(4); + expect(aiMessages[0].role).toBe('assistant'); + expect(aiMessages[1].role).toBe('tool'); + expect(aiMessages[2].role).toBe('user'); + expect(aiMessages[3].role).toBe('assistant'); + + // tool-result is immediately after the assistant tool-call message + const toolResultContent = (aiMessages[1] as { content: Array<{ toolCallId: string }> }) + .content[0]; + expect(toolResultContent.toolCallId).toBe('tc-1'); + + // user interim message is after the tool-result + const userContent = (aiMessages[2] as { content: Array<{ type: string; text: string }> }) + .content[0]; + expect(userContent.text).toBe('Actually, what is 2+2?'); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/integration/batched-tool-execution.test.ts b/packages/@n8n/agents/src/__tests__/integration/batched-tool-execution.test.ts index c3f1d3a6d2e..ce330ce3592 100644 --- a/packages/@n8n/agents/src/__tests__/integration/batched-tool-execution.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/batched-tool-execution.test.ts @@ -106,7 +106,7 @@ describe('batched tool execution integration', () => { const resumedStream = await agent.resume( 'stream', { approved: true }, - { runId: next.runId!, toolCallId: next.toolCallId! }, + { runId: next.runId, toolCallId: next.toolCallId }, ); const resumedChunks = await collectStreamChunks(resumedStream.stream); diff --git a/packages/@n8n/agents/src/__tests__/integration/concurrent-tool-execution.test.ts b/packages/@n8n/agents/src/__tests__/integration/concurrent-tool-execution.test.ts index ce74dc1f766..3b8d2b4fb89 100644 --- a/packages/@n8n/agents/src/__tests__/integration/concurrent-tool-execution.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/concurrent-tool-execution.test.ts @@ -8,7 +8,7 @@ import { createAgentWithConcurrentMixedTools, collectTextDeltas, } from './helpers'; -import { isLlmMessage, type StreamChunk } from '../../index'; +import type { StreamChunk } from '../../index'; const describe = describeIf('anthropic'); @@ -120,7 +120,7 @@ describe('concurrent tool execution integration', () => { const resumedStream = await agent.resume( 'stream', { approved: true }, - { runId: next.runId!, toolCallId: next.toolCallId! }, + { runId: next.runId, toolCallId: next.toolCallId }, ); const resumedChunks = await collectStreamChunks(resumedStream.stream); @@ -147,13 +147,8 @@ describe('concurrent tool execution integration', () => { const chunks = await collectStreamChunks(fullStream); - // list_files should auto-execute — its result should appear as a message chunk - const toolResultChunks = chunks.filter( - (c) => - c.type === 'message' && - isLlmMessage(c.message) && - c.message.content.some((p) => p.type === 'tool-result'), - ); + // list_files should auto-execute — its result should appear as a discrete tool-result chunk + const toolResultChunks = chunksOfType(chunks, 'tool-result'); // delete_file should be suspended const suspendedChunks = chunksOfType(chunks, 'tool-call-suspended'); @@ -170,12 +165,7 @@ describe('concurrent tool execution integration', () => { ); // list_files result should be present even though delete_file suspended - const listResult = toolResultChunks.find( - (c) => - c.type === 'message' && - isLlmMessage(c.message) && - c.message.content.some((p) => p.type === 'tool-result' && p.toolName === 'list_files'), - ); + const listResult = toolResultChunks.find((c) => c.toolName === 'list_files'); expect(listResult).toBeDefined(); } }); @@ -204,7 +194,7 @@ describe('concurrent tool execution integration', () => { 'content' in m ? m.content .filter((c) => c.type === 'text') - .map((c) => ({ type: 'text-delta' as const, delta: c.text })) + .map((c) => ({ type: 'text-delta' as const, id: '', delta: c.text })) : [], ), ); diff --git a/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts b/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts index ae8bf281c05..c34ec03c4cc 100644 --- a/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/events-and-abort.test.ts @@ -175,42 +175,53 @@ describe('event system — stream', () => { }); // --------------------------------------------------------------------------- -// result.getState() +// getState() // --------------------------------------------------------------------------- -describe('result.getState()', () => { - it('generate() result reports success after a successful run', async () => { +describe('getState()', () => { + it('returns idle before first run', () => { const agent = createSimpleAgent(); - const result = await agent.generate('Say hello'); - expect(result.getState().status).toBe('success'); + const state = agent.getState(); + expect(state.status).toBe('idle'); + expect(state.messageList.messages).toHaveLength(0); }); - it('stream() result reports success after the stream is fully consumed', async () => { + it('returns success after a successful generate()', async () => { const agent = createSimpleAgent(); - const { stream, getState } = await agent.stream('Say hello'); + await agent.generate('Say hello'); + const state = agent.getState(); + expect(state.status).toBe('success'); + }); + + it('returns success after a completed stream()', async () => { + const agent = createSimpleAgent(); + const { stream } = await agent.stream('Say hello'); await collectStreamChunks(stream); - expect(getState().status).toBe('success'); + const state = agent.getState(); + expect(state.status).toBe('success'); }); - it('stream() getState() is running while the stream is being consumed', async () => { + it('state is running during the generate loop (observed via event)', async () => { const agent = createSimpleAgent(); - const { stream, getState } = await agent.stream('Say hello'); - // State is running before the stream is consumed - expect(getState().status).toBe('running'); + let stateWhileRunning: string | undefined; + agent.on(AgentEvent.TurnStart, () => { + stateWhileRunning = agent.getState().status; + }); - await collectStreamChunks(stream); + await agent.generate('Say hello'); - expect(getState().status).toBe('success'); + expect(stateWhileRunning).toBe('running'); }); - it('generate() result reflects resourceId and threadId from RunOptions', async () => { + it('reflects resourceId and threadId from RunOptions', async () => { const agent = createSimpleAgent(); - const result = await agent.generate('Say hello', { + await agent.generate('Say hello', { persistence: { resourceId: 'user-123', threadId: 'thread-abc' }, }); - expect(result.getState().persistence?.resourceId).toBe('user-123'); - expect(result.getState().persistence?.threadId).toBe('thread-abc'); + const state = agent.getState(); + expect(state.persistence?.resourceId).toBe('user-123'); + expect(state.persistence?.threadId).toBe('thread-abc'); }); }); diff --git a/packages/@n8n/agents/src/__tests__/integration/helpers.ts b/packages/@n8n/agents/src/__tests__/integration/helpers.ts index 1831018bc13..5d026083175 100644 --- a/packages/@n8n/agents/src/__tests__/integration/helpers.ts +++ b/packages/@n8n/agents/src/__tests__/integration/helpers.ts @@ -7,7 +7,6 @@ import { z } from 'zod'; import { Agent, type ContentToolCall, - type ContentToolResult, filterLlmMessages, Tool, type StreamChunk, @@ -404,10 +403,10 @@ export const findAllToolCalls = (messages: AgentMessage[]): ContentToolCall[] => .map((m) => m.content.filter((c) => c.type === 'tool-call')) .flat(); }; -export const findAllToolResults = (messages: AgentMessage[]): ContentToolResult[] => { - return filterLlmMessages(messages) - .filter((m) => m.content.find((c) => c.type === 'tool-result')) - .map((m) => m.content.find((c) => c.type === 'tool-result') as ContentToolResult); +export const findAllToolResults = (messages: AgentMessage[]): ContentToolCall[] => { + return filterLlmMessages(messages).flatMap((m) => + m.content.filter((c): c is ContentToolCall => c.type === 'tool-call' && c.state !== 'pending'), + ); }; export const collectTextDeltas = (chunks: StreamChunk[]): string => { return chunks diff --git a/packages/@n8n/agents/src/__tests__/integration/interim-user-message-during-suspend.test.ts b/packages/@n8n/agents/src/__tests__/integration/interim-user-message-during-suspend.test.ts new file mode 100644 index 00000000000..7e131291faf --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/interim-user-message-during-suspend.test.ts @@ -0,0 +1,214 @@ +/** + * Regression test: interim user message while a tool-call is suspended. + * + * Old architecture bug: if a user sent a new message between a tool-call + * suspension and its eventual resume, the message list would contain: + * + * assistant{tool-call} → user{interim} → tool{tool-result} + * + * This order is invalid for AI SDK providers (tool-result must immediately + * follow its tool-call). The new architecture stores the result ON the + * tool-call block, so toAiMessages always emits: + * + * assistant{tool-call} → tool{tool-result} → user{interim} → assistant{reply} + * + * The tool-result is always adjacent to its tool-call regardless of what n8n + * messages come after it in the list. + * + * This test drives the full scenario end-to-end and asserts that: + * 1. The final result has finishReason 'stop' (no provider error). + * 2. The tool-call block on the originating assistant message transitions to + * state 'resolved' with the expected output. + * 3. The interim user/assistant messages are still present in memory. + */ +import { afterEach, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { describeIf, createSqliteMemory, getModel } from './helpers'; +import { Agent, filterLlmMessages, Memory, Tool } from '../../index'; +import type { AgentDbMessage } from '../../index'; +import type { ContentToolCall, Message } from '../../types/sdk/message'; + +const describe = describeIf('anthropic'); + +describe('interim user message during tool suspension', () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + for (const fn of cleanups) fn(); + cleanups.length = 0; + }); + + function buildInterruptibleAgent(mem: Memory): Agent { + const deleteTool = new Tool('delete_file') + .description('Delete a file at the given path') + .input(z.object({ path: z.string().describe('File path to delete') })) + .output(z.object({ deleted: z.boolean(), path: z.string() })) + .suspend(z.object({ message: z.string(), severity: z.string() })) + .resume(z.object({ approved: z.boolean() })) + .handler(async ({ path }, ctx) => { + if (!ctx.resumeData) { + return await ctx.suspend({ message: `Delete "${path}"?`, severity: 'destructive' }); + } + if (!ctx.resumeData.approved) return { deleted: false, path }; + return { deleted: true, path }; + }); + + return new Agent('interim-test-agent') + .model(getModel('anthropic')) + .instructions( + 'You are a file manager. When asked to delete a file, use the delete_file tool. Be concise.', + ) + .tool(deleteTool) + .memory(mem) + .checkpoint('memory'); + } + + for (const method of ['generate', 'stream'] as const) { + it(`[${method}] interim message does not break provider message ordering`, async () => { + const { memory, cleanup } = createSqliteMemory(); + cleanups.push(cleanup); + + const threadId = `thread-interim-${method}`; + const resourceId = 'res-interim'; + const persistence = { threadId, resourceId }; + const mem = new Memory().storage(memory); + + const agent = buildInterruptibleAgent(mem); + + // ---------------------------------------------------------------- + // Turn 1: trigger the tool suspension + // ---------------------------------------------------------------- + const suspendResult = await agent.generate('Please delete /tmp/interim-test.txt', { + persistence, + }); + + expect(suspendResult.finishReason).toBe('tool-calls'); + expect(suspendResult.pendingSuspend).toBeDefined(); + const { runId, toolCallId } = suspendResult.pendingSuspend![0]; + + // ---------------------------------------------------------------- + // Interim turn: send a new message while the tool is suspended. + // Build a fresh agent instance to simulate a separate request. + // ---------------------------------------------------------------- + const interimAgent = new Agent('interim-agent') + .model(getModel('anthropic')) + .instructions('You are helpful. Answer questions concisely.') + .memory(mem); + + const interimResult = await interimAgent.generate('What is 1 + 1?', { persistence }); + expect(interimResult.finishReason).toBe('stop'); + + // ---------------------------------------------------------------- + // Resume turn: approve the suspended tool call + // ---------------------------------------------------------------- + let resumeFinishReason: string; + if (method === 'generate') { + const result = await agent.resume( + 'generate', + { approved: true }, + { + runId, + toolCallId, + }, + ); + resumeFinishReason = result.finishReason ?? 'stop'; + } else { + const { stream } = await agent.resume( + 'stream', + { approved: true }, + { + runId, + toolCallId, + }, + ); + // Drain the stream + const reader = stream.getReader(); + let finishReason = 'stop'; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if ((value as { type: string }).type === 'finish') { + finishReason = (value as { finishReason?: string }).finishReason ?? 'stop'; + } + } + resumeFinishReason = finishReason; + } + + // ---------------------------------------------------------------- + // Assertions + // ---------------------------------------------------------------- + // 1. No provider error — the ordering was valid + expect(resumeFinishReason).toBe('stop'); + + // 2. The originating assistant message's tool-call block is resolved + const allMessages = await memory.getMessages(threadId); + const llmMessages = filterLlmMessages(allMessages); + + const ourBlock = llmMessages + .flatMap((m) => m.content.filter((c): c is ContentToolCall => c.type === 'tool-call')) + .find((b) => b.toolCallId === toolCallId); + + expect(ourBlock).toBeDefined(); + expect(ourBlock!.state).toBe('resolved'); + + // 3. The interim user/assistant exchange is present in memory + const userMessages = allMessages.filter( + (m): m is AgentDbMessage & Message => 'role' in m && m.role === 'user', + ); + // Turn-1 user + interim user (at minimum) + expect(userMessages.length).toBeGreaterThanOrEqual(2); + }); + } + + it('preserves chronological ordering of messages in memory after resume', async () => { + const { memory, cleanup } = createSqliteMemory(); + cleanups.push(cleanup); + + const threadId = 'thread-interim-ordering'; + const resourceId = 'res-ordering'; + const persistence = { threadId, resourceId }; + const mem = new Memory().storage(memory); + + const agent = buildInterruptibleAgent(mem); + + // Turn 1: suspend + const suspendResult = await agent.generate('Delete /tmp/order-test.txt', { persistence }); + expect(suspendResult.finishReason).toBe('tool-calls'); + const { runId, toolCallId } = suspendResult.pendingSuspend![0]; + + // Interim turn + const interimAgent = new Agent('interim-ordering') + .model(getModel('anthropic')) + .instructions('Answer concisely.') + .memory(mem); + await interimAgent.generate('Say hi', { persistence }); + + // Resume + const resumeResult = await agent.resume( + 'generate', + { approved: true }, + { + runId, + toolCallId, + }, + ); + expect(resumeResult.finishReason).toBe('stop'); + + // The tool-call is resolved + const allMessages = await memory.getMessages(threadId); + const llmMessages = filterLlmMessages(allMessages); + const ourBlock = llmMessages + .flatMap((m) => m.content.filter((c): c is ContentToolCall => c.type === 'tool-call')) + .find((b) => b.toolCallId === toolCallId); + + expect(ourBlock).toBeDefined(); + expect(ourBlock!.state).toBe('resolved'); + + // Messages are in chronological order (createdAt ascending) + const timestamps = allMessages.map((m) => m.createdAt.getTime()); + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/integration/json-schema-validation.test.ts b/packages/@n8n/agents/src/__tests__/integration/json-schema-validation.test.ts index 120d9ac48a3..f7bfcebe50f 100644 --- a/packages/@n8n/agents/src/__tests__/integration/json-schema-validation.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/json-schema-validation.test.ts @@ -72,12 +72,12 @@ describe('JSON Schema validation — non-MCP tools with raw JSON Schema', () => // The handler should have been called with valid data expect(handler).toHaveBeenCalledWith(expect.objectContaining({ age: 25 }), expect.anything()); - // No tool-result should carry an error flag + // No tool-call block should have state 'rejected' const allMessages = filterLlmMessages(result.messages); - const toolResults = allMessages.flatMap((m) => - m.content.filter((c) => c.type === 'tool-result'), + const toolCallBlocks = allMessages.flatMap((m) => + m.content.filter((c) => c.type === 'tool-call'), ); - expect(toolResults.every((r) => !r.isError)).toBe(true); + expect(toolCallBlocks.every((c) => (c as { state: string }).state !== 'rejected')).toBe(true); }); it('allows the LLM to self-correct after receiving a JSON Schema validation error', async () => { @@ -105,12 +105,12 @@ describe('JSON Schema validation — non-MCP tools with raw JSON Schema', () => expect(result.finishReason).toBe('stop'); expect(result.error).toBeUndefined(); - // There should be at least two tool-result messages: one error, one success + // There should be at least two tool-call messages: one rejected, one resolved const allMessages = filterLlmMessages(result.messages); - const toolResultMessages = allMessages.filter((m) => - m.content.some((c) => c.type === 'tool-result'), + const toolCallMessages = allMessages.filter((m) => + m.content.some((c) => c.type === 'tool-call'), ); - expect(toolResultMessages.length).toBeGreaterThanOrEqual(2); + expect(toolCallMessages.length).toBeGreaterThanOrEqual(2); // The successful handler call should have received a valid age expect(callCount).toBeGreaterThanOrEqual(1); diff --git a/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts b/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts index 1ef55316036..f13983f9fa0 100644 --- a/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/mcp-runtime.test.ts @@ -17,7 +17,7 @@ import { chunksOfType, } from './helpers'; import { startSseServer, type TestServer } from './mcp-server-helpers'; -import { Agent, McpClient, Tool, isLlmMessage } from '../../index'; +import { Agent, McpClient, Tool } from '../../index'; // --------------------------------------------------------------------------- // McpClient constructor validation — no MCP server required @@ -234,13 +234,10 @@ describe_llm('agent stream() with MCP tool', () => { const { stream } = await agent.stream('Echo "stream works" using tools_echo.'); const chunks = await collectStreamChunks(stream); - const messageChunks = chunksOfType(chunks, 'message'); - const messages = messageChunks.map((c) => c.message); - - const hasToolCall = messages.some( - (m) => isLlmMessage(m) && m.content.some((c) => c.type === 'tool-call'), - ); - expect(hasToolCall).toBe(true); + // Tool calls now ride their own discrete `tool-call` chunks rather than + // being wrapped in `message` envelopes. + const toolCallChunks = chunksOfType(chunks, 'tool-call'); + expect(toolCallChunks.length).toBeGreaterThan(0); await client.close(); }); diff --git a/packages/@n8n/agents/src/__tests__/integration/memory/memory-custom-backend.test.ts b/packages/@n8n/agents/src/__tests__/integration/memory/memory-custom-backend.test.ts index 9e434511b9d..4dcb8a1aa9f 100644 --- a/packages/@n8n/agents/src/__tests__/integration/memory/memory-custom-backend.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/memory/memory-custom-backend.test.ts @@ -8,7 +8,7 @@ import { expect, it, beforeEach } from 'vitest'; import { Agent, Memory, type AgentDbMessage } from '../../../index'; -import type { BuiltMemory, Thread } from '../../../types/sdk/memory'; +import type { BuiltMemory, MemoryDescriptor, Thread } from '../../../types/sdk/memory'; import { describeIf, findLastTextContent, getModel } from '../helpers'; const describe = describeIf('anthropic'); @@ -17,6 +17,9 @@ const describe = describeIf('anthropic'); // Custom in-memory BuiltMemory implementation (simulates Redis, DynamoDB, etc.) // --------------------------------------------------------------------------- class CustomMapMemory implements BuiltMemory { + describe(): MemoryDescriptor { + throw new Error('Method not implemented.'); + } readonly threads = new Map(); readonly messages = new Map(); readonly workingMemory = new Map(); diff --git a/packages/@n8n/agents/src/__tests__/integration/memory/memory-postgres.test.ts b/packages/@n8n/agents/src/__tests__/integration/memory/memory-postgres.test.ts index 840ed9ce243..3a30c22a3f7 100644 --- a/packages/@n8n/agents/src/__tests__/integration/memory/memory-postgres.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/memory/memory-postgres.test.ts @@ -61,6 +61,18 @@ afterAll(async () => { } }, 30_000); +/** + * Create a PostgresMemory instance backed by the test container connection string. + * Uses a simple inline CredentialProvider that returns the raw URL. + */ +function makePostgresMemory(namespace: string): PostgresMemory { + return new PostgresMemory({ + type: 'connection', + connection: { connectionType: 'url', connection: { url: connectionString } }, + options: { namespace }, + }); +} + /** describe that requires Docker — tests are no-ops without it. */ function describeWithDocker(name: string, fn: () => void) { describe(name, () => { @@ -74,7 +86,7 @@ function describeWithDocker(name: string, fn: () => void) { describeWithDocker('PostgresMemory saveThread upsert', () => { it('preserves existing title and metadata when not provided', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'upsert_test' }); + const mem = makePostgresMemory('upsert_test'); await mem.saveThread({ id: 'upsert-t1', @@ -95,7 +107,7 @@ describeWithDocker('PostgresMemory saveThread upsert', () => { }); it('overwrites title and metadata when explicitly provided', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'upsert_ow' }); + const mem = makePostgresMemory('upsert_ow'); await mem.saveThread({ id: 'upsert-t2', @@ -121,7 +133,7 @@ describeWithDocker('PostgresMemory saveThread upsert', () => { describeWithDocker('PostgresMemory unit tests', () => { it('creates tables on first use and round-trips a thread', async () => { - const mem = new PostgresMemory({ connection: connectionString }); + const mem = makePostgresMemory('default'); const thread = await mem.saveThread({ id: 'thread-1', @@ -141,7 +153,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('saves and retrieves messages with limit', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'msg_test' }); + const mem = makePostgresMemory('msg_test'); await mem.saveThread({ id: 't1', resourceId: 'u1' }); @@ -180,7 +192,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('saves and retrieves working memory keyed by resourceId', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_test' }); + const mem = makePostgresMemory('wm_test'); expect( await mem.getWorkingMemory({ threadId: 'thread-1', resourceId: 'user-1', scope: 'resource' }), @@ -207,7 +219,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('saves and retrieves working memory keyed by threadId (no resourceId)', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_thread_test' }); + const mem = makePostgresMemory('wm_thread_test'); expect( await mem.getWorkingMemory({ threadId: 'thread-1', resourceId: 'user-1', scope: 'thread' }), @@ -225,7 +237,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('isolates working memory by resourceId', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_iso_test' }); + const mem = makePostgresMemory('wm_iso_test'); await mem.saveWorkingMemory( { threadId: 'thread-a', resourceId: 'user-a', scope: 'resource' }, @@ -247,7 +259,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('stores scope=resource when resourceId is provided', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'wm_scope_test' }); + const mem = makePostgresMemory('wm_scope_test'); await mem.saveWorkingMemory( { threadId: 'thread-1', resourceId: 'res-1', scope: 'resource' }, @@ -266,10 +278,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('stores scope=thread when only threadId is provided', async () => { - const mem = new PostgresMemory({ - connection: connectionString, - namespace: 'wm_scope_thread_test', - }); + const mem = makePostgresMemory('wm_scope_thread_test'); await mem.saveWorkingMemory( { threadId: 'thread-1', resourceId: 'user-1', scope: 'thread' }, @@ -288,10 +297,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('does not mix resource-scoped and thread-scoped entries with the same key value', async () => { - const mem = new PostgresMemory({ - connection: connectionString, - namespace: 'wm_scope_iso_test', - }); + const mem = makePostgresMemory('wm_scope_iso_test'); const sharedKey = 'same-id'; await mem.saveWorkingMemory( @@ -318,7 +324,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('deletes thread and cascades to messages', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'del_test' }); + const mem = makePostgresMemory('del_test'); await mem.saveThread({ id: 'del-t1', resourceId: 'u1' }); await mem.saveMessages({ @@ -342,7 +348,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('stores and queries embeddings with pgvector', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_test' }); + const mem = makePostgresMemory('vec_test'); await mem.saveThread({ id: 'vec-t1', resourceId: 'u1' }); @@ -375,7 +381,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('filters embeddings by resourceId with scope=resource (default)', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_res' }); + const mem = makePostgresMemory('vec_res'); await mem.saveEmbeddings({ threadId: 't1', @@ -410,7 +416,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('filters embeddings by threadId with scope=thread', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_thr' }); + const mem = makePostgresMemory('vec_thr'); await mem.saveEmbeddings({ threadId: 't1', @@ -443,7 +449,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('resource scope excludes embeddings from other resources', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_iso' }); + const mem = makePostgresMemory('vec_iso'); await mem.saveEmbeddings({ threadId: 't1', @@ -470,7 +476,7 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('stores resourceId in the embeddings table', async () => { - const mem = new PostgresMemory({ connection: connectionString, namespace: 'vec_col' }); + const mem = makePostgresMemory('vec_col'); await mem.saveEmbeddings({ threadId: 't1', @@ -492,8 +498,8 @@ describeWithDocker('PostgresMemory unit tests', () => { }); it('isolates namespaces', async () => { - const mem1 = new PostgresMemory({ connection: connectionString, namespace: 'ns_a' }); - const mem2 = new PostgresMemory({ connection: connectionString, namespace: 'ns_b' }); + const mem1 = makePostgresMemory('ns_a'); + const mem2 = makePostgresMemory('ns_b'); await mem1.saveThread({ id: 'shared-id', resourceId: 'u1', title: 'From A' }); await mem2.saveThread({ id: 'shared-id', resourceId: 'u1', title: 'From B' }); @@ -520,7 +526,7 @@ function describeWithDockerAndApi(name: string, fn: () => void) { describeWithDockerAndApi('PostgresMemory agent integration', () => { it('recalls previous messages across turns', async () => { - const store = new PostgresMemory({ connection: connectionString, namespace: 'agent_recall' }); + const store = makePostgresMemory('agent_recall'); const memory = new Memory().storage(store).lastMessages(10); const agent = new Agent('pg-recall-test') @@ -540,7 +546,7 @@ describeWithDockerAndApi('PostgresMemory agent integration', () => { }); it('persists resource-scoped working memory via Postgres backend', async () => { - const store = new PostgresMemory({ connection: connectionString, namespace: 'agent_wm' }); + const store = makePostgresMemory('agent_wm'); const memory = new Memory() .storage(store) .lastMessages(10) @@ -574,10 +580,7 @@ describeWithDockerAndApi('PostgresMemory agent integration', () => { }); it('persists thread-scoped working memory via Postgres backend', async () => { - const store = new PostgresMemory({ - connection: connectionString, - namespace: 'agent_thread_wm', - }); + const store = makePostgresMemory('agent_thread_wm'); const memory = new Memory() .storage(store) .lastMessages(10) @@ -617,7 +620,7 @@ describeWithDockerAndApi('PostgresMemory agent integration', () => { }); it('works with stream() path', async () => { - const store = new PostgresMemory({ connection: connectionString, namespace: 'agent_stream' }); + const store = makePostgresMemory('agent_stream'); const memory = new Memory().storage(store).lastMessages(10); const agent = new Agent('pg-stream-test') diff --git a/packages/@n8n/agents/src/__tests__/integration/multi-tool-calls.test.ts b/packages/@n8n/agents/src/__tests__/integration/multi-tool-calls.test.ts index 30c9c686e13..c07ed07a2fe 100644 --- a/packages/@n8n/agents/src/__tests__/integration/multi-tool-calls.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/multi-tool-calls.test.ts @@ -6,7 +6,6 @@ import { collectStreamChunks, getModel, chunksOfType, - findAllToolResults, collectTextDeltas, } from './helpers'; import { Agent, Tool } from '../../index'; @@ -43,15 +42,14 @@ describe('multi-tool-calls integration', () => { ); const chunks = await collectStreamChunks(fullStream); - const messageChunks = chunksOfType(chunks, 'message'); - const toolCallResults = findAllToolResults(messageChunks.map((c) => c.message)); + const toolCallResults = chunksOfType(chunks, 'tool-result'); // Should have called the tool multiple times const priceCalls = toolCallResults.filter((tc) => tc.toolName === 'lookup_price'); expect(priceCalls.length).toBeGreaterThanOrEqual(2); // Each call should have its own correct output (not all pointing to the first result) - const outputs = priceCalls.map((tc) => tc.result as { product: string; price: number }); + const outputs = priceCalls.map((tc) => tc.output as { product: string; price: number }); // Verify that different products got different prices (index-based merging works) const uniquePrices = new Set(outputs.map((o) => o.price)); @@ -90,8 +88,7 @@ describe('multi-tool-calls integration', () => { const { stream: fullStream } = await agent.stream('What is 3 + 4 and also what is 5 * 6?'); const chunks = await collectStreamChunks(fullStream); - const messageChunks = chunksOfType(chunks, 'message'); - const toolCallResults = findAllToolResults(messageChunks.map((c) => c.message)); + const toolCallResults = chunksOfType(chunks, 'tool-result'); const toolCalls = toolCallResults.filter( (tc) => tc.toolName === 'add' || tc.toolName === 'multiply', @@ -104,8 +101,8 @@ describe('multi-tool-calls integration', () => { expect(addCall).toBeDefined(); expect(multiplyCall).toBeDefined(); - expect((addCall!.result as { result: number }).result).toBe(7); - expect((multiplyCall!.result as { result: number }).result).toBe(30); + expect((addCall!.output as { result: number }).result).toBe(7); + expect((multiplyCall!.output as { result: number }).result).toBe(30); }); it('correctly merges results via the run() path', async () => { @@ -126,15 +123,14 @@ describe('multi-tool-calls integration', () => { 'What are the lengths of "hello" and "world"? Look up each one separately.', ); const chunks = await collectStreamChunks(fullStream); - const messageChunks = chunksOfType(chunks, 'message'); - const toolCallResults = findAllToolResults(messageChunks.map((c) => c.message)); + const toolCallResults = chunksOfType(chunks, 'tool-result'); const lengthCalls = toolCallResults.filter((tc) => tc.toolName === 'get_length'); expect(lengthCalls.length).toBeGreaterThanOrEqual(2); // Each should have correct output for (const call of lengthCalls) { - const output = call.result as { text: string; length: number }; + const output = call.output as { text: string; length: number }; expect(output.length).toBe(output.text.length); } }); diff --git a/packages/@n8n/agents/src/__tests__/integration/orphaned-tool-messages.test.ts b/packages/@n8n/agents/src/__tests__/integration/orphaned-tool-messages.test.ts index 0f218530c17..06a8c2f07b9 100644 --- a/packages/@n8n/agents/src/__tests__/integration/orphaned-tool-messages.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/orphaned-tool-messages.test.ts @@ -28,95 +28,92 @@ describe('orphaned tool messages in memory', () => { } /** - * Seed memory with a conversation that has tool-call / tool-result pairs - * surrounded by plain user/assistant exchanges. + * Seed memory with a conversation that has settled tool-call blocks + * (state: 'resolved') surrounded by plain user/assistant exchanges. * - * Message layout (indices 0–7): - * 0: user "How many widgets?" - * 1: assistant text + tool-call(call_1) - * 2: tool tool-result(call_1) - * 3: assistant "There are 10 widgets" - * 4: user "What about gadgets?" - * 5: assistant text + tool-call(call_2) - * 6: tool tool-result(call_2) - * 7: assistant "There are 5 gadgets" + * Message layout (indices 0–5): + * 0: user "How many widgets?" + * 1: assistant text + tool-call(call_1, state:'resolved', output:{count:10}) + * 2: assistant "There are 10 widgets" + * 3: user "What about gadgets?" + * 4: assistant text + tool-call(call_2, state:'resolved', output:{count:5}) + * 5: assistant "There are 5 gadgets" */ function buildSeedMessages(): AgentDbMessage[] { + const now = Date.now(); return [ { id: 'm1', - createdAt: new Date(), + createdAt: new Date(now), role: 'user', content: [{ type: 'text', text: 'How many widgets do we have?' }], }, { id: 'm2', - createdAt: new Date(), + createdAt: new Date(now + 1), role: 'assistant', content: [ { type: 'text', text: 'Let me look that up.' }, - { type: 'tool-call', toolCallId: 'call_1', toolName: 'lookup', input: { id: 'widgets' } }, + { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'lookup', + input: { id: 'widgets' }, + state: 'resolved', + output: { count: 10 }, + }, ], }, { id: 'm3', - createdAt: new Date(), - role: 'tool', - content: [ - { type: 'tool-result', toolCallId: 'call_1', toolName: 'lookup', result: { count: 10 } }, - ], - }, - { - id: 'm4', - createdAt: new Date(), + createdAt: new Date(now + 2), role: 'assistant', content: [{ type: 'text', text: 'There are 10 widgets in stock.' }], }, { - id: 'm5', - createdAt: new Date(), + id: 'm4', + createdAt: new Date(now + 3), role: 'user', content: [{ type: 'text', text: 'What about gadgets?' }], }, { - id: 'm6', - createdAt: new Date(), + id: 'm5', + createdAt: new Date(now + 4), role: 'assistant', content: [ { type: 'text', text: 'Let me check.' }, - { type: 'tool-call', toolCallId: 'call_2', toolName: 'lookup', input: { id: 'gadgets' } }, + { + type: 'tool-call', + toolCallId: 'call_2', + toolName: 'lookup', + input: { id: 'gadgets' }, + state: 'resolved', + output: { count: 5 }, + }, ], }, { - id: 'm7', - createdAt: new Date(), - role: 'tool', - content: [ - { type: 'tool-result', toolCallId: 'call_2', toolName: 'lookup', result: { count: 5 } }, - ], - }, - { - id: 'm8', - createdAt: new Date(), + id: 'm6', + createdAt: new Date(now + 5), role: 'assistant', content: [{ type: 'text', text: 'There are 5 gadgets in stock.' }], }, ]; } - it('handles orphaned tool results when tool-call message is truncated from history', async () => { + it('handles partial history window when earlier messages are truncated', async () => { const { memory, cleanup } = createSqliteMemory(); cleanups.push(cleanup); const threadId = 'thread-orphan-result'; - // Seed 8 messages into the thread + // Seed 6 messages into the thread await memory.saveMessages({ threadId, messages: buildSeedMessages() }); - // lastMessages=6 → loads messages 2–7 - // Message at index 2 is a tool-result for call_1, but the matching - // assistant+tool-call (index 1) is truncated. This is an orphaned tool result. - const mem = new Memory().storage(memory).lastMessages(6); + // lastMessages=4 → loads messages 2–5 + // Each tool-call block carries its own result (state:'resolved'), so there + // are no orphan issues regardless of window boundaries. + const mem = new Memory().storage(memory).lastMessages(4); const agent = new Agent('orphan-result-test') .model(getModel('anthropic')) @@ -132,7 +129,7 @@ describe('orphaned tool messages in memory', () => { expect(result.finishReason).toBe('stop'); }); - it('handles orphaned tool calls when tool-result message is truncated from history', async () => { + it('handles pending tool-call blocks (interrupted turn) in history', async () => { const { memory, cleanup } = createSqliteMemory(); cleanups.push(cleanup); @@ -140,8 +137,9 @@ describe('orphaned tool messages in memory', () => { const now = Date.now(); // Store a conversation where the last saved message is an assistant - // with a tool-call but the tool-result was never persisted (simulating - // a partial save / interrupted turn). + // with a pending tool-call block (simulating a partial save / interrupted turn). + // stripOrphanedToolMessages will drop the pending block so the LLM receives + // only the user message. const messages: AgentDbMessage[] = [ { id: 'm1', @@ -160,6 +158,7 @@ describe('orphaned tool messages in memory', () => { toolCallId: 'call_orphan', toolName: 'lookup', input: { id: 'widgets' }, + state: 'pending', }, ], }, diff --git a/packages/@n8n/agents/src/__tests__/integration/provider-options.test.ts b/packages/@n8n/agents/src/__tests__/integration/provider-options.test.ts index b307a73cb5e..bcdece99156 100644 --- a/packages/@n8n/agents/src/__tests__/integration/provider-options.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/provider-options.test.ts @@ -183,7 +183,7 @@ describe('external abort signal', () => { }); expect(result.finishReason).toBe('error'); - expect(result.getState().status).toBe('cancelled'); + expect(agent.getState().status).toBe('cancelled'); }); it('cancels a stream() call via external AbortSignal', async () => { diff --git a/packages/@n8n/agents/src/__tests__/integration/provider-tools.test.ts b/packages/@n8n/agents/src/__tests__/integration/provider-tools.test.ts index 1c16c09f48d..8549c2b858f 100644 --- a/packages/@n8n/agents/src/__tests__/integration/provider-tools.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/provider-tools.test.ts @@ -55,10 +55,8 @@ describe('provider tools integration', () => { const lastFinish = finishChunks[finishChunks.length - 1]; expect(lastFinish?.type === 'finish' && lastFinish.finishReason).toBe('stop'); - // Collect tool calls from message chunks - const messageChunks = chunksOfType(chunks, 'message'); - const allMessages = messageChunks.map((c) => c.message); - const toolCalls = findAllToolCalls(allMessages); + // Tool calls now ride their own discrete `tool-call` chunks + const toolCalls = chunksOfType(chunks, 'tool-call'); const webSearchCall = toolCalls.find((tc) => tc.toolName.includes('web_search')); expect(webSearchCall).toBeDefined(); @@ -104,9 +102,8 @@ describe('provider tools integration', () => { expect(suspended.runId).toBeTruthy(); expect(suspended.toolCallId).toBeTruthy(); - // The web search provider tool call should appear in the message history - const messageChunks = chunksOfType(chunks, 'message'); - const toolCalls = findAllToolCalls(messageChunks.map((c) => c.message)); + // The web search provider tool call should appear as a discrete tool-call chunk + const toolCalls = chunksOfType(chunks, 'tool-call'); const webSearchCall = toolCalls.find((tc) => tc.toolName.includes('web_search')); expect(webSearchCall).toBeDefined(); @@ -115,8 +112,8 @@ describe('provider tools integration', () => { 'stream', { approved: true }, { - runId: suspended.runId!, - toolCallId: suspended.toolCallId!, + runId: suspended.runId, + toolCallId: suspended.toolCallId, }, ); const resumeChunks = await collectStreamChunks(resumeStream.stream); diff --git a/packages/@n8n/agents/src/__tests__/integration/state-restore-after-suspension.test.ts b/packages/@n8n/agents/src/__tests__/integration/state-restore-after-suspension.test.ts index 4d137e4d946..db5c1967a9b 100644 --- a/packages/@n8n/agents/src/__tests__/integration/state-restore-after-suspension.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/state-restore-after-suspension.test.ts @@ -155,16 +155,8 @@ describe('state restore after suspension', () => { const errorChunks = resumedChunks.filter((c) => c.type === 'error'); expect(errorChunks).toHaveLength(0); - // Stream must contain the tool result message - const toolResultChunks = resumedChunks.filter( - (c) => - c.type === 'message' && - 'message' in c && - 'content' in (c.message as object) && - (c.message as { content: Array<{ type: string }> }).content.some( - (part) => part.type === 'tool-result', - ), - ); + // Stream must contain a discrete tool-result chunk for the resumed call + const toolResultChunks = chunksOfType(resumedChunks, 'tool-result'); expect(toolResultChunks.length).toBeGreaterThan(0); // Stream must end with a finish chunk (not error) diff --git a/packages/@n8n/agents/src/__tests__/integration/stream-timing.test.ts b/packages/@n8n/agents/src/__tests__/integration/stream-timing.test.ts index 01317f024b2..6f354aa254d 100644 --- a/packages/@n8n/agents/src/__tests__/integration/stream-timing.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/stream-timing.test.ts @@ -7,7 +7,7 @@ import { Agent, Tool } from '../../index'; const describe = describeIf('anthropic'); describe('stream timing', () => { - it('tool-call-delta chunks arrive incrementally (not all buffered)', async () => { + it('tool-input-delta chunks arrive incrementally (not all buffered)', async () => { const agent = new Agent('timing-test') .model(getModel('anthropic')) .instructions( @@ -31,16 +31,21 @@ describe('stream timing', () => { const reader = result.stream.getReader(); - // Track timestamps of each reader.read() that returns a tool-call-delta + // Track timestamps of each reader.read() that returns a tool-input-delta + // for the set_code tool. We seed `setCodeToolCallId` from the matching + // tool-input-start so subsequent deltas can be filtered by toolCallId. // This measures when the reader YIELDS each chunk, not when the agent enqueues it. const deltaReadTimes: number[] = []; const start = Date.now(); + let setCodeToolCallId: string | undefined; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = value; - if (chunk.type === 'tool-call-delta' && (chunk as { name?: string }).name === 'set_code') { + if (chunk.type === 'tool-input-start' && chunk.toolName === 'set_code') { + setCodeToolCallId = chunk.toolCallId; + } else if (chunk.type === 'tool-input-delta' && chunk.toolCallId === setCodeToolCallId) { deltaReadTimes.push(Date.now() - start); } } diff --git a/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts b/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts index cab5d5af576..009e027f96e 100644 --- a/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/sub-agent.test.ts @@ -5,10 +5,8 @@ import { collectStreamChunks, collectTextDeltas, describeIf, - findAllToolResults, getModel, } from './helpers'; -import type { StreamChunk } from '../../index'; import { Agent } from '../../index'; const describe = describeIf('anthropic'); @@ -33,10 +31,7 @@ describe('sub-agent (asTool) integration', () => { const chunks = await collectStreamChunks(fullStream); const text = collectTextDeltas(chunks); - const messageChunks = chunksOfType(chunks, 'message') as Array< - StreamChunk & { type: 'message' } - >; - const toolResults = findAllToolResults(messageChunks.map((c) => c.message)); + const toolResults = chunksOfType(chunks, 'tool-result'); // The orchestrator should have called the sub-agent tool expect(toolResults.length).toBeGreaterThan(0); @@ -44,7 +39,7 @@ describe('sub-agent (asTool) integration', () => { expect(mathCall).toBeDefined(); // The output should contain the sub-agent's response - expect(mathCall!.result).toBeDefined(); + expect(mathCall!.output).toBeDefined(); // The final text should reference 60 expect(text).toBeTruthy(); @@ -80,10 +75,7 @@ describe('sub-agent (asTool) integration', () => { 'Translate "hello" to French and then make it uppercase.', ); const chunks = await collectStreamChunks(fullStream); - const messageChunks = chunksOfType(chunks, 'message') as Array< - StreamChunk & { type: 'message' } - >; - const toolResults = findAllToolResults(messageChunks.map((c) => c.message)); + const toolResults = chunksOfType(chunks, 'tool-result'); // Should have called both tools expect(toolResults.length).toBeGreaterThanOrEqual(2); diff --git a/packages/@n8n/agents/src/__tests__/integration/to-model-output.test.ts b/packages/@n8n/agents/src/__tests__/integration/to-model-output.test.ts index ea02736c66d..162fbb969b4 100644 --- a/packages/@n8n/agents/src/__tests__/integration/to-model-output.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/to-model-output.test.ts @@ -63,11 +63,12 @@ describe('toModelOutput integration', () => { expect(rawOutput.total).toBe(3); expect(rawOutput.records[0].data).toBe('x'.repeat(200)); - // ContentToolResult in messages stores the transformed output (what the LLM saw) + // Tool-call block in messages stores the transformed output (what the LLM saw) const toolResults = findAllToolResults(result.messages); const searchToolResult = toolResults.find((tr) => tr.toolName === 'search_db'); expect(searchToolResult).toBeDefined(); - const modelOutput = searchToolResult!.result as { summary: string }; + expect(searchToolResult!.state).toBe('resolved'); + const modelOutput = (searchToolResult as unknown as { output: { summary: string } }).output; expect(modelOutput.summary).toContain('Found 3 records'); expect(modelOutput.summary).toContain('Widget A'); }); @@ -106,15 +107,14 @@ describe('toModelOutput integration', () => { const { stream } = await agent.stream('Get report RPT-001'); const chunks = await collectStreamChunks(stream); - // The tool result messages in the stream contain the transformed output - const messageChunks = chunksOfType(chunks, 'message'); - const toolResults = findAllToolResults(messageChunks.map((c) => c.message)); + // The discrete tool-result chunks in the stream contain the transformed output + const toolResults = chunksOfType(chunks, 'tool-result'); const reportResult = toolResults.find((tr) => tr.toolName === 'fetch_report'); expect(reportResult).toBeDefined(); // The model output (transformed) should have the truncated fields - const modelOutput = reportResult!.result as { id: string; title: string; pageCount: number }; + const modelOutput = reportResult!.output as { id: string; title: string; pageCount: number }; expect(modelOutput.id).toBe('RPT-001'); expect(modelOutput.title).toBe('Q4 Sales Report'); expect(modelOutput.pageCount).toBe(42); @@ -140,11 +140,14 @@ describe('toModelOutput integration', () => { const result = await agent.generate('Echo the message "hello world"'); - // Without toModelOutput, tool result in messages should have the raw output + // Without toModelOutput, tool-call block in messages has the raw output const toolResults = findAllToolResults(result.messages); const echoResult = toolResults.find((tr) => tr.toolName === 'echo'); expect(echoResult).toBeDefined(); - expect((echoResult!.result as { echoed: string }).echoed).toBe('hello world'); + expect(echoResult!.state).toBe('resolved'); + expect((echoResult as unknown as { output: { echoed: string } }).output.echoed).toBe( + 'hello world', + ); // And toolCalls should also have the same raw output expect(result.toolCalls).toBeDefined(); @@ -196,11 +199,14 @@ describe('toModelOutput integration', () => { expect(multiplyEntry).toBeDefined(); expect((multiplyEntry!.output as { result: number }).result).toBe(56); - // Tool result in messages stores the transformed output for the LLM + // Tool-call block in messages stores the transformed output for the LLM const toolResults = findAllToolResults(result.messages); const multiplyToolResult = toolResults.find((tr) => tr.toolName === 'multiply'); expect(multiplyToolResult).toBeDefined(); - const modelOutput = multiplyToolResult!.result as { answer: number; note: string }; + expect(multiplyToolResult!.state).toBe('resolved'); + const modelOutput = ( + multiplyToolResult as unknown as { output: { answer: number; note: string } } + ).output; expect(modelOutput.answer).toBe(56); expect(modelOutput.note).toBe('multiplication complete'); diff --git a/packages/@n8n/agents/src/__tests__/integration/tool-call-upsert.test.ts b/packages/@n8n/agents/src/__tests__/integration/tool-call-upsert.test.ts new file mode 100644 index 00000000000..70266aaf66f --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/integration/tool-call-upsert.test.ts @@ -0,0 +1,222 @@ +/** + * Upsert contract: after a HITL suspend/resume cycle backed by SqliteMemory, + * the thread must contain exactly ONE assistant message with the tool-call + * block (no duplicate rows), and that block must have state: 'resolved'. + * + * The upsert matters because on resume the runtime calls saveToMemory with + * turnDelta() which includes the now-resolved assistant message restored from + * the checkpoint. Without upsert-by-id, a second row would be inserted for + * the same message, breaking the thread ordering contract. + * + * Note: messages with state:'pending' are transient and are NOT written to + * memory during suspension — they only live in the checkpoint. Memory only + * receives the final settled state after resume completes. + */ +import { afterEach, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { describeIf, createSqliteMemory, getModel } from './helpers'; +import { Agent, filterLlmMessages, Memory, Tool } from '../../index'; +import type { AgentDbMessage } from '../../index'; +import type { ContentToolCall, Message } from '../../types/sdk/message'; + +const describe = describeIf('anthropic'); + +describe('tool-call upsert via suspend/resume (SqliteMemory)', () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + for (const fn of cleanups) fn(); + cleanups.length = 0; + }); + + function extractToolCallBlocks(messages: AgentDbMessage[]): ContentToolCall[] { + return filterLlmMessages(messages).flatMap((m) => + m.content.filter((c): c is ContentToolCall => c.type === 'tool-call'), + ); + } + + function buildInterruptibleAgent(memory: ReturnType['memory']): Agent { + const deleteTool = new Tool('delete_file') + .description('Delete a file at the given path') + .input(z.object({ path: z.string().describe('File path to delete') })) + .output(z.object({ deleted: z.boolean(), path: z.string() })) + .suspend(z.object({ message: z.string(), severity: z.string() })) + .resume(z.object({ approved: z.boolean() })) + .handler(async ({ path }, ctx) => { + if (!ctx.resumeData) { + return await ctx.suspend({ message: `Delete "${path}"?`, severity: 'destructive' }); + } + if (!ctx.resumeData.approved) return { deleted: false, path }; + return { deleted: true, path }; + }); + + return new Agent('upsert-test-agent') + .model(getModel('anthropic')) + .instructions( + 'You are a file manager. When asked to delete a file, use the delete_file tool. Be concise.', + ) + .tool(deleteTool) + .memory(new Memory().storage(memory)) + .checkpoint('memory'); + } + + it('after resume, thread has exactly one resolved tool-call block (no duplicate rows)', async () => { + const { memory, cleanup } = createSqliteMemory(); + cleanups.push(cleanup); + + const threadId = 'thread-upsert-resolved'; + const resourceId = 'res-1'; + const persistence = { threadId, resourceId }; + + const agent = buildInterruptibleAgent(memory); + + // Turn 1: trigger the suspend — messages with pending tool-call are + // stored in the checkpoint only, NOT in SqliteMemory yet. + const suspendResult = await agent.generate('Please delete /tmp/foo.txt', { + persistence, + }); + + expect(suspendResult.finishReason).toBe('tool-calls'); + expect(suspendResult.pendingSuspend).toBeDefined(); + const { runId, toolCallId } = suspendResult.pendingSuspend![0]; + + // Before resume: no tool-call blocks in memory (pending stays in checkpoint) + const msgsBefore = await memory.getMessages(threadId); + const blocksBefore = extractToolCallBlocks(msgsBefore); + expect(blocksBefore).toHaveLength(0); + + // Turn 2: resume with approval — on completion saveToMemory is called and + // the assistant message (now resolved) is written for the first time. + const resumeResult = await agent.resume( + 'generate', + { approved: true }, + { + runId, + toolCallId, + }, + ); + + expect(resumeResult.finishReason).toBe('stop'); + + // After resume: exactly one resolved tool-call block, no duplicate rows + const msgsAfter = await memory.getMessages(threadId); + const blocksAfter = extractToolCallBlocks(msgsAfter); + + expect(blocksAfter).toHaveLength(1); + expect(blocksAfter[0].state).toBe('resolved'); + expect(blocksAfter[0].toolCallId).toBe(toolCallId); + expect((blocksAfter[0] as ContentToolCall & { state: 'resolved' }).output).toMatchObject({ + deleted: true, + }); + + // No duplicate assistant messages with tool-call blocks + const assistantMsgsWithToolCalls = filterLlmMessages(msgsAfter).filter( + (m) => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), + ); + expect(assistantMsgsWithToolCalls).toHaveLength(1); + }); + + it('after resume with denial, thread has exactly one resolved tool-call block', async () => { + const { memory, cleanup } = createSqliteMemory(); + cleanups.push(cleanup); + + const threadId = 'thread-upsert-denied'; + const resourceId = 'res-2'; + const persistence = { threadId, resourceId }; + + const agent = buildInterruptibleAgent(memory); + + const suspendResult = await agent.generate('Please delete /tmp/bar.txt', { + persistence, + }); + expect(suspendResult.finishReason).toBe('tool-calls'); + const { runId, toolCallId } = suspendResult.pendingSuspend![0]; + + // Before resume: no messages in memory + const msgsBefore = await memory.getMessages(threadId); + expect(extractToolCallBlocks(msgsBefore)).toHaveLength(0); + + const resumeResult = await agent.resume( + 'generate', + { approved: false }, + { + runId, + toolCallId, + }, + ); + expect(resumeResult.finishReason).toBe('stop'); + + const msgsAfter = await memory.getMessages(threadId); + const blocksAfter = extractToolCallBlocks(msgsAfter); + + // Tool ran and returned {deleted: false} — still resolved, not rejected + expect(blocksAfter).toHaveLength(1); + expect(blocksAfter[0].state).toBe('resolved'); + const output = (blocksAfter[0] as ContentToolCall & { state: 'resolved' }).output; + expect(output).toMatchObject({ deleted: false }); + + // No duplicate rows + const assistantMsgsWithToolCalls = filterLlmMessages(msgsAfter).filter( + (m) => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), + ); + expect(assistantMsgsWithToolCalls).toHaveLength(1); + }); + + it('if same thread is resumed twice (re-suspend then resume again), still no duplicate rows', async () => { + const { memory, cleanup } = createSqliteMemory(); + cleanups.push(cleanup); + + const threadId = 'thread-upsert-double'; + const resourceId = 'res-3'; + const persistence = { threadId, resourceId }; + + // Use a tool that always re-suspends on first call and approves on second + let callCount = 0; + const confirmTool = new Tool('confirm') + .description('Confirm an action') + .input(z.object({ action: z.string() })) + .output(z.object({ done: z.boolean() })) + .suspend(z.object({ question: z.string() })) + .resume(z.object({ yes: z.boolean() })) + .handler(async ({ action }, ctx) => { + callCount++; + if (!ctx.resumeData) { + return await ctx.suspend({ question: `Confirm: ${action}?` }); + } + return { done: ctx.resumeData.yes }; + }); + + const agent = new Agent('double-upsert-agent') + .model(getModel('anthropic')) + .instructions('Use confirm tool for every action. Be concise.') + .tool(confirmTool) + .memory(new Memory().storage(memory)) + .checkpoint('memory'); + + // Turn 1: suspend + const r1 = await agent.generate('confirm action: foo', { persistence }); + expect(r1.finishReason).toBe('tool-calls'); + const { runId, toolCallId } = r1.pendingSuspend![0]; + + // No messages in memory yet + expect(await memory.getMessages(threadId)).toHaveLength(0); + + // Resume: completes + const r2 = await agent.resume('generate', { yes: true }, { runId, toolCallId }); + expect(r2.finishReason).toBe('stop'); + + const finalMessages = await memory.getMessages(threadId); + const toolCallBlocks = extractToolCallBlocks(finalMessages); + + // Exactly one tool-call block, no duplicates + expect(toolCallBlocks).toHaveLength(1); + expect(toolCallBlocks[0].state).toBe('resolved'); + + // And the assistant message with the tool-call appears exactly once + const assistantMsgsWithCalls = filterLlmMessages(finalMessages).filter( + (m): m is Message => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), + ); + expect(assistantMsgsWithCalls).toHaveLength(1); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/integration/tool-error-handling.test.ts b/packages/@n8n/agents/src/__tests__/integration/tool-error-handling.test.ts index 74f3b7fe1bb..93e11979d2b 100644 --- a/packages/@n8n/agents/src/__tests__/integration/tool-error-handling.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/tool-error-handling.test.ts @@ -5,7 +5,6 @@ import { collectStreamChunks, chunksOfType, collectTextDeltas, - findAllToolResults, createAgentWithAlwaysErrorTool, createAgentWithFlakyTool, } from './helpers'; @@ -55,20 +54,20 @@ describe('tool error handling integration', () => { expect(mentionsFailure).toBe(true); }); - it('error tool-result appears in the message list', async () => { + it('error tool-result appears in the stream', async () => { const agent = createAgentWithAlwaysErrorTool('anthropic'); const { stream } = await agent.stream('Fetch the data for id "abc123".'); const chunks = await collectStreamChunks(stream); - // There should be a tool-result message in the stream - const messageChunks = chunksOfType(chunks, 'message'); - const toolResults = findAllToolResults(messageChunks.map((c) => c.message)); + // There should be a discrete tool-result chunk for the failed call + const toolResults = chunksOfType(chunks, 'tool-result'); // The tool should have been called and produced a result (even if it errored) expect(toolResults.length).toBeGreaterThan(0); const brokenResult = toolResults.find((r) => r.toolName === 'broken_tool'); expect(brokenResult).toBeDefined(); + expect(brokenResult!.isError).toBe(true); }); it('LLM can self-correct by retrying a flaky tool', async () => { diff --git a/packages/@n8n/agents/src/__tests__/integration/tool-interrupt.test.ts b/packages/@n8n/agents/src/__tests__/integration/tool-interrupt.test.ts index f53afa9b593..e8303ce6aa6 100644 --- a/packages/@n8n/agents/src/__tests__/integration/tool-interrupt.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/tool-interrupt.test.ts @@ -8,7 +8,7 @@ import { createAgentWithMixedTools, createAgentWithParallelInterruptibleCalls, } from './helpers'; -import { isLlmMessage, type StreamChunk } from '../../index'; +import type { StreamChunk } from '../../index'; const describe = describeIf('anthropic'); @@ -36,13 +36,8 @@ describe('tool interrupt integration', () => { ); // No tool-result should appear (tool is suspended) - const contentChunks = chunks.filter( - (c) => - c.type === 'message' && - 'content' in c && - (c.content as { type: string }).type === 'tool-result', - ); - expect(contentChunks).toHaveLength(0); + const toolResultChunks = chunksOfType(chunks, 'tool-result'); + expect(toolResultChunks).toHaveLength(0); }); it('resumes the stream after resume with approval', async () => { @@ -58,19 +53,14 @@ describe('tool interrupt integration', () => { const resumedStream = await agent.resume( 'stream', { approved: true }, - { runId: suspended.runId!, toolCallId: suspended.toolCallId! }, + { runId: suspended.runId, toolCallId: suspended.toolCallId }, ); const resumedChunks = await collectStreamChunks(resumedStream.stream); const resumedTypes = resumedChunks.map((c) => c.type); - // After approval, tool-result should appear as content chunk - const toolResultChunks = resumedChunks.filter( - (c) => - c.type === 'message' && - isLlmMessage(c.message) && - c.message.content.some((c) => c.type === 'tool-result'), - ); + // After approval, a discrete tool-result chunk should appear + const toolResultChunks = chunksOfType(resumedChunks, 'tool-result'); expect(toolResultChunks.length).toBeGreaterThan(0); expect(resumedTypes).toContain('text-delta'); @@ -89,7 +79,7 @@ describe('tool interrupt integration', () => { const resumedStream = await agent.resume( 'stream', { approved: false }, - { runId: suspended.runId!, toolCallId: suspended.toolCallId! }, + { runId: suspended.runId, toolCallId: suspended.toolCallId }, ); const resumedChunks = await collectStreamChunks(resumedStream.stream); @@ -119,7 +109,7 @@ describe('tool interrupt integration', () => { const stream2 = await agent.resume( 'stream', { approved: true }, - { runId: suspended1.runId!, toolCallId: suspended1.toolCallId! }, + { runId: suspended1.runId, toolCallId: suspended1.toolCallId }, ); const chunks2 = await collectStreamChunks(stream2.stream); @@ -136,7 +126,7 @@ describe('tool interrupt integration', () => { const stream3 = await agent.resume( 'stream', { approved: true }, - { runId: suspended2.runId!, toolCallId: suspended2.toolCallId! }, + { runId: suspended2.runId, toolCallId: suspended2.toolCallId }, ); const chunks3 = await collectStreamChunks(stream3.stream); @@ -162,13 +152,8 @@ describe('tool interrupt integration', () => { const chunks = await collectStreamChunks(fullStream); - // list_files should auto-execute — its result should appear as content - const toolResultChunks = chunks.filter( - (c) => - c.type === 'message' && - isLlmMessage(c.message) && - c.message.content.some((c) => c.type === 'tool-result'), - ); + // list_files should auto-execute — its result should appear as a discrete tool-result chunk + const toolResultChunks = chunksOfType(chunks, 'tool-result'); expect(toolResultChunks.length).toBeGreaterThan(0); // delete_file should be suspended diff --git a/packages/@n8n/agents/src/__tests__/integration/workspace/workspace-agent.test.ts b/packages/@n8n/agents/src/__tests__/integration/workspace/workspace-agent.test.ts index 254ea77d7de..7161a30ca55 100644 --- a/packages/@n8n/agents/src/__tests__/integration/workspace/workspace-agent.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/workspace/workspace-agent.test.ts @@ -69,7 +69,10 @@ describe('workspace agent integration', () => { const readResult = toolResults.find((tr) => tr.toolName === 'workspace_read_file'); expect(readResult).toBeDefined(); - expect((readResult!.result as { content: string }).content).toContain('Hello from n8n!'); + expect(readResult!.state).toBe('resolved'); + expect((readResult as unknown as { output: { content: string } }).output.content).toContain( + 'Hello from n8n!', + ); expect(memFs.getFileContent('/greeting.txt')).toBe('Hello from n8n!'); }); @@ -103,7 +106,8 @@ describe('workspace agent integration', () => { const toolResults = findAllToolResults(result.messages); const execResult = toolResults.find((tr) => tr.toolName === 'workspace_execute_command'); expect(execResult).toBeDefined(); - expect((execResult!.result as { success: boolean }).success).toBe(true); + expect(execResult!.state).toBe('resolved'); + expect((execResult as unknown as { output: { success: boolean } }).output.success).toBe(true); }); it('agent uses workspace_mkdir and workspace_list_files together', async () => { @@ -130,7 +134,8 @@ describe('workspace agent integration', () => { const toolResults = findAllToolResults(result.messages); const listResult = toolResults.find((tr) => tr.toolName === 'workspace_list_files'); expect(listResult).toBeDefined(); - const entries = (listResult!.result as unknown as { entries: FileEntry[] }).entries; + expect(listResult!.state).toBe('resolved'); + const entries = (listResult as unknown as { output: { entries: FileEntry[] } }).output.entries; const names = entries.map((e) => e.name); expect(names).toContain('index.ts'); expect(names).toContain('README.md'); @@ -201,7 +206,8 @@ describe('workspace agent integration', () => { const toolResults = findAllToolResults(result.messages); const statResult = toolResults.find((tr) => tr.toolName === 'workspace_file_stat'); expect(statResult).toBeDefined(); - const stat = statResult!.result as { type: string; size: number }; + expect(statResult!.state).toBe('resolved'); + const stat = (statResult as unknown as { output: { type: string; size: number } }).output; expect(stat.type).toBe('file'); expect(stat.size).toBe(29); }); @@ -233,7 +239,10 @@ describe('workspace agent integration', () => { const readResult = toolResults.find((tr) => tr.toolName === 'workspace_read_file'); expect(readResult).toBeDefined(); - expect((readResult!.result as { content: string }).content).toContain('export default {}'); + expect(readResult!.state).toBe('resolved'); + expect((readResult as unknown as { output: { content: string } }).output.content).toContain( + 'export default {}', + ); expect(memFs.getFileContent('/app/config.ts')).toBe('export default {}'); }); diff --git a/packages/@n8n/agents/src/__tests__/integration/zod-validation-error.test.ts b/packages/@n8n/agents/src/__tests__/integration/zod-validation-error.test.ts index 77572db975a..d89bd6d556e 100644 --- a/packages/@n8n/agents/src/__tests__/integration/zod-validation-error.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/zod-validation-error.test.ts @@ -45,12 +45,12 @@ describe('Zod validation errors surface to LLM and allow self-correction', () => expect(result.finishReason).toBe('stop'); expect(result.error).toBeUndefined(); - // At least two tool-result messages: one error, one success + // At least two tool-call messages: one rejected, one resolved const allMessages = filterLlmMessages(result.messages); - const toolResultMessages = allMessages.filter((m) => - m.content.some((c) => c.type === 'tool-result'), + const toolCallMessages = allMessages.filter((m) => + m.content.some((c) => c.type === 'tool-call'), ); - expect(toolResultMessages.length).toBeGreaterThanOrEqual(2); + expect(toolCallMessages.length).toBeGreaterThanOrEqual(2); // The final response should mention a user (age 25 or similar) const text = findLastTextContent(result.messages); diff --git a/packages/@n8n/agents/src/__tests__/message-list.test.ts b/packages/@n8n/agents/src/__tests__/message-list.test.ts index 1da019f3923..96dd92eba8d 100644 --- a/packages/@n8n/agents/src/__tests__/message-list.test.ts +++ b/packages/@n8n/agents/src/__tests__/message-list.test.ts @@ -1,6 +1,6 @@ import { AgentMessageList } from '../runtime/message-list'; import { isLlmMessage } from '../sdk/message'; -import type { AgentDbMessage, AgentMessage, Message } from '../types/sdk/message'; +import type { AgentDbMessage, AgentMessage, ContentToolCall, Message } from '../types/sdk/message'; function makeUserMsg(text: string): AgentMessage { return { role: 'user', content: [{ type: 'text', text }] }; @@ -174,3 +174,118 @@ describe('AgentMessageList — deserialize', () => { expect(newMsg.createdAt.getTime()).toBeGreaterThan(futureTs.getTime()); }); }); + +// --------------------------------------------------------------------------- +// setToolCallResult / setToolCallError +// --------------------------------------------------------------------------- + +function makePendingToolCallMsg(toolCallId: string): AgentMessage { + return { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId, + toolName: 'my_tool', + input: { x: 1 }, + state: 'pending', + }, + ], + }; +} + +describe('AgentMessageList — setToolCallResult', () => { + it('sets state and output on the matching tool-call block', () => { + const list = new AgentMessageList(); + list.addResponse([makePendingToolCallMsg('id-1')]); + + const host = list.setToolCallResult('id-1', { ok: true }); + expect(host).toBeDefined(); + + const block = (host as Message).content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(block.state).toBe('resolved'); + expect((block as ContentToolCall & { state: 'resolved' }).output).toEqual({ ok: true }); + }); + + it('promotes a history-only message into responseDelta after setToolCallResult', () => { + const list = new AgentMessageList(); + const histMsg: AgentDbMessage = { + id: 'hist-1', + createdAt: new Date('2024-01-01T00:00:01.000Z'), + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-hist', + toolName: 'my_tool', + input: {}, + state: 'pending', + }, + ], + }; + list.addHistory([histMsg]); + + // Before: not in responseDelta (history only) + expect(list.responseDelta()).toHaveLength(0); + + list.setToolCallResult('tc-hist', { done: true }); + + // After: promoted to responseDelta + const delta = list.responseDelta(); + expect(delta).toHaveLength(1); + const block = (delta[0] as Message).content.find( + (c) => c.type === 'tool-call', + ) as ContentToolCall; + expect(block.state).toBe('resolved'); + }); + + it('is a no-op when toolCallId is unknown', () => { + const list = new AgentMessageList(); + list.addResponse([makePendingToolCallMsg('id-1')]); + + const result = list.setToolCallResult('unknown-id', { x: 1 }); + expect(result).toBeUndefined(); + // List unchanged + expect(list.responseDelta()).toHaveLength(1); + }); + + it('Set semantics make repeated calls idempotent (no duplicate messages)', () => { + const list = new AgentMessageList(); + list.addResponse([makePendingToolCallMsg('id-1')]); + + list.setToolCallResult('id-1', { ok: true }); + list.setToolCallResult('id-1', { ok: true }); + + expect(list.responseDelta()).toHaveLength(1); + }); +}); + +describe('AgentMessageList — setToolCallError', () => { + it('stringifies errors and clears any prior output', () => { + const list = new AgentMessageList(); + list.addResponse([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'id-1', + toolName: 'my_tool', + input: {}, + state: 'resolved', + output: { prev: true }, + }, + ], + }, + ]); + + const host = list.setToolCallError('id-1', new Error('boom')); + expect(host).toBeDefined(); + + const block = (host as Message).content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(block.state).toBe('rejected'); + expect((block as ContentToolCall & { state: 'rejected' }).error).toBe('Error: boom'); + // output should be gone + expect((block as unknown as { output?: unknown }).output).toBeUndefined(); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/model-factory.test.ts b/packages/@n8n/agents/src/__tests__/model-factory.test.ts index ab237b09fd5..96f29ee4688 100644 --- a/packages/@n8n/agents/src/__tests__/model-factory.test.ts +++ b/packages/@n8n/agents/src/__tests__/model-factory.test.ts @@ -9,6 +9,8 @@ type ProviderOpts = { headers?: Record; }; +// All providers are mocked via jest.mock so require() inside the registry entries +// returns these stubs instead of the real packages. jest.mock('@ai-sdk/anthropic', () => ({ createAnthropic: (opts?: ProviderOpts) => (model: string) => ({ provider: 'anthropic', @@ -33,6 +35,119 @@ jest.mock('@ai-sdk/openai', () => ({ }), })); +jest.mock('@ai-sdk/google', () => ({ + createGoogleGenerativeAI: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'google', + modelId: model, + apiKey: opts?.apiKey, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/xai', () => ({ + createXai: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'xai', + modelId: model, + apiKey: opts?.apiKey, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/groq', () => ({ + createGroq: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'groq', + modelId: model, + apiKey: opts?.apiKey, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/deepseek', () => ({ + createDeepSeek: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'deepseek', + modelId: model, + apiKey: opts?.apiKey, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/cohere', () => ({ + createCohere: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'cohere', + modelId: model, + apiKey: opts?.apiKey, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/mistral', () => ({ + createMistral: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'mistral', + modelId: model, + apiKey: opts?.apiKey, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/gateway', () => ({ + createGateway: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'vercel', + modelId: model, + apiKey: opts?.apiKey, + baseURL: opts?.baseURL, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/azure', () => ({ + createAzure: + (opts?: { apiKey?: string; resourceName?: string; apiVersion?: string; baseURL?: string }) => + (model: string) => ({ + provider: 'azure-openai', + modelId: model, + apiKey: opts?.apiKey, + resourceName: opts?.resourceName, + apiVersion: opts?.apiVersion, + specificationVersion: 'v3', + }), +})); + +jest.mock('@openrouter/ai-sdk-provider', () => ({ + createOpenRouter: (opts?: ProviderOpts) => (model: string) => ({ + provider: 'openrouter', + modelId: model, + apiKey: opts?.apiKey, + baseURL: opts?.baseURL, + fetch: opts?.fetch, + specificationVersion: 'v3', + }), +})); + +jest.mock('@ai-sdk/amazon-bedrock', () => ({ + createAmazonBedrock: + (opts?: { + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string; + }) => + (model: string) => ({ + provider: 'aws-bedrock', + modelId: model, + region: opts?.region, + accessKeyId: opts?.accessKeyId, + secretAccessKey: opts?.secretAccessKey, + specificationVersion: 'v3', + }), +})); + const mockProxyAgent = jest.fn(); jest.mock('undici', () => ({ ProxyAgent: mockProxyAgent, @@ -58,15 +173,13 @@ describe('createModel', () => { expect(model.modelId).toBe('claude-sonnet-4-5'); }); - it('should accept an object config with url', () => { + it('should accept an object config with baseURL', () => { const model = createModel({ id: 'openai/gpt-4o', apiKey: 'sk-test', - url: 'https://custom.endpoint.com/v1', + baseURL: 'https://custom.endpoint.com/v1', }) as unknown as Record; expect(model.provider).toBe('openai'); - expect(model.modelId).toBe('gpt-4o'); - expect(model.apiKey).toBe('sk-test'); expect(model.baseURL).toBe('https://custom.endpoint.com/v1'); }); @@ -130,4 +243,113 @@ describe('createModel', () => { createModel('anthropic/claude-sonnet-4-5'); expect(mockProxyAgent).toHaveBeenCalledWith('http://https-proxy:8080'); }); + + describe('standard providers', () => { + it.each(['groq', 'deepseek', 'cohere', 'mistral', 'google', 'xai'])( + 'should create model for %s', + (provider) => { + const model = createModel({ + id: `${provider}/some-model`, + apiKey: 'test-key', + }) as unknown as Record; + expect(model.provider).toBe(provider); + expect(model.modelId).toBe('some-model'); + expect(model.apiKey).toBe('test-key'); + }, + ); + + it('should create model for vercel gateway', () => { + const model = createModel({ + id: 'vercel/gpt-4o', + apiKey: 'vk-test', + }) as unknown as Record; + expect(model.provider).toBe('vercel'); + expect(model.modelId).toBe('gpt-4o'); + }); + + it('should create model for openrouter', () => { + const model = createModel({ + id: 'openrouter/openai/gpt-4o', + apiKey: 'or-test', + }) as unknown as Record; + expect(model.provider).toBe('openrouter'); + expect(model.modelId).toBe('openai/gpt-4o'); + expect(model.apiKey).toBe('or-test'); + }); + }); + + describe('azure-openai', () => { + it('should create model with resourceName', () => { + const model = createModel({ + id: 'azure-openai/gpt-4o', + apiKey: 'az-key', + resourceName: 'my-resource', + apiVersion: '2024-02-01', + }) as unknown as Record; + expect(model.provider).toBe('azure-openai'); + expect(model.modelId).toBe('gpt-4o'); + expect(model.apiKey).toBe('az-key'); + expect(model.resourceName).toBe('my-resource'); + expect(model.apiVersion).toBe('2024-02-01'); + }); + + it('should throw if resourceName is missing', () => { + expect(() => createModel({ id: 'azure-openai/gpt-4o', apiKey: 'az-key' })).toThrow( + /Invalid credentials for provider "azure-openai"/, + ); + }); + }); + + describe('aws-bedrock', () => { + it('should create model with AWS credentials', () => { + const model = createModel({ + id: 'aws-bedrock/amazon.titan-text-lite-v1', + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }) as unknown as Record; + expect(model.provider).toBe('aws-bedrock'); + expect(model.modelId).toBe('amazon.titan-text-lite-v1'); + expect(model.region).toBe('us-east-1'); + expect(model.accessKeyId).toBe('AKIAIOSFODNN7EXAMPLE'); + }); + + it('should throw if region is missing', () => { + expect(() => + createModel({ + id: 'aws-bedrock/amazon.titan-text-lite-v1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'secret', + }), + ).toThrow(/Invalid credentials for provider "aws-bedrock"/); + }); + + it('should throw if accessKeyId is missing', () => { + expect(() => + createModel({ + id: 'aws-bedrock/amazon.titan-text-lite-v1', + region: 'us-east-1', + secretAccessKey: 'secret', + }), + ).toThrow(/Invalid credentials for provider "aws-bedrock"/); + }); + }); + + describe('unsupported provider', () => { + it('should throw for ollama', () => { + expect(() => createModel('ollama/llama3')).toThrow(/Unsupported provider: "ollama"/); + }); + + it('should include supported providers in the error message', () => { + expect(() => createModel('unknown-provider/some-model')).toThrow(/Supported providers:/); + }); + + it('should throw when no model ID is provided', () => { + expect(() => createModel('')).toThrow(/Model ID is required/); + }); + + it('should throw when model has no slash', () => { + expect(() => createModel('anthropic-only')).toThrow(/expected "provider\/model-name"/); + }); + }); }); diff --git a/packages/@n8n/agents/src/__tests__/parse.test.ts b/packages/@n8n/agents/src/__tests__/parse.test.ts new file mode 100644 index 00000000000..81befddc844 --- /dev/null +++ b/packages/@n8n/agents/src/__tests__/parse.test.ts @@ -0,0 +1,146 @@ +import type { JSONSchema7 } from 'json-schema'; +import { z } from 'zod'; + +import { parseWithSchema } from '../utils/parse'; + +// --------------------------------------------------------------------------- +// parseWithSchema — Zod schemas +// --------------------------------------------------------------------------- + +describe('parseWithSchema — Zod schemas', () => { + it('returns success with parsed data for valid input', async () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const result = await parseWithSchema(schema, { name: 'Alice', age: 30 }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual({ name: 'Alice', age: 30 }); + }); + + it('coerces and transforms values as defined in the schema', async () => { + const schema = z.object({ id: z.string().transform((s) => s.toUpperCase()) }); + const result = await parseWithSchema(schema, { id: 'abc' }); + + expect(result.success).toBe(true); + if (result.success) expect((result.data as { id: string }).id).toBe('ABC'); + }); + + it('returns failure with an error message for wrong type', async () => { + const schema = z.object({ count: z.number() }); + const result = await parseWithSchema(schema, { count: 'not-a-number' }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toBeTruthy(); + }); + + it('returns failure when a required field is missing', async () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const result = await parseWithSchema(schema, { name: 'Alice' }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toMatch(/required/i); + }); + + it('returns failure when a value violates a refinement', async () => { + const schema = z.object({ age: z.number().min(18, 'must be at least 18') }); + const result = await parseWithSchema(schema, { age: 5 }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toContain('must be at least 18'); + }); +}); + +// --------------------------------------------------------------------------- +// parseWithSchema — JSON Schema (AJV) +// --------------------------------------------------------------------------- + +describe('parseWithSchema — JSON Schema', () => { + it('returns success with the original data for valid input', async () => { + const schema = { + type: 'object' as const, + properties: { name: { type: 'string' }, age: { type: 'integer' } }, + required: ['name', 'age'], + } as JSONSchema7; + const result = await parseWithSchema(schema, { name: 'Bob', age: 25 }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual({ name: 'Bob', age: 25 }); + }); + + it('returns failure when a property has the wrong type', async () => { + const schema = { + type: 'object' as const, + properties: { id: { type: 'string' } }, + required: ['id'], + } as JSONSchema7; + const result = await parseWithSchema(schema, { id: 42 }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toBeTruthy(); + }); + + it('returns failure when a required property is missing', async () => { + const schema = { + type: 'object' as const, + properties: { + name: { type: 'string' }, + age: { type: 'integer' }, + }, + required: ['name', 'age'], + } as JSONSchema7; + const result = await parseWithSchema(schema, { name: 'Alice' }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toBeTruthy(); + }); + + it('returns failure when a numeric constraint is violated', async () => { + const schema = { + type: 'object' as const, + properties: { age: { type: 'integer', minimum: 18, maximum: 99 } }, + required: ['age'], + } as JSONSchema7; + + const tooLow = await parseWithSchema(schema, { age: 5 }); + expect(tooLow.success).toBe(false); + + const tooHigh = await parseWithSchema(schema, { age: 150 }); + expect(tooHigh.success).toBe(false); + + const valid = await parseWithSchema(schema, { age: 30 }); + expect(valid.success).toBe(true); + }); + + it('returns failure for an enum constraint violation', async () => { + const schema = { + type: 'object' as const, + properties: { status: { type: 'string', enum: ['active', 'inactive'] } }, + required: ['status'], + } as JSONSchema7; + + const invalid = await parseWithSchema(schema, { status: 'pending' }); + expect(invalid.success).toBe(false); + + const valid = await parseWithSchema(schema, { status: 'active' }); + expect(valid.success).toBe(true); + }); + + it('validates nested object properties', async () => { + const schema = { + type: 'object' as const, + properties: { + address: { + type: 'object', + properties: { zip: { type: 'string' } }, + required: ['zip'], + }, + }, + required: ['address'], + } as JSONSchema7; + + const valid = await parseWithSchema(schema, { address: { zip: '10001' } }); + expect(valid.success).toBe(true); + + const invalid = await parseWithSchema(schema, { address: { zip: 12345 } }); + expect(invalid.success).toBe(false); + }); +}); diff --git a/packages/@n8n/agents/src/__tests__/sqlite-memory.test.ts b/packages/@n8n/agents/src/__tests__/sqlite-memory.test.ts index 35d64b22306..22dd3060bbd 100644 --- a/packages/@n8n/agents/src/__tests__/sqlite-memory.test.ts +++ b/packages/@n8n/agents/src/__tests__/sqlite-memory.test.ts @@ -578,7 +578,7 @@ describe('SqliteMemory — queryEmbeddings', () => { describe('SqliteMemory — namespace', () => { it('rejects invalid namespace characters', () => { expect(() => new SqliteMemory({ url: 'file::memory:', namespace: 'bad-ns!' })).toThrow( - /Invalid namespace/, + /invalid_string/, ); }); diff --git a/packages/@n8n/agents/src/__tests__/strip-orphaned-tool-messages.test.ts b/packages/@n8n/agents/src/__tests__/strip-orphaned-tool-messages.test.ts index 960ec996158..1471541754a 100644 --- a/packages/@n8n/agents/src/__tests__/strip-orphaned-tool-messages.test.ts +++ b/packages/@n8n/agents/src/__tests__/strip-orphaned-tool-messages.test.ts @@ -2,52 +2,38 @@ import { stripOrphanedToolMessages } from '../runtime/strip-orphaned-tool-messag import type { AgentMessage, Message } from '../types/sdk/message'; describe('stripOrphanedToolMessages', () => { - it('returns messages unchanged when all tool pairs are complete', () => { + it('returns messages unchanged when all tool-calls are settled', () => { const messages: AgentMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, { role: 'assistant', content: [ { type: 'text', text: 'Looking up...' }, - { type: 'tool-call', toolCallId: 'c1', toolName: 'lookup', input: {} }, + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'lookup', + input: {}, + state: 'resolved', + output: 42, + }, ], }, - { - role: 'tool', - content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'lookup', result: 42 }], - }, { role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }, ]; const result = stripOrphanedToolMessages(messages); - expect(result).toBe(messages); + expect(result).toEqual(messages); }); - it('strips orphaned tool-result when matching tool-call is missing', () => { - const messages: AgentMessage[] = [ - { - role: 'tool', - content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'lookup', result: 42 }], - }, - { role: 'assistant', content: [{ type: 'text', text: 'There are 42.' }] }, - { role: 'user', content: [{ type: 'text', text: 'Thanks' }] }, - ]; - - const result = stripOrphanedToolMessages(messages) as Message[]; - - expect(result).toHaveLength(2); - expect(result[0].role).toBe('assistant'); - expect(result[1].role).toBe('user'); - }); - - it('strips orphaned tool-call when matching tool-result is missing', () => { + it('drops pending tool-call blocks while preserving sibling content', () => { const messages: AgentMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'Check it' }] }, { role: 'assistant', content: [ { type: 'text', text: 'Checking...' }, - { type: 'tool-call', toolCallId: 'c1', toolName: 'lookup', input: {} }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'lookup', input: {}, state: 'pending' }, ], }, ]; @@ -61,12 +47,14 @@ describe('stripOrphanedToolMessages', () => { expect(assistantMsg.content[0].type).toBe('text'); }); - it('drops assistant message entirely if it only contained an orphaned tool-call', () => { + it('drops empty messages after pending strip', () => { const messages: AgentMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'Do it' }] }, { role: 'assistant', - content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'action', input: {} }], + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'action', input: {}, state: 'pending' }, + ], }, ]; @@ -76,44 +64,45 @@ describe('stripOrphanedToolMessages', () => { expect(result[0].role).toBe('user'); }); - it('handles mixed scenario: one complete pair and one orphaned result', () => { + it('mixed scenario — only pending blocks are removed', () => { const messages: AgentMessage[] = [ - { - role: 'tool', - content: [ - { type: 'tool-result', toolCallId: 'orphan', toolName: 'lookup', result: 'stale' }, - ], - }, - { role: 'assistant', content: [{ type: 'text', text: 'Old result' }] }, - { role: 'user', content: [{ type: 'text', text: 'New question' }] }, { role: 'assistant', content: [ - { type: 'text', text: 'Looking up...' }, - { type: 'tool-call', toolCallId: 'c2', toolName: 'lookup', input: {} }, + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'lookup', + input: {}, + state: 'resolved', + output: 99, + }, + { + type: 'tool-call', + toolCallId: 'c2', + toolName: 'delete', + input: {}, + state: 'pending', + }, + { + type: 'tool-call', + toolCallId: 'c3', + toolName: 'create', + input: {}, + state: 'rejected', + error: 'boom', + }, ], }, - { - role: 'tool', - content: [{ type: 'tool-result', toolCallId: 'c2', toolName: 'lookup', result: 99 }], - }, - { role: 'assistant', content: [{ type: 'text', text: '99 items' }] }, ]; const result = stripOrphanedToolMessages(messages) as Message[]; - expect(result).toHaveLength(5); - expect(result[0].role).toBe('assistant'); - expect(result[0].content[0]).toEqual( - expect.objectContaining({ type: 'text', text: 'Old result' }), - ); - - const toolCallMsg = result.find( - (m) => m.role === 'assistant' && m.content.some((c) => c.type === 'tool-call'), - ); - expect(toolCallMsg).toBeDefined(); - const toolResultMsg = result.find((m) => m.role === 'tool'); - expect(toolResultMsg).toBeDefined(); + expect(result).toHaveLength(1); + const blocks = result[0].content; + // c2 (pending) should be removed; c1 (resolved) and c3 (rejected) stay + expect(blocks).toHaveLength(2); + expect(blocks.map((b) => (b as { toolCallId: string }).toolCallId)).toEqual(['c1', 'c3']); }); it('preserves custom (non-LLM) messages', () => { @@ -127,8 +116,16 @@ describe('stripOrphanedToolMessages', () => { const messages: AgentMessage[] = [ customMsg, { - role: 'tool', - content: [{ type: 'tool-result', toolCallId: 'orphan', toolName: 'x', result: null }], + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'x', + input: {}, + state: 'pending', + }, + ], }, ]; @@ -137,14 +134,4 @@ describe('stripOrphanedToolMessages', () => { expect(result).toHaveLength(1); expect(result[0]).toBe(customMsg); }); - - it('returns same array reference when no orphans exist (no-op fast path)', () => { - const messages: AgentMessage[] = [ - { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Hello!' }] }, - ]; - - const result = stripOrphanedToolMessages(messages); - expect(result).toBe(messages); - }); }); diff --git a/packages/@n8n/agents/src/__tests__/title-generation.test.ts b/packages/@n8n/agents/src/__tests__/title-generation.test.ts index fdee4af6e19..f575b67b7ca 100644 --- a/packages/@n8n/agents/src/__tests__/title-generation.test.ts +++ b/packages/@n8n/agents/src/__tests__/title-generation.test.ts @@ -120,39 +120,4 @@ describe('generateTitleFromMessage', () => { const call = mockGenerateText.mock.calls[0][0]; expect(call.messages[0].content).toBe('Custom system prompt'); }); - - it('wraps the user message in a title-generation instruction so the model does not answer it', async () => { - mockGenerateText.mockResolvedValue({ text: 'Berlin rain alert' }); - await generateTitleFromMessage(fakeModel, 'Build a daily Berlin rain alert workflow'); - const call = mockGenerateText.mock.calls[0][0]; - expect(call.messages[1].role).toBe('user'); - expect(call.messages[1].content).toContain('Generate a title'); - expect(call.messages[1].content).toContain(''); - expect(call.messages[1].content).toContain('Build a daily Berlin rain alert workflow'); - expect(call.messages[1].content).toContain(''); - }); - - it('drops a streamed code fence and everything after it', async () => { - mockGenerateText.mockResolvedValue({ - text: 'Here\'s your chat workflow with the requested configuration:\n\n```json\n{\n "nodes": []\n}\n```', - }); - const result = await generateTitleFromMessage( - fakeModel, - 'build me a chat workflow with openai', - ); - expect(result).toBe("Here's your chat workflow with the requested configuration"); - expect(result).not.toContain('```'); - expect(result).not.toContain('\n'); - }); - - it('collapses embedded newlines and stray backticks into a single-line title', async () => { - mockGenerateText.mockResolvedValue({ - text: 'Scryfall\nrandom `card` workflow', - }); - const result = await generateTitleFromMessage( - fakeModel, - 'build a workflow that queries Scryfall for a random card', - ); - expect(result).toBe('Scryfall random card workflow'); - }); }); diff --git a/packages/@n8n/agents/src/__tests__/tool.test.ts b/packages/@n8n/agents/src/__tests__/tool.test.ts index 482df9ed01f..43bba1c6bcd 100644 --- a/packages/@n8n/agents/src/__tests__/tool.test.ts +++ b/packages/@n8n/agents/src/__tests__/tool.test.ts @@ -123,6 +123,37 @@ describe('Tool builder — without approval', () => { }); }); +// --------------------------------------------------------------------------- +// Tool builder — .systemInstruction() +// --------------------------------------------------------------------------- + +describe('Tool builder — .systemInstruction()', () => { + it('build() carries the systemInstruction onto the BuiltTool', () => { + const tool = new Tool('fetch') + .description('Fetch data') + .systemInstruction('Always fetch with the cache disabled.') + .input(z.object({ id: z.string() })) + .handler(async ({ id }) => { + return await Promise.resolve({ data: id }); + }) + .build(); + + expect(tool.systemInstruction).toBe('Always fetch with the cache disabled.'); + }); + + it('build() leaves systemInstruction undefined when not set', () => { + const tool = new Tool('fetch') + .description('Fetch data') + .input(z.object({ id: z.string() })) + .handler(async ({ id }) => { + return await Promise.resolve({ data: id }); + }) + .build(); + + expect(tool.systemInstruction).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // wrapToolForApproval — requireApproval: true // --------------------------------------------------------------------------- diff --git a/packages/@n8n/agents/src/codegen/generate-agent-code.ts b/packages/@n8n/agents/src/codegen/generate-agent-code.ts deleted file mode 100644 index 9cf1d63fdfd..00000000000 --- a/packages/@n8n/agents/src/codegen/generate-agent-code.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type prettier from 'prettier'; - -import type { - AgentSchema, - EvalSchema, - GuardrailSchema, - MemorySchema, - ToolSchema, -} from '../types/sdk/schema'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function escapeTemplateLiteral(str: string): string { - return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$'); -} - -function escapeSingleQuote(str: string): string { - return JSON.stringify(str).slice(1, -1).replace(/'/g, "\\'"); -} - -let prettierInstance: typeof prettier | undefined; - -/** - * Format TypeScript source code using Prettier. - * Loaded lazily to avoid startup cost when not generating code. - */ -async function formatCode(code: string): Promise { - prettierInstance ??= await import('prettier'); - return await prettierInstance.format(code, { - parser: 'typescript', - singleQuote: true, - useTabs: true, - trailingComma: 'all', - printWidth: 100, - }); -} - -/** - * Compile-time exhaustive check. If a new property is added to AgentSchema - * but not handled in generateAgentCode(), TypeScript will report an error - * here because the destructured rest object won't be empty. - */ -function assertAllHandled(_: Record): void { - // intentionally empty — this is a compile-time-only check -} - -// --------------------------------------------------------------------------- -// Section builders — each returns `.method(...)` chain fragments -// --------------------------------------------------------------------------- - -function modelParts(model: AgentSchema['model']): string[] { - if (model.provider && model.name) { - return [`.model('${escapeSingleQuote(model.provider)}', '${escapeSingleQuote(model.name)}')`]; - } - if (model.name) { - return [`.model('${escapeSingleQuote(model.name)}')`]; - } - return []; -} - -function toolPart(tool: ToolSchema): { part: string; usesWorkflowTool: boolean } { - if (!tool.editable) { - return { - part: `.tool(new WorkflowTool('${escapeSingleQuote(tool.name)}'))`, - usesWorkflowTool: true, - }; - } - const parts = [`new Tool('${escapeSingleQuote(tool.name)}')`]; - parts.push(`.description('${escapeSingleQuote(tool.description)}')`); - if (tool.inputSchemaSource) parts.push(`.input(${tool.inputSchemaSource})`); - if (tool.outputSchemaSource) parts.push(`.output(${tool.outputSchemaSource})`); - if (tool.suspendSchemaSource) parts.push(`.suspend(${tool.suspendSchemaSource})`); - if (tool.resumeSchemaSource) parts.push(`.resume(${tool.resumeSchemaSource})`); - if (tool.handlerSource) parts.push(`.handler(${tool.handlerSource})`); - if (tool.toMessageSource) parts.push(`.toMessage(${tool.toMessageSource})`); - if (tool.requireApproval) parts.push('.requireApproval()'); - if (tool.needsApprovalFnSource) parts.push(`.needsApprovalFn(${tool.needsApprovalFnSource})`); - return { part: `.tool(${parts.join('')})`, usesWorkflowTool: false }; -} - -function evalPart(ev: EvalSchema): string { - const parts = [`new Eval('${escapeSingleQuote(ev.name)}')`]; - if (ev.description) parts.push(`.description('${escapeSingleQuote(ev.description)}')`); - if (ev.modelId) parts.push(`.model('${escapeSingleQuote(ev.modelId)}')`); - if (ev.credentialName) parts.push(`.credential('${escapeSingleQuote(ev.credentialName)}')`); - if (ev.handlerSource) { - parts.push(ev.type === 'check' ? `.check(${ev.handlerSource})` : `.judge(${ev.handlerSource})`); - } - return `.eval(${parts.join('')})`; -} - -function guardrailPart(g: GuardrailSchema): string { - const method = g.position === 'input' ? 'inputGuardrail' : 'outputGuardrail'; - return `.${method}(${g.source})`; -} - -function memoryPart(memory: MemorySchema): string { - if (memory.source) { - return `.memory(${memory.source})`; - } - return `.memory(new Memory().lastMessages(${memory.lastMessages ?? 10}))`; -} - -function thinkingPart(thinking: NonNullable): string { - const props: string[] = []; - if (thinking.budgetTokens !== undefined) props.push(`budgetTokens: ${thinking.budgetTokens}`); - if (thinking.reasoningEffort) props.push(`reasoningEffort: '${thinking.reasoningEffort}'`); - if (props.length > 0) { - return `.thinking('${thinking.provider}', { ${props.join(', ')} })`; - } - return `.thinking('${thinking.provider}')`; -} - -function buildImports(schema: AgentSchema, needsWorkflowTool: boolean): string { - const agentImports = new Set(['Agent']); - if (schema.tools.some((t) => t.editable)) agentImports.add('Tool'); - if (needsWorkflowTool) agentImports.add('WorkflowTool'); - if (schema.memory) agentImports.add('Memory'); - if (schema.mcp && schema.mcp.length > 0) agentImports.add('McpClient'); - if (schema.evaluations.length > 0) agentImports.add('Eval'); - - const toolsNeedZod = schema.tools.some( - (t) => - (t.inputSchemaSource?.includes('z.') ?? false) || - (t.outputSchemaSource?.includes('z.') ?? false), - ); - const structuredOutputNeedsZod = - schema.config.structuredOutput.schemaSource?.includes('z.') ?? false; - - let imports = `import { ${Array.from(agentImports).sort().join(', ')} } from '@n8n/agents';`; - if (toolsNeedZod || structuredOutputNeedsZod) imports += "\nimport { z } from 'zod';"; - return imports; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export async function generateAgentCode(schema: AgentSchema, agentName: string): Promise { - // Destructure every top-level property. If a new property is added to - // AgentSchema, TypeScript will error on assertAllHandled below until - // you handle it here AND add it to the destructure. - const { - model, - credential, - instructions, - description: _description, // entity-level, not in code - tools, - providerTools, - memory, - evaluations, - guardrails, - mcp, - telemetry, - checkpoint, - config, - ...rest - } = schema; - - // If this errors, you added a property to AgentSchema but didn't - // destructure it above. Add it to the destructure and handle it below. - assertAllHandled(rest); - - const { thinking, toolCallConcurrency, requireToolApproval, structuredOutput, ...configRest } = - config; - assertAllHandled(configRest); - - // No manual indentation — Prettier formats at the end. - const parts: string[] = []; - let needsWorkflowTool = false; - - parts.push(`export default new Agent('${escapeSingleQuote(agentName)}')`); - parts.push(...modelParts(model)); - - if (credential) parts.push(`.credential('${escapeSingleQuote(credential)}')`); - if (instructions) parts.push(`.instructions(\`${escapeTemplateLiteral(instructions)}\`)`); - - for (const tool of tools) { - const { part, usesWorkflowTool } = toolPart(tool); - if (usesWorkflowTool) needsWorkflowTool = true; - parts.push(part); - } - - for (const pt of providerTools) { - parts.push(`.providerTool(${pt.source})`); - } - - if (memory) parts.push(memoryPart(memory)); - - for (const ev of evaluations) { - parts.push(evalPart(ev)); - } - - for (const g of guardrails) { - parts.push(guardrailPart(g)); - } - - if (mcp && mcp.length > 0) { - const configs = mcp.map((s) => s.configSource).join(', '); - parts.push(`.mcp(new McpClient([${configs}]))`); - } - - if (telemetry) parts.push(`.telemetry(${telemetry.source})`); - if (checkpoint) parts.push(`.checkpoint('${escapeSingleQuote(checkpoint)}')`); - if (thinking) parts.push(thinkingPart(thinking)); - if (toolCallConcurrency) parts.push(`.toolCallConcurrency(${toolCallConcurrency})`); - if (requireToolApproval) parts.push('.requireToolApproval()'); - if (structuredOutput.enabled && structuredOutput.schemaSource) { - parts.push(`.structuredOutput(${structuredOutput.schemaSource})`); - } - - const imports = buildImports(schema, needsWorkflowTool); - const raw = `${imports}\n\n${parts.join('')};\n`; - return await formatCode(raw); -} diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 540798ca800..425e886735b 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -28,6 +28,7 @@ export type { SerializableAgentState, AgentRunState, MemoryConfig, + MemoryDescriptor, TitleGenerationConfig, Thread, SemanticRecallConfig, @@ -44,7 +45,7 @@ export type { ProviderOptions } from '@ai-sdk/provider-utils'; export { AgentEvent } from './types'; export type { AgentEventData, AgentEventHandler } from './types'; -export { Tool } from './sdk/tool'; +export { Tool, wrapToolForApproval } from './sdk/tool'; export { Memory } from './sdk/memory'; export { Guardrail } from './sdk/guardrail'; export { Eval } from './sdk/eval'; @@ -55,6 +56,7 @@ export { Telemetry } from './sdk/telemetry'; export { LangSmithTelemetry } from './integrations/langsmith'; export type { LangSmithTelemetryConfig } from './integrations/langsmith'; export { Agent } from './sdk/agent'; +export type { AgentSnapshot } from './sdk/agent'; export type { AgentBuilder, CredentialProvider, @@ -73,7 +75,6 @@ export type { ContentReasoning, ContentText, ContentToolCall, - ContentToolResult, Message, MessageContent, MessageRole, @@ -82,19 +83,10 @@ export type { AgentDbMessage, } from './types/sdk/message'; export type { HandlerExecutor } from './types/sdk/handler-executor'; -export type { - AgentSchema, - ToolSchema, - MemorySchema, - EvalSchema, - ThinkingSchema, - ProviderToolSchema, - GuardrailSchema, - McpServerSchema, - TelemetrySchema, -} from './types/sdk/schema'; -export { generateAgentCode } from './codegen/generate-agent-code'; -export { filterLlmMessages, isLlmMessage } from './sdk/message'; +export { + filterLlmMessages, + isLlmMessage, +} from './sdk/message'; export { fetchProviderCatalog } from './sdk/catalog'; export { providerCapabilities } from './sdk/provider-capabilities'; export type { ProviderCapability } from './sdk/provider-capabilities'; @@ -105,14 +97,19 @@ export type { ModelCost, ModelLimits, } from './sdk/catalog'; -export { SqliteMemory } from './storage/sqlite-memory'; +export { SqliteMemory, SqliteMemoryConfigSchema } from './storage/sqlite-memory'; export { UPDATE_WORKING_MEMORY_TOOL_NAME, WORKING_MEMORY_DEFAULT_INSTRUCTION, } from './runtime/working-memory'; export type { SqliteMemoryConfig } from './storage/sqlite-memory'; export { PostgresMemory } from './storage/postgres-memory'; -export type { PostgresMemoryConfig } from './storage/postgres-memory'; +export type { + PostgresConnectionOptions, + PostgresConstructorOptions, +} from './storage/postgres-memory'; +export { BaseMemory } from './storage/base-memory'; +export type { ToolDescriptor } from './types/sdk/tool-descriptor'; export { createModel } from './runtime/model-factory'; export { generateTitleFromMessage } from './runtime/title-generation'; @@ -151,3 +148,7 @@ export type { SpawnProcessOptions, ProcessInfo, } from './workspace'; + +export type { JSONObject, JSONArray, JSONValue } from './types/utils/json'; + +export { isZodSchema, zodToJsonSchema } from './utils/zod'; diff --git a/packages/@n8n/agents/src/runtime/agent-runtime.ts b/packages/@n8n/agents/src/runtime/agent-runtime.ts index 2fd6c6a965d..7600efcbbcb 100644 --- a/packages/@n8n/agents/src/runtime/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/agent-runtime.ts @@ -1,6 +1,5 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils'; -import { generateText, Output, streamText } from 'ai'; -import Ajv from 'ajv'; +import { generateText, streamText, Output } from 'ai'; import type { z } from 'zod'; import { zodToJsonSchema, type JsonSchema7Type } from 'zod-to-json-schema'; @@ -39,10 +38,8 @@ import { generateRunId, RunStateManager } from './run-state'; import { accumulateUsage, applySubAgentUsage, - extractToolResults, + extractSettledToolCalls, makeErrorStream, - makeErrorToolResultMessage, - makeToolResultMessage, normalizeInput, } from './runtime-helpers'; import { convertChunk } from './stream'; @@ -57,8 +54,8 @@ import { toAiSdkTools, } from './tool-adapter'; import { buildWorkingMemoryTool } from './working-memory'; -import type { AgentEventSpecificData } from '../types/runtime/event'; import { AgentEvent } from '../types/runtime/event'; +import type { AgentEventData } from '../types/runtime/event'; import type { AgentPersistenceOptions, ExecutionOptions, @@ -66,13 +63,9 @@ import type { PersistedExecutionOptions, ToolResultEntry, } from '../types/sdk/agent'; -import type { - AgentDbMessage, - AgentMessage, - ContentToolResult, - Message, -} from '../types/sdk/message'; +import type { AgentDbMessage, AgentMessage, ContentToolCall, Message } from '../types/sdk/message'; import type { JSONObject, JSONValue } from '../types/utils/json'; +import { parseWithSchema } from '../utils/parse'; import { isZodSchema } from '../utils/zod'; export interface AgentRuntimeConfig { @@ -100,22 +93,10 @@ export interface AgentRuntimeConfig { toolCallConcurrency?: number; titleGeneration?: TitleGenerationConfig; telemetry?: BuiltTelemetry; - /** - * Pre-fetched model cost from the catalog. When provided, skips the per-run - * catalog fetch. Set once during Agent.build() and shared across per-run runtimes. - */ - modelCost?: ModelCost; - /** - * Shared RunStateManager for suspend/resume. When provided, all per-run runtimes - * use the same store so that resume() can find state from a prior stream()/generate() call. - */ - runState?: RunStateManager; } const MAX_LOOP_ITERATIONS = 20; -const ajv = new Ajv({ strict: false }); - const EMPTY_MESSAGE_LIST: SerializedMessageList = { messages: [], historyIds: [], @@ -137,12 +118,22 @@ type ToolCallOutcome = | { outcome: 'success'; toolEntry: ToolResultEntry; + /** + * Output as the LLM sees it (after `toModelOutput`). Same as + * `toolEntry.output` when no `toModelOutput` transform is configured. + * Surfaced on the `tool-result` wire chunk so consumers see what the + * LLM saw (rather than the larger raw output). + */ + modelOutput: unknown; subAgentUsage?: SubAgentUsage[]; customMessage?: AgentMessage; - message: AgentMessage; } - | { outcome: 'suspended'; payload: unknown; resumeSchema: JsonSchema7Type } - | { outcome: 'error'; error: unknown; message: AgentMessage } + | { + outcome: 'suspended'; + payload: unknown; + resumeSchema: JsonSchema7Type; + } + | { outcome: 'error'; error: unknown } | { outcome: 'noop' }; // tool call shouldn't be saved or logged anywhere, usually means that if was executed by AI SDK /** A tool call that completed successfully. */ @@ -151,9 +142,9 @@ interface ToolCallSuccess { toolName: string; input: JSONValue; toolEntry: ToolResultEntry; + modelOutput: unknown; subAgentUsage?: SubAgentUsage[]; customMessage?: AgentMessage; - message: AgentMessage; } /** Info about a tool call that suspended (before persistence — no runId yet). */ @@ -172,7 +163,6 @@ interface ToolCallError { toolName: string; input: JSONValue; error: unknown; - message: AgentMessage; } /** Result of executing a batch of tool calls (before persistence). */ @@ -184,6 +174,22 @@ interface ToolCallBatchResult { pending: Record; } +/** Shared input for the private generate/stream loops. */ +interface LoopContext { + list: AgentMessageList; + options?: RunOptions & ExecutionOptions; + runId: string; + pendingResume?: PendingResume; +} + +/** Shared input for the tool-call batch iterators. */ +interface ToolBatchContext { + toolMap: Map; + list: AgentMessageList; + runId: string; + telemetry?: BuiltTelemetry; +} + /** * Core agent execution engine using the Vercel AI SDK directly. * @@ -208,17 +214,12 @@ export class AgentRuntime { private modelCost: ModelCost | undefined; - /** Unique identifier for the current run. Set at the start of generate/stream/resume. */ - private runId = ''; - - /** Execution options for the current run (excludes persistence). Set at the start of generate/stream/resume. */ - private executionOptions: ExecutionOptions | undefined; + /** Resolved telemetry for the current run (own config or inherited from parent). */ constructor(config: AgentRuntimeConfig) { this.config = config; - this.runState = config.runState ?? new RunStateManager(config.checkpointStorage); + this.runState = new RunStateManager(config.checkpointStorage); this.eventBus = config.eventBus ?? new AgentEventBus(); - this.modelCost = config.modelCost; this.currentState = { persistence: undefined, status: 'idle', @@ -242,28 +243,20 @@ export class AgentRuntime { input: AgentMessage[] | string, options?: RunOptions & ExecutionOptions, ): Promise { - this.runId = generateRunId(); - this.executionOptions = options; - this.updateState({ persistence: options?.persistence }); + const runId = generateRunId(); let list: AgentMessageList | undefined = undefined; try { - list = await this.initRun(input); - const rawResult = await this.runGenerateLoop(list); - return this.finalizeGenerate(rawResult, list); + list = await this.initRun(input, options); + const rawResult = await this.runGenerateLoop({ list, options, runId }); + return this.finalizeGenerate(rawResult, list, runId); } catch (error) { - await this.flushTelemetry(); + await this.flushTelemetry(options); const isAbort = this.eventBus.isAborted; this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); if (!isAbort) { - this.emitEvent({ type: AgentEvent.Error, message: String(error), error }); + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); } - return { - runId: this.runId, - messages: list?.responseDelta() ?? [], - finishReason: 'error', - error, - getState: () => this.getState(), - }; + return { runId, messages: list?.responseDelta() ?? [], finishReason: 'error', error }; } } @@ -272,26 +265,20 @@ export class AgentRuntime { input: AgentMessage[] | string, options?: RunOptions & ExecutionOptions, ): Promise { - this.runId = generateRunId(); - this.executionOptions = options; - this.updateState({ persistence: options?.persistence }); + const runId = generateRunId(); let list: AgentMessageList; try { - list = await this.initRun(input); + list = await this.initRun(input, options); } catch (error) { const isAbort = this.eventBus.isAborted; this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); if (!isAbort) { - this.emitEvent({ type: AgentEvent.Error, message: String(error), error }); + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); } - return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() }; + return { runId, stream: makeErrorStream(error) }; } - return { - runId: this.runId, - stream: this.startStreamLoop(list), - getState: () => this.getState(), - }; + return { runId, stream: this.startStreamLoop({ list, options, runId }) }; } /** @@ -317,9 +304,8 @@ export class AgentRuntime { data: unknown, options: { runId: string; toolCallId: string } & ExecutionOptions, ): Promise { - this.runId = options.runId; - const state = await this.runState.resume(this.runId); - if (!state) throw new Error(`No suspended run found for runId: ${this.runId}`); + const state = await this.runState.resume(options.runId); + if (!state) throw new Error(`No suspended run found for runId: ${options.runId}`); const toolCall = state.pendingToolCalls[options.toolCallId]; if (!toolCall) throw new Error(`No tool call found for toolCallId: ${options.toolCallId}`); @@ -329,9 +315,9 @@ export class AgentRuntime { let resumeData: unknown = data; if (tool.resumeSchema) { - const parseResult = await tool.resumeSchema.safeParseAsync(data); + const parseResult = await parseWithSchema(tool.resumeSchema, data); if (!parseResult.success) { - throw new Error(`Invalid resume payload: ${parseResult.error.message}`); + throw new Error(`Invalid resume payload: ${parseResult.error}`); } resumeData = parseResult.data as JSONValue; } @@ -339,8 +325,7 @@ export class AgentRuntime { try { const list = AgentMessageList.deserialize(state.messageList); - // Merge persisted execution options with fresh caller options. - // runId and toolCallId are resume-routing keys, not run options. + // Merge persisted execution options with fresh caller options const { runId: _rid, toolCallId: _tcid, ...callerExecOptions } = options; const persisted = state.executionOptions ?? {}; const mergedExecOptions: ExecutionOptions = { @@ -348,11 +333,13 @@ export class AgentRuntime { ...callerExecOptions, }; - this.executionOptions = mergedExecOptions; - this.updateState({ persistence: state.persistence }); + const resumeOptions: RunOptions & ExecutionOptions = { + persistence: state.persistence, + ...mergedExecOptions, + }; // Pass abortSignal to event bus - this.eventBus.resetAbort(this.executionOptions?.abortSignal); + this.eventBus.resetAbort(resumeOptions.abortSignal); const pendingResume: PendingResume = { pendingToolCalls: state.pendingToolCalls, @@ -367,34 +354,42 @@ export class AgentRuntime { await this.setListWorkingMemoryConfig(list, state.persistence); if (method === 'generate') { - const rawResult = await this.runGenerateLoop(list, pendingResume); + const rawResult = await this.runGenerateLoop({ + list, + options: resumeOptions, + runId: options.runId, + pendingResume, + }); if (!rawResult.pendingSuspend) { - await this.cleanupRun(); + await this.cleanupRun(options.runId); } - return this.finalizeGenerate(rawResult, list); + return this.finalizeGenerate(rawResult, list, options.runId); } return { - runId: this.runId, - stream: this.startStreamLoop(list, pendingResume), - getState: () => this.getState(), + runId: options.runId, + stream: this.startStreamLoop({ + list, + options: resumeOptions, + runId: options.runId, + pendingResume, + }), }; } catch (error) { const isAbort = this.eventBus.isAborted; this.updateState({ status: isAbort ? 'cancelled' : 'failed' }); if (!isAbort) { - this.emitEvent({ type: AgentEvent.Error, message: String(error), error }); + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); } if (method === 'generate') { return { - runId: this.runId, + runId: options.runId, messages: [], finishReason: 'error' as const, error, - getState: () => this.getState(), }; } - return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() }; + return { runId: options.runId, stream: makeErrorStream(error) }; } } @@ -408,12 +403,14 @@ export class AgentRuntime { * The system prompt is NOT stored in the list; list.forLlm(instructions) * prepends it at every LLM call site. */ - private async buildMessageList(input: AgentMessage[]): Promise { + private async buildMessageList( + input: AgentMessage[], + options?: RunOptions, + ): Promise { const list = new AgentMessageList(); - const persistence = this.currentState.persistence; - if (this.config.memory && persistence?.threadId) { - const memMessages = await this.config.memory.getMessages(persistence.threadId, { + if (this.config.memory && options?.persistence?.threadId) { + const memMessages = await this.config.memory.getMessages(options.persistence.threadId, { limit: this.config.lastMessages ?? 10, }); if (memMessages.length > 0) { @@ -422,12 +419,17 @@ export class AgentRuntime { } // Semantic recall — retrieve relevant past messages beyond the history window - if (this.config.semanticRecall && persistence?.threadId) { - await this.performSemanticRecall(list, input, persistence.threadId, persistence.resourceId); + if (this.config.semanticRecall && options?.persistence?.threadId) { + await this.performSemanticRecall( + list, + input, + options.persistence.threadId, + options.persistence.resourceId, + ); } // Attach working memory to the list — forLlm() appends it to the system prompt. - await this.setListWorkingMemoryConfig(list, persistence); + await this.setListWorkingMemoryConfig(list, options?.persistence); list.addInput(input); return list; @@ -548,41 +550,51 @@ export class AgentRuntime { * emit AgentStart, fetch model cost, normalize input, and build the message list. * Throws if buildMessageList fails; callers catch and handle the error. */ - private async initRun(input: AgentMessage[] | string): Promise { - this.eventBus.resetAbort(this.executionOptions?.abortSignal); - this.updateState({ status: 'running' }); - this.emitEvent({ type: AgentEvent.AgentStart }); + private async initRun( + input: AgentMessage[] | string, + options?: RunOptions & ExecutionOptions, + ): Promise { + this.eventBus.resetAbort(options?.abortSignal); + this.updateState({ + status: 'running', + persistence: options?.persistence, + }); + this.eventBus.emit({ type: AgentEvent.AgentStart }); await this.ensureModelCost(); const normalizedInput = normalizeInput(input); - return await this.buildMessageList(normalizedInput); + return await this.buildMessageList(normalizedInput, options); } /** * Post-loop finalization for generate: apply cost, set model id, roll up sub-agent usage, * transition to success, and emit AgentEnd. Returns the finalized result. */ - private finalizeGenerate(result: GenerateResult, list: AgentMessageList): GenerateResult { - result.runId = this.runId; + private finalizeGenerate( + result: GenerateResult, + list: AgentMessageList, + runId: string, + ): GenerateResult { + result.runId = runId; result.usage = this.applyCost(result.usage); result.model = this.modelIdString; const finalized = applySubAgentUsage(result); this.updateState({ status: 'success', messageList: list.serialize() }); - this.emitEvent({ type: AgentEvent.AgentEnd, messages: finalized.messages }); - return { ...finalized, getState: () => this.getState() }; + this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: finalized.messages }); + return finalized; } - /** Resolve telemetry: own config wins, then inherited from executionOptions, then nothing. */ - private resolveTelemetry(): BuiltTelemetry | undefined { + /** Resolve telemetry: own config wins, then inherited from options, then nothing. */ + private resolveTelemetry(options?: ExecutionOptions): BuiltTelemetry | undefined { if (this.config.telemetry) return this.config.telemetry; - const inherited = this.executionOptions?.telemetry; + const inherited = options?.telemetry; if (!inherited) return undefined; return { ...inherited, functionId: this.config.name }; } /** Best-effort flush of telemetry provider. Never throws. */ - private async flushTelemetry(): Promise { + private async flushTelemetry(options?: ExecutionOptions): Promise { try { - const resolved = this.resolveTelemetry(); + const resolved = this.resolveTelemetry(options); if (resolved?.provider) { await resolved.provider.forceFlush(); } @@ -592,8 +604,8 @@ export class AgentRuntime { } /** Map resolved telemetry to AI SDK's experimental_telemetry shape. */ - private buildTelemetryOptions(): Record { - const t = this.resolveTelemetry(); + private buildTelemetryOptions(options?: ExecutionOptions): Record { + const t = this.resolveTelemetry(options); if (!t?.enabled) return {}; return { @@ -609,19 +621,18 @@ export class AgentRuntime { }; } - /** - * Core generate loop using generateText (non-streaming). - * - * @param list - Message list for this turn. Grows during the loop via addResponse(). - * @param pendingResume - When resuming a suspended run, contains the pending tool calls - * to execute before the first LLM call. - */ - private async runGenerateLoop( - list: AgentMessageList, - pendingResume?: PendingResume, - ): Promise { - const { model, toolMap, aiTools, providerOptions, hasTools, outputSpec } = - this.buildLoopContext(); + /** Core generate loop using generateText (non-streaming). */ + private async runGenerateLoop(ctx: LoopContext): Promise { + const { list, options, runId, pendingResume } = ctx; + const { + model, + toolMap, + aiTools, + providerOptions, + hasTools, + outputSpec, + effectiveInstructions, + } = this.buildLoopContext({ ...options, persistence: options?.persistence }); let totalUsage: TokenUsage | undefined; let lastFinishReason: FinishReason = 'stop'; @@ -630,14 +641,11 @@ export class AgentRuntime { const collectedSubAgentUsage: SubAgentUsage[] = []; // Resolve pending tool calls from a resumed run before the first LLM call. - const runTelemetry = this.resolveTelemetry(); + const runTelemetry = this.resolveTelemetry(options); + const toolCtx: ToolBatchContext = { toolMap, list, runId, telemetry: runTelemetry }; + if (pendingResume) { - const batch = await this.iteratePendingToolCallsConcurrent( - pendingResume, - toolMap, - list, - runTelemetry, - ); + const batch = await this.iteratePendingToolCallsConcurrent({ ...toolCtx, pendingResume }); for (const r of batch.results) { toolCallSummary.push(r.toolEntry); @@ -645,7 +653,13 @@ export class AgentRuntime { } if (Object.keys(batch.pending).length > 0) { - const suspendRunId = await this.persistSuspension(batch.pending, list, totalUsage); + const suspendRunId = await this.persistSuspension( + batch.pending, + options, + list, + totalUsage, + runId, + ); return { runId: suspendRunId, messages: list.responseDelta(), @@ -659,30 +673,29 @@ export class AgentRuntime { suspendPayload: s.payload, resumeSchema: s.resumeSchema, })), - getState: () => this.getState(), }; } } - const maxIterations = this.executionOptions?.maxIterations ?? MAX_LOOP_ITERATIONS; + const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS; for (let i = 0; i < maxIterations; i++) { if (this.eventBus.isAborted) { this.updateState({ status: 'cancelled' }); throw new Error('Agent run was aborted'); } - this.emitEvent({ type: AgentEvent.TurnStart }); + this.eventBus.emit({ type: AgentEvent.TurnStart }); const result = await generateText({ model, - messages: list.forLlm(this.config.instructions, this.config.instructionProviderOptions), + messages: list.forLlm(effectiveInstructions, this.config.instructionProviderOptions), abortSignal: this.eventBus.signal, ...(hasTools ? { tools: aiTools } : {}), ...(providerOptions ? { providerOptions: providerOptions as Record } : {}), ...(outputSpec ? { output: outputSpec } : {}), - ...this.buildTelemetryOptions(), + ...this.buildTelemetryOptions(options), }); const aiFinishReason = result.finishReason; @@ -698,16 +711,14 @@ export class AgentRuntime { if (outputSpec) { structuredOutput = result.output; } - this.emitTurnEnd(newMessages, extractToolResults(newMessages)); + this.emitTurnEnd(newMessages, extractSettledToolCalls(newMessages)); break; } - const batch = await this.iterateToolCallsConcurrent( - result.toolCalls, - toolMap, - list, - runTelemetry, - ); + const batch = await this.iterateToolCallsConcurrent({ + ...toolCtx, + toolCalls: result.toolCalls, + }); for (const r of batch.results) { toolCallSummary.push(r.toolEntry); @@ -715,7 +726,13 @@ export class AgentRuntime { } if (Object.keys(batch.pending).length > 0) { - const suspendRunId = await this.persistSuspension(batch.pending, list, totalUsage); + const suspendRunId = await this.persistSuspension( + batch.pending, + options, + list, + totalUsage, + runId, + ); return { runId: suspendRunId, messages: list.responseDelta(), @@ -729,12 +746,11 @@ export class AgentRuntime { suspendPayload: s.payload, resumeSchema: s.resumeSchema, })), - getState: () => this.getState(), }; } // Emit TurnEnd after all tool calls in this iteration are processed - this.emitTurnEnd(newMessages, extractToolResults(list.responseDelta())); + this.emitTurnEnd(newMessages, extractSettledToolCalls(list.responseDelta())); } if (lastFinishReason === 'tool-calls') { @@ -743,77 +759,95 @@ export class AgentRuntime { ); } - await this.saveToMemory(list); - await this.flushTelemetry(); + await this.saveToMemory(list, options); + await this.flushTelemetry(options); - const persistence = this.currentState.persistence; - if (this.config.titleGeneration && persistence?.threadId && this.config.memory) { - void generateThreadTitle({ + if (this.config.titleGeneration && options?.persistence?.threadId && this.config.memory) { + const titlePromise = generateThreadTitle({ memory: this.config.memory, - threadId: persistence.threadId, - resourceId: persistence.resourceId, + threadId: options.persistence.threadId, + resourceId: options.persistence.resourceId, titleConfig: this.config.titleGeneration, agentModel: this.config.model, turnDelta: list.turnDelta(), }); + if (this.config.titleGeneration.sync) { + await titlePromise; + } } return { - runId: this.runId, + runId: runId ?? '', messages: list.responseDelta(), finishReason: lastFinishReason, usage: totalUsage, ...(structuredOutput !== undefined && { structuredOutput }), ...(toolCallSummary.length > 0 && { toolCalls: toolCallSummary }), ...(collectedSubAgentUsage.length > 0 && { subAgentUsage: collectedSubAgentUsage }), - getState: () => this.getState(), }; } /** * Wire up a ReadableStream and start the stream loop asynchronously. * Returns the readable side immediately; the loop runs in the background. - * - * @param pendingResume - When resuming a suspended run, contains the pending tool calls - * to execute before the first LLM stream starts. */ - private startStreamLoop( - list: AgentMessageList, - pendingResume?: PendingResume, - ): ReadableStream { + private startStreamLoop(ctx: LoopContext): ReadableStream { + const { options, runId } = ctx; const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); - this.runStreamLoop(list, writer, pendingResume).catch(async (error: unknown) => { - await this.flushTelemetry(); - await this.cleanupRun(); - try { - await writer.write({ type: 'error', error }); - await writer.write({ type: 'finish', finishReason: 'error' }); - await writer.close(); - } catch { - writer.abort(error).catch(() => {}); - } - }); + // Bridge tool-execution lifecycle events into the stream so consumers + // can show a mid-flight indicator between the LLM's tool-call message + // and the eventual tool-result message. Writer queues writes in order + // so the fire-and-forget is safe. + const onToolExecutionStart = (data: AgentEventData): void => { + if (data.type !== AgentEvent.ToolExecutionStart) return; + // Swallow rejections: if the writer is already closed/errored (e.g. + // an abort raced ahead of the subscription cleanup) there is nothing + // useful to do with the chunk. + writer + .write({ + type: 'tool-execution-start', + toolCallId: data.toolCallId, + toolName: data.toolName, + }) + .catch(() => {}); + }; + this.eventBus.on(AgentEvent.ToolExecutionStart, onToolExecutionStart); + + this.runStreamLoop({ ...ctx, writer }) + .catch(async (error: unknown) => { + await this.flushTelemetry(options); + await this.cleanupRun(runId); + try { + await writer.write({ type: 'error', error }); + await writer.write({ type: 'finish', finishReason: 'error' }); + await writer.close(); + } catch { + writer.abort(error).catch(() => {}); + } + }) + .finally(() => { + this.eventBus.off(AgentEvent.ToolExecutionStart, onToolExecutionStart); + }); return readable; } - /** - * Core stream loop using streamText. - * - * @param list - Message list for this turn. Grows during the loop via addResponse(). - * @param writer - Stream writer to emit StreamChunks to the consumer. - * @param pendingResume - When resuming a suspended run, contains the pending tool calls - * to execute before the first LLM call. - */ + /** Core stream loop using streamText. */ private async runStreamLoop( - list: AgentMessageList, - writer: WritableStreamDefaultWriter, - pendingResume?: PendingResume, + ctx: LoopContext & { writer: WritableStreamDefaultWriter }, ): Promise { - const { model, toolMap, aiTools, providerOptions, hasTools, outputSpec } = - this.buildLoopContext(); + const { list, options, runId, pendingResume, writer } = ctx; + const { + model, + toolMap, + aiTools, + providerOptions, + hasTools, + outputSpec, + effectiveInstructions, + } = this.buildLoopContext({ ...options, persistence: options?.persistence }); const writeChunk = async (chunk: StreamChunk): Promise => { await writer.write(chunk); @@ -823,10 +857,10 @@ export class AgentRuntime { let lastFinishReason: FinishReason = 'stop'; let structuredOutput: unknown; const collectedSubAgentUsage: SubAgentUsage[] = []; - const maxIterations = this.executionOptions?.maxIterations ?? MAX_LOOP_ITERATIONS; + const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS; const closeStreamWithError = async (error: unknown, status: AgentRunState): Promise => { - await this.cleanupRun(); + await this.cleanupRun(runId); this.updateState({ status }); await writer.write({ type: 'error', error }); await writer.write({ type: 'finish', finishReason: 'error' }); @@ -840,21 +874,22 @@ export class AgentRuntime { }; // Resolve pending tool calls from a resumed run before the first LLM call. - const runTelemetry = this.resolveTelemetry(); + const runTelemetry = this.resolveTelemetry(options); + const toolCtx: ToolBatchContext = { toolMap, list, runId, telemetry: runTelemetry }; if (pendingResume) { try { - const batch = await this.iteratePendingToolCallsConcurrent( + const batch = await this.iteratePendingToolCallsConcurrent({ + ...toolCtx, pendingResume, - toolMap, - list, - runTelemetry, - ); + }); for (const r of batch.results) { if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage); await writer.write({ - type: 'message', - message: r.message, + type: 'tool-result', + toolCallId: r.toolCallId, + toolName: r.toolName, + output: r.modelOutput, }); if (r.customMessage) { await writer.write({ type: 'message', message: r.customMessage }); @@ -863,13 +898,22 @@ export class AgentRuntime { for (const e of batch.errors) { await writer.write({ - type: 'message', - message: e.message, + type: 'tool-result', + toolCallId: e.toolCallId, + toolName: e.toolName, + output: e.error, + isError: true, }); } if (Object.keys(batch.pending).length > 0) { - const suspendRunId = await this.persistSuspension(batch.pending, list, totalUsage); + const suspendRunId = await this.persistSuspension( + batch.pending, + options, + list, + totalUsage, + runId, + ); for (const s of batch.suspensions) { await writer.write({ type: 'tool-call-suspended', @@ -886,7 +930,7 @@ export class AgentRuntime { return; } } catch (error) { - this.emitEvent({ type: AgentEvent.Error, message: String(error), error }); + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); await closeStreamWithError(error, 'failed'); return; } @@ -895,18 +939,18 @@ export class AgentRuntime { for (let i = 0; i < maxIterations; i++) { if (await handleAbort()) return; - this.emitEvent({ type: AgentEvent.TurnStart }); - + this.eventBus.emit({ type: AgentEvent.TurnStart }); + const messages = list.forLlm(effectiveInstructions, this.config.instructionProviderOptions); const result = streamText({ model, - messages: list.forLlm(this.config.instructions, this.config.instructionProviderOptions), + messages, abortSignal: this.eventBus.signal, ...(hasTools ? { tools: aiTools } : {}), ...(providerOptions ? { providerOptions: providerOptions as Record } : {}), ...(outputSpec ? { output: outputSpec } : {}), - ...this.buildTelemetryOptions(), + ...this.buildTelemetryOptions(options), }); // Consume the stream. When the AbortSignal fires mid-stream the @@ -914,13 +958,17 @@ export class AgentRuntime { // We catch that here and close the consumer stream with an error chunk. try { for await (const chunk of result.fullStream) { - if (chunk.type === 'finish' || chunk.type === 'finish-step') continue; + // Filter only the SDK's terminal `finish` chunk — the runtime + // emits its own consolidated `finish` after the loop completes. + // `start-step` / `finish-step` are passed through so consumers + // can use them as LLM-iteration boundaries. + if (chunk.type === 'finish') continue; const converted = convertChunk(chunk); if (converted) await writeChunk(converted); } } catch (streamError) { if (await handleAbort()) return; - this.emitEvent({ + this.eventBus.emit({ type: AgentEvent.Error, message: String(streamError), error: streamError, @@ -947,22 +995,24 @@ export class AgentRuntime { if (outputSpec) { structuredOutput = await result.output; } - this.emitTurnEnd(newMessages, extractToolResults(newMessages)); + this.emitTurnEnd(newMessages, extractSettledToolCalls(newMessages)); break; } const toolCalls = await result.toolCalls; try { - const batch = await this.iterateToolCallsConcurrent(toolCalls, toolMap, list, runTelemetry); + const batch = await this.iterateToolCallsConcurrent({ ...toolCtx, toolCalls }); if (await handleAbort()) return; for (const r of batch.results) { if (r.subAgentUsage) collectedSubAgentUsage.push(...r.subAgentUsage); await writer.write({ - type: 'message', - message: r.message, + type: 'tool-result', + toolCallId: r.toolCallId, + toolName: r.toolName, + output: r.modelOutput, }); if (r.customMessage) { await writer.write({ type: 'message', message: r.customMessage }); @@ -971,13 +1021,22 @@ export class AgentRuntime { for (const e of batch.errors) { await writer.write({ - type: 'message', - message: e.message, + type: 'tool-result', + toolCallId: e.toolCallId, + toolName: e.toolName, + output: e.error, + isError: true, }); } if (Object.keys(batch.pending).length > 0) { - const suspendRunId = await this.persistSuspension(batch.pending, list, totalUsage); + const suspendRunId = await this.persistSuspension( + batch.pending, + options, + list, + totalUsage, + runId, + ); for (const s of batch.suspensions) { await writer.write({ type: 'tool-call-suspended', @@ -994,13 +1053,13 @@ export class AgentRuntime { return; } } catch (error) { - this.emitEvent({ type: AgentEvent.Error, message: String(error), error }); + this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }); await closeStreamWithError(error, 'failed'); return; } // Emit TurnEnd after all tool calls in this iteration are processed - this.emitTurnEnd(newMessages, extractToolResults(list.responseDelta())); + this.emitTurnEnd(newMessages, extractSettledToolCalls(list.responseDelta())); } const costUsage = this.applyCost(totalUsage); @@ -1019,46 +1078,54 @@ export class AgentRuntime { }); try { - await this.saveToMemory(list); + await this.saveToMemory(list, options); - const persistence = this.currentState.persistence; - if (this.config.titleGeneration && persistence && this.config.memory) { - void generateThreadTitle({ + if (this.config.titleGeneration && options?.persistence && this.config.memory) { + const titlePromise = generateThreadTitle({ memory: this.config.memory, - threadId: persistence.threadId, - resourceId: persistence.resourceId, + threadId: options.persistence.threadId, + resourceId: options.persistence.resourceId, titleConfig: this.config.titleGeneration, agentModel: this.config.model, turnDelta: list.turnDelta(), }); + if (this.config.titleGeneration.sync) { + await titlePromise; + } } - await this.cleanupRun(); - await this.flushTelemetry(); + await this.cleanupRun(runId); + await this.flushTelemetry(options); this.updateState({ status: 'success', messageList: list.serialize() }); - this.emitEvent({ type: AgentEvent.AgentEnd, messages: list.responseDelta() }); + this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: list.responseDelta() }); } finally { await writer.close(); } } /** Persist the current-turn delta to memory. */ - private async saveToMemory(list: AgentMessageList): Promise { - const persistence = this.currentState.persistence; - if (!this.config.memory || !persistence) return; + private async saveToMemory( + list: AgentMessageList, + options: RunOptions | undefined, + ): Promise { + if (!this.config.memory || !options?.persistence) return; const delta = list.turnDelta(); if (delta.length === 0) return; await saveMessagesToThread( this.config.memory, - persistence.threadId, - persistence.resourceId, + options.persistence.threadId, + options.persistence.resourceId, delta, ); // Generate and save embeddings if semantic recall is configured if (this.config.semanticRecall?.embedder && this.config.memory.saveEmbeddings) { - await this.saveEmbeddingsForMessages(persistence.threadId, persistence.resourceId, delta); + await this.saveEmbeddingsForMessages( + options.persistence.threadId, + options.persistence.resourceId, + delta, + ); } } @@ -1186,16 +1253,16 @@ export class AgentRuntime { * even if one throws, then re-throws the first error. */ private async iterateToolCallsConcurrent( - toolCalls: Array<{ - toolCallId: string; - toolName: string; - input: unknown; - providerExecuted?: boolean; - }>, - toolMap: Map, - list: AgentMessageList, - resolvedTelemetry?: BuiltTelemetry, + ctx: ToolBatchContext & { + toolCalls: Array<{ + toolCallId: string; + toolName: string; + input: unknown; + providerExecuted?: boolean; + }>; + }, ): Promise { + const { toolCalls, toolMap, list, runId, telemetry: resolvedTelemetry } = ctx; const executableCalls = toolCalls.filter((tc) => !tc.providerExecuted); const executableCallsById = new Map(executableCalls.map((tc) => [tc.toolCallId, tc])); const unexecutedIds = new Set(executableCalls.map((tc) => tc.toolCallId)); @@ -1240,12 +1307,12 @@ export class AgentRuntime { const toolInput = tc.input as JSONValue; if (result.status === 'rejected') { + list.setToolCallError(tc.toolCallId, result.reason); errors.push({ toolCallId: tc.toolCallId, toolName: tc.toolName, input: toolInput, error: result.reason, - message: makeErrorToolResultMessage(tc.toolCallId, tc.toolName, result.reason), }); } else if (result.value.outcome === 'suspended') { hasSuspension = true; @@ -1263,6 +1330,7 @@ export class AgentRuntime { input: toolInput, suspendPayload: result.value.payload, resumeSchema: result.value.resumeSchema, + runId, }; } else if (result.value.outcome === 'success') { results.push({ @@ -1270,9 +1338,9 @@ export class AgentRuntime { toolName: tc.toolName, input: toolInput, toolEntry: result.value.toolEntry, + modelOutput: result.value.modelOutput, subAgentUsage: result.value.subAgentUsage, customMessage: result.value.customMessage, - message: result.value.message, }); } else if (result.value.outcome === 'error') { errors.push({ @@ -1280,7 +1348,6 @@ export class AgentRuntime { toolName: tc.toolName, input: toolInput, error: result.value.error, - message: result.value.message, }); } else if (result.value.outcome === 'noop') { // noop @@ -1315,11 +1382,9 @@ export class AgentRuntime { * Returns a `ToolCallBatchResult` — the caller handles persistence. */ private async iteratePendingToolCallsConcurrent( - pendingResume: PendingResume, - toolMap: Map, - list: AgentMessageList, - resolvedTelemetry?: BuiltTelemetry, + ctx: ToolBatchContext & { pendingResume: PendingResume }, ): Promise { + const { pendingResume, toolMap, list, runId, telemetry: resolvedTelemetry } = ctx; const resumedId = pendingResume.resumeToolCallId; const resumedEntry = pendingResume.pendingToolCalls[resumedId]; if (!resumedEntry) { @@ -1349,6 +1414,7 @@ export class AgentRuntime { suspended: true, suspendPayload: processResult.payload, resumeSchema: processResult.resumeSchema, + runId, }; suspensions.push({ toolCallId: resumedId, @@ -1363,9 +1429,9 @@ export class AgentRuntime { toolName: resumedToolName, input: resumedEntry.input, toolEntry: processResult.toolEntry, + modelOutput: processResult.modelOutput, subAgentUsage: processResult.subAgentUsage, customMessage: processResult.customMessage, - message: processResult.message, }); } else if (processResult.outcome === 'error') { errors.push({ @@ -1373,7 +1439,6 @@ export class AgentRuntime { toolName: resumedToolName, input: resumedEntry.input, error: processResult.error, - message: processResult.message, }); } else if (processResult.outcome === 'noop') { // noop @@ -1409,12 +1474,13 @@ export class AgentRuntime { // Execute unexecuted tools via iterateToolCallsConcurrent if (unexecuted.length > 0) { - const batch = await this.iterateToolCallsConcurrent( - unexecuted, + const batch = await this.iterateToolCallsConcurrent({ + toolCalls: unexecuted, toolMap, list, - resolvedTelemetry, - ); + runId, + telemetry: resolvedTelemetry, + }); results.push(...batch.results); suspensions.push(...batch.suspensions); errors.push(...batch.errors); @@ -1445,7 +1511,7 @@ export class AgentRuntime { ): Promise { const builtTool = toolMap.get(toolName); - this.emitEvent({ + this.eventBus.emit({ type: AgentEvent.ToolExecutionStart, toolCallId, toolName, @@ -1453,56 +1519,55 @@ export class AgentRuntime { }); const makeToolError = (error: unknown): ToolCallOutcome => { - this.emitEvent({ + this.eventBus.emit({ type: AgentEvent.ToolExecutionEnd, toolCallId, toolName, result: error, isError: true, }); - const errorMsg = makeErrorToolResultMessage(toolCallId, toolName, error); - list.addResponse([errorMsg]); - return { outcome: 'error', error, message: errorMsg }; + list.setToolCallError(toolCallId, error); + return { outcome: 'error', error }; }; if (!builtTool) { return makeToolError(new Error(`Tool ${toolName} not found`)); } - // AI SDK automatically parses tool input and creates a tool-result message for it. - // If the tool-result message is an error, we don't need to execute the tool again. - const existingToolResults = list + // Check if this tool-call block was already settled (e.g. by provider-executed tools). + // If so, emit ToolExecutionEnd and skip re-execution. + type SettledToolCall = ContentToolCall & { state: 'resolved' | 'rejected' }; + const settledBlock = list .responseDelta() - .filter((m) => isLlmMessage(m) && m.role === 'tool') - .flatMap((m) => (m as Message).content.filter((content) => content.type === 'tool-result')); - const existingToolResult = existingToolResults.find((r) => r.toolCallId === toolCallId); + .flatMap((m) => (isLlmMessage(m) && 'content' in m ? (m as Message).content : [])) + .find( + (c): c is SettledToolCall => + c.type === 'tool-call' && c.toolCallId === toolCallId && c.state !== 'pending', + ); - if (existingToolResult) { - this.emitEvent({ + if (settledBlock) { + let settledResult: unknown; + if (settledBlock.state === 'resolved') { + settledResult = settledBlock.output; + } else { + settledResult = settledBlock.error; + } + this.eventBus.emit({ type: AgentEvent.ToolExecutionEnd, toolCallId, toolName, - result: existingToolResult.result, - isError: !!existingToolResult.isError, + result: settledResult, + isError: settledBlock.state === 'rejected', }); return { outcome: 'noop' }; } if (builtTool.inputSchema) { - if (isZodSchema(builtTool.inputSchema)) { - const result = await builtTool.inputSchema.safeParseAsync(toolInput); - if (!result.success) { - return makeToolError(new Error(`Invalid tool input: ${result.error.message}`)); - } - toolInput = result.data as JSONValue; - } else { - const validate = ajv.compile(builtTool.inputSchema); - const valid = validate(toolInput); - if (!valid) { - const message = ajv.errorsText(validate.errors); - return makeToolError(new Error(`Invalid tool input: ${message}`)); - } + const result = await parseWithSchema(builtTool.inputSchema, toolInput); + if (!result.success) { + return makeToolError(new Error(`Invalid tool input: ${result.error}`)); } + toolInput = result.data as JSONValue; } let toolResult: unknown; @@ -1514,9 +1579,9 @@ export class AgentRuntime { if (isSuspendedToolResult(toolResult)) { if (builtTool?.suspendSchema) { - const parseResult = await builtTool.suspendSchema.safeParseAsync(toolResult.payload); + const parseResult = await parseWithSchema(builtTool.suspendSchema, toolResult.payload); if (!parseResult.success) { - return makeToolError(new Error(`Invalid suspend payload: ${parseResult.error.message}`)); + return makeToolError(new Error(`Invalid suspend payload: ${parseResult.error}`)); } toolResult.payload = parseResult.data as JSONValue; } @@ -1524,8 +1589,17 @@ export class AgentRuntime { const error = new Error(`Tool ${toolName} has no resume schema`); return makeToolError(error); } - const resumeSchema = zodToJsonSchema(builtTool.resumeSchema); - return { outcome: 'suspended', payload: toolResult.payload, resumeSchema }; + const resumeSchema = isZodSchema(builtTool.resumeSchema) + ? zodToJsonSchema(builtTool.resumeSchema) + : builtTool.resumeSchema; + if (!resumeSchema) { + return makeToolError(new Error('Invalid resume schema')); + } + return { + outcome: 'suspended', + payload: toolResult.payload, + resumeSchema, + }; } let actualResult = toolResult; @@ -1535,7 +1609,7 @@ export class AgentRuntime { extractedSubAgentUsage = toolResult.subAgentUsage; } - this.emitEvent({ + this.eventBus.emit({ type: AgentEvent.ToolExecutionEnd, toolCallId, toolName, @@ -1549,8 +1623,7 @@ export class AgentRuntime { ? builtTool.toModelOutput(actualResult) : actualResult; - const toolResultMsg = makeToolResultMessage(toolCallId, toolName, modelResult); - list.addResponse([toolResultMsg]); + list.setToolCallResult(toolCallId, modelResult as JSONValue); const customMessage = builtTool?.toMessage?.(actualResult); if (customMessage) { @@ -1565,35 +1638,58 @@ export class AgentRuntime { output: actualResult, transformed: !!builtTool.toModelOutput, }, + modelOutput: modelResult, subAgentUsage: extractedSubAgentUsage, customMessage, - message: toolResultMsg, }; } /** Build common LLM call dependencies shared by both the generate and stream loops. */ - private buildLoopContext() { - const wmTool = this.buildWorkingMemoryToolForRun(this.currentState.persistence); + private buildLoopContext( + execOptions?: ExecutionOptions & { persistence?: AgentPersistenceOptions }, + ) { + const wmTool = this.buildWorkingMemoryToolForRun(execOptions?.persistence); const allUserTools = wmTool ? [...(this.config.tools ?? []), wmTool] : (this.config.tools ?? []); const aiTools = toAiSdkTools(allUserTools); const aiProviderTools = toAiSdkProviderTools(this.config.providerTools); const allTools = { ...aiTools, ...aiProviderTools }; + const model = createModel(this.config.model); return { - model: createModel(this.config.model), + model, toolMap: buildToolMap(allUserTools), aiTools: allTools, - providerOptions: this.buildCallProviderOptions(this.executionOptions?.providerOptions), + providerOptions: this.buildCallProviderOptions(execOptions?.providerOptions), hasTools: Object.keys(allTools).length > 0, outputSpec: this.config.structuredOutput ? Output.object({ schema: this.config.structuredOutput }) : undefined, + effectiveInstructions: this.composeEffectiveInstructions(allUserTools), }; } /** - * Build the updateWorkingMemory BuiltTool for the current run. + * Merge tool-attached `systemInstruction` fragments into the agent's + * configured instructions. Fragments are wrapped in a single + * `` block, prepended above the user's instructions so + * the user's text remains the dominant tail of the prompt and can still + * override defaults if needed. + */ + private composeEffectiveInstructions(tools: BuiltTool[]): string { + const fragments = tools + .map((t) => t.systemInstruction) + .filter((s): s is string => typeof s === 'string' && s.trim().length > 0); + + const userInstructions = this.config.instructions; + if (fragments.length === 0) return userInstructions; + + const block = `\n${fragments.map((f) => `- ${f}`).join('\n')}\n`; + return userInstructions ? `${block}\n\n${userInstructions}` : block; + } + + /** + * Build the update_working_memory BuiltTool for the current run. * Returns undefined when working memory is not configured or persistence is unavailable. */ private buildWorkingMemoryToolForRun(persistence: AgentPersistenceOptions | undefined) { @@ -1608,45 +1704,47 @@ export class AgentRuntime { /** * Persist a suspended run state and update the current state snapshot. - * Returns the runId (reuses this.runId when resuming to prevent dangling runs). + * Returns the runId (reuses existingRunId when resuming to prevent dangling runs). */ private async persistSuspension( pendingToolCalls: Record, + options: (RunOptions & ExecutionOptions) | undefined, list: AgentMessageList, totalUsage: TokenUsage | undefined, + existingRunId?: string, ): Promise { + const runId = existingRunId ?? generateRunId(); + // Only persist maxIterations. providerOptions are intentionally excluded // because they may contain sensitive data (API keys, auth headers). const executionOptions: PersistedExecutionOptions | undefined = - this.executionOptions?.maxIterations !== undefined - ? { maxIterations: this.executionOptions.maxIterations } - : undefined; + options?.maxIterations !== undefined ? { maxIterations: options.maxIterations } : undefined; const state: SerializableAgentState = { - persistence: this.currentState.persistence, + persistence: options?.persistence, status: 'suspended', messageList: list.serialize(), pendingToolCalls, usage: totalUsage, executionOptions, }; - await this.runState.suspend(this.runId, state); + await this.runState.suspend(runId, state); this.updateState({ status: 'suspended', pendingToolCalls, messageList: list.serialize() }); - return this.runId; + return runId; } /** Clean up stored state for a run when it finishes without re-suspending. */ - private async cleanupRun(): Promise { - if (this.runId) { - await this.runState.complete(this.runId); + private async cleanupRun(runId: string | undefined): Promise { + if (runId) { + await this.runState.complete(runId); } } /** Emit a TurnEnd event when an assistant message is present in `newMessages`. */ - private emitTurnEnd(newMessages: AgentMessage[], toolResults: ContentToolResult[]): void { + private emitTurnEnd(newMessages: AgentMessage[], toolResults: ContentToolCall[]): void { const assistantMsg = newMessages.find((m) => 'role' in m && m.role === 'assistant'); if (assistantMsg) { - this.emitEvent({ type: AgentEvent.TurnEnd, message: assistantMsg, toolResults }); + this.eventBus.emit({ type: AgentEvent.TurnEnd, message: assistantMsg, toolResults }); } } @@ -1705,10 +1803,6 @@ export class AgentRuntime { }; } - private emitEvent(data: AgentEventSpecificData): void { - this.eventBus.emit({ ...data, runId: this.runId }); - } - private resolveWorkingMemoryParams(options: AgentPersistenceOptions | undefined) { if (!options) return null; if (!this.config.workingMemory) return null; diff --git a/packages/@n8n/agents/src/runtime/event-bus.ts b/packages/@n8n/agents/src/runtime/event-bus.ts index 03f88e3eb83..c44d002339e 100644 --- a/packages/@n8n/agents/src/runtime/event-bus.ts +++ b/packages/@n8n/agents/src/runtime/event-bus.ts @@ -32,6 +32,10 @@ export class AgentEventBus { set.add(handler); } + off(event: AgentEvent, handler: AgentEventHandler): void { + this.handlers.get(event)?.delete(handler); + } + emit(data: AgentEventData): void { const set = this.handlers.get(data.type); if (!set) return; diff --git a/packages/@n8n/agents/src/runtime/memory-store.ts b/packages/@n8n/agents/src/runtime/memory-store.ts index 3b449721c97..a6e16e0b108 100644 --- a/packages/@n8n/agents/src/runtime/memory-store.ts +++ b/packages/@n8n/agents/src/runtime/memory-store.ts @@ -1,4 +1,4 @@ -import type { BuiltMemory, Thread } from '../types'; +import type { BuiltMemory, MemoryDescriptor, Thread } from '../types'; import type { AgentDbMessage } from '../types/sdk/message'; interface StoredMessage { @@ -78,6 +78,8 @@ export class InMemoryMemory implements BuiltMemory { /** * Save messages to the thread established by the most recent `saveThread` call. * Always call `saveThread` before `saveMessages` to set the thread context. + * Upserts by message id — if a message with the same id already exists, it is + * replaced in place (preserving insertion order). New messages are appended. */ // eslint-disable-next-line @typescript-eslint/require-await async saveMessages(args: { @@ -86,8 +88,16 @@ export class InMemoryMemory implements BuiltMemory { messages: AgentDbMessage[]; }): Promise { const existing = this.messagesByThread.get(args.threadId) ?? []; + const byId = new Map(existing.map((s, i) => [s.message.id, i])); for (const msg of args.messages) { - existing.push({ message: msg, createdAt: msg.createdAt }); + const entry: StoredMessage = { message: msg, createdAt: msg.createdAt }; + const idx = byId.get(msg.id); + if (idx !== undefined) { + existing[idx] = entry; + } else { + byId.set(msg.id, existing.length); + existing.push(entry); + } } this.messagesByThread.set(args.threadId, existing); } @@ -102,6 +112,10 @@ export class InMemoryMemory implements BuiltMemory { ); } } + + describe(): MemoryDescriptor { + return { name: 'memory', constructorName: this.constructor.name, connectionParams: {} }; + } } /** diff --git a/packages/@n8n/agents/src/runtime/message-list.ts b/packages/@n8n/agents/src/runtime/message-list.ts index 05a45608f85..5a878e63021 100644 --- a/packages/@n8n/agents/src/runtime/message-list.ts +++ b/packages/@n8n/agents/src/runtime/message-list.ts @@ -2,11 +2,13 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils'; import type { ModelMessage } from 'ai'; import { toAiMessages } from './messages'; +import { stringifyError } from './runtime-helpers'; import { stripOrphanedToolMessages } from './strip-orphaned-tool-messages'; import { buildWorkingMemoryInstruction } from './working-memory'; import { filterLlmMessages, getCreatedAt } from '../sdk/message'; import type { SerializedMessageList } from '../types/runtime/message-list'; -import type { AgentDbMessage, AgentMessage } from '../types/sdk/message'; +import type { AgentDbMessage, AgentMessage, ContentToolCall } from '../types/sdk/message'; +import type { JSONValue } from '../types/utils/json'; export type { SerializedMessageList }; @@ -134,6 +136,76 @@ export class AgentMessageList { this.sortAllByCreatedAt(); } + /** + * Locate the assistant message hosting the given toolCallId and mark the + * block as resolved with the supplied output. + * + * Returns the mutated host message, or `undefined` if the toolCallId is + * not found (internal invariant violation — caller should log/throw). + */ + setToolCallResult(toolCallId: string, output: JSONValue): AgentDbMessage | undefined { + const host = this.findToolCallHost(toolCallId); + if (!host) return undefined; + + const block = this.findToolCallBlock(host, toolCallId); + if (!block) return undefined; + + const mutableBlock = block; + mutableBlock.state = 'resolved'; + (mutableBlock as Extract).output = output; + if ('error' in mutableBlock) { + delete (mutableBlock as { error: unknown }).error; + } + + this.responseSet.add(host); + return host; + } + + /** + * Locate the assistant message hosting the given toolCallId and mark the + * block as rejected with the supplied error. + * + * Returns the mutated host message, or `undefined` if the toolCallId is + * not found (internal invariant violation — caller should log/throw). + */ + setToolCallError(toolCallId: string, error: unknown): AgentDbMessage | undefined { + const host = this.findToolCallHost(toolCallId); + if (!host) return undefined; + + const block = this.findToolCallBlock(host, toolCallId)!; + const mutableBlock = block; + mutableBlock.state = 'rejected'; + (mutableBlock as Extract).error = stringifyError(error); + if ('output' in mutableBlock) { + delete (mutableBlock as { output: unknown }).output; + } + + this.responseSet.add(host); + return host; + } + + private findToolCallHost(toolCallId: string): AgentDbMessage | undefined { + // Start from the last message and go backwards to find the host message + for (let i = this.all.length - 1; i >= 0; i--) { + const m = this.all[i]; + if ( + 'content' in m && + Array.isArray(m.content) && + m.content.some((c) => c.type === 'tool-call' && c.toolCallId === toolCallId) + ) { + return m; + } + } + return undefined; + } + + private findToolCallBlock(host: AgentDbMessage, toolCallId: string): ContentToolCall | undefined { + if (!('content' in host) || !Array.isArray(host.content)) return undefined; + return host.content.find( + (c): c is ContentToolCall => c.type === 'tool-call' && c.toolCallId === toolCallId, + ); + } + /** * Full LLM context for a generateText / streamText call. * Prepends the system prompt (with working memory appended if configured), diff --git a/packages/@n8n/agents/src/runtime/messages.ts b/packages/@n8n/agents/src/runtime/messages.ts index 2f274b7b0e3..88b2aae62a8 100644 --- a/packages/@n8n/agents/src/runtime/messages.ts +++ b/packages/@n8n/agents/src/runtime/messages.ts @@ -17,7 +17,6 @@ import type { ContentReasoning, ContentText, ContentToolCall, - ContentToolResult, Message, MessageContent, } from '../types/sdk/message'; @@ -54,10 +53,6 @@ function isToolCall(block: MessageContent): block is ContentToolCall { return block.type === 'tool-call'; } -function isToolResult(block: MessageContent): block is ContentToolResult { - return block.type === 'tool-result'; -} - /** * Parse a JSONValue that may be a stringified JSON object back into * its parsed form. Non-string values pass through unchanged. @@ -92,32 +87,6 @@ function toAiContent(block: MessageContent): AiContentPart | undefined { input: parseJsonValue(block.input), providerExecuted: block.providerExecuted, }; - } - if (isToolResult(block)) { - if (block.isError) { - if (typeof block.result === 'string') { - base = { - type: 'tool-result', - toolCallId: block.toolCallId, - toolName: block.toolName, - output: { type: 'error-text', value: block.result }, - }; - } else { - base = { - type: 'tool-result', - toolCallId: block.toolCallId, - toolName: block.toolName, - output: { type: 'error-json', value: block.result }, - }; - } - } else { - base = { - type: 'tool-result', - toolCallId: block.toolCallId, - toolName: block.toolName, - output: { type: 'json', value: block.result }, - }; - } } else if (isReasoning(block)) { base = { type: 'reasoning', text: block.text }; } @@ -128,6 +97,36 @@ function toAiContent(block: MessageContent): AiContentPart | undefined { return base; } +/** Build an AI SDK ToolResultPart from a resolved/rejected ContentToolCall. */ +function toolCallToResultPart( + block: ContentToolCall & { state: 'resolved' | 'rejected' }, +): ToolResultPart { + if (block.state === 'resolved') { + return { + type: 'tool-result', + toolCallId: block.toolCallId, + toolName: block.toolName, + output: { type: 'json', value: block.output }, + }; + } + // rejected + const errorValue = block.error; + if (typeof errorValue === 'string') { + return { + type: 'tool-result', + toolCallId: block.toolCallId, + toolName: block.toolName, + output: { type: 'error-text', value: errorValue }, + }; + } + return { + type: 'tool-result', + toolCallId: block.toolCallId, + toolName: block.toolName, + output: { type: 'error-json', value: errorValue as JSONValue }, + }; +} + /** Convert a single AI SDK content part to an n8n MessageContent block. */ function fromAiContent(part: AiContentPart): MessageContent | undefined { const providerOptions = 'providerOptions' in part ? part.providerOptions : undefined; @@ -159,35 +158,11 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined { toolName: part.toolName, input: part.input as JSONValue, providerExecuted: part.providerExecuted, + state: 'pending', }; break; - case 'tool-result': { - const { output } = part; - let result: JSONValue; - let isError: boolean | undefined; - if (output.type === 'json') { - result = output.value; - } else if (output.type === 'text') { - result = output.value; - } else if (output.type === 'error-json') { - result = output.value; - isError = true; - } else if (output.type === 'error-text') { - result = output.value; - isError = true; - } else { - result = null; - isError = true; - } - base = { - type: 'tool-result', - toolCallId: part.toolCallId, - toolName: part.toolName, - result, - isError, - }; - break; - } + case 'tool-result': + return undefined; // Ignore these types, because HITL is handled by our runtime case 'tool-approval-request': case 'tool-approval-response': @@ -201,82 +176,172 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined { return base; } -/** Convert a single n8n Message to an AI SDK ModelMessage. */ -export function toAiMessage(msg: Message): ModelMessage { - let base: ModelMessage; +/** + * Convert a single n8n Message to one or more AI SDK ModelMessages. + * + * For assistant messages with resolved/rejected tool-call blocks, this emits: + * 1. The assistant ModelMessage (tool-call parts only, no result fields) + * 2. One tool ModelMessage per settled tool-call block (resolved or rejected) + * + * Pending tool-call blocks are silently skipped (defense-in-depth; the strip + * step should already have removed them before forLlm() calls toAiMessages). + */ +function toAiMessageList(msg: Message): ModelMessage[] { switch (msg.role) { case 'system': { const text = msg.content .filter(isText) .map((b) => b.text) .join(''); - base = { role: 'system', content: text }; - break; + const base: ModelMessage = { role: 'system', content: text }; + return [msg.providerOptions ? { ...base, providerOptions: msg.providerOptions } : base]; } case 'user': { const parts = msg.content .map(toAiContent) .filter((p): p is TextPart | FilePart => p?.type === 'text' || p?.type === 'file'); - base = { role: 'user', content: parts }; - break; + const base: ModelMessage = { role: 'user', content: parts }; + return [msg.providerOptions ? { ...base, providerOptions: msg.providerOptions } : base]; } case 'assistant': { - const parts = msg.content - .map(toAiContent) - .filter( - (p): p is TextPart | ReasoningPart | ToolCallPart | ToolResultPart | FilePart => - p?.type === 'text' || - p?.type === 'reasoning' || - p?.type === 'tool-call' || - p?.type === 'tool-result' || - p?.type === 'file', - ); - base = { role: 'assistant', content: parts }; - break; + const assistantParts: AiContentPart[] = []; + const resultMessages: ModelMessage[] = []; + + for (const block of msg.content) { + if (block.type === 'tool-call') { + if (!('state' in block)) { + // Legacy DB block - skip it + continue; + } + if (block.state === 'pending') { + // Skip pending blocks — defense-in-depth (strip step removes them first) + continue; + } + // Emit tool-call part (without result fields) + assistantParts.push({ + type: 'tool-call', + toolCallId: block.toolCallId, + toolName: block.toolName, + input: parseJsonValue(block.input), + providerExecuted: block.providerExecuted, + }); + // Emit corresponding tool-result message immediately after + const resultPart = toolCallToResultPart(block); + resultMessages.push({ role: 'tool', content: [resultPart] }); + } else { + const part = toAiContent(block); + if (part) assistantParts.push(part); + } + } + + const transformedMessages: ModelMessage[] = []; + + if (assistantParts.length > 0) { + const assistantBase: ModelMessage = { + role: 'assistant', + content: assistantParts as Array< + TextPart | ReasoningPart | ToolCallPart | ToolResultPart | FilePart + >, + }; + const assistantMsg: ModelMessage = msg.providerOptions + ? { ...assistantBase, providerOptions: msg.providerOptions } + : assistantBase; + transformedMessages.push(assistantMsg); + } + if (resultMessages.length > 0) { + transformedMessages.push(...resultMessages); + } + + return transformedMessages; } case 'tool': { - const parts = msg.content - .map(toAiContent) - .filter((p): p is ToolResultPart => p?.type === 'tool-result'); - base = { role: 'tool', content: parts }; - break; + // Legacy role: 'tool' messages (from old DB rows). Don't emit them. + return []; } default: throw new Error(`Unknown role: ${msg.role as string}`); } - - if (msg.providerOptions) { - return { ...base, providerOptions: msg.providerOptions }; - } - return base; } /** Convert n8n Messages to AI SDK ModelMessages for passing to stream/generateText. */ export function toAiMessages(messages: Message[]): ModelMessage[] { - return messages.map(toAiMessage); + return messages.flatMap(toAiMessageList); } -/** Convert a single AI SDK ModelMessage to an n8n AgentDbMessage (with a generated id). */ -export function fromAiMessage(msg: ModelMessage): AgentMessage { - const rawContent = msg.content; - const content: MessageContent[] = - typeof rawContent === 'string' - ? [{ type: 'text', text: rawContent }] - : rawContent.map(fromAiContent).filter((p): p is MessageContent => p !== undefined); - const message: AgentMessage = { role: msg.role, content }; - if ('providerOptions' in msg && msg.providerOptions) { - message.providerOptions = msg.providerOptions; - } - return message; -} - -/** Convert AI SDK ModelMessages to n8n AgentDbMessages (each with a generated id). */ +/** + * Convert AI SDK ModelMessages to n8n AgentMessages. + * + * This is a stateful walk: when a role:'tool' ModelMessage is encountered, + * the matching tool-call block on the preceding assistant message is mutated + * to 'resolved' or 'rejected'. The tool message itself is not emitted as a + * separate n8n message. + * + * If a tool-result references a toolCallId not in the index (orphan), it is + * silently dropped. + */ export function fromAiMessages(messages: ModelMessage[]): AgentMessage[] { - return messages.map(fromAiMessage); + // Map from toolCallId → ContentToolCall block (mutable ref inside the n8n message) + const toolCallIndex = new Map(); + const result: AgentMessage[] = []; + + for (const msg of messages) { + if (msg.role === 'tool') { + // Merge tool results back into the matching tool-call blocks + const toolParts = msg.content as ToolResultPart[]; + for (const part of toolParts) { + const block = toolCallIndex.get(part.toolCallId); + if (!block) continue; // orphan — drop + + const { output } = part; + if (output.type === 'json' || output.type === 'text') { + const mutableBlock = block as Extract; + mutableBlock.state = 'resolved'; + mutableBlock.output = output.value as JSONValue; + } else if (output.type === 'error-json') { + const mutableBlock = block as Extract; + mutableBlock.state = 'rejected'; + mutableBlock.error = JSON.stringify(output.value); + } else if (output.type === 'error-text') { + const mutableBlock = block as Extract; + mutableBlock.state = 'rejected'; + mutableBlock.error = output.value; + } else { + const mutableBlock = block as Extract; + mutableBlock.state = 'rejected'; + mutableBlock.error = JSON.stringify(output); + } + } + // Do not emit a separate n8n message for tool results + continue; + } + + const rawContent = msg.content; + const content: MessageContent[] = + typeof rawContent === 'string' + ? [{ type: 'text', text: rawContent }] + : rawContent.map(fromAiContent).filter((p): p is MessageContent => p !== undefined); + + const agentMsg: AgentMessage = { role: msg.role, content }; + if ('providerOptions' in msg && msg.providerOptions) { + agentMsg.providerOptions = msg.providerOptions; + } + result.push(agentMsg); + + // Index any tool-call blocks for later merging with tool-result messages + if (msg.role === 'assistant') { + for (const block of content) { + if (block.type === 'tool-call' && block.toolCallId) { + toolCallIndex.set(block.toolCallId, block); + } + } + } + } + + return result; } export function fromAiFinishReason(reason: AiFinishReason): FinishReason { diff --git a/packages/@n8n/agents/src/runtime/model-factory.ts b/packages/@n8n/agents/src/runtime/model-factory.ts index 8d77c4f00af..553798d54a9 100644 --- a/packages/@n8n/agents/src/runtime/model-factory.ts +++ b/packages/@n8n/agents/src/runtime/model-factory.ts @@ -1,16 +1,16 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ /* eslint-disable @typescript-eslint/no-require-imports */ import type { EmbeddingModel, LanguageModel } from 'ai'; import type * as Undici from 'undici'; +import { + PROVIDER_CREDENTIAL_SCHEMAS, + type ProviderId, + type ProviderCredentials, +} from './provider-credentials'; import type { ModelConfig } from '../types/sdk/agent'; type FetchFn = typeof globalThis.fetch; -type CreateProviderFn = (opts?: { - apiKey?: string; - baseURL?: string; - fetch?: FetchFn; - headers?: Record; -}) => (model: string) => LanguageModel; type CreateEmbeddingProviderFn = (opts?: { apiKey?: string }) => { embeddingModel(model: string): EmbeddingModel; }; @@ -39,6 +39,124 @@ function getProxyFetch(): FetchFn | undefined { })) as FetchFn; } +type EntryBuilder

= ( + creds: ProviderCredentials

, + modelName: string, + fetch: FetchFn | undefined, +) => LanguageModel; + +type RegistryEntry

= { + build: EntryBuilder

; +}; + +type ProviderRegistry = { + [P in ProviderId]: RegistryEntry

; +}; + +/** + * Registry of language model providers. + * Each entry maps a provider id to a builder that loads its @ai-sdk/* package + * and instantiates the model. Credentials are Zod-validated before being passed in. + */ +const LANGUAGE_PROVIDERS: ProviderRegistry = { + openai: { + build: (creds, model, fetch) => { + const { createOpenAI } = require('@ai-sdk/openai') as typeof import('@ai-sdk/openai'); + return createOpenAI({ ...creds, fetch })(model); + }, + }, + anthropic: { + build: (creds, model, fetch) => { + const { createAnthropic } = + require('@ai-sdk/anthropic') as typeof import('@ai-sdk/anthropic'); + return createAnthropic({ ...creds, fetch })(model); + }, + }, + google: { + build: (creds, model, fetch) => { + const { createGoogleGenerativeAI } = + require('@ai-sdk/google') as typeof import('@ai-sdk/google'); + return createGoogleGenerativeAI({ ...creds, fetch })(model); + }, + }, + xai: { + build: (creds, model, fetch) => { + const { createXai } = require('@ai-sdk/xai') as typeof import('@ai-sdk/xai'); + return createXai({ ...creds, fetch })(model); + }, + }, + groq: { + build: (creds, model, fetch) => { + const { createGroq } = require('@ai-sdk/groq') as typeof import('@ai-sdk/groq'); + return createGroq({ ...creds, fetch })(model); + }, + }, + deepseek: { + build: (creds, model, fetch) => { + const { createDeepSeek } = require('@ai-sdk/deepseek') as typeof import('@ai-sdk/deepseek'); + return createDeepSeek({ ...creds, fetch })(model); + }, + }, + cohere: { + build: (creds, model, fetch) => { + const { createCohere } = require('@ai-sdk/cohere') as typeof import('@ai-sdk/cohere'); + return createCohere({ ...creds, fetch })(model); + }, + }, + mistral: { + build: (creds, model, fetch) => { + const { createMistral } = require('@ai-sdk/mistral') as typeof import('@ai-sdk/mistral'); + return createMistral({ ...creds, fetch })(model); + }, + }, + vercel: { + build: (creds, model, fetch) => { + const { createGateway } = require('@ai-sdk/gateway') as typeof import('@ai-sdk/gateway'); + return createGateway({ ...creds, fetch })(model); + }, + }, + openrouter: { + build: (creds, model, fetch) => { + const { createOpenRouter } = + require('@openrouter/ai-sdk-provider') as typeof import('@openrouter/ai-sdk-provider'); + return createOpenRouter({ apiKey: creds.apiKey, baseURL: creds.baseURL, fetch })(model); + }, + }, + 'azure-openai': { + build: (creds, model, fetch) => { + const { createAzure } = require('@ai-sdk/azure') as typeof import('@ai-sdk/azure'); + const { baseURL, resourceName, apiVersion, apiKey } = creds; + let normalizedBaseURL = baseURL; + // SDK expects url like `https://resourceName.openai.azure.com/openai` + if (normalizedBaseURL) { + const url = new URL(normalizedBaseURL); + if (!url.pathname.endsWith('/openai')) { + url.pathname = url.pathname.replace(/\/?$/, '/openai'); + normalizedBaseURL = url.toString(); + } + } + return createAzure({ resourceName, apiKey, baseURL: normalizedBaseURL, apiVersion, fetch })( + model, + ); + }, + }, + 'aws-bedrock': { + build: (creds, model, fetch) => { + const { createAmazonBedrock } = + require('@ai-sdk/amazon-bedrock') as typeof import('@ai-sdk/amazon-bedrock'); + return createAmazonBedrock({ + region: creds.region, + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + fetch, + })(model); + }, + }, +}; + +const SUPPORTED_PROVIDERS = Object.keys(LANGUAGE_PROVIDERS).join(', '); + /** * Provider packages are loaded dynamically via require() so only the * provider needed at runtime must be installed. @@ -48,55 +166,44 @@ export function createModel(config: ModelConfig): LanguageModel { return config; } - const stripEmpty = (value: T | undefined): T | undefined => { - if (!value) return undefined; - if (typeof value === 'string' && value.trim() === '') return undefined; - return value; - }; - - const modelId = stripEmpty(typeof config === 'string' ? config : config.id); - const apiKey = stripEmpty(typeof config === 'string' ? undefined : config.apiKey); - const baseURL = stripEmpty(typeof config === 'string' ? undefined : config.url); - const headers = typeof config === 'string' ? undefined : config.headers; - - if (!modelId) { + const rawId = typeof config === 'string' ? config : config.id; + if (!rawId || rawId.trim() === '') { throw new Error('Model ID is required'); } - const [provider, ...rest] = modelId.split('/'); - const modelName = rest.join('/'); - const fetch = getProxyFetch(); - - switch (provider) { - case 'anthropic': { - const { createAnthropic } = require('@ai-sdk/anthropic') as { - createAnthropic: CreateProviderFn; - }; - return createAnthropic({ apiKey, baseURL, fetch, headers })(modelName); - } - case 'openai': { - const { createOpenAI } = require('@ai-sdk/openai') as { - createOpenAI: CreateProviderFn; - }; - return createOpenAI({ apiKey, baseURL, fetch, headers })(modelName); - } - case 'google': { - const { createGoogleGenerativeAI } = require('@ai-sdk/google') as { - createGoogleGenerativeAI: CreateProviderFn; - }; - return createGoogleGenerativeAI({ apiKey, baseURL, fetch, headers })(modelName); - } - case 'xai': { - const { createXai } = require('@ai-sdk/xai') as { - createXai: CreateProviderFn; - }; - return createXai({ apiKey, baseURL, fetch, headers })(modelName); - } - default: - throw new Error( - `Unsupported provider: "${provider}". Supported: anthropic, openai, google, xai`, - ); + const slashIndex = rawId.indexOf('/'); + if (slashIndex <= 0) { + throw new Error(`Invalid model ID "${rawId}": expected "provider/model-name" format`); } + const provider = rawId.slice(0, slashIndex) as ProviderId; + const modelName = rawId.slice(slashIndex + 1); + + const entry = LANGUAGE_PROVIDERS[provider]; + if (!entry) { + throw new Error( + `Unsupported provider: "${provider}". Supported providers: ${SUPPORTED_PROVIDERS}`, + ); + } + + // Collect credential fields: strip `id`, pass the rest to Zod validation. + let credFields: Record = {}; + if (typeof config !== 'string') { + const { id: _id, ...rest } = config as { id: string; [k: string]: unknown }; + credFields = rest; + } + + const schema = PROVIDER_CREDENTIAL_SCHEMAS[provider]; + const parsed = schema.safeParse(credFields); + if (!parsed.success) { + const issues = parsed.error.issues + .map((i) => ` - ${i.path.join('.')}: ${i.message}`) + .join('\n'); + throw new Error(`Invalid credentials for provider "${provider}":\n${issues}`); + } + + const fetch = getProxyFetch(); + // Type cast: the registry guarantees the schema and builder are aligned per provider. + return (entry.build as EntryBuilder)(parsed.data as never, modelName, fetch); } /** diff --git a/packages/@n8n/agents/src/runtime/provider-credentials.ts b/packages/@n8n/agents/src/runtime/provider-credentials.ts new file mode 100644 index 00000000000..a41b48ec4b3 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/provider-credentials.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +const apiKeyCreds = z.object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), +}); + +/** + * Per-provider Zod schemas for credential validation. + * Keys are the provider prefixes used in model IDs (e.g. 'anthropic' in 'anthropic/claude-sonnet-4-5'). + */ +export const PROVIDER_CREDENTIAL_SCHEMAS = { + openai: apiKeyCreds, + anthropic: apiKeyCreds, + google: apiKeyCreds, + xai: apiKeyCreds, + groq: apiKeyCreds, + deepseek: apiKeyCreds, + cohere: apiKeyCreds, + mistral: apiKeyCreds, + vercel: apiKeyCreds, + openrouter: apiKeyCreds, + + 'azure-openai': z.object({ + apiKey: z.string().optional(), + resourceName: z.string().min(1, 'Azure resourceName is required'), + apiVersion: z.string().optional(), + baseURL: z.string().optional(), + }), + 'aws-bedrock': z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS accessKeyId is required'), + secretAccessKey: z.string().min(1, 'AWS secretAccessKey is required'), + sessionToken: z.string().optional(), + }), +} as const; + +export type ProviderId = keyof typeof PROVIDER_CREDENTIAL_SCHEMAS; +export type ProviderCredentials

= z.infer< + (typeof PROVIDER_CREDENTIAL_SCHEMAS)[P] +>; diff --git a/packages/@n8n/agents/src/runtime/runtime-helpers.ts b/packages/@n8n/agents/src/runtime/runtime-helpers.ts index 18a01fa0b35..268ac7eb391 100644 --- a/packages/@n8n/agents/src/runtime/runtime-helpers.ts +++ b/packages/@n8n/agents/src/runtime/runtime-helpers.ts @@ -4,8 +4,7 @@ */ import type { GenerateResult, StreamChunk, TokenUsage } from '../types'; import { toTokenUsage } from './stream'; -import type { AgentMessage, ContentToolResult } from '../types/sdk/message'; -import type { JSONValue } from '../types/utils/json'; +import type { AgentMessage, ContentToolCall } from '../types/sdk/message'; /** * Normalize caller input to `AgentMessage[]` for the runtime. String input becomes a @@ -18,55 +17,16 @@ export function normalizeInput(input: AgentMessage[] | string): AgentMessage[] { return input; } -/** Build an AI SDK tool ModelMessage for a tool execution result. */ -export function makeToolResultMessage( - toolCallId: string, - toolName: string, - result: unknown, -): AgentMessage { - return { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId, - toolName, - result: result as JSONValue, - }, - ], - }; +/** Stringify an error value for use in a rejected tool-call block. */ +export function stringifyError(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); } -/** - * Build an AI SDK tool ModelMessage for a tool execution error. - * The LLM receives this as a tool result so it can self-correct on the next iteration. - * The error is surfaced via the output json value so the LLM can read and reason about it. - */ -export function makeErrorToolResultMessage( - toolCallId: string, - toolName: string, - error: unknown, -): AgentMessage { - const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - return { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId, - toolName, - result: { error: message } as JSONValue, - isError: true, - }, - ], - }; -} - -/** Extract all tool-result content parts from a flat list of agent messages. */ -export function extractToolResults(messages: AgentMessage[]): ContentToolResult[] { +/** Extract all settled (resolved or rejected) tool-call blocks from a flat list of agent messages. */ +export function extractSettledToolCalls(messages: AgentMessage[]): ContentToolCall[] { return messages .flatMap((m) => ('content' in m ? m.content : [])) - .filter((c): c is ContentToolResult => c.type === 'tool-result'); + .filter((c): c is ContentToolCall => c.type === 'tool-call' && c.state !== 'pending'); } /** diff --git a/packages/@n8n/agents/src/runtime/stream.ts b/packages/@n8n/agents/src/runtime/stream.ts index ba97f2b5137..1e7163b2bc4 100644 --- a/packages/@n8n/agents/src/runtime/stream.ts +++ b/packages/@n8n/agents/src/runtime/stream.ts @@ -39,72 +39,62 @@ export function toTokenUsage( return result; } -/** Convert a single AI SDK v6 fullStream chunk to an n8n StreamChunk (or undefined to skip). */ +/** + * Convert a single AI SDK v6 fullStream chunk to an n8n StreamChunk + */ export function convertChunk(c: TextStreamPart): StreamChunk | undefined { switch (c.type) { + case 'start-step': + return { type: 'start-step' }; + + case 'finish-step': + return { type: 'finish-step' }; + + case 'text-start': + return { type: 'text-start', id: c.id }; + case 'text-delta': - return { type: 'text-delta', delta: c.text ?? '' }; + return { type: 'text-delta', id: c.id, delta: c.text ?? '' }; + + case 'text-end': + return { type: 'text-end', id: c.id }; + + case 'reasoning-start': + return { type: 'reasoning-start', id: c.id }; case 'reasoning-delta': - return { type: 'reasoning-delta', delta: c.text ?? '' }; + return { type: 'reasoning-delta', id: c.id, delta: c.text ?? '' }; + + case 'reasoning-end': + return { type: 'reasoning-end', id: c.id }; + + case 'tool-input-start': + // AI SDK uses `id` to carry the toolCallId on tool-input-* chunks. + return { type: 'tool-input-start', toolCallId: c.id, toolName: c.toolName }; + + case 'tool-input-delta': + return { type: 'tool-input-delta', toolCallId: c.id, delta: c.delta }; case 'tool-call': return { - type: 'message', - message: { - role: 'tool', - content: [ - { - type: 'tool-call', - toolCallId: c.toolCallId, - toolName: c.toolName ?? '', - input: c.input as JSONValue, - }, - ], - }, - }; - - case 'tool-input-start': - return { - type: 'tool-call-delta', - name: c.toolName, - }; - - case 'tool-input-delta': - return { - type: 'tool-call-delta', - ...(c.delta !== undefined && { argumentsDelta: c.delta }), + type: 'tool-call', + toolCallId: c.toolCallId, + toolName: c.toolName ?? '', + input: c.input as JSONValue, }; case 'tool-result': return { - type: 'message', - message: { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: c.toolCallId ?? '', - toolName: c.toolName ?? '', - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - result: c.output && 'value' in c.output ? (c.output.value as JSONValue) : null, - }, - ], - }, + type: 'tool-result', + toolCallId: c.toolCallId ?? '', + toolName: c.toolName ?? '', + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + output: c.output && 'value' in c.output ? (c.output.value as JSONValue) : null, }; case 'error': return { type: 'error', error: c.error }; - case 'finish-step': { - const usage = toTokenUsage(c.usage); - return { - type: 'finish', - finishReason: (c.finishReason ?? 'stop') as FinishReason, - ...(usage && { usage }), - }; - } - case 'finish': { const usage = toTokenUsage(c.totalUsage); return { diff --git a/packages/@n8n/agents/src/runtime/strip-orphaned-tool-messages.ts b/packages/@n8n/agents/src/runtime/strip-orphaned-tool-messages.ts index 11f6ffa1b98..9a115f442fe 100644 --- a/packages/@n8n/agents/src/runtime/strip-orphaned-tool-messages.ts +++ b/packages/@n8n/agents/src/runtime/strip-orphaned-tool-messages.ts @@ -2,43 +2,15 @@ import { isLlmMessage } from '../sdk/message'; import type { AgentMessage, MessageContent } from '../types/sdk/message'; /** - * Strip orphaned tool-call and tool-result content from a message list. - * - * When memory loads the last N messages, the window boundary can split - * tool-call / tool-result pairs, leaving one side without its counterpart. - * Sending these orphans to the LLM causes provider errors because tool - * calls and results must always be paired. + * Strip pending tool-call blocks from a message list before sending to the LLM. * * This function: - * 1. Collects all toolCallIds present in tool-call and tool-result blocks. - * 2. Identifies orphans — calls without a matching result and vice-versa. - * 3. Strips orphaned content blocks from their messages. - * 4. Drops messages that become empty after stripping (e.g. a tool message - * whose only content was the orphaned result). - * 5. Preserves non-tool content (text, reasoning, files) in mixed messages. + * 1. Drops any tool-call block whose state is 'pending'. + * 2. If a message becomes empty after stripping, drops the message entirely. + * 3. Preserves all other content (text, reasoning, files, resolved/rejected + * tool-call blocks, and non-LLM custom messages). */ export function stripOrphanedToolMessages(messages: T[]): T[] { - const callIds = new Set(); - const resultIds = new Set(); - - for (const msg of messages) { - if (!isLlmMessage(msg)) continue; - for (const block of msg.content) { - if (block.type === 'tool-call' && block.toolCallId) { - callIds.add(block.toolCallId); - } else if (block.type === 'tool-result' && block.toolCallId) { - resultIds.add(block.toolCallId); - } - } - } - - const orphanedCallIds = new Set([...callIds].filter((id) => !resultIds.has(id))); - const orphanedResultIds = new Set([...resultIds].filter((id) => !callIds.has(id))); - - if (orphanedCallIds.size === 0 && orphanedResultIds.size === 0) { - return messages; - } - const result: T[] = []; for (const msg of messages) { @@ -48,14 +20,7 @@ export function stripOrphanedToolMessages(messages: T[]) } const filtered = msg.content.filter((block: MessageContent) => { - if (block.type === 'tool-call' && block.toolCallId && orphanedCallIds.has(block.toolCallId)) { - return false; - } - if ( - block.type === 'tool-result' && - block.toolCallId && - orphanedResultIds.has(block.toolCallId) - ) { + if (block.type === 'tool-call' && block.state === 'pending') { return false; } return true; diff --git a/packages/@n8n/agents/src/runtime/title-generation.ts b/packages/@n8n/agents/src/runtime/title-generation.ts index 9aa29a7afda..e4fded3a0e1 100644 --- a/packages/@n8n/agents/src/runtime/title-generation.ts +++ b/packages/@n8n/agents/src/runtime/title-generation.ts @@ -33,6 +33,16 @@ const DEFAULT_TITLE_INSTRUCTIONS = [ 'Title: Scryfall random card workflow', ].join('\n'); +const DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS = [ + 'Generate a short title and a single emoji for a conversation based on the user message.', + 'Respond with ONLY a JSON object: {"title": "...", "emoji": "..."}', + 'Rules:', + '- Title: 2 to 6 words, max 50 characters, sentence case', + '- Title must not contain emoji, quotes, colons, or markdown formatting', + '- Emoji: exactly one emoji that represents the topic', + '- Respond with the JSON object only, no other text', +].join('\n'); + const TRIVIAL_MESSAGE_MAX_CHARS = 15; const TRIVIAL_MESSAGE_MAX_WORDS = 3; const MAX_TITLE_LENGTH = 80; @@ -117,7 +127,67 @@ export async function generateTitleFromMessage( } /** - * Generate a title for a thread if it doesn't already have one. + * Generate a sanitized title and a representative emoji from a user message. + * + * Asks the LLM for a `{"title": "...", "emoji": "..."}` JSON object and parses + * it; falls back to treating the whole response as a plain title if the model + * ignores the JSON format. + * + * Returns `null` on empty/trivial input or empty LLM output. + */ +export async function generateTitleAndEmojiFromMessage( + model: LanguageModel, + userMessage: string, + opts?: { instructions?: string }, +): Promise<{ title: string; emoji?: string } | null> { + const trimmed = userMessage.trim(); + if (!trimmed) return null; + + if (isTrivialMessage(trimmed)) { + return null; + } + + const result = await generateText({ + model, + messages: [ + { role: 'system', content: opts?.instructions ?? DEFAULT_TITLE_AND_EMOJI_INSTRUCTIONS }, + { role: 'user', content: trimmed }, + ], + }); + + let text = result.text?.trim(); + if (!text) return null; + + // Strip ... blocks (e.g. from DeepSeek R1) before JSON parsing. + text = text.replace(/[\s\S]*?<\/think>/g, '').trim(); + if (!text) return null; + + let rawTitle = ''; + let emoji: string | undefined; + + const jsonMatch = /\{[\s\S]*\}/.exec(text); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]) as { title?: string; emoji?: string }; + rawTitle = parsed.title?.trim() ?? ''; + emoji = parsed.emoji?.trim() ?? undefined; + } catch { + // Model returned something that looked like JSON but wasn't parseable — + // fall back to using the whole response as the title. + rawTitle = text; + } + } else { + rawTitle = text; + } + + const title = sanitizeTitle(rawTitle); + if (!title) return null; + + return { title, emoji }; +} + +/** + * Generate a title and emoji for a thread if it doesn't already have one. * * Designed to run fire-and-forget after the agent response is complete. * All errors are caught and logged — title generation failures never @@ -148,16 +218,21 @@ export async function generateThreadTitle(opts: { const titleModelId = opts.titleConfig.model ?? opts.agentModel; const titleModel = createModel(titleModelId); - const title = await generateTitleFromMessage(titleModel, userText, { + const generated = await generateTitleAndEmojiFromMessage(titleModel, userText, { instructions: opts.titleConfig.instructions, }); - if (!title) return; + if (!generated) return; + + const { title, emoji } = generated; + + // Store emoji in thread metadata + const metadata = { ...(thread?.metadata ?? {}), ...(emoji && { emoji }) }; await opts.memory.saveThread({ id: opts.threadId, resourceId: opts.resourceId, title, - metadata: thread?.metadata, + metadata, }); } catch (error) { logger.warn('Failed to generate thread title', { error }); diff --git a/packages/@n8n/agents/src/runtime/working-memory.ts b/packages/@n8n/agents/src/runtime/working-memory.ts index 08352278d5f..bba20342013 100644 --- a/packages/@n8n/agents/src/runtime/working-memory.ts +++ b/packages/@n8n/agents/src/runtime/working-memory.ts @@ -4,7 +4,7 @@ import type { BuiltTool } from '../types'; type ZodObjectSchema = z.ZodObject; -export const UPDATE_WORKING_MEMORY_TOOL_NAME = 'updateWorkingMemory'; +export const UPDATE_WORKING_MEMORY_TOOL_NAME = 'update_working_memory'; /** * The default instruction block injected into the system prompt when working memory @@ -19,7 +19,7 @@ export const WORKING_MEMORY_DEFAULT_INSTRUCTION = [ /** * Generate the system prompt instruction for working memory. - * Tells the LLM to call the updateWorkingMemory tool when it has new information to persist. + * Tells the LLM to call the update_working_memory tool when it has new information to persist. * * @param template - The working memory template or schema. * @param structured - Whether the working memory is structured (JSON schema). @@ -73,7 +73,7 @@ export interface WorkingMemoryToolConfig { } /** - * Build the updateWorkingMemory BuiltTool that the agent calls to persist working memory. + * Build the update_working_memory BuiltTool that the agent calls to persist working memory. * * For freeform working memory the input schema is `{ memory: string }`. * For structured working memory the input schema is the configured Zod object schema, diff --git a/packages/@n8n/agents/src/sdk/agent.ts b/packages/@n8n/agents/src/sdk/agent.ts index bc1b279833d..057e3b5af20 100644 --- a/packages/@n8n/agents/src/sdk/agent.ts +++ b/packages/@n8n/agents/src/sdk/agent.ts @@ -1,12 +1,13 @@ import type { ProviderOptions } from '@ai-sdk/provider-utils'; import { z } from 'zod'; -import type { ModelCost } from './catalog'; -import type { AgentRuntimeConfig } from '../runtime/agent-runtime'; +import type { Eval } from './eval'; +import type { McpClient } from './mcp-client'; +import { Memory } from './memory'; +import { Telemetry } from './telemetry'; +import { Tool, wrapToolForApproval } from './tool'; import { AgentRuntime } from '../runtime/agent-runtime'; import { AgentEventBus } from '../runtime/event-bus'; -import { InMemoryMemory } from '../runtime/memory-store'; -import { RunStateManager } from '../runtime/run-state'; import { createAgentToolResult } from '../runtime/tool-adapter'; import type { AgentEvent, @@ -17,57 +18,60 @@ import type { BuiltGuardrail, BuiltMemory, BuiltProviderTool, - BuiltTelemetry, BuiltTool, + BuiltTelemetry, CheckpointStore, ExecutionOptions, GenerateResult, MemoryConfig, ModelConfig, Provider, - ResumeOptions, RunOptions, + SerializableAgentState, StreamResult, SubAgentUsage, ThinkingConfig, ThinkingConfigFor, + ResumeOptions, } from '../types'; -import { getModelCost } from './catalog'; -import type { Eval } from './eval'; -import { fromSchema, type FromSchemaOptions } from './from-schema'; -import type { McpClient } from './mcp-client'; -import { Memory } from './memory'; -import { Telemetry } from './telemetry'; -import { Tool, wrapToolForApproval } from './tool'; -import type { StreamChunk } from '../types/sdk/agent'; import type { AgentBuilder } from '../types/sdk/agent-builder'; -import type { CredentialProvider } from '../types/sdk/credential-provider'; import type { AgentMessage } from '../types/sdk/message'; -import type { - AgentSchema, - EvalSchema, - GuardrailSchema, - McpServerSchema, - MemorySchema, - ProviderToolSchema, - ThinkingSchema, - ToolSchema, -} from '../types/sdk/schema'; -import { zodToJsonSchema } from '../utils/zod'; import type { Workspace } from '../workspace/workspace'; const DEFAULT_LAST_MESSAGES = 10; type ToolParameter = BuiltTool | { build(): BuiltTool }; +/** + * Lightweight read-only view of an agent's configured state. + * Returned by `Agent.snapshot` for testing and debugging purposes. + */ +export interface AgentSnapshot { + /** Agent name. */ + name: string; + /** Parsed model identifier. Both fields are null if no model has been set. */ + model: { provider: string | null; name: string | null }; + /** Instruction text passed to `.instructions()`, or null if not set. */ + instructions: string | null; + /** Minimal description of each registered tool. */ + tools: ReadonlyArray<{ name: string; description: string | undefined }>; + /** True when `.memory()` has been configured. */ + hasMemory: boolean; + /** The thinking config if set, otherwise null. */ + thinking: ThinkingConfig | null; + /** Tool-call concurrency limit if set, otherwise null. */ + toolCallConcurrency: number | null; + /** Whether `.requireToolApproval()` was called. */ + requireToolApproval: boolean; +} + /** * Builder for creating AI agents with a fluent API. * * Usage: * ```typescript * const agent = new Agent('assistant') - * .model('anthropic', 'claude-sonnet-4') // typed: Agent<'anthropic'> - * .credential('anthropic') + * .model('anthropic', 'claude-sonnet-4') * .instructions('You are a helpful assistant.') * .tool(searchTool); * @@ -78,9 +82,7 @@ type ToolParameter = BuiltTool | { build(): BuiltTool }; export class Agent implements BuiltAgent, AgentBuilder { readonly name: string; - private modelId?: string; - - private modelConfigObj?: ModelConfig; + private modelConfig?: ModelConfig; private instructionProviderOpts?: ProviderOptions; @@ -106,11 +108,7 @@ export class Agent implements BuiltAgent, AgentBuilder { private thinkingConfig?: ThinkingConfig; - private credentialName?: string; - - private credProvider?: CredentialProvider; - - private resolvedKey?: string; + private runtime?: AgentRuntime; private concurrencyValue?: number; @@ -124,13 +122,9 @@ export class Agent implements BuiltAgent, AgentBuilder { private mcpClients: McpClient[] = []; - private buildPromise: Promise | undefined; + private buildPromise: Promise | undefined; - /** Handlers registered via on() — copied into each per-run event bus at creation time. */ - private agentHandlers = new Map>(); - - /** Event buses for all currently active runs, used to broadcast abort(). */ - private activeEventBuses = new Set(); + private eventBus = new AgentEventBus(); private workspaceInstance?: Workspace; @@ -138,22 +132,6 @@ export class Agent implements BuiltAgent, AgentBuilder { this.name = name; } - /** - * Reconstruct a live Agent from an AgentSchema JSON. - * Custom tool handlers are proxied through the injected HandlerExecutor. - * - * This is the inverse of `Agent.describe()`. - */ - static async fromSchema( - schema: AgentSchema, - name: string, - options: FromSchemaOptions, - ): Promise { - const agent = new Agent(name); - await fromSchema(agent, schema, options); - return agent; - } - hasCheckpointStorage(): boolean { return this.checkpointStore !== undefined; } @@ -176,11 +154,9 @@ export class Agent implements BuiltAgent, AgentBuilder { */ model(providerOrIdOrConfig: string | ModelConfig, modelName?: string): this { if (typeof providerOrIdOrConfig === 'string') { - this.modelId = modelName ? `${providerOrIdOrConfig}/${modelName}` : providerOrIdOrConfig; - this.modelConfigObj = undefined; + this.modelConfig = modelName ? `${providerOrIdOrConfig}/${modelName}` : providerOrIdOrConfig; } else { - this.modelConfigObj = providerOrIdOrConfig; - this.modelId = undefined; + this.modelConfig = providerOrIdOrConfig; } return this; } @@ -211,7 +187,7 @@ export class Agent implements BuiltAgent, AgentBuilder { return this; } - /** @internal Read the declared tools (used by the compile step to detect workflow tool markers). */ + /** Read the declared tools. Lists only tools added via tool() */ get declaredTools(): BuiltTool[] { return this.tools; } @@ -289,54 +265,6 @@ export class Agent implements BuiltAgent, AgentBuilder { return this; } - /** - * Declare a credential this agent requires. The execution engine resolves - * the credential name to an API key at build time and injects it into the - * model configuration — user code never handles raw keys. - * - * @example - * ```typescript - * const agent = new Agent('assistant') - * .model('anthropic/claude-sonnet-4-5') - * .credential('anthropic') - * .instructions('You are helpful.'); - * ``` - */ - credential(name: string): this { - this.credentialName = name; - return this; - } - - /** - * Attach a credential provider that resolves credential identifiers to - * decrypted API keys at build time. When both `.credential()` and - * `.credentialProvider()` are set, the provider resolves the credential - * before model creation — no subclassing required. - * - * @example - * ```typescript - * const agent = new Agent('assistant') - * .model('anthropic', 'claude-sonnet-4') - * .credential('credential-id-123') - * .credentialProvider(myProvider) - * .instructions('You are helpful.'); - * ``` - */ - credentialProvider(provider: CredentialProvider): this { - this.credProvider = provider; - return this; - } - - /** @internal Read the declared credential name (used by the execution engine). */ - protected get declaredCredential(): string | undefined { - return this.credentialName; - } - - /** @internal Set the resolved API key (called by the execution engine before super.build()). */ - protected set resolvedApiKey(key: string) { - this.resolvedKey = key; - } - /** * Set a structured output schema. When set, the agent's response will be * parsed into a typed object matching the schema, available as `result.output`. @@ -463,29 +391,10 @@ export class Agent implements BuiltAgent, AgentBuilder { /** * Register a handler for an agent lifecycle event. - * Handlers are forwarded into every per-run event bus so they fire for all concurrent runs. - * Use off() to remove the handler when it is no longer needed. + * Handlers are called synchronously during the agentic loop. */ on(event: AgentEvent, handler: AgentEventHandler): void { - let set = this.agentHandlers.get(event); - if (!set) { - set = new Set(); - this.agentHandlers.set(event, set); - } - set.add(handler); - } - - /** - * Remove a previously registered event handler. - * A no-op if the handler was never registered. - */ - off(event: AgentEvent, handler: AgentEventHandler): void { - const set = this.agentHandlers.get(event); - if (!set) return; - set.delete(handler); - if (set.size === 0) { - this.agentHandlers.delete(event); - } + this.eventBus.on(event, handler); } /** @@ -550,216 +459,63 @@ export class Agent implements BuiltAgent, AgentBuilder { } /** - * Return a schema object describing the agent's declared configuration. - * This is a synchronous introspection method — it does not build the agent - * or connect to any external services. + * Return a lightweight read-only snapshot of the agent's configured state. + * Useful for testing and debugging — does not trigger a build. */ - describe(): AgentSchema { - // --- Model --- - let model: AgentSchema['model']; - if (this.modelConfigObj) { - model = { provider: null, name: null, raw: 'object' }; - } else if (this.modelId) { - const slashIdx = this.modelId.indexOf('/'); + get snapshot(): AgentSnapshot { + let model: AgentSnapshot['model']; + const rawModelId = + typeof this.modelConfig === 'string' + ? this.modelConfig + : this.modelConfig && typeof this.modelConfig === 'object' && 'id' in this.modelConfig + ? this.modelConfig.id + : undefined; + + if (rawModelId) { + const slashIdx = rawModelId.indexOf('/'); if (slashIdx === -1) { - model = { provider: null, name: this.modelId }; + model = { provider: null, name: rawModelId }; } else { model = { - provider: this.modelId.slice(0, slashIdx), - name: this.modelId.slice(slashIdx + 1), + provider: rawModelId.slice(0, slashIdx), + name: rawModelId.slice(slashIdx + 1), }; } } else { model = { provider: null, name: null }; } - // --- Tools (custom / workflow) --- - const toolSchemas: ToolSchema[] = this.tools.map((tool) => { - const isWorkflow = '__workflowTool' in tool && Boolean(tool.__workflowTool); - return { - name: tool.name, - description: tool.description, - type: isWorkflow ? ('workflow' as const) : ('custom' as const), - editable: !isWorkflow, - // Source strings — null, CLI patches with original TypeScript - inputSchemaSource: null, - outputSchemaSource: null, - handlerSource: tool.handler?.toString() ?? null, - suspendSchemaSource: null, - resumeSchemaSource: null, - toMessageSource: null, - requireApproval: tool.withDefaultApproval ?? false, - needsApprovalFnSource: null, - providerOptions: tool.providerOptions ?? null, - // Display fields — JSON Schema for UI rendering - inputSchema: zodToJsonSchema(tool.inputSchema), - outputSchema: zodToJsonSchema(tool.outputSchema), - // UI badge indicators — for approval-wrapped tools, hasSuspend/hasResume - // reflect the approval mechanism, not user-declared suspend/resume - hasSuspend: Boolean(tool.suspendSchema), - hasResume: Boolean(tool.resumeSchema), - hasToMessage: Boolean(tool.toMessage), - }; - }); - - // --- Provider tools --- - const providerToolSchemas: ProviderToolSchema[] = this.providerTools.map((pt) => ({ - name: pt.name, - source: '', - })); - - // --- Guardrails --- - const guardrails: GuardrailSchema[] = [ - ...this.inputGuardrails.map((g) => ({ - name: g.name, - guardType: g.guardType, - strategy: g.strategy, - position: 'input' as const, - config: g._config, - source: '', - })), - ...this.outputGuardrails.map((g) => ({ - name: g.name, - guardType: g.guardType, - strategy: g.strategy, - position: 'output' as const, - config: g._config, - source: '', - })), - ]; - - // --- MCP servers --- - let mcp: McpServerSchema[] | null = null; - if (this.mcpClients.length > 0) { - mcp = []; - for (const client of this.mcpClients) { - for (const serverName of client.serverNames) { - mcp.push({ - name: serverName, - configSource: '', - }); - } - } - } - - // --- Telemetry --- - const telemetry = this.telemetryBuilder || this.telemetryConfig ? { source: '' } : null; - - // --- Checkpoint --- - const checkpoint = this.checkpointStore === 'memory' ? 'memory' : null; - - // --- Memory --- - let memory: MemorySchema | null = null; - if (this.memoryConfig) { - const mc = this.memoryConfig; - let semanticRecall: MemorySchema['semanticRecall'] = null; - if (mc.semanticRecall) { - semanticRecall = { - topK: mc.semanticRecall.topK, - messageRange: mc.semanticRecall.messageRange - ? { - before: mc.semanticRecall.messageRange.before, - after: mc.semanticRecall.messageRange.after, - } - : null, - embedder: mc.semanticRecall.embedder ?? null, - }; - } - - let workingMemory: MemorySchema['workingMemory'] = null; - if (mc.workingMemory) { - workingMemory = { - type: mc.workingMemory.structured ? 'structured' : 'freeform', - ...(mc.workingMemory.schema - ? { schema: zodToJsonSchema(mc.workingMemory.schema) ?? undefined } - : {}), - ...(mc.workingMemory.template ? { template: mc.workingMemory.template } : {}), - }; - } - - memory = { - // TODO: each BuiltMemory should have describe() method to return a config showing connection params and other metadata - // this config must have enough information to rebuild the memory instance - source: null, - storage: mc.memory instanceof InMemoryMemory ? 'memory' : 'custom', - lastMessages: mc.lastMessages ?? null, - semanticRecall, - workingMemory, - }; - } - - // --- Evaluations --- - const evaluations: EvalSchema[] = this.agentEvals.map((e) => ({ - name: e.name, - description: e.description ?? null, - type: e.evalType, - modelId: e.modelId ?? null, - hasCredential: e.credentialName !== null, - credentialName: e.credentialName, - handlerSource: null, - })); - - // --- Structured output --- - // TODO: define structured output schema handling better - const structuredOutput = { - enabled: Boolean(this.outputSchema), - schemaSource: null as string | null, - }; - - // --- Thinking --- - let thinking: ThinkingSchema | null = null; - if (this.thinkingConfig) { - const provider = this.modelId?.split('/')[0]; - if (provider === 'anthropic') { - thinking = { - provider: 'anthropic', - budgetTokens: - 'budgetTokens' in this.thinkingConfig - ? (this.thinkingConfig as { budgetTokens?: number }).budgetTokens - : undefined, - }; - } else if (provider === 'openai') { - thinking = { - provider: 'openai', - reasoningEffort: - 'reasoningEffort' in this.thinkingConfig - ? String((this.thinkingConfig as { reasoningEffort?: string }).reasoningEffort) - : undefined, - }; - } - } - return { + name: this.name, model, - credential: this.credentialName ?? null, instructions: this.instructionsText ?? null, - description: null, - tools: toolSchemas, - providerTools: providerToolSchemas, - memory, - evaluations, - guardrails, - mcp, - telemetry, - checkpoint, - config: { - structuredOutput, - thinking, - toolCallConcurrency: this.concurrencyValue ?? null, - requireToolApproval: this.requireToolApprovalValue, - }, + tools: this.tools.map((t) => ({ name: t.name, description: t.description })), + hasMemory: this.memoryConfig !== undefined, + thinking: this.thinkingConfig ?? null, + toolCallConcurrency: this.concurrencyValue ?? null, + requireToolApproval: this.requireToolApprovalValue, }; } + /** Return the latest state snapshot of the agent. Returns `{ status: 'idle' }` before first run. */ + getState(): SerializableAgentState { + if (!this.runtime) { + return { + persistence: undefined, + status: 'idle', + messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] }, + pendingToolCalls: {}, + }; + } + return this.runtime.getState(); + } + /** - * Cancel all currently active runs on this agent. - * Synchronous — sets an abort flag on every active event bus; - * the agentic loop in each run checks it asynchronously. + * Cancel the currently running agent. + * Synchronous — sets an abort flag; the agentic loop checks it asynchronously. */ abort(): void { - for (const bus of this.activeEventBuses) { - bus.abort(); - } + this.eventBus.abort(); } /** Generate a response (non-streaming). Lazy-builds on first call. */ @@ -767,13 +523,8 @@ export class Agent implements BuiltAgent, AgentBuilder { input: AgentMessage[] | string, options?: RunOptions & ExecutionOptions, ): Promise { - const config = await this.ensureBuilt(); - const { runtime, bus } = this.createRuntime(config); - try { - return await runtime.generate(this.toMessages(input), options); - } finally { - this.cleanupBus(bus); - } + const runtime = await this.ensureBuilt(); + return await runtime.generate(this.toMessages(input), options); } /** Stream a response. Lazy-builds on first call. */ @@ -781,15 +532,8 @@ export class Agent implements BuiltAgent, AgentBuilder { input: AgentMessage[] | string, options?: RunOptions & ExecutionOptions, ): Promise { - const config = await this.ensureBuilt(); - const { runtime, bus } = this.createRuntime(config); - try { - const result = await runtime.stream(this.toMessages(input), options); - return { ...result, stream: this.trackStreamBus(result.stream, bus) }; - } catch (error) { - this.cleanupBus(bus); - throw error; - } + const runtime = await this.ensureBuilt(); + return await runtime.stream(this.toMessages(input), options); } /** Resume a suspended tool call with data. Lazy-builds on first call. */ @@ -808,23 +552,11 @@ export class Agent implements BuiltAgent, AgentBuilder { data: unknown, options: ResumeOptions & ExecutionOptions, ): Promise { - const config = await this.ensureBuilt(); + const runtime = await this.ensureBuilt(); if (method === 'generate') { - const { runtime, bus } = this.createRuntime(config); - try { - return await runtime.resume('generate', data, options); - } finally { - this.cleanupBus(bus); - } - } - const { runtime, bus } = this.createRuntime(config); - try { - const result = await runtime.resume('stream', data, options); - return { ...result, stream: this.trackStreamBus(result.stream, bus) }; - } catch (error) { - this.cleanupBus(bus); - throw error; + return await runtime.resume('generate', data, options); } + return await runtime.resume('stream', data, options); } approve(method: 'generate', options: ResumeOptions & ExecutionOptions): Promise; @@ -856,7 +588,7 @@ export class Agent implements BuiltAgent, AgentBuilder { * concurrent callers share one build operation. On error the promise is * cleared so the caller can retry. */ - private async ensureBuilt(): Promise { + private async ensureBuilt(): Promise { if (!this.buildPromise) { const p = this.build(); this.buildPromise = p; @@ -867,90 +599,14 @@ export class Agent implements BuiltAgent, AgentBuilder { return await this.buildPromise; } - /** - * Create a fresh AgentRuntime from the shared config, wiring a new event bus - * with all registered agent-level handlers copied in. The bus is registered - * in activeEventBuses so that abort() can reach it. Callers are responsible - * for deregistering the bus when the run finishes. - * - * AgentRuntime is not supposed to be reused across runs, it gets created and destroyed for each run. - */ - private createRuntime(config: AgentRuntimeConfig): { runtime: AgentRuntime; bus: AgentEventBus } { - const bus = new AgentEventBus(); - for (const [event, handlers] of this.agentHandlers) { - for (const handler of handlers) { - bus.on(event, handler); - } - } - this.activeEventBuses.add(bus); - const runtime = new AgentRuntime({ ...config, eventBus: bus }); - return { runtime, bus }; - } - - /** - * Wrap a stream so that the bus is deregistered from activeEventBuses - * when the stream closes — whether the consumer drains it, cancels it, or - * the source errors. - * - * The bus is cleaned up in all three observable terminal states: - * - `pull` reaches `done` (source finished normally) - * - `pull` throws (source errored) - * - `cancel` is called (consumer explicitly cancelled the stream) - * - * The one case that cannot be detected without GC hooks is a consumer that - * holds a reference to the stream but never reads or cancels it. Callers - * should always drain or cancel the returned stream. - */ - private trackStreamBus( - stream: ReadableStream, - bus: AgentEventBus, - ): ReadableStream { - let cleanedUp = false; - const cleanup = () => { - if (!cleanedUp) { - cleanedUp = true; - this.cleanupBus(bus); - } - }; - - const reader = stream.getReader(); - - return new ReadableStream({ - async pull(controller) { - try { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - cleanup(); - } else { - controller.enqueue(value); - } - } catch (error) { - controller.error(error); - cleanup(); - } - }, - cancel() { - reader.cancel().catch(() => {}); - cleanup(); - }, - }); - } - - private cleanupBus(bus: AgentEventBus): void { - this.activeEventBuses.delete(bus); - bus.dispose(); - } - private toMessages(input: string | AgentMessage[]): AgentMessage[] { if (Array.isArray(input)) return input; return [{ role: 'user', content: [{ type: 'text', text: input }] }]; } - /** @internal Validate configuration and produce a shared AgentRuntimeConfig. Overridden by the execution engine. */ - protected async build(): Promise { - const hasModel = this.modelId ?? this.modelConfigObj; - if (!hasModel) { + /** @internal Validate configuration and produce an AgentRuntime. Overridden by the execution engine. */ + protected async build(): Promise { + if (!this.modelConfig) { throw new Error(`Agent "${this.name}" requires a model`); } if (!this.instructionsText) { @@ -1019,28 +675,7 @@ export class Agent implements BuiltAgent, AgentBuilder { ); } - // Resolve credential via provider before building the model config. - if (this.credProvider && this.credentialName) { - const resolved = await this.credProvider.resolve(this.credentialName); - this.resolvedKey = resolved.apiKey; - } - - let modelConfig: ModelConfig; - if (this.modelConfigObj) { - if ( - this.resolvedKey && - typeof this.modelConfigObj === 'object' && - 'id' in this.modelConfigObj - ) { - modelConfig = { ...this.modelConfigObj, apiKey: this.resolvedKey }; - } else { - modelConfig = this.modelConfigObj; - } - } else if (this.resolvedKey) { - modelConfig = { id: this.modelId!, apiKey: this.resolvedKey }; - } else { - modelConfig = this.modelId!; - } + const modelConfig: ModelConfig = this.modelConfig; let instructions = this.instructionsText; if (this.workspaceInstance) { @@ -1050,24 +685,7 @@ export class Agent implements BuiltAgent, AgentBuilder { } } - // Prefetch model cost once — shared across all per-run runtimes. - let modelCost: ModelCost | undefined; - try { - const modelId = - typeof modelConfig === 'string' - ? modelConfig - : 'id' in modelConfig - ? modelConfig.id - : undefined; - modelCost = modelId ? await getModelCost(modelId) : undefined; - } catch { - // Catalog unavailable — proceed without cost data - } - - // Shared RunStateManager so resume() can find state from a prior stream()/generate() call. - const runState = new RunStateManager(this.checkpointStore); - - return { + this.runtime = new AgentRuntime({ name: this.name, model: modelConfig, instructions, @@ -1081,11 +699,12 @@ export class Agent implements BuiltAgent, AgentBuilder { structuredOutput: this.outputSchema, checkpointStorage: this.checkpointStore, thinking: this.thinkingConfig, + eventBus: this.eventBus, toolCallConcurrency: this.concurrencyValue, titleGeneration: this.memoryConfig?.titleGeneration, telemetry: this.telemetryConfig ?? (await this.telemetryBuilder?.build()), - modelCost, - runState, - }; + }); + + return this.runtime; } } diff --git a/packages/@n8n/agents/src/sdk/eval.ts b/packages/@n8n/agents/src/sdk/eval.ts index 0654783f809..e6760f541bb 100644 --- a/packages/@n8n/agents/src/sdk/eval.ts +++ b/packages/@n8n/agents/src/sdk/eval.ts @@ -1,6 +1,7 @@ import { filterLlmMessages } from './message'; import { AgentRuntime } from '../runtime/agent-runtime'; import type { BuiltEval, CheckFn, EvalInput, EvalScore, JudgeFn, JudgeHandlerFn } from '../types'; +import type { ModelConfig } from '../types/sdk/agent'; import type { AgentMessage } from '../types/sdk/message'; /** Extract text content from LLM messages (custom messages are skipped). */ @@ -54,8 +55,6 @@ export class Eval { private credentialName?: string; - private _resolvedApiKey?: string; - constructor(name: string) { this.evalName = name; } @@ -68,6 +67,7 @@ export class Eval { /** Set the judge model (LLM-as-judge mode). */ model(modelId: string): this { + // TODO: support full model config this.modelId = modelId; return this; } @@ -78,16 +78,6 @@ export class Eval { return this; } - /** @internal Read the declared credential name (used by the execution engine). */ - protected get declaredCredential(): string | undefined { - return this.credentialName; - } - - /** @internal Set the resolved API key for the judge model. */ - protected set resolvedApiKey(key: string) { - this._resolvedApiKey = key; - } - /** * Set a deterministic check function. * Mutually exclusive with `.judge()`. @@ -146,9 +136,10 @@ export class Eval { // LLM-as-judge mode const judgeFn = this.judgeFn!; - const modelConfig: string | { id: `${string}/${string}`; apiKey: string } = this._resolvedApiKey - ? { id: this.modelId! as `${string}/${string}`, apiKey: this._resolvedApiKey } - : this.modelId!; + if (!this.modelId) { + throw new Error(`Eval "${this.evalName}" uses .judge() but no .model() was set`); + } + const modelConfig: ModelConfig = this.modelId; const runtime = new AgentRuntime({ name: `${name}-judge`, diff --git a/packages/@n8n/agents/src/sdk/from-schema.ts b/packages/@n8n/agents/src/sdk/from-schema.ts deleted file mode 100644 index c70047361aa..00000000000 --- a/packages/@n8n/agents/src/sdk/from-schema.ts +++ /dev/null @@ -1,364 +0,0 @@ -import type { JSONSchema7 } from 'json-schema'; -import type { ZodType } from 'zod'; - -import type { BuiltEval, BuiltGuardrail, BuiltTelemetry, BuiltTool } from '../types'; -import { McpClient } from './mcp-client'; -import { Memory } from './memory'; -import { wrapToolForApproval } from './tool'; -import type { AgentBuilder } from '../types/sdk/agent-builder'; -import type { CredentialProvider } from '../types/sdk/credential-provider'; -import type { EvalInput, EvalScore, JudgeInput } from '../types/sdk/eval'; -import type { HandlerExecutor } from '../types/sdk/handler-executor'; -import type { McpServerConfig } from '../types/sdk/mcp'; -import type { AgentMessage } from '../types/sdk/message'; -import type { - AgentSchema, - EvalSchema, - GuardrailSchema, - McpServerSchema, - ProviderToolSchema, - TelemetrySchema, - ToolSchema, -} from '../types/sdk/schema'; -import type { InterruptibleToolContext, ToolContext } from '../types/sdk/tool'; -import type { JSONObject } from '../types/utils/json'; - -export interface FromSchemaOptions { - handlerExecutor: HandlerExecutor; - credentialProvider?: CredentialProvider; -} - -/** Sentinel used to signal that a sandboxed handler called ctx.suspend(). */ -const SUSPEND_MARKER = Symbol.for('n8n.agent.suspend'); - -interface SuspendResult { - [key: symbol]: true; - payload: unknown; -} - -export function isSuspendResult(value: unknown): value is SuspendResult { - return ( - typeof value === 'object' && - value !== null && - (value as Record)[SUSPEND_MARKER] === true - ); -} - -/** - * Reconstruct a live Agent from an AgentSchema JSON. - * - * This is the inverse of `Agent.describe()` — it takes a serialised schema - * (produced by `describe()` or stored in the database) and rebuilds a - * fully-configured Agent instance with proxy handlers that delegate tool - * execution to the provided `HandlerExecutor`. - * - * All source expressions in the schema (provider tools, MCP configs, - * telemetry, structured output, suspend/resume schemas) are evaluated - * via `HandlerExecutor.evaluateExpression()` / `evaluateSchema()`. - * - * The `agent` parameter is the Agent instance to configure (avoids circular import). - */ -export async function fromSchema( - agent: AgentBuilder, - schema: AgentSchema, - options: FromSchemaOptions, -): Promise { - const { handlerExecutor } = options; - - applyModel(agent, schema.model); - - if (schema.credential !== null) { - agent.credential(schema.credential); - } - - if (schema.instructions !== null) { - agent.instructions(schema.instructions); - } - - await applyTools(agent, schema.tools, handlerExecutor); - await applyProviderTools(agent, schema.providerTools, handlerExecutor); - applyConfig(agent, schema.config); - applyMemory(agent, schema); - applyGuardrails(agent, schema.guardrails); - applyEvals(agent, schema.evaluations, handlerExecutor); - await applyStructuredOutput(agent, schema.config.structuredOutput, handlerExecutor); - - if (options.credentialProvider) { - agent.credentialProvider(options.credentialProvider); - } - - await applyMcpServers(agent, schema.mcp, handlerExecutor); - await applyTelemetry(agent, schema.telemetry, handlerExecutor); -} - -// --------------------------------------------------------------------------- -// Helpers – each handles one section of the AgentSchema -// --------------------------------------------------------------------------- - -function applyModel(agent: AgentBuilder, model: AgentSchema['model']): void { - if (model.provider && model.name) { - agent.model(model.provider, model.name); - } else if (model.name) { - agent.model(model.name); - } -} - -async function applyTools( - agent: AgentBuilder, - tools: ToolSchema[], - executor: HandlerExecutor, -): Promise { - const addedTools = new Set(); - for (const ts of tools) { - if (addedTools.has(ts.name)) { - throw new Error(`Schema has multiple definitions of tool ${ts.name}`); - } - addedTools.add(ts.name); - - if (!ts.editable) { - agent.tool({ - name: ts.name, - description: ts.description, - __workflowTool: true, - workflowName: ts.name, - } as unknown as BuiltTool); - continue; - } - - const schemas: { suspend?: ZodType; resume?: ZodType } = {}; - if (ts.suspendSchemaSource) { - schemas.suspend = await executor.evaluateSchema(ts.suspendSchemaSource); - } - if (ts.resumeSchemaSource) { - schemas.resume = await executor.evaluateSchema(ts.resumeSchemaSource); - } - - const builtTool = buildToolFromSchema(ts, executor, schemas); - agent.tool(builtTool); - } -} - -async function applyProviderTools( - agent: AgentBuilder, - providerTools: ProviderToolSchema[], - executor: HandlerExecutor, -): Promise { - for (const pt of providerTools) { - if (pt.source) { - const evaluated = (await executor.evaluateExpression(pt.source)) as { - name: `${string}.${string}`; - args?: Record; - }; - agent.providerTool({ - name: evaluated.name, - args: evaluated.args ?? {}, - }); - } else { - agent.providerTool({ - name: pt.name as `${string}.${string}`, - args: {}, - }); - } - } -} - -function applyConfig(agent: AgentBuilder, config: AgentSchema['config']): void { - if (config.thinking !== null) { - const { provider, ...thinkingConfig } = config.thinking; - agent.thinking(provider, thinkingConfig); - } - - if (config.toolCallConcurrency !== null) { - agent.toolCallConcurrency(config.toolCallConcurrency); - } - - if (config.requireToolApproval) { - agent.requireToolApproval(); - } -} - -function applyMemory(agent: AgentBuilder, schema: AgentSchema): void { - if (schema.memory !== null) { - const memory = new Memory(); - if (schema.memory.lastMessages !== null) { - memory.lastMessages(schema.memory.lastMessages); - } - agent.memory(memory); - } - - if (schema.checkpoint !== null) { - agent.checkpoint(schema.checkpoint); - } -} - -function applyGuardrails(agent: AgentBuilder, guardrails: GuardrailSchema[]): void { - for (const g of guardrails) { - const builtGuardrail: BuiltGuardrail = { - name: g.name, - guardType: g.guardType, - strategy: g.strategy, - _config: g.config, - }; - if (g.position === 'input') { - agent.inputGuardrail(builtGuardrail); - } else { - agent.outputGuardrail(builtGuardrail); - } - } -} - -function applyEvals( - agent: AgentBuilder, - evaluations: EvalSchema[], - executor: HandlerExecutor, -): void { - for (const evalSchema of evaluations) { - const builtEval = buildEvalFromSchema(evalSchema, executor); - agent.eval(builtEval); - } -} - -async function applyStructuredOutput( - agent: AgentBuilder, - structuredOutput: AgentSchema['config']['structuredOutput'], - executor: HandlerExecutor, -): Promise { - if (structuredOutput.enabled && structuredOutput.schemaSource) { - const outputSchema = await executor.evaluateSchema(structuredOutput.schemaSource); - agent.structuredOutput(outputSchema); - } -} - -async function applyMcpServers( - agent: AgentBuilder, - mcp: McpServerSchema[] | null, - executor: HandlerExecutor, -): Promise { - if (!mcp || mcp.length === 0) return; - - const mcpConfigs: McpServerConfig[] = []; - for (const m of mcp) { - if (m.configSource) { - const config = (await executor.evaluateExpression(m.configSource)) as McpServerConfig; - mcpConfigs.push(config); - } - } - - if (mcpConfigs.length > 0) { - agent.mcp(new McpClient(mcpConfigs)); - } -} - -async function applyTelemetry( - agent: AgentBuilder, - telemetry: TelemetrySchema | null, - executor: HandlerExecutor, -): Promise { - if (telemetry?.source) { - const built = (await executor.evaluateExpression(telemetry.source)) as BuiltTelemetry; - agent.telemetry(built); - } -} - -// --------------------------------------------------------------------------- -// Tool & Eval builders -// --------------------------------------------------------------------------- - -/** - * Build a `BuiltTool` from a `ToolSchema` with a proxy handler that - * delegates execution to the `HandlerExecutor`. - * - * For interruptible tools (hasSuspend), the proxy handles ctx.suspend at - * the host level: the sandbox receives a stub suspend that records the - * payload, and the proxy calls the real ctx.suspend on the host. - */ -function buildToolFromSchema( - toolSchema: ToolSchema, - executor: HandlerExecutor, - preEvaluated?: { suspend?: ZodType; resume?: ZodType }, -): BuiltTool { - const handler = async ( - input: unknown, - ctx: ToolContext | InterruptibleToolContext, - ): Promise => { - if (toolSchema.hasSuspend && 'suspend' in ctx) { - // Interruptible tool: the real ctx.suspend is a host-side function. - // We pass serialisable ctx data into the sandbox, and the sandbox - // returns a marker if suspend was called. Then we call the real - // ctx.suspend on the host. - const interruptCtx = ctx; - const result = await executor.executeTool(toolSchema.name, input, { - resumeData: interruptCtx.resumeData, - parentTelemetry: ctx.parentTelemetry, - }); - - if (isSuspendResult(result)) { - return await interruptCtx.suspend(result.payload); - } - return result; - } - - // Non-interruptible tool: pass ctx through directly (only serialisable - // fields like parentTelemetry). - return await executor.executeTool(toolSchema.name, input, { - parentTelemetry: ctx.parentTelemetry, - }); - }; - - // toMessage: The runtime calls toMessage synchronously (agent-runtime.ts). - // When the executor provides a sync variant (executeToMessageSync), use it - // directly for an immediate result. Otherwise fall back to async with a - // stale-cache workaround. - let toMessage: ((output: unknown) => AgentMessage | undefined) | undefined; - if (toolSchema.hasToMessage) { - if (executor.executeToMessageSync) { - const syncExecutor = executor.executeToMessageSync.bind(executor); - toMessage = (output: unknown): AgentMessage | undefined => { - return syncExecutor(toolSchema.name, output); - }; - } else { - throw new Error('Executor does not support executeToMessageSync'); - } - } - - const built: BuiltTool = { - name: toolSchema.name, - description: toolSchema.description, - inputSchema: (toolSchema.inputSchema as JSONSchema7) ?? undefined, - handler, - toMessage, - suspendSchema: preEvaluated?.suspend, - resumeSchema: preEvaluated?.resume, - providerOptions: toolSchema.providerOptions - ? (toolSchema.providerOptions as Record) - : undefined, - }; - - // If the tool requires approval, wrap it with the approval gate. - // This re-applies the same wrapping that Tool.build() does at define time. - if (toolSchema.requireApproval) { - return wrapToolForApproval(built, { requireApproval: true }); - } - - return built; -} - -/** - * Build a `BuiltEval` from an `EvalSchema` with a proxy _run function - * that delegates execution to the `HandlerExecutor`. - */ -function buildEvalFromSchema(evalSchema: EvalSchema, executor: HandlerExecutor): BuiltEval { - return { - name: evalSchema.name, - description: evalSchema.description ?? undefined, - evalType: evalSchema.type, - modelId: evalSchema.modelId ?? null, - credentialName: evalSchema.credentialName ?? null, - _run: async (evalInput: EvalInput): Promise => { - // For judge evals, the llm function is bound inside the module - // when the full module runs in the sandbox. The executor passes - // the input to _run() which already has llm in its closure. - return await executor.executeEval(evalSchema.name, evalInput as EvalInput | JudgeInput); - }, - }; -} diff --git a/packages/@n8n/agents/src/sdk/provider-capabilities.ts b/packages/@n8n/agents/src/sdk/provider-capabilities.ts index 4fcc145ce69..6f47b66de1f 100644 --- a/packages/@n8n/agents/src/sdk/provider-capabilities.ts +++ b/packages/@n8n/agents/src/sdk/provider-capabilities.ts @@ -29,7 +29,9 @@ export const providerCapabilities: Record< groq: {}, deepseek: {}, mistral: {}, - openrouter: {}, cohere: {}, - ollama: {}, + vercel: {}, + openrouter: {}, + 'azure-openai': {}, + 'aws-bedrock': {}, }; diff --git a/packages/@n8n/agents/src/sdk/provider-tools.ts b/packages/@n8n/agents/src/sdk/provider-tools.ts index ec641cc2028..4073babc4ca 100644 --- a/packages/@n8n/agents/src/sdk/provider-tools.ts +++ b/packages/@n8n/agents/src/sdk/provider-tools.ts @@ -1,6 +1,6 @@ import type { BuiltProviderTool } from '../types'; -interface WebSearchConfig { +interface AnthropicWebSearchConfig { maxUses?: number; allowedDomains?: string[]; blockedDomains?: string[]; @@ -13,6 +13,30 @@ interface WebSearchConfig { }; } +interface OpenAIWebSearchConfig { + /** + * When set to `true`, lets the model fetch page content from the open web. + * Defaults to OpenAI's own defaults when omitted. + */ + externalWebAccess?: boolean; + /** Restrict results to the given domains (allow-list). */ + filters?: { + allowedDomains?: string[]; + }; + /** + * How much surrounding page content to include per result. Trades off + * token cost against answer quality. Defaults to OpenAI's own default. + */ + searchContextSize?: 'low' | 'medium' | 'high'; + userLocation?: { + type: 'approximate'; + city?: string; + region?: string; + country?: string; + timezone?: string; + }; +} + /** * Factory for creating provider-defined tools. * @@ -36,7 +60,7 @@ export const providerTools = { * .build(); * ``` */ - anthropicWebSearch(config?: WebSearchConfig): BuiltProviderTool { + anthropicWebSearch(config?: AnthropicWebSearchConfig): BuiltProviderTool { const args: Record = {}; if (config?.maxUses !== undefined) { @@ -53,11 +77,57 @@ export const providerTools = { } return { + // Intentionally on the pre-dynamic-filtering version. The newer + // `web_search_20260209` forces a server-side code-execution pipeline + // (the AI SDK auto-adds the `code-execution-web-tools-2026-02-09` + // beta) which is slower, emits code_execution tool results on every + // search, and is only officially supported on Sonnet 4.6 / Opus 4.6 + // / Opus 4.7. The 20250305 version is fast, stable, and works + // across all Claude models that support web search. + // See https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool name: 'anthropic.web_search_20250305', args, }; }, + /** + * OpenAI's web search tool — available via the Responses API. Gives the + * model access to real-time web content with automatic citations. + * + * Only works on models that the Responses API supports (e.g. GPT-4o + * and successors). Older chat-completions-only models will reject it. + * + * @example + * ```typescript + * const agent = new Agent('researcher') + * .model('openai/gpt-4o') + * .instructions('Research topics using web search.') + * .providerTool(providerTools.openaiWebSearch({ searchContextSize: 'medium' })) + * .build(); + * ``` + */ + openaiWebSearch(config?: OpenAIWebSearchConfig): BuiltProviderTool { + const args: Record = {}; + + if (config?.externalWebAccess !== undefined) { + args.externalWebAccess = config.externalWebAccess; + } + if (config?.filters) { + args.filters = config.filters; + } + if (config?.searchContextSize) { + args.searchContextSize = config.searchContextSize; + } + if (config?.userLocation) { + args.userLocation = config.userLocation; + } + + return { + name: 'openai.web_search', + args, + }; + }, + openaiImageGeneration(): BuiltProviderTool { return { name: 'openai.image_generation', diff --git a/packages/@n8n/agents/src/sdk/tool.ts b/packages/@n8n/agents/src/sdk/tool.ts index 2194901cf75..8120b9323f0 100644 --- a/packages/@n8n/agents/src/sdk/tool.ts +++ b/packages/@n8n/agents/src/sdk/tool.ts @@ -1,8 +1,11 @@ +import type { JSONSchema7 } from 'json-schema'; import { z } from 'zod'; import type { BuiltTool, InterruptibleToolContext, ToolContext } from '../types'; import type { AgentMessage } from '../types/sdk/message'; +import type { ToolDescriptor } from '../types/sdk/tool-descriptor'; import type { JSONObject } from '../types/utils/json'; +import { isZodSchema, zodToJsonSchema } from '../utils/zod'; const APPROVAL_SUSPEND_SCHEMA = z.object({ type: z.literal('approval'), @@ -14,6 +17,10 @@ const APPROVAL_RESUME_SCHEMA = z.object({ approved: z.boolean(), }); +type ZodOrJsonSchema = z.ZodType | JSONSchema7; + +type OutputType = TOutput extends z.ZodType ? z.infer : unknown; + export interface ApprovalConfig { requireApproval?: boolean; needsApprovalFn?: (args: unknown) => Promise | boolean; @@ -65,8 +72,8 @@ export function wrapToolForApproval(tool: BuiltTool, config: ApprovalConfig): Bu }; } -type HandlerContext = S extends z.ZodTypeAny - ? R extends z.ZodTypeAny +type HandlerContext = S extends z.ZodType + ? R extends z.ZodType ? InterruptibleToolContext, z.infer> : ToolContext : ToolContext; @@ -90,10 +97,10 @@ type HandlerContext = S extends z.ZodTypeAny * @template TResume - Zod schema type for the resume payload */ export class Tool< - TInput extends z.ZodTypeAny = z.ZodTypeAny, - TOutput extends z.ZodTypeAny = z.ZodTypeAny, - TSuspend extends z.ZodTypeAny | undefined = undefined, - TResume extends z.ZodTypeAny | undefined = undefined, + TInput extends ZodOrJsonSchema = z.ZodTypeAny, + TOutput extends ZodOrJsonSchema = z.ZodTypeAny, + TSuspend extends ZodOrJsonSchema | undefined = undefined, + TResume extends ZodOrJsonSchema | undefined = undefined, > { private name: string; @@ -103,18 +110,18 @@ export class Tool< private outputSchema?: TOutput; - private suspendSchemaValue?: z.ZodTypeAny; + private suspendSchemaValue?: TSuspend; - private resumeSchemaValue?: z.ZodTypeAny; + private resumeSchemaValue?: TResume; private handlerFn?: ( - input: z.infer, + input: OutputType, ctx: HandlerContext, - ) => Promise>; + ) => Promise>; - private toMessageFn?: (output: z.infer) => AgentMessage; + private toMessageFn?: (output: OutputType) => AgentMessage; - private toModelOutputFn?: (output: z.infer) => unknown; + private toModelOutputFn?: (output: OutputType) => unknown; private providerOptionsValue?: Record; @@ -122,6 +129,8 @@ export class Tool< private needsApprovalFnValue?: (args: unknown) => Promise | boolean; + private systemInstructionText?: string; + constructor(name: string) { this.name = name; } @@ -132,29 +141,43 @@ export class Tool< return this; } + /** + * Attach a behavioural directive to this tool. When the tool is registered + * with an agent, the runtime injects this text into the agent's system + * prompt under a `` block, where the LLM weighs it heavily + * for "should I call this tool?" decisions. + * + * Use sparingly — only for guidance the description alone doesn't reliably + * convey (e.g. "prefer this tool over plain text when X"). + */ + systemInstruction(text: string): this { + this.systemInstructionText = text; + return this; + } + /** Set the input Zod schema. Required before building. */ - input(schema: S): Tool { + input(schema: S): Tool { const self = this as unknown as Tool; self.inputSchema = schema; return self; } /** Set the output Zod schema. Optional. */ - output(schema: S): Tool { + output(schema: S): Tool { const self = this as unknown as Tool; self.outputSchema = schema; return self; } /** Set the suspend payload schema. Must be paired with .resume(). */ - suspend(schema: S): Tool { + suspend(schema: S): Tool { const self = this as unknown as Tool; self.suspendSchemaValue = schema; return self; } /** Set the resume payload schema. Must be paired with .suspend(). */ - resume(schema: R): Tool { + resume(schema: R): Tool { const self = this as unknown as Tool; self.resumeSchemaValue = schema; return self; @@ -166,15 +189,15 @@ export class Tool< */ handler( fn: ( - input: z.infer, + input: OutputType, ctx: HandlerContext, - ) => Promise>, + ) => Promise>, ): this { this.handlerFn = fn; return this; } - toMessage(toMessage: (output: z.infer) => AgentMessage): this { + toMessage(toMessage: (output: OutputType) => AgentMessage): this { this.toMessageFn = toMessage; return this; } @@ -186,7 +209,7 @@ export class Tool< * Useful for truncating large outputs, redacting sensitive data, or reformatting * the result for better LLM comprehension. */ - toModelOutput(fn: (output: z.infer) => unknown): this { + toModelOutput(fn: (output: OutputType) => unknown): this { this.toModelOutputFn = fn; return this; } @@ -198,7 +221,7 @@ export class Tool< } /** Conditionally require approval based on the tool's input. Mutually exclusive with .suspend()/.resume(). */ - needsApprovalFn(fn: (args: z.infer) => Promise | boolean): this { + needsApprovalFn(fn: (args: OutputType) => Promise | boolean): this { this.needsApprovalFnValue = fn as (args: unknown) => Promise | boolean; return this; } @@ -255,6 +278,7 @@ export class Tool< const built: BuiltTool = { name: this.name, description: this.desc, + systemInstruction: this.systemInstructionText, suspendSchema: this.suspendSchemaValue, resumeSchema: this.resumeSchemaValue, toMessage: this.toMessageFn as (output: unknown) => AgentMessage | undefined, @@ -277,4 +301,36 @@ export class Tool< return built; } + + /** + * Return a lightweight JSON descriptor of this tool's metadata. + * Does NOT require .build() to be called first. + * Used by the JSON-config flow to store tool metadata without executing the handler. + */ + describe(): ToolDescriptor { + if (!this.name) throw new Error('Tool name is required'); + if (!this.desc) throw new Error(`Tool "${this.name}" requires a description`); + if (!this.inputSchema) throw new Error(`Tool "${this.name}" requires an input schema`); + + const inputSchema = isZodSchema(this.inputSchema) + ? zodToJsonSchema(this.inputSchema) + : this.inputSchema; + const outputSchema = this.outputSchema + ? isZodSchema(this.outputSchema) + ? zodToJsonSchema(this.outputSchema) + : this.outputSchema + : null; + return { + name: this.name, + description: this.desc, + systemInstruction: this.systemInstructionText ?? null, + inputSchema: inputSchema as JSONSchema7, + outputSchema: outputSchema as JSONSchema7, + hasSuspend: this.suspendSchemaValue !== undefined, + hasResume: this.resumeSchemaValue !== undefined, + hasToMessage: this.toMessageFn !== undefined, + requireApproval: this.requireApprovalValue ?? false, + providerOptions: this.providerOptionsValue ?? null, + }; + } } diff --git a/packages/@n8n/agents/src/sdk/verify.ts b/packages/@n8n/agents/src/sdk/verify.ts index cf619e13d46..3edbbb4c3c1 100644 --- a/packages/@n8n/agents/src/sdk/verify.ts +++ b/packages/@n8n/agents/src/sdk/verify.ts @@ -45,15 +45,7 @@ export function verify(source: string): VerifyResult { } if (/process\.env\b/.test(source)) { - errors.push( - 'process.env is not available. Use .credential() for API keys, or const variables for configuration.', - ); - } - - if (!/\.credential\s*\(/.test(source)) { - errors.push( - "No .credential() found. Every agent must declare a credential (e.g. .credential('anthropic')).", - ); + errors.push('process.env is not available. Use const variables for configuration.'); } return { ok: errors.length === 0, errors }; diff --git a/packages/@n8n/agents/src/storage/base-memory.ts b/packages/@n8n/agents/src/storage/base-memory.ts new file mode 100644 index 00000000000..ff756899126 --- /dev/null +++ b/packages/@n8n/agents/src/storage/base-memory.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/promise-function-async */ +import type { BuiltMemory, MemoryDescriptor, Thread } from '../types/sdk/memory'; +import type { AgentDbMessage } from '../types/sdk/message'; +import type { JSONObject } from '../types/utils/json'; + +export abstract class BaseMemory + implements BuiltMemory +{ + constructor( + protected readonly name: string, + protected readonly constructorOptions: TConstructorOptions, + ) {} + + getThread(_threadId: string): Promise { + throw new Error('Method not implemented.'); + } + saveThread(_thread: Omit): Promise { + throw new Error('Method not implemented.'); + } + deleteThread(_threadId: string): Promise { + throw new Error('Method not implemented.'); + } + getMessages( + _threadId: string, + _opts?: { limit?: number; before?: Date }, + ): Promise { + throw new Error('Method not implemented.'); + } + saveMessages(_args: { + threadId: string; + resourceId: string; + messages: AgentDbMessage[]; + }): Promise { + throw new Error('Method not implemented.'); + } + deleteMessages(_messageIds: string[]): Promise { + throw new Error('Method not implemented.'); + } + search?( + _query: string, + _opts?: { + scope?: 'thread' | 'resource'; + threadId?: string; + resourceId?: string; + topK?: number; + messageRange?: { before: number; after: number }; + }, + ): Promise { + throw new Error('Method not implemented.'); + } + getWorkingMemory?(_params: { + threadId: string; + resourceId: string; + scope: 'resource' | 'thread'; + }): Promise { + throw new Error('Method not implemented.'); + } + saveWorkingMemory?( + _params: { threadId: string; resourceId: string; scope: 'resource' | 'thread' }, + _content: string, + ): Promise { + throw new Error('Method not implemented.'); + } + saveEmbeddings?(_opts: { + scope?: 'thread' | 'resource'; + threadId?: string; + resourceId?: string; + entries: Array<{ id: string; vector: number[]; text: string; model: string }>; + }): Promise { + throw new Error('Method not implemented.'); + } + queryEmbeddings?(_opts: { + scope?: 'thread' | 'resource'; + threadId?: string; + resourceId?: string; + vector: number[]; + topK: number; + }): Promise> { + throw new Error('Method not implemented.'); + } + + close?(): Promise { + throw new Error('Method not implemented.'); + } + + describe(): MemoryDescriptor { + return { + name: this.name, + constructorName: this.constructor.name, + connectionParams: this.constructorOptions, + }; + } +} diff --git a/packages/@n8n/agents/src/storage/postgres-memory.ts b/packages/@n8n/agents/src/storage/postgres-memory.ts index 2c665610251..7c389905d1e 100644 --- a/packages/@n8n/agents/src/storage/postgres-memory.ts +++ b/packages/@n8n/agents/src/storage/postgres-memory.ts @@ -1,7 +1,7 @@ import type { Pool, PoolClient } from 'pg'; -import type { ConnectionOptions } from 'tls'; -import type { BuiltMemory, Thread } from '../types/sdk/memory'; +import { BaseMemory } from './base-memory'; +import type { Thread } from '../types/sdk/memory'; import type { AgentDbMessage, AgentMessage } from '../types/sdk/message'; interface ThreadRow { @@ -40,24 +40,24 @@ function parseJsonSafe(text: string): unknown { } } -export interface PostgresConnectionConfig { - /** Postgres host. Defaults to 'localhost'. */ - host?: string; - /** Postgres port. Defaults to 5432. */ - port?: number; - /** Database name. */ - database?: string; - /** Database user. */ - user?: string; - /** Database password. */ - password?: string | (() => string | Promise); -} - -export interface PostgresMemoryConfig { - // --- Connection --- - /** Connection URL string (e.g. 'postgresql://user:pass@localhost:5432/db') or individual connection parameters. */ - connection: string | PostgresConnectionConfig; +export type PostgresConnectionOptions = + | { connectionType: 'url'; connection: { url: string } } + | { + connectionType: 'config'; + connection: { + /** Postgres host. Defaults to 'localhost'. */ host?: string; + /** Postgres port. Defaults to 5432. */ + port?: number; + /** Database name. */ + database?: string; + /** Database user. */ + user?: string; + /** Database password. Always credential-backed — never a raw string. */ + password?: string; + }; + }; +export type PostgresMemoryOptions = { // --- Pool settings --- /** Connection pool configuration. */ pool?: { @@ -75,32 +75,47 @@ export interface PostgresMemoryConfig { // --- Security --- /** SSL configuration. `true` for default SSL, or a TLS ConnectionOptions object. */ - ssl?: boolean | ConnectionOptions; + ssl?: boolean; // --- SDK options --- /** Table name prefix for multi-tenant isolation. Alphanumeric and underscores only. */ namespace?: string; -} +}; -export class PostgresMemory implements BuiltMemory { +export type PostgresConstructorOptions = ( + | { + type: 'credential'; + credential: string; + } + | { + type: 'connection'; + connection: PostgresConnectionOptions; + } +) & { + options?: PostgresMemoryOptions; +}; + +export class PostgresMemory extends BaseMemory { private initPromise: Promise | null = null; private embeddingsInitPromise: Promise | null = null; - private readonly config: PostgresMemoryConfig; - private readonly ns: string; - constructor(config: PostgresMemoryConfig) { - if (config.namespace !== undefined) { - if (!/^[a-zA-Z0-9_]+$/.test(config.namespace)) { + constructor( + protected readonly constructorOptions: PostgresConstructorOptions, + private readonly resolveConfig?: (credential: string) => Promise, + ) { + super('postgres', constructorOptions); + const namespace = constructorOptions.options?.namespace; + if (namespace !== undefined) { + if (!/^[a-zA-Z0-9_]+$/.test(namespace)) { throw new Error( - `Invalid namespace "${config.namespace}": must be alphanumeric and underscores only`, + `Invalid namespace "${namespace}": must be alphanumeric and underscores only`, ); } } - this.config = config; - this.ns = config.namespace ? `${config.namespace}_` : ''; + this.ns = namespace ? `${namespace}_` : ''; } // ── Lazy initialisation ────────────────────────────────────────────── @@ -115,34 +130,53 @@ export class PostgresMemory implements BuiltMemory { private async _initialize(): Promise { const { Pool: PgPool } = await import('pg'); - const conn = this.config.connection; - const connectionOpts = - typeof conn === 'string' - ? { connectionString: conn } - : { - ...(conn.host && { host: conn.host }), - ...(conn.port && { port: conn.port }), - ...(conn.database && { database: conn.database }), - ...(conn.user && { user: conn.user }), - ...(conn.password !== undefined && { password: conn.password }), - }; + let connectionOpts: Record; + if (this.constructorOptions.type === 'credential' && !this.resolveConfig) { + throw new Error('resolveConfig() was not provided in constructor options'); + } + + const config = + this.constructorOptions.type === 'credential' + ? await this.resolveConfig!(this.constructorOptions.credential) + : this.constructorOptions.connection; + + if (config.connectionType === 'url') { + const url = config.connection.url; + connectionOpts = { connectionString: url }; + } else { + const cfg = config.connection; + const host = cfg.host; + const port = cfg.port; + const database = cfg.database; + const user = cfg.user; + const password = cfg.password; + connectionOpts = { + ...(host !== undefined && { host }), + ...(port !== undefined && { port }), + ...(database !== undefined && { database }), + ...(user !== undefined && { user }), + ...(password !== undefined && { password }), + }; + } + + const opts = this.constructorOptions.options; const pool = new PgPool({ ...connectionOpts, // Pool - ...(this.config.pool?.max !== undefined && { max: this.config.pool.max }), - ...(this.config.pool?.min !== undefined && { min: this.config.pool.min }), - ...(this.config.pool?.idleTimeoutMillis !== undefined && { - idleTimeoutMillis: this.config.pool.idleTimeoutMillis, + ...(opts?.pool?.max !== undefined && { max: opts.pool.max }), + ...(opts?.pool?.min !== undefined && { min: opts.pool.min }), + ...(opts?.pool?.idleTimeoutMillis !== undefined && { + idleTimeoutMillis: opts.pool.idleTimeoutMillis, }), - ...(this.config.pool?.connectionTimeoutMillis !== undefined && { - connectionTimeoutMillis: this.config.pool.connectionTimeoutMillis, + ...(opts?.pool?.connectionTimeoutMillis !== undefined && { + connectionTimeoutMillis: opts.pool.connectionTimeoutMillis, }), - ...(this.config.pool?.allowExitOnIdle !== undefined && { - allowExitOnIdle: this.config.pool.allowExitOnIdle, + ...(opts?.pool?.allowExitOnIdle !== undefined && { + allowExitOnIdle: opts.pool.allowExitOnIdle, }), // Security - ...(this.config.ssl !== undefined && { ssl: this.config.ssl }), + ...(opts?.ssl !== undefined && { ssl: opts.ssl }), }); await pool.query( diff --git a/packages/@n8n/agents/src/storage/sqlite-memory.ts b/packages/@n8n/agents/src/storage/sqlite-memory.ts index bb3f03d9aea..95a13d56436 100644 --- a/packages/@n8n/agents/src/storage/sqlite-memory.ts +++ b/packages/@n8n/agents/src/storage/sqlite-memory.ts @@ -1,6 +1,8 @@ import type { Client, InArgs } from '@libsql/client'; +import { z } from 'zod'; -import type { BuiltMemory, Thread } from '../types/sdk/memory'; +import { BaseMemory } from './base-memory'; +import type { Thread } from '../types/sdk/memory'; import type { AgentDbMessage } from '../types/sdk/message'; /** Safe JSON.parse wrapper — returns undefined on failure. */ @@ -18,12 +20,19 @@ function float32ToBuffer(arr: number[]): Buffer { return Buffer.from(f32.buffer); } -export interface SqliteMemoryConfig { - url: string; // e.g. 'file:./data.db' - namespace?: string; // table name prefix -} +export const SqliteMemoryConfigSchema = z.object({ + /** libsql connection URL. Use `'file:./path/to/db.sqlite'` for a local file. */ + url: z.string().min(1), + /** Optional table name prefix for multi-tenant isolation. Alphanumeric and underscores only. */ + namespace: z + .string() + .regex(/^[a-zA-Z0-9_]+$/) + .optional(), +}); -export class SqliteMemory implements BuiltMemory { +export type SqliteMemoryConfig = z.infer; + +export class SqliteMemory extends BaseMemory { private initPromise: Promise | null = null; private embeddingsInitPromise: Promise | null = null; @@ -32,16 +41,10 @@ export class SqliteMemory implements BuiltMemory { private readonly ns: string; - constructor(config: SqliteMemoryConfig) { - if (config.namespace !== undefined) { - if (!/^[a-zA-Z0-9_]+$/.test(config.namespace)) { - throw new Error( - `Invalid namespace "${config.namespace}": must be alphanumeric and underscores only`, - ); - } - } - this.config = config; - this.ns = config.namespace ? `${config.namespace}_` : ''; + constructor(protected readonly constructorOptions: SqliteMemoryConfig) { + super('sqlite', constructorOptions); + this.config = SqliteMemoryConfigSchema.parse(constructorOptions); + this.ns = constructorOptions.namespace ? `${constructorOptions.namespace}_` : ''; } // ── Lazy initialisation ────────────────────────────────────────────── diff --git a/packages/@n8n/agents/src/types/index.ts b/packages/@n8n/agents/src/types/index.ts index 013d8de4b59..d10b8500e44 100644 --- a/packages/@n8n/agents/src/types/index.ts +++ b/packages/@n8n/agents/src/types/index.ts @@ -9,7 +9,6 @@ export type { ContentReasoning, ContentFile, ContentToolCall, - ContentToolResult, ContentInvalidToolCall, ContentProvider, Message, @@ -61,6 +60,7 @@ export type { export type { Thread, BuiltMemory, + MemoryDescriptor, SemanticRecallConfig, MemoryConfig, CheckpointStore, diff --git a/packages/@n8n/agents/src/types/runtime/event.ts b/packages/@n8n/agents/src/types/runtime/event.ts index 0d5de54a446..75de2929686 100644 --- a/packages/@n8n/agents/src/types/runtime/event.ts +++ b/packages/@n8n/agents/src/types/runtime/event.ts @@ -1,5 +1,4 @@ -import type { AgentPersistenceOptions } from '../sdk/agent'; -import type { AgentMessage, ContentToolResult } from '../sdk/message'; +import type { AgentMessage, ContentToolCall } from '../sdk/message'; export const enum AgentEvent { AgentStart = 'agent_start', @@ -11,16 +10,11 @@ export const enum AgentEvent { Error = 'error', } -export type SharedAgentEventData = { - runId: string; - persistence?: AgentPersistenceOptions; -}; - -export type AgentEventSpecificData = +export type AgentEventData = | { type: AgentEvent.AgentStart } | { type: AgentEvent.AgentEnd; messages: AgentMessage[] } | { type: AgentEvent.TurnStart } - | { type: AgentEvent.TurnEnd; message: AgentMessage; toolResults: ContentToolResult[] } + | { type: AgentEvent.TurnEnd; message: AgentMessage; toolResults: ContentToolCall[] } | { type: AgentEvent.ToolExecutionStart; toolCallId: string; toolName: string; args: unknown } | { type: AgentEvent.ToolExecutionEnd; @@ -31,8 +25,6 @@ export type AgentEventSpecificData = } | { type: AgentEvent.Error; message: string; error: unknown }; -export type AgentEventData = SharedAgentEventData & AgentEventSpecificData; - export type AgentEventHandler = (data: AgentEventData) => void; // Can be used for observability or controlling the agent. The idea that HITL, guardrails, logging, etc. can be done as middleware and single point of entry. diff --git a/packages/@n8n/agents/src/types/sdk/agent-builder.ts b/packages/@n8n/agents/src/types/sdk/agent-builder.ts index c5d9f54351b..546241eb145 100644 --- a/packages/@n8n/agents/src/types/sdk/agent-builder.ts +++ b/packages/@n8n/agents/src/types/sdk/agent-builder.ts @@ -1,5 +1,4 @@ import type { ModelConfig } from './agent'; -import type { CredentialProvider } from './credential-provider'; import type { BuiltEval } from './eval'; import type { BuiltGuardrail } from './guardrail'; import type { CheckpointStore } from './memory'; @@ -16,7 +15,6 @@ import type { BuiltProviderTool, BuiltTool } from './tool'; */ export interface AgentBuilder { model(providerOrIdOrConfig: string | ModelConfig, modelName?: string): this; - credential(name: string): this; instructions(text: string): this; tool(t: BuiltTool | BuiltTool[]): this; providerTool(t: BuiltProviderTool): this; @@ -25,7 +23,6 @@ export interface AgentBuilder { requireToolApproval(): this; memory(m: unknown): this; checkpoint(storage: 'memory' | CheckpointStore): this; - credentialProvider(p: CredentialProvider): this; inputGuardrail(g: BuiltGuardrail): this; outputGuardrail(g: BuiltGuardrail): this; eval(e: BuiltEval): this; diff --git a/packages/@n8n/agents/src/types/sdk/agent.ts b/packages/@n8n/agents/src/types/sdk/agent.ts index 87589a58a5c..fc9697a0e4d 100644 --- a/packages/@n8n/agents/src/types/sdk/agent.ts +++ b/packages/@n8n/agents/src/types/sdk/agent.ts @@ -4,6 +4,7 @@ import type { JsonSchema7Type } from 'zod-to-json-schema'; import type { AgentMessage, ContentMetadata } from './message'; import type { BuiltTool } from './tool'; +import type { ProviderId, ProviderCredentials } from '../../runtime/provider-credentials'; import type { AgentEvent, AgentEventHandler } from '../runtime/event'; import type { SerializedMessageList } from '../runtime/message-list'; import type { BuiltTelemetry } from '../telemetry'; @@ -27,12 +28,20 @@ export type TokenUsage = Record; +}[ProviderId]; + export type ModelConfig = | string - | { id: string; apiKey?: string; url?: string; headers?: Record } + | TypedModelConfig + | { id: string; [k: string]: unknown } + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- LanguageModel is semantically distinct from string | LanguageModel; -/* eslint-enable @typescript-eslint/no-redundant-type-constituents */ export interface AgentResult { id?: string; @@ -53,6 +62,47 @@ export interface AgentResult { export type StreamChunk = ContentMetadata & ( + | { type: 'start-step' } + | { type: 'finish-step' } + | { type: 'text-start'; id: string } + | { type: 'text-delta'; id: string; delta: string } + | { type: 'text-end'; id: string } + | { type: 'reasoning-start'; id: string } + | { type: 'reasoning-delta'; id: string; delta: string } + | { type: 'reasoning-end'; id: string } + | { type: 'tool-input-start'; toolCallId: string; toolName: string } + | { type: 'tool-input-delta'; toolCallId: string; delta: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + | { + /** + * Emitted just before a tool handler starts executing. Bridged from + * the runtime event bus (not part of the AI SDK fullStream). Pairs + * with the subsequent `tool-result` to let consumers show a + * mid-flight indicator between "LLM picked a tool" and "result arrived". + */ + type: 'tool-execution-start'; + toolCallId: string; + toolName: string; + } + | { + type: 'tool-result'; + toolCallId: string; + toolName: string; + output: unknown; + isError?: boolean; + } + | { + type: 'tool-call-suspended'; + runId: string; + toolCallId: string; + toolName: string; + input?: unknown; + suspendPayload?: unknown; + /** JSON Schema describing the shape of data to send when resuming. */ + resumeSchema?: JsonSchema7Type; + } + // `message` is reserved for sub-agent / app-defined `CustomAgentMessage` + | { type: 'message'; message: AgentMessage } | { type: 'finish'; finishReason: FinishReason; @@ -62,41 +112,7 @@ export type StreamChunk = ContentMetadata & subAgentUsage?: SubAgentUsage[]; totalCost?: number; } - | { - type: 'text-delta'; - id?: string; - delta: string; - } - | { - type: 'reasoning-delta'; - id?: string; - delta: string; - } - | { - type: 'tool-call-delta'; - id?: string; - name?: string; - argumentsDelta?: string; - } - | { - type: 'error'; - error: unknown; - } - | { - type: 'message'; - message: AgentMessage; - id?: string; - } - | { - type: 'tool-call-suspended'; - runId?: string; - toolCallId?: string; - toolName?: string; - input?: unknown; - suspendPayload?: unknown; - /** JSON Schema describing the shape of data to send when resuming. */ - resumeSchema?: JsonSchema7Type; - } + | { type: 'error'; error: unknown } ); export interface RunOptions { @@ -167,8 +183,6 @@ export interface GenerateResult { * callers can handle them without try/catch. */ error?: unknown; - /** Return a snapshot of the agent state at the end of this run. */ - getState(): SerializableAgentState; } export interface StreamResult { @@ -176,12 +190,6 @@ export interface StreamResult { runId: string; /** The readable stream of chunks. */ stream: ReadableStream; - /** - * Return the current agent state for this run. - * May be called at any time — during streaming to observe live status, - * or after the stream closes to confirm the terminal state. - */ - getState(): SerializableAgentState; } export interface ResumeOptions { @@ -205,6 +213,8 @@ export interface BuiltAgent { asTool(description: string): BuiltTool; + getState(): SerializableAgentState; + /** Cancel the currently running agent. Synchronous — sets an abort flag that the agentic loop checks asynchronously. */ abort(): void; @@ -256,6 +266,7 @@ export type PendingToolCall = { suspended: true; suspendPayload: unknown; resumeSchema: JsonSchema7Type; + runId: string; } | { suspended: false; diff --git a/packages/@n8n/agents/src/types/sdk/credential-provider.ts b/packages/@n8n/agents/src/types/sdk/credential-provider.ts index b28b6aed234..563be266632 100644 --- a/packages/@n8n/agents/src/types/sdk/credential-provider.ts +++ b/packages/@n8n/agents/src/types/sdk/credential-provider.ts @@ -1,8 +1,7 @@ -/** A resolved credential containing at minimum an API key. */ -export interface ResolvedCredential { - apiKey: string; +/** A resolved credential. May contain an API key and/or provider-specific fields. */ +export type ResolvedCredential = { [key: string]: unknown; -} +}; /** Summary of a credential available for use. */ export interface CredentialListItem { diff --git a/packages/@n8n/agents/src/types/sdk/memory.ts b/packages/@n8n/agents/src/types/sdk/memory.ts index 4953c996b1a..52c38011033 100644 --- a/packages/@n8n/agents/src/types/sdk/memory.ts +++ b/packages/@n8n/agents/src/types/sdk/memory.ts @@ -2,6 +2,20 @@ import type { z } from 'zod'; import type { ModelConfig, SerializableAgentState } from './agent'; import type { AgentDbMessage } from './message'; +import type { JSONObject } from '../utils/json'; + +/** + * Serializable descriptor returned by BuiltMemory.describe(). + * Contains enough information to reconstruct the backend from a schema without exposing secrets. + */ +export interface MemoryDescriptor { + /** Backend name (e.g. 'postgres', 'sqlite', 'memory'). Used as key in memoryRegistry. */ + name: string; + /** Constructor name (e.g. 'PostgresMemory', 'SqliteMemory'). Used to construct the backend. */ + constructorName: string; + /** Non-secret, serializable connection parameters. CredentialConfig refs are safe to store. */ + connectionParams: TParams | null; +} export interface Thread { id: string; @@ -84,6 +98,8 @@ export interface BuiltMemory { // --- Lifecycle (optional) --- /** Close the connection pool / release resources. No-op for in-memory backends. */ close?(): Promise; + /** Return a serializable descriptor of this backend for schema persistence. */ + describe(): MemoryDescriptor; } // --- Semantic Recall Config --- @@ -103,6 +119,8 @@ export interface TitleGenerationConfig { model?: ModelConfig; /** Custom instructions for the title generation prompt. Replaces the defaults entirely. */ instructions?: string; + /** When true, title generation is awaited before returning the result. Default: false (fire-and-forget). */ + sync?: boolean; } /** Full memory configuration bundle passed from builder to runtime. */ diff --git a/packages/@n8n/agents/src/types/sdk/message.ts b/packages/@n8n/agents/src/types/sdk/message.ts index 7a4dc8f0840..935829f2b0c 100644 --- a/packages/@n8n/agents/src/types/sdk/message.ts +++ b/packages/@n8n/agents/src/types/sdk/message.ts @@ -8,7 +8,6 @@ export type MessageContent = | ContentText | ContentToolCall | ContentInvalidToolCall - | ContentToolResult | ContentReasoning | ContentFile | ContentCitation @@ -90,7 +89,7 @@ export type ContentToolCall = ContentMetadata & { /** * The identifier of the tool call. It must be unique across all tool calls. */ - toolCallId?: string; + toolCallId: string; /** * The name of the tool that should be called. @@ -104,31 +103,11 @@ export type ContentToolCall = ContentMetadata & { input: JSONValue; providerExecuted?: boolean; -}; - -export type ContentToolResult = ContentMetadata & { - type: 'tool-result'; - - /** - * The name of the tool that was called. - */ - toolName: string; - - /** - * The ID of the tool call that this result is associated with. - */ - toolCallId: string; - - /** - * Result of the tool call. This is a JSON-serializable object. - */ - result: JSONValue; - - /** - * Optional flag if the result is an error or an error message. - */ - isError?: boolean; -}; +} & ( + | { state: 'pending' } + | { state: 'resolved'; output: JSONValue } + | { state: 'rejected'; error: string } + ); export type ContentInvalidToolCall = ContentMetadata & { type: 'invalid-tool-call'; diff --git a/packages/@n8n/agents/src/types/sdk/tool-descriptor.ts b/packages/@n8n/agents/src/types/sdk/tool-descriptor.ts new file mode 100644 index 00000000000..f6c6fbe080b --- /dev/null +++ b/packages/@n8n/agents/src/types/sdk/tool-descriptor.ts @@ -0,0 +1,21 @@ +import type { JSONSchema7 } from 'json-schema'; + +export interface ToolDescriptor { + name: string; + description: string; + /** + * Behavioural directive paired with the tool. Persisted on the descriptor + * so it survives the JSON-config save → publish → reconstruct cycle for + * custom tools — without this, `Tool.systemInstruction(...)` would only + * apply to in-memory/runtime-injected tools and would silently drop on + * reload. + */ + systemInstruction: string | null; + inputSchema: JSONSchema7 | null; + outputSchema: JSONSchema7 | null; + hasSuspend: boolean; + hasResume: boolean; + hasToMessage: boolean; + requireApproval: boolean; + providerOptions: Record | null; +} diff --git a/packages/@n8n/agents/src/types/sdk/tool.ts b/packages/@n8n/agents/src/types/sdk/tool.ts index 20969844e5e..9056787f1b5 100644 --- a/packages/@n8n/agents/src/types/sdk/tool.ts +++ b/packages/@n8n/agents/src/types/sdk/tool.ts @@ -26,8 +26,16 @@ export interface InterruptibleToolContext { export interface BuiltTool { readonly name: string; readonly description: string; - readonly suspendSchema?: ZodType; - readonly resumeSchema?: ZodType; + /** + * Behavioural directive paired with the tool, injected into the agent's + * system prompt under a `` block when the tool is added. + * Use for guidance the LLM needs to *decide whether to call* the tool — + * tool descriptions answer "what does this do?" but are weighted lower + * than system instructions for usage decisions. + */ + readonly systemInstruction?: string; + readonly suspendSchema?: ZodType | JSONSchema7; + readonly resumeSchema?: ZodType | JSONSchema7; readonly withDefaultApproval?: boolean; readonly toMessage?: (output: unknown) => AgentMessage | undefined; /** @@ -44,7 +52,7 @@ export interface BuiltTool { * (MCP tools). Use `isZodSchema()` to distinguish between the two at runtime. */ readonly inputSchema?: ZodType | JSONSchema7; - readonly outputSchema?: ZodType; + readonly outputSchema?: ZodType | JSONSchema7; /** True for tools sourced from an MCP server. */ readonly mcpTool?: boolean; /** Name of the MCP server this tool belongs to. Set when mcpTool is true. */ @@ -56,6 +64,17 @@ export interface BuiltTool { * Example: `{ anthropic: { eagerInputStreaming: true } }` */ readonly providerOptions?: Record; + /** + * Arbitrary platform-specific metadata attached to the tool. + */ + readonly metadata?: Record; + /** + * Whether the tool has source code that can be introspected. + * When `false`, the tool is treated as a platform-managed marker (e.g. an + * externally-resolved tool) and its source is not introspected. + * Defaults to `true` when absent. + */ + readonly editable?: boolean; } /** diff --git a/packages/@n8n/agents/src/utils/parse.ts b/packages/@n8n/agents/src/utils/parse.ts new file mode 100644 index 00000000000..05c6455f83f --- /dev/null +++ b/packages/@n8n/agents/src/utils/parse.ts @@ -0,0 +1,40 @@ +import type AjvType from 'ajv'; +import type { JSONSchema7 } from 'json-schema'; +import type { ZodType } from 'zod'; + +import { isZodSchema } from './zod'; + +export type ParseResult = + | { success: true; data: T } + | { success: false; error: string }; + +let ajvInstance: InstanceType | undefined; + +function getAjv(): InstanceType { + if (!ajvInstance) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { default: Ajv } = require('ajv') as { default: typeof AjvType }; + ajvInstance = new Ajv({ strict: false }); + } + return ajvInstance; +} + +/** + * Validate `data` against a Zod schema or a raw JSON Schema. + * Returns a unified success/failure result, with parsed data on success. + */ +export async function parseWithSchema( + schema: ZodType | JSONSchema7, + data: unknown, +): Promise { + if (isZodSchema(schema)) { + const result = await schema.safeParseAsync(data); + if (result.success) return { success: true, data: result.data }; + return { success: false, error: result.error.message }; + } + + const ajv = getAjv(); + const validate = ajv.compile(schema); + if (validate(data)) return { success: true, data }; + return { success: false, error: ajv.errorsText(validate.errors) }; +} diff --git a/packages/@n8n/agents/src/utils/zod.ts b/packages/@n8n/agents/src/utils/zod.ts index 078f56336d1..1a63f41c143 100644 --- a/packages/@n8n/agents/src/utils/zod.ts +++ b/packages/@n8n/agents/src/utils/zod.ts @@ -2,12 +2,12 @@ import type { JSONSchema7 } from 'json-schema'; import type { ZodType } from 'zod'; import { zodToJsonSchema as zodToJsonSchemaImpl } from 'zod-to-json-schema'; -/** Type guard: returns true when a tool input schema is a Zod schema (as opposed to raw JSON Schema). */ -export function isZodSchema(schema: ZodType | JSONSchema7): schema is ZodType { +/** Type guard: returns true when a value is a Zod schema (as opposed to raw JSON Schema or any other shape). */ +export function isZodSchema(schema: unknown): schema is ZodType { return ( typeof schema === 'object' && schema !== null && - typeof (schema as ZodType).safeParse === 'function' + typeof (schema as { safeParse?: unknown }).safeParse === 'function' ); } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/code-builder-node-search-engine.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/code-builder-node-search-engine.ts index c2e9fb7596f..82ebe411597 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/code-builder-node-search-engine.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/code-builder-node-search-engine.ts @@ -115,19 +115,23 @@ export class CodeBuilderNodeSearchEngine { * @param limit - Maximum number of results to return * @returns Array of matching nodes sorted by relevance */ - searchByName(query: string, limit: number = 20): CodeBuilderNodeSearchResult[] { + searchByName( + query: string, + limit: number = 20, + nodeFilter?: (nodeId: string) => boolean, + ): CodeBuilderNodeSearchResult[] { + const nodeTypes = nodeFilter + ? this.nodeTypes.filter((node) => nodeFilter(node.name)) + : this.nodeTypes; + // Use sublimeSearch for fuzzy matching - const searchResults = sublimeSearch( - query, - this.nodeTypes, - NODE_SEARCH_KEYS, - ); + const searchResults = sublimeSearch(query, nodeTypes, NODE_SEARCH_KEYS); const queryLower = query.toLowerCase().trim(); const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name)); // Direct type name match on all nodeTypes (catches nodes sublimeSearch ranked too low) - const typeNameMatches = this.nodeTypes + const typeNameMatches = nodeTypes .filter((node) => { if (fuzzyResultNames.has(node.name)) return false; return getTypeName(node.name).toLowerCase() === queryLower; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/test/code-builder-node-search-engine.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/test/code-builder-node-search-engine.test.ts index 6558f20dde3..27e39bc4e6f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/test/code-builder-node-search-engine.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/engines/test/code-builder-node-search-engine.test.ts @@ -141,6 +141,23 @@ describe('CodeBuilderNodeSearchEngine', () => { expect(results.length).toBeLessThanOrEqual(2); }); + it('should apply nodeFilter before ranking and limiting results', () => { + const httpToolNode = createNodeType({ + name: 'n8n-nodes-base.httpRequestTool', + displayName: 'HTTP Request Tool', + description: 'Makes HTTP requests as an AI tool', + group: ['output'], + inputs: [], + outputs: ['ai_tool'], + }); + const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, httpToolNode]); + + const results = engine.searchByName('http', 1, (nodeId) => nodeId.endsWith('Tool')); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('n8n-nodes-base.httpRequestTool'); + }); + it('should combine scores for multiple matches', () => { const results = searchEngine.searchByName('request'); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts index 0dbdf8bbbd5..f94c029a03b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts @@ -21,6 +21,7 @@ export { generateCodeBuilderThreadId } from './utils/code-builder-session'; export { NodeTypeParser } from './utils/node-type-parser'; export { ParseValidateHandler, WorkflowCodeParseError } from './handlers/parse-validate-handler'; export { createCodeBuilderSearchTool } from './tools/code-builder-search.tool'; +export type { CodeBuilderSearchToolOptions } from './tools/code-builder-search.tool'; export { createCodeBuilderGetTool } from './tools/code-builder-get.tool'; export type { CodeBuilderGetToolOptions } from './tools/code-builder-get.tool'; export { createGetSuggestedNodesTool } from './tools/get-suggested-nodes.tool'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts index d02dad3e46b..a2e00f42bb0 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts @@ -44,9 +44,10 @@ export function isValidPathComponent(component: string): boolean { export function validatePathWithinBase(filePath: string, baseDir: string): boolean { const resolvedPath = resolve(filePath); const resolvedBase = resolve(baseDir); + const separator = process.platform === 'win32' ? '\\' : '/'; // Path must start with base directory (with trailing separator to prevent prefix attacks) - return resolvedPath.startsWith(resolvedBase + '/') || resolvedPath === resolvedBase; + return resolvedPath.startsWith(resolvedBase + separator) || resolvedPath === resolvedBase; } /** diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts index 75549b810df..5ea0bb0a522 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts @@ -10,7 +10,7 @@ */ import { tool } from '@langchain/core/tools'; -import type { IParameterBuilderHint, IRelatedNode } from 'n8n-workflow'; +import { isTriggerNodeType, type IParameterBuilderHint, type IRelatedNode } from 'n8n-workflow'; import { z } from 'zod'; import { @@ -25,28 +25,6 @@ import { } from '../utils/discriminator-utils'; import type { NodeTypeParser, ParsedNodeType } from '../utils/node-type-parser'; -/** - * Trigger node types that don't have "trigger" in their name - * but still function as workflow entry points - */ -const TRIGGER_NODE_TYPES = new Set([ - 'n8n-nodes-base.webhook', - 'n8n-nodes-base.cron', // Legacy schedule trigger - 'n8n-nodes-base.emailReadImap', // Email polling trigger - 'n8n-nodes-base.telegramBot', // Can act as webhook trigger - 'n8n-nodes-base.start', // Legacy trigger -]); - -/** - * Check if a node type is a trigger - */ -export function isTriggerNodeType(type: string): boolean { - if (TRIGGER_NODE_TYPES.has(type)) { - return true; - } - return type.toLowerCase().includes('trigger'); -} - /** * Simplified operation info for discriminator display */ @@ -112,9 +90,10 @@ function getRelatedNodesWithHints( nodeTypeParser: NodeTypeParser, nodeId: string, version: number, + nodeFilter?: (nodeId: string) => boolean, ): IRelatedNode[] | undefined { const nodeType = nodeTypeParser.getNodeType(nodeId, version); - return nodeType?.builderHint?.relatedNodes; + return nodeType?.builderHint?.relatedNodes?.filter((r) => nodeFilter?.(r.nodeType) ?? true); } /** @@ -447,112 +426,128 @@ export function formatNodeResult( return parts.join('\n'); } +export interface CodeBuilderSearchToolOptions { + /** Optional predicate to exclude nodes from results. Return `false` to filter a node out. */ + nodeFilter?: (nodeId: string) => boolean; +} + +/** + * Search for a single query and return the formatted result block. + * Extracted to keep the tool handler's cyclomatic complexity within limits. + */ +function searchForQuery( + nodeTypeParser: NodeTypeParser, + query: string, + nodeFilter?: (nodeId: string) => boolean, +): string { + const results = nodeTypeParser.searchNodeTypes(query, 5, nodeFilter); + + if (results.length === 0) { + return `## "${query}"\nNo nodes found. Try a different search term.`; + } + + // Track which node IDs have been shown to avoid duplicates + const shownNodeIds = new Set(results.map((node: ParsedNodeType) => node.id)); + + const allNodeLines: string[] = []; + let totalRelatedCount = 0; + + for (const node of results) { + // Format the search result node + const triggerTag = node.isTrigger ? ' [TRIGGER]' : ''; + const basicInfo = `- ${node.id}${triggerTag}\n Display Name: ${node.displayName}\n Version: ${node.version}\n Description: ${node.description}`; + + // Get builder hint + const builderHint = formatBuilderHint(nodeTypeParser, node.id, node.version); + + // Check for new relatedNodes format with hints + const relatedNodesWithHints = getRelatedNodesWithHints( + nodeTypeParser, + node.id, + node.version, + nodeFilter, + ); + + // Get discriminator info + const discInfo = getDiscriminatorInfo(nodeTypeParser, node.id, node.version); + const discStr = formatDiscriminatorInfo(discInfo, node.id); + + const parts = [basicInfo]; + if (builderHint) parts.push(builderHint); + + // If using new format with hints, display @relatedNodes section instead of expanding + if (relatedNodesWithHints && relatedNodesWithHints.length > 0) { + const relatedNodesStr = formatRelatedNodesWithHints(relatedNodesWithHints); + if (relatedNodesStr) parts.push(relatedNodesStr); + } else { + // Legacy format: expand related nodes as [RELATED] entries + const relatedNodeIds = collectAllRelatedNodeIds( + nodeTypeParser, + [{ id: node.id, version: node.version }], + shownNodeIds, + ); + + // Add discriminator info to current node, then push it + if (discStr) parts.push(discStr); + allNodeLines.push(parts.join('\n')); + + for (const relatedId of relatedNodeIds) { + if (nodeFilter && !nodeFilter(relatedId)) continue; + + const nodeType = nodeTypeParser.getNodeType(relatedId); + if (nodeType) { + const version = Array.isArray(nodeType.version) + ? nodeType.version[nodeType.version.length - 1] + : nodeType.version; + const relatedTriggerTag = isTriggerNodeType(relatedId) ? ' [TRIGGER]' : ''; + const relatedBasicInfo = `- ${relatedId}${relatedTriggerTag} [RELATED]\n Display Name: ${nodeType.displayName}\n Version: ${version}\n Description: ${nodeType.description}`; + + // Get builder hint for related node too + const relatedBuilderHint = formatBuilderHint(nodeTypeParser, relatedId, version); + + // Get discriminator info for related node + const relatedDiscInfo = getDiscriminatorInfo(nodeTypeParser, relatedId, version); + const relatedDiscStr = formatDiscriminatorInfo(relatedDiscInfo, relatedId); + + const relatedParts = [relatedBasicInfo]; + if (relatedBuilderHint) relatedParts.push(relatedBuilderHint); + if (relatedDiscStr) relatedParts.push(relatedDiscStr); + + allNodeLines.push(relatedParts.join('\n')); + + // Mark as shown to prevent duplicates + shownNodeIds.add(relatedId); + totalRelatedCount++; + } + } + continue; // Skip the common push below since we handled it in the legacy branch + } + + if (discStr) parts.push(discStr); + allNodeLines.push(parts.join('\n')); + } + + const countSuffix = totalRelatedCount > 0 ? ` (+ ${totalRelatedCount} related)` : ''; + return `## "${query}"\nFound ${results.length} nodes${countSuffix}:\n\n${allNodeLines.join('\n\n')}`; +} + /** * Create the simplified node search tool for code builder * Accepts multiple queries and returns separate results for each * Includes discriminator information for nodes with resource/operation or mode patterns */ -export function createCodeBuilderSearchTool(nodeTypeParser: NodeTypeParser) { +export function createCodeBuilderSearchTool( + nodeTypeParser: NodeTypeParser, + options?: CodeBuilderSearchToolOptions, +) { + const { nodeFilter } = options ?? {}; + return tool( async (input: { queries: string[] }) => { - const allResults: string[] = []; - - for (const query of input.queries) { - const results = nodeTypeParser.searchNodeTypes(query, 5); - - if (results.length === 0) { - allResults.push(`## "${query}"\nNo nodes found. Try a different search term.`); - } else { - // Track which node IDs have been shown to avoid duplicates - const shownNodeIds = new Set(results.map((node: ParsedNodeType) => node.id)); - - const allNodeLines: string[] = []; - let totalRelatedCount = 0; - - for (const node of results) { - // Format the search result node - const triggerTag = node.isTrigger ? ' [TRIGGER]' : ''; - const basicInfo = `- ${node.id}${triggerTag}\n Display Name: ${node.displayName}\n Version: ${node.version}\n Description: ${node.description}`; - - // Get builder hint - const builderHint = formatBuilderHint(nodeTypeParser, node.id, node.version); - - // Check for new relatedNodes format with hints - const relatedNodesWithHints = getRelatedNodesWithHints( - nodeTypeParser, - node.id, - node.version, - ); - - // Get discriminator info - const discInfo = getDiscriminatorInfo(nodeTypeParser, node.id, node.version); - const discStr = formatDiscriminatorInfo(discInfo, node.id); - - const parts = [basicInfo]; - if (builderHint) parts.push(builderHint); - - // If using new format with hints, display @relatedNodes section instead of expanding - if (relatedNodesWithHints && relatedNodesWithHints.length > 0) { - const relatedNodesStr = formatRelatedNodesWithHints(relatedNodesWithHints); - if (relatedNodesStr) parts.push(relatedNodesStr); - } else { - // Legacy format: expand related nodes as [RELATED] entries - const relatedNodeIds = collectAllRelatedNodeIds( - nodeTypeParser, - [{ id: node.id, version: node.version }], - shownNodeIds, - ); - - // Add related nodes immediately after their parent search result - // First, add discriminator info to current node - if (discStr) parts.push(discStr); - allNodeLines.push(parts.join('\n')); - - for (const relatedId of relatedNodeIds) { - const nodeType = nodeTypeParser.getNodeType(relatedId); - if (nodeType) { - const version = Array.isArray(nodeType.version) - ? nodeType.version[nodeType.version.length - 1] - : nodeType.version; - const relatedTriggerTag = isTriggerNodeType(relatedId) ? ' [TRIGGER]' : ''; - const relatedBasicInfo = `- ${relatedId}${relatedTriggerTag} [RELATED]\n Display Name: ${nodeType.displayName}\n Version: ${version}\n Description: ${nodeType.description}`; - - // Get builder hint for related node too - const relatedBuilderHint = formatBuilderHint(nodeTypeParser, relatedId, version); - - // Get discriminator info for related node - const relatedDiscInfo = getDiscriminatorInfo(nodeTypeParser, relatedId, version); - const relatedDiscStr = formatDiscriminatorInfo(relatedDiscInfo, relatedId); - - const relatedParts = [relatedBasicInfo]; - if (relatedBuilderHint) relatedParts.push(relatedBuilderHint); - if (relatedDiscStr) relatedParts.push(relatedDiscStr); - - allNodeLines.push(relatedParts.join('\n')); - - // Mark as shown to prevent duplicates - shownNodeIds.add(relatedId); - totalRelatedCount++; - } - } - continue; // Skip the common push below since we handled it in the legacy branch - } - - if (discStr) parts.push(discStr); - allNodeLines.push(parts.join('\n')); - } - - const countSuffix = totalRelatedCount > 0 ? ` (+ ${totalRelatedCount} related)` : ''; - - allResults.push( - `## "${query}"\nFound ${results.length} nodes${countSuffix}:\n\n${allNodeLines.join('\n\n')}`, - ); - } - } - - const response = allResults.join('\n\n---\n\n'); - - return response; + const allResults = input.queries.map((query) => + searchForQuery(nodeTypeParser, query, nodeFilter), + ); + return allResults.join('\n\n---\n\n'); }, { name: 'search_nodes', diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/utils/node-type-parser.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/utils/node-type-parser.ts index 539f5a357d7..e88502b6169 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/utils/node-type-parser.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/utils/node-type-parser.ts @@ -68,8 +68,12 @@ export class NodeTypeParser { * Search for nodes by name or description * Returns up to `limit` results */ - searchNodeTypes(query: string, limit: number = 5): ParsedNodeType[] { - const results = this.searchEngine.searchByName(query, limit); + searchNodeTypes( + query: string, + limit: number = 5, + nodeFilter?: (nodeId: string) => boolean, + ): ParsedNodeType[] { + const results = this.searchEngine.searchByName(query, limit, nodeFilter); return results.map((result) => { // Find the full node type to check if it's a trigger diff --git a/packages/@n8n/ai-workflow-builder.ee/src/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/index.ts index 7f96b3fcda5..7611b80981a 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/index.ts @@ -18,6 +18,7 @@ export { ParseValidateHandler, WorkflowCodeParseError, createCodeBuilderSearchTool, + type CodeBuilderSearchToolOptions, createCodeBuilderGetTool, createGetSuggestedNodesTool, stripImportStatements, diff --git a/packages/@n8n/api-types/src/__tests__/agent-builder-admin-settings.test.ts b/packages/@n8n/api-types/src/__tests__/agent-builder-admin-settings.test.ts new file mode 100644 index 00000000000..1f2d39010fa --- /dev/null +++ b/packages/@n8n/api-types/src/__tests__/agent-builder-admin-settings.test.ts @@ -0,0 +1,95 @@ +import { AgentBuilderAdminSettingsUpdateDto, agentBuilderAdminSettingsSchema } from '../agents'; + +describe('AgentBuilderAdminSettingsUpdateDto', () => { + describe('valid payloads', () => { + test.each([ + { name: 'mode=default', payload: { mode: 'default' } }, + { + name: 'mode=custom anthropic', + payload: { + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }, + }, + { + name: 'mode=custom openai with arbitrary provider id (api-types stays runtime-agnostic)', + payload: { + mode: 'custom', + provider: 'openai', + credentialId: 'cred-1', + modelName: 'gpt-4o', + }, + }, + { + name: 'mode=custom aws-bedrock', + payload: { + mode: 'custom', + provider: 'aws-bedrock', + credentialId: 'cred-1', + modelName: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }, + }, + ])('parses $name', ({ payload }) => { + expect(AgentBuilderAdminSettingsUpdateDto.safeParse(payload).success).toBe(true); + }); + }); + + describe('invalid payloads', () => { + test.each([ + { name: 'missing mode', payload: {} }, + { name: 'unknown mode', payload: { mode: 'foo' } }, + { + name: 'mode=custom missing provider', + payload: { mode: 'custom', credentialId: 'cred-1', modelName: 'm' }, + }, + { + name: 'mode=custom missing credentialId', + payload: { mode: 'custom', provider: 'anthropic', modelName: 'm' }, + }, + { + name: 'mode=custom missing modelName', + payload: { mode: 'custom', provider: 'anthropic', credentialId: 'cred-1' }, + }, + { + name: 'mode=custom empty modelName', + payload: { + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: '', + }, + }, + { + name: 'mode=custom empty provider', + payload: { + mode: 'custom', + provider: '', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }, + }, + { + name: 'mode=default with extra custom fields is silently stripped (still parses)', + payload: { mode: 'default', provider: 'anthropic' }, + expectsSuccess: true, + }, + ])('$name', ({ payload, expectsSuccess = false }) => { + expect(AgentBuilderAdminSettingsUpdateDto.safeParse(payload).success).toBe(expectsSuccess); + }); + }); + + it('inferred type alias matches the schema', () => { + // Compile-time assertion: the discriminator narrows the type. + const sample = AgentBuilderAdminSettingsUpdateDto.parse({ mode: 'default' }); + if (sample.mode === 'default') { + // no extra fields + expect(Object.keys(sample)).toEqual(['mode']); + } + }); + + it('agentBuilderAdminSettingsSchema is the same schema as the DTO export', () => { + expect(AgentBuilderAdminSettingsUpdateDto).toBe(agentBuilderAdminSettingsSchema); + }); +}); diff --git a/packages/@n8n/api-types/src/agent-builder-interactive.ts b/packages/@n8n/api-types/src/agent-builder-interactive.ts new file mode 100644 index 00000000000..5ca6c6ee48d --- /dev/null +++ b/packages/@n8n/api-types/src/agent-builder-interactive.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; + +/** + * Canonical names of the interactive agent-builder tools. + * + * `toolName` is the discriminator on the wire: SSE `toolSuspended` events, + * persisted tool-call parts, and the FE InteractivePayload union all dispatch + * by it. There is no separate `interactionType` field — the tool name IS the + * interaction kind. + */ +export const ASK_LLM_TOOL_NAME = 'ask_llm' as const; +export const ASK_CREDENTIAL_TOOL_NAME = 'ask_credential' as const; +export const ASK_QUESTION_TOOL_NAME = 'ask_question' as const; + +export const interactiveToolNameSchema = z.union([ + z.literal(ASK_LLM_TOOL_NAME), + z.literal(ASK_CREDENTIAL_TOOL_NAME), + z.literal(ASK_QUESTION_TOOL_NAME), +]); + +export type InteractiveToolName = z.infer; + +// --------------------------------------------------------------------------- +// ask_llm +// --------------------------------------------------------------------------- + +export const askLlmInputSchema = z.object({ + purpose: z + .string() + .optional() + .describe( + 'Short sentence describing why the model is needed, e.g. "Main LLM for the Slack triage agent"', + ), +}); + +export const askLlmResumeSchema = z.object({ + provider: z.string(), + model: z.string(), + credentialId: z.string(), + credentialName: z.string(), +}); + +export type AskLlmInput = z.infer; +export type AskLlmResume = z.infer; + +// --------------------------------------------------------------------------- +// ask_credential +// --------------------------------------------------------------------------- + +export const askCredentialInputSchema = z.object({ + purpose: z.string().describe('One short sentence describing what this credential is used for'), + nodeType: z + .string() + .optional() + .describe('The n8n node type requiring this credential, e.g. "n8n-nodes-base.slack"'), + credentialType: z + .string() + .describe( + 'The credential type name to request for this slot, e.g. "slackApi". When the slot accepts multiple credential types, pick the single best match (typically the OAuth or first listed type).', + ), + slot: z.string().optional().describe('Credential slot name on the node, e.g. "slackApi"'), +}); + +export const askCredentialResumeSchema = z.union([ + z.object({ credentialId: z.string(), credentialName: z.string() }), + z.object({ skipped: z.literal(true) }), +]); + +export type AskCredentialInput = z.infer; +export type AskCredentialResume = z.infer; + +// --------------------------------------------------------------------------- +// ask_question +// --------------------------------------------------------------------------- + +export const askQuestionOptionSchema = z.object({ + label: z.string().describe('Display label for this option'), + value: z.string().describe('Internal value for this option'), + description: z.string().optional().describe('Optional additional explanation'), +}); + +export const askQuestionInputSchema = z.object({ + question: z.string().describe('The question to display to the user'), + options: z + .array(askQuestionOptionSchema) + .min(1) + .describe( + 'Choices to present. With a single option the tool auto-resolves to that option without rendering a card.', + ), + allowMultiple: z + .boolean() + .optional() + .describe('If true the user may select more than one option; defaults to false'), +}); + +export const askQuestionResumeSchema = z.object({ + values: z.array(z.string()).min(1), +}); + +export type AskQuestionOption = z.infer; +export type AskQuestionInput = z.infer; +export type AskQuestionResume = z.infer; + +// --------------------------------------------------------------------------- +// Discriminated union of all resume payloads (used by AgentBuildResumeDto) +// --------------------------------------------------------------------------- + +export const interactiveResumeDataSchema = z.union([ + askLlmResumeSchema, + askCredentialResumeSchema, + askQuestionResumeSchema, +]); + +export type InteractiveResumeData = z.infer; diff --git a/packages/@n8n/api-types/src/agent-sse.ts b/packages/@n8n/api-types/src/agent-sse.ts new file mode 100644 index 00000000000..ff0e5d35d9b --- /dev/null +++ b/packages/@n8n/api-types/src/agent-sse.ts @@ -0,0 +1,96 @@ +/** + * Wire format for the agent builder/chat SSE stream. Each SSE `data:` line is + * exactly one `AgentSseEvent` JSON object. + * + * Per-turn events carry the SDK's natural block ids: + * + * - `text-*` and `reasoning-*` events carry the SDK's per-block `id`. + * - `tool-*` events carry the SDK's `toolCallId`. + * - `start-step` / `finish-step` mark LLM iteration boundaries. + * + * The frontend groups deltas by these ids and uses `start-step` / `finish-step` + * to decide when a new ChatMessage cursor should open. There is no + * server-minted `messageId` — the FE generates its own UUID per ChatMessage + * for v-for keys only. + * + * `runId` is included on `ToolSuspendedPayload` and echoed back by the + * frontend on resume. The SDK stores `runId` on each `PendingToolCall` and + * surfaces it on every suspended-tool chunk; the FE doesn't need to derive it + * server-side. + * + * Note: there is no separate "resumed" event. After the user resumes a + * suspended tool, the SDK runs the tool's handler (which returns + * `ctx.resumeData`) and emits a normal `tool-result` event. Consumers see the + * resume payload as the `output` on that `tool-result`. + * + */ + +import type { AgentPersistedMessageContentPart } from './agents'; + +export interface ToolSuspendedPayload { + toolCallId: string; + /** Run id of the suspended turn; FE echoes this back on `POST /build/resume`. */ + runId: string; + /** Also the discriminator on the wire (no separate interactionType field). */ + toolName: string; + /** Shape determined by toolName via the corresponding Ask*InputSchema. */ + input: unknown; +} + +/** + * Custom (sub-agent / app-defined) message envelope. Tool-call and tool-result + * events ride their own discrete chunk types — only `CustomAgentMessage`-style + * payloads use this shape. + */ +export interface AgentSseMessage { + role: string; + content: AgentPersistedMessageContentPart[]; +} + +export type AgentSseEvent = + | { type: 'start-step' } + | { type: 'finish-step' } + | { type: 'text-start'; id: string } + | { type: 'text-delta'; id: string; delta: string } + | { type: 'text-end'; id: string } + | { type: 'reasoning-start'; id: string } + | { type: 'reasoning-delta'; id: string; delta: string } + | { type: 'reasoning-end'; id: string } + | { type: 'tool-input-start'; toolCallId: string; toolName: string } + | { type: 'tool-input-delta'; toolCallId: string; delta: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + | { + /** + * Mid-flight indicator: the LLM has finished emitting the tool call and + * the runtime has started invoking the handler. Sent between `tool-call` + * and the eventual `tool-result` so the FE can flip the indicator from + * "pending" (LLM committed) to "running" (handler in flight). + */ + type: 'tool-execution-start'; + toolCallId: string; + toolName: string; + } + | { + type: 'tool-result'; + toolCallId: string; + toolName: string; + output: unknown; + isError?: boolean; + } + | { type: 'tool-call-suspended'; payload: ToolSuspendedPayload } + | { type: 'message'; message: AgentSseMessage } + | { type: 'working-memory-update'; toolName: string } + | { type: 'code-delta'; delta: string } + | { type: 'config-updated' } + | { type: 'tool-updated' } + | { + type: 'error'; + message: string; + /** + * Optional discriminator for distinct error classes. + */ + errorCode?: string; + /** Backend-emitted ids of the missing config slots; only set when `errorCode` is `agent_misconfigured`. */ + missing?: string[]; + } + | { type: 'done'; sessionId?: string }; diff --git a/packages/@n8n/api-types/src/agents.ts b/packages/@n8n/api-types/src/agents.ts new file mode 100644 index 00000000000..48ab91034d5 --- /dev/null +++ b/packages/@n8n/api-types/src/agents.ts @@ -0,0 +1,311 @@ +import { + CHAT_TRIGGER_NODE_TYPE, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + SCHEDULE_TRIGGER_NODE_TYPE, +} from 'n8n-workflow'; +import { z } from 'zod'; + +/** + * Describes a chat platform integration that agents can connect to. + * Source of truth: the backend `ChatIntegrationRegistry`. + */ +export interface ChatIntegrationDescriptor { + type: string; + label: string; + icon: string; + credentialTypes: string[]; +} + +/** + * Node types a workflow can use as its trigger to be eligible as an agent + * tool. Single source of truth for both the backend compatibility check + * (`workflow-tool-factory.ts:SUPPORTED_TRIGGERS`) and the frontend Available + * list's pre-filter. Body-node incompatibility (Wait / RespondToWebhook) is + * enforced separately at save time. + */ +export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [ + MANUAL_TRIGGER_NODE_TYPE, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + CHAT_TRIGGER_NODE_TYPE, + SCHEDULE_TRIGGER_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, +] as const; + +/** + * Node types in a workflow's body that disqualify it from being used as an + * agent tool (execution model can't handle pause/respond-style nodes). Single + * source of truth for the backend `validateCompatibility` check in + * `workflow-tool-factory.ts` and the frontend pre-check in + * `AgentToolsModal.vue` so the two sides can't drift. + */ +export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [ + 'n8n-nodes-base.wait', + 'n8n-nodes-base.form', + 'n8n-nodes-base.respondToWebhook', +] as const; + +export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule'; + +export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT = + 'Automated message: you were triggered on schedule.'; + +export interface AgentCredentialIntegration { + type: string; + credentialId: string; + credentialName: string; +} + +export interface AgentScheduleIntegration { + type: typeof AGENT_SCHEDULE_TRIGGER_TYPE; + active: boolean; + cronExpression: string; + wakeUpPrompt: string; +} + +export type AgentIntegration = AgentCredentialIntegration | AgentScheduleIntegration; + +export interface AgentScheduleConfig { + active: boolean; + cronExpression: string; + wakeUpPrompt: string; +} + +export interface AgentIntegrationStatusEntry { + type: string; + credentialId?: string; +} + +export interface AgentIntegrationStatusResponse { + status: 'connected' | 'disconnected'; + integrations: AgentIntegrationStatusEntry[]; +} + +export function isAgentScheduleIntegration( + integration: AgentIntegration | null | undefined, +): integration is AgentScheduleIntegration { + return integration?.type === AGENT_SCHEDULE_TRIGGER_TYPE; +} + +export function isAgentCredentialIntegration( + integration: AgentIntegration | null | undefined, +): integration is AgentCredentialIntegration { + return ( + integration !== null && + integration !== undefined && + integration.type !== AGENT_SCHEDULE_TRIGGER_TYPE && + 'credentialId' in integration && + typeof integration.credentialId === 'string' + ); +} + +export interface NodeToolConfig { + nodeType: string; + nodeTypeVersion: number; + nodeParameters?: Record; + credentials?: Record; +} + +interface BaseAgentJsonToolRef { + name?: string; + description?: string; + workflow?: string; + node?: NodeToolConfig; + requireApproval?: boolean; + allOutputs?: boolean; +} + +export type AgentJsonToolRef = + | (BaseAgentJsonToolRef & { + type: 'custom'; + id: string; + }) + | (BaseAgentJsonToolRef & { + type: 'workflow'; + id?: never; + }) + | (BaseAgentJsonToolRef & { + type: 'node'; + id?: never; + }); + +export interface AgentJsonSkillRef { + type: 'skill'; + id: string; +} + +export type AgentJsonConfigRef = AgentJsonToolRef | AgentJsonSkillRef; + +export interface AgentSkill { + name: string; + description: string; + instructions: string; +} + +export interface AgentSkillMutationResponse { + id: string; + skill: AgentSkill; + versionId: string | null; +} + +export interface AgentJsonConfig { + name: string; + description?: string; + /** Optional icon/emoji shown in the agent builder header. */ + icon?: { type: 'icon' | 'emoji'; value: string }; + model: string; + credential?: string; + instructions: string; + memory?: { + enabled: boolean; + storage: 'n8n' | 'sqlite' | 'postgres'; + connection?: Record; + lastMessages?: number; + semanticRecall?: { + topK: number; + scope?: 'thread' | 'resource'; + messageRange?: { before: number; after: number }; + embedder?: string; + }; + }; + tools?: AgentJsonToolRef[]; + skills?: AgentJsonSkillRef[]; + providerTools?: Record>; + /** + * Triggers (scheduled execution + chat integrations) attached to this agent. + * Mirrors the contents of `agent.integrations` storage column so the builder + * can read and modify triggers through the same JSON config flow as tools. + */ + integrations?: AgentIntegration[]; + config?: { + thinking?: { + provider: 'anthropic' | 'openai'; + budgetTokens?: number; + reasoningEffort?: string; + }; + toolCallConcurrency?: number; + requireToolApproval?: boolean; + nodeTools?: { + enabled: boolean; + }; + }; +} + +/** + * The snapshot of an agent at publish time. Returned by publish/unpublish + * endpoints as part of the agent payload so the UI can derive publish state + * (`not-published` / `published-no-changes` / `published-with-changes`) from + * `agent.versionId` vs `publishedVersion.publishedFromVersionId`. + */ +export interface AgentPublishedVersionDto { + schema: AgentJsonConfig | null; + skills: Record | null; + publishedFromVersionId: string; + model: string | null; + provider: string | null; + credentialId: string | null; + publishedById: string | null; +} + +/** + * A single part inside a persisted chat/builder message. Mirrors the content + * parts emitted by the agents SDK; known `type` values are enumerated for + * autocomplete but the field is left open because new SDK versions may + * introduce additional kinds. + */ +export interface AgentPersistedMessageContentPart { + type: 'text' | 'reasoning' | 'tool-call' | (string & {}); + text?: string; + toolName?: string; + toolCallId?: string; + input?: unknown; + state?: string; + output?: unknown; + error?: string; +} + +/** + * Persisted chat/builder message shape returned by + * `GET /projects/:projectId/agents/v2/:agentId/chat/messages` and + * `GET /projects/:projectId/agents/v2/:agentId/build/messages`. The UI + * converts these into its own display-oriented representation. + * + * Distinct from the request-body `AgentChatMessageDto` (a single outbound + * message) — this is the history shape, one entry per persisted turn. + */ +export interface AgentPersistedMessageDto { + id: string; + role: 'user' | 'assistant' | (string & {}); + content: AgentPersistedMessageContentPart[]; +} +// ─── Agent builder admin settings ───────────────────────────────────────── +// The agent builder uses a model picked by the instance admin. By default it +// runs through the n8n AI assistant proxy; admins can switch to a custom +// provider + credential at any time. + +/** Default model name used when the builder runs through the proxy or the env-var backstop. */ +export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-5' as const; + +export const agentBuilderModeSchema = z.enum(['default', 'custom']); +export type AgentBuilderMode = z.infer; + +/** + * Discriminated union of the persisted admin settings. + * + * The builder defaults to the n8n AI assistant proxy. An admin can switch to + * a custom provider/credential at any time. Provider id values must come from + * the agent runtime's supported list (see `mapCredentialForProvider` on the + * backend) — the schema accepts any non-empty string here so the api-types + * package doesn't need to know the runtime list; the backend validates the + * provider against the runtime mapper. + */ +export const agentBuilderAdminSettingsSchema = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('default') }), + z.object({ + mode: z.literal('custom'), + provider: z.string().min(1), + credentialId: z.string().min(1), + modelName: z.string().min(1), + }), +]); +export type AgentBuilderAdminSettings = z.infer; + +export const agentBuilderAdminSettingsResponseSchema = z.object({ + settings: agentBuilderAdminSettingsSchema, + isConfigured: z.boolean(), +}); +export type AgentBuilderAdminSettingsResponse = z.infer< + typeof agentBuilderAdminSettingsResponseSchema +>; + +/** Body schema for the PATCH /agent-builder/settings endpoint. */ +export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema; +export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings; + +export const agentBuilderStatusResponseSchema = z.object({ + isConfigured: z.boolean(), +}); +export type AgentBuilderStatusResponse = z.infer; + +/** + * One still-open interactive tool call, surfaced alongside persisted messages + * so the FE can re-attach a `runId` to suspended interactive cards after a + * page refresh. + */ +export interface AgentBuilderOpenSuspension { + toolCallId: string; + runId: string; +} + +/** + * Response body of `GET /projects/:projectId/agents/v2/:agentId/build/messages`. + * + * `messages` is the merged history (persisted memory + any in-flight checkpoint + * messages). `openSuspensions` carries the runIds for every still-open + * interactive tool call so the FE can resume them. + */ +export interface AgentBuilderMessagesResponse { + messages: AgentPersistedMessageDto[]; + openSuspensions: AgentBuilderOpenSuspension[]; +} diff --git a/packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts b/packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts new file mode 100644 index 00000000000..b6f565e55ac --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/__tests__/agent-skill.dto.test.ts @@ -0,0 +1,19 @@ +import { CreateAgentSkillDto } from '../create-agent-skill.dto'; +import { UpdateAgentSkillDto } from '../update-agent-skill.dto'; + +describe('agent skill DTOs', () => { + const validSkill = { + name: 'Summarize Notes', + description: 'Summarizes meeting notes', + instructions: 'Extract decisions and action items.', + }; + + it('accepts natural-language descriptions', () => { + expect(CreateAgentSkillDto.safeParse(validSkill).success).toBe(true); + expect( + UpdateAgentSkillDto.safeParse({ + description: 'Extracts decisions from notes', + }).success, + ).toBe(true); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts new file mode 100644 index 00000000000..e1f49e5150c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/agent-build-resume.dto.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { interactiveResumeDataSchema } from '../../agent-builder-interactive'; +import { Z } from '../../zod-class'; + +/** + * Body of `POST /:agentId/build/resume`. + * + * `runId` is sent by the frontend; it originates from the + * `tool-call-suspended` chunk (live) or the `openSuspensions` sidecar + * returned by `GET /build/messages` (history reload). + */ +export class AgentBuildResumeDto extends Z.class({ + runId: z.string().min(1), + toolCallId: z.string().min(1), + resumeData: interactiveResumeDataSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts new file mode 100644 index 00000000000..41a2006a029 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/agent-chat-message.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class AgentChatMessageDto extends Z.class({ + message: z.string().min(1), + sessionId: z.string().min(1).optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts b/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts new file mode 100644 index 00000000000..71bcf08215a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/agent-integration.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class AgentIntegrationDto extends Z.class({ + type: z.string().min(1), + credentialId: z.string().min(1), +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts b/packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts new file mode 100644 index 00000000000..d60bf7fa4cb --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/create-agent-skill.dto.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +/** Hard cap on a skill body. Large enough for serious playbooks, small enough + * to keep a single skill from blowing past the LLM's context window when loaded. */ +export const AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH = 10_000; + +export const agentSkillSchema = z.object({ + name: z.string().min(1).max(128), + description: z.string().min(1).max(512), + instructions: z.string().min(1).max(AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH), +}); + +export class CreateAgentSkillDto extends Z.class({ + ...agentSkillSchema.shape, +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts b/packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts new file mode 100644 index 00000000000..5fb7236d844 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/create-agent.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class CreateAgentDto extends Z.class({ + name: z.string().min(1), +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts new file mode 100644 index 00000000000..0e008869f2e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/update-agent-config.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class UpdateAgentConfigDto extends Z.class({ + config: z.record(z.unknown()), +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts new file mode 100644 index 00000000000..825880f3ab2 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/update-agent-schedule.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class UpdateAgentScheduleDto extends Z.class({ + cronExpression: z.string(), + wakeUpPrompt: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts new file mode 100644 index 00000000000..605734512b4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/update-agent-skill.dto.ts @@ -0,0 +1,8 @@ +import { agentSkillSchema } from './create-agent-skill.dto'; +import { Z } from '../../zod-class'; + +export class UpdateAgentSkillDto extends Z.class({ + name: agentSkillSchema.shape.name.optional(), + description: agentSkillSchema.shape.description.optional(), + instructions: agentSkillSchema.shape.instructions.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts b/packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts new file mode 100644 index 00000000000..4847a9d9e1e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/agents/update-agent.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class UpdateAgentDto extends Z.class({ + name: z.string().optional(), + updatedAt: z.string().optional(), + description: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index fb285cb0c78..c97bbf17a5c 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -235,6 +235,20 @@ export { export { VersionSinceDateQueryDto } from './instance-version-history/version-since-date-query.dto'; export { VersionQueryDto } from './instance-version-history/version-query.dto'; +export { CreateAgentDto } from './agents/create-agent.dto'; +export { UpdateAgentDto } from './agents/update-agent.dto'; +export { UpdateAgentConfigDto } from './agents/update-agent-config.dto'; +export { UpdateAgentScheduleDto } from './agents/update-agent-schedule.dto'; +export { + AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH, + CreateAgentSkillDto, + agentSkillSchema, +} from './agents/create-agent-skill.dto'; +export { UpdateAgentSkillDto } from './agents/update-agent-skill.dto'; +export { AgentIntegrationDto } from './agents/agent-integration.dto'; +export { AgentChatMessageDto } from './agents/agent-chat-message.dto'; +export { AgentBuildResumeDto } from './agents/agent-build-resume.dto'; + export { CreateEncryptionKeyDto } from './encryption/create-encryption-key.dto'; export { ListEncryptionKeysQueryDto, diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 1bced411847..82e86dc6710 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -316,6 +316,19 @@ export type FrontendModuleSettings = { /** Whether system roles (admin, editor) have external secrets scopes. */ systemRolesEnabled: boolean; }; + + /** + * Client settings for the agents module. + */ + agents?: { + /** + * Enabled agent sub-feature modules. Each token unlocks a specific + * capability inside the agents module (see the backend's + * `AGENTS_MODULE_NAMES` for the known set). Controlled via + * `N8N_AGENTS_MODULES` (comma-separated). + */ + modules: string[]; + }; }; export type N8nEnvFeatFlagValue = boolean | string | number | undefined; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index da313139689..a1e82379ff8 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -8,6 +8,31 @@ export type * from './user'; export type * from './api-keys'; export type * from './community-node-types'; export type * from './quick-connect'; +export * from './agents'; +export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from './agent-sse'; +export { + ASK_LLM_TOOL_NAME, + ASK_CREDENTIAL_TOOL_NAME, + ASK_QUESTION_TOOL_NAME, + interactiveToolNameSchema, + askLlmInputSchema, + askLlmResumeSchema, + askCredentialInputSchema, + askCredentialResumeSchema, + askQuestionOptionSchema, + askQuestionInputSchema, + askQuestionResumeSchema, + interactiveResumeDataSchema, + type InteractiveToolName, + type AskLlmInput, + type AskLlmResume, + type AskCredentialInput, + type AskCredentialResume, + type AskQuestionOption, + type AskQuestionInput, + type AskQuestionResume, + type InteractiveResumeData, +} from './agent-builder-interactive'; export * from './instance-registry-types'; export { chatHubConversationModelSchema, diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 60cf4f305d3..470467abeb2 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -3,6 +3,7 @@ import { CommaSeparatedStringArray, Config, Env } from '@n8n/config'; import { UnknownModuleError } from './errors/unknown-module.error'; export const MODULE_NAMES = [ + 'agents', 'insights', 'external-secrets', 'community-packages', diff --git a/packages/@n8n/config/src/configs/agents.config.ts b/packages/@n8n/config/src/configs/agents.config.ts new file mode 100644 index 00000000000..28ff58fe7ca --- /dev/null +++ b/packages/@n8n/config/src/configs/agents.config.ts @@ -0,0 +1,44 @@ +import { CommaSeparatedStringArray } from '../custom-types'; +import { Config, Env } from '../decorators'; + +/** + * Known agent sub-feature modules. Add a token here to make it valid in + * `N8N_AGENTS_MODULES`. The backend fails fast on unknown tokens so typos + * surface at startup instead of silently disabling a feature. + */ +export const AGENTS_MODULE_NAMES = ['node-tools-searcher'] as const; + +export type AgentsModuleName = (typeof AGENTS_MODULE_NAMES)[number]; + +class AgentsModuleArray extends CommaSeparatedStringArray { + constructor(str: string) { + super(str); + + for (const name of this) { + if (!AGENTS_MODULE_NAMES.includes(name)) { + throw new Error( + `Unknown agents module: "${name}". Valid tokens: ${AGENTS_MODULE_NAMES.join(', ')}.`, + ); + } + } + } +} + +@Config +export class AgentsConfig { + /** TTL in seconds for agent checkpoint records. Stale checkpoints older than this are pruned. */ + @Env('N8N_AGENTS_CHECKPOINT_TTL') + checkpointTtlSeconds: number = 345600; // 96 hours + + /** + * Comma-separated list of agent sub-feature modules to enable. Each entry + * gates a specific frontend/runtime capability inside the agents module. + * Currently known: `node-tools-searcher` (surfaces the "Built-in node tools" + * toggle in the agent editor). + * + * Gates the UI surface only — existing agents persisted with a given + * capability turned on continue to run even if its token is removed here. + */ + @Env('N8N_AGENTS_MODULES') + modules: AgentsModuleArray = []; +} diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index 6e39c2a6307..188a3f7ec66 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -37,6 +37,7 @@ export const LOG_SCOPES = [ 'ssrf-protection', 'token-exchange', 'instance-ai', + 'agents', 'sub-agent-eval', 'instance-version-history', 'instance-settings-loader', diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index d1cec59876c..fc6891b1b05 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { AgentsConfig } from './configs/agents.config'; import { AiAssistantConfig } from './configs/ai-assistant.config'; import { AiBuilderConfig } from './configs/ai-builder.config'; import { AiConfig } from './configs/ai.config'; @@ -78,6 +79,7 @@ export { ChatTriggerConfig } from './configs/chat-trigger.config'; export { InstanceAiConfig } from './configs/instance-ai.config'; export { ExpressionEngineConfig } from './configs/expression-engine.config'; export { PasswordConfig } from './configs/password.config'; +export { AgentsConfig } from './configs/agents.config'; export { RedisConfig } from './configs/redis.config'; const protocolSchema = z.enum(['http', 'https']); @@ -267,6 +269,9 @@ export class GlobalConfig { @Nested instanceAi: InstanceAiConfig; + @Nested + agents: AgentsConfig; + @Nested expressionEngine: ExpressionEngineConfig; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index e5a8b619267..e972ffeda1d 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -545,6 +545,10 @@ describe('GlobalConfig', () => { mcpManagedByEnv: false, mcpAccessEnabled: false, }, + agents: { + checkpointTtlSeconds: 345600, + modules: [], + }, } satisfies GlobalConfigShape; it('should use all default values when no env variables are defined', () => { diff --git a/packages/@n8n/db/src/migrations/common/1783000000000-CreateAgentTables.ts b/packages/@n8n/db/src/migrations/common/1783000000000-CreateAgentTables.ts new file mode 100644 index 00000000000..b103e7f285d --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1783000000000-CreateAgentTables.ts @@ -0,0 +1,111 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/** + * Build the agent tables and required indexes for it - handling storage of the new + * agents a first-class citizens feature. + */ +export class CreateAgentTables1783000000000 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, createIndex, column } }: MigrationContext) { + await createTable('agents') + .withColumns( + column('id').varchar(36).primary.notNull, + column('name').varchar(128).notNull, + column('description').varchar(512), + column('projectId').varchar(255).notNull, + column('credentialId').varchar(255), + column('provider').varchar(128), + column('model').varchar(128), + column('integrations').json.notNull.default("'[]'"), + column('schema').json, + column('tools').json.notNull.default("'{}'"), + column('skills').json.notNull.default("'{}'"), + column('versionId').varchar(36), + ) + .withIndexOn('projectId') + .withForeignKey('projectId', { + tableName: 'project', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await createIndex('agents', ['projectId']); + + await createTable('agent_checkpoints') + .withColumns( + column('runId').varchar(255).primary.notNull, + column('agentId').varchar(255), + column('state').text, + column('expired').bool.default(false).notNull, + ) + .withIndexOn('agentId') + .withForeignKey('agentId', { + tableName: 'agents', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await createTable('agents_resources').withColumns( + column('id').varchar(255).primary.notNull, + column('metadata').text, + ).withTimestamps; + + await createTable('agents_threads') + .withColumns( + column('id').varchar(36).primary.notNull, + column('resourceId').varchar(255).notNull, + column('title').varchar(255), + column('metadata').text, + ) + .withIndexOn('resourceId').withTimestamps; + + await createTable('agents_messages') + .withColumns( + column('id').varchar(36).primary.notNull, + column('threadId').varchar(255).notNull, + column('resourceId').varchar(255).notNull, + column('role').varchar(36).notNull, + column('type').varchar(36), + column('content').json.notNull, + ) + .withIndexOn(['threadId', 'createdAt']) + .withForeignKey('threadId', { + tableName: 'agents_threads', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await createIndex('agents_messages', ['threadId', 'createdAt']); + + await createTable('agent_published_version') + .withColumns( + column('agentId').varchar(36).primary.notNull, + column('schema').json, + column('publishedFromVersionId').varchar(36).notNull, + column('model').varchar(128), + column('provider').varchar(128), + column('credentialId').varchar(36), + column('publishedById').uuid, + column('tools').json, + column('skills').json, + ) + .withForeignKey('agentId', { + tableName: 'agents', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('publishedById', { + tableName: 'user', + columnName: 'id', + onDelete: 'SET NULL', + }).withTimestamps; + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable('agent_published_version'); + await dropTable('agents_messages'); + await dropTable('agents_threads'); + await dropTable('agents_resources'); + await dropTable('agent_checkpoints'); + await dropTable('agents'); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1783000000001-CreateAgentExecutionTables.ts b/packages/@n8n/db/src/migrations/common/1783000000001-CreateAgentExecutionTables.ts new file mode 100644 index 00000000000..cac56e0b9e9 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1783000000001-CreateAgentExecutionTables.ts @@ -0,0 +1,78 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/** + * Agent runs no longer share `execution_entity` with workflow executions. + * They live in two dedicated tables: + * - `agent_execution_threads` — per-session aggregate (token usage, + * session number, title, emoji). Renamed from `execution_threads`. + * - `agent_execution` — per-message run record with typed + * columns. Replaces the agent rows that previously lived in + * `execution_entity` + free-form key/value rows in + * `execution_metadata`. + */ +export class CreateAgentExecutionTables1783000000001 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + // ── Create new agent execution threads and execution recording tables ── + + await createTable('agent_execution_threads') + .withColumns( + column('id').varchar(36).primary, + column('agentId').varchar(36).notNull, + column('agentName').varchar(255).notNull, + column('projectId').varchar(255).notNull, + column('sessionNumber').int.notNull.default(0), + column('totalPromptTokens').int.notNull.default(0), + column('totalCompletionTokens').int.notNull.default(0), + column('totalCost').double.notNull.default(0), + column('totalDuration').int.notNull.default(0), + column('title').varchar(255), + column('emoji').varchar(8), + ) + .withIndexOn('agentId') + .withIndexOn('projectId') + .withForeignKey('agentId', { + tableName: 'agents', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('projectId', { + tableName: 'project', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await createTable('agent_execution') + .withColumns( + column('id').varchar(36).primary, + column('threadId').varchar(36).notNull, + column('status').varchar(16).notNull.withEnumCheck(['success', 'error']), + column('startedAt').timestampTimezone(3), + column('stoppedAt').timestampTimezone(3), + column('duration').int.notNull.default(0), + column('userMessage').text.notNull, + column('assistantResponse').text.notNull, + column('model').varchar(255), + column('promptTokens').int, + column('completionTokens').int, + column('totalTokens').int, + column('cost').double, + column('toolCalls').json, + column('timeline').json, + column('error').text, + column('hitlStatus').varchar(16).withEnumCheck(['suspended', 'resumed']), + column('workingMemory').text, + column('source').varchar(32), + ) + .withIndexOn(['threadId', 'createdAt']) + .withForeignKey('threadId', { + tableName: 'agent_execution_threads', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable('agent_execution'); + await dropTable('agent_execution_threads'); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 48a8df365c0..732e85f03cb 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -168,6 +168,8 @@ import { AddTracingContextToExecution1777045000000 } from '../common/17770450000 import { AddLangsmithIdsToInstanceAiRunSnapshots1777100000000 } from '../common/1777100000000-AddLangsmithIdsToInstanceAiRunSnapshots'; import { CreateAiBuilderTemporaryWorkflowTable1777281990043 } from '../common/1777281990043-CreateAiBuilderTemporaryWorkflowTable'; import { AddExecutionDeduplicationKey1778000000000 } from '../common/1778000000000-AddExecutionDeduplicationKey'; +import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables'; +import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -336,9 +338,11 @@ export const postgresMigrations: Migration[] = [ CreateFavoritesTable1776150756000, CreateDeploymentKeyTable1777000000000, AddJweKeyIndexesToDeploymentKey1777023444000, - AddLangsmithIdsToInstanceAiRunSnapshots1777100000000, - AddExecutionDeduplicationKey1778000000000, AddTracingContextToExecution1777045000000, + AddLangsmithIdsToInstanceAiRunSnapshots1777100000000, CreateAiBuilderTemporaryWorkflowTable1777281990043, ExpandVariablesValueColumnToText1777420800000, + AddExecutionDeduplicationKey1778000000000, + CreateAgentTables1783000000000, + CreateAgentExecutionTables1783000000001, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index bd54b154f13..043535d5a00 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -161,6 +161,8 @@ import { AddTracingContextToExecution1777045000000 } from '../common/17770450000 import { AddLangsmithIdsToInstanceAiRunSnapshots1777100000000 } from '../common/1777100000000-AddLangsmithIdsToInstanceAiRunSnapshots'; import { CreateAiBuilderTemporaryWorkflowTable1777281990043 } from '../common/1777281990043-CreateAiBuilderTemporaryWorkflowTable'; import { AddExecutionDeduplicationKey1778000000000 } from '../common/1778000000000-AddExecutionDeduplicationKey'; +import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables'; +import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -323,10 +325,12 @@ const sqliteMigrations: Migration[] = [ CreateFavoritesTable1776150756000, CreateDeploymentKeyTable1777000000000, AddJweKeyIndexesToDeploymentKey1777023444000, - AddLangsmithIdsToInstanceAiRunSnapshots1777100000000, - AddExecutionDeduplicationKey1778000000000, AddTracingContextToExecution1777045000000, + AddLangsmithIdsToInstanceAiRunSnapshots1777100000000, CreateAiBuilderTemporaryWorkflowTable1777281990043, + AddExecutionDeduplicationKey1778000000000, + CreateAgentTables1783000000000, + CreateAgentExecutionTables1783000000001, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/decorators/src/controller/route.ts b/packages/@n8n/decorators/src/controller/route.ts index a2bf46f980c..41492bd175e 100644 --- a/packages/@n8n/decorators/src/controller/route.ts +++ b/packages/@n8n/decorators/src/controller/route.ts @@ -10,6 +10,8 @@ interface RouteOptions { usesTemplates?: boolean; /** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */ skipAuth?: boolean; + /** When this flag is set to true, requests from bot user agents (e.g. Slackbot) are allowed through */ + allowBots?: boolean; allowSkipPreviewAuth?: boolean; /** When this flag is set to true, the endpoint can be accessed without authentication */ allowUnauthenticated?: boolean; @@ -38,6 +40,7 @@ const RouteFactory = routeMetadata.middlewares = options.middlewares ?? []; routeMetadata.usesTemplates = options.usesTemplates ?? false; routeMetadata.skipAuth = options.skipAuth ?? false; + routeMetadata.allowBots = options.allowBots ?? false; routeMetadata.allowSkipPreviewAuth = options.allowSkipPreviewAuth ?? false; routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false; routeMetadata.allowUnauthenticated = options.allowUnauthenticated ?? false; diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index cf4166ed3ff..c17bb670aa3 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -30,6 +30,8 @@ export interface RouteMetadata { middlewares: RequestHandler[]; usesTemplates: boolean; skipAuth: boolean; + /** Whether to allow requests from bot user agents (e.g. Slackbot) */ + allowBots: boolean; allowSkipPreviewAuth: boolean; allowSkipMFA: boolean; allowUnauthenticated: boolean; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts index bd945e2009a..fdc9b75137a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreRedis/VectorStoreRedis.node.test.ts @@ -505,11 +505,14 @@ describe('VectorStoreRedis.node', () => { } as any; const node = new RedisNode.VectorStoreRedis(); - - const execution = (node as any).populateVectorStore(context, {}, [], 0); - - await expect(execution).rejects.toThrow(NodeOperationError); - await expect(execution).rejects.toThrow('Error: fail'); + await expect((node as any).populateVectorStore(context, {}, [], 0)).rejects.toBeInstanceOf( + NodeOperationError, + ); + await expect((node as any).populateVectorStore(context, {}, [], 0)).rejects.toMatchObject({ + message: 'Error: fail', + description: 'Please check your index/schema and parameters', + context: { itemIndex: 0 }, + }); expect(loadOptionsFunctions.logger.info).toHaveBeenCalledWith( 'Error while populating the store: fail', diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 7901a4b7519..cc19af3b9f7 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -2,6 +2,16 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` [ + "agent:create", + "agent:read", + "agent:update", + "agent:delete", + "agent:list", + "agent:execute", + "agent:publish", + "agent:unpublish", + "agent:manage", + "agent:*", "aiAssistant:manage", "aiAssistant:*", "annotationTag:create", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 7dfb5fd4e8f..2017dbac74d 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -1,6 +1,7 @@ export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const; export const RESOURCES = { + agent: [...DEFAULT_OPERATIONS, 'execute', 'publish', 'unpublish', 'manage'] as const, aiAssistant: ['manage'] as const, annotationTag: [...DEFAULT_OPERATIONS] as const, auditLogs: ['manage'] as const, @@ -98,7 +99,7 @@ export const PROJECT_VIEWER_ROLE_SLUG = 'project:viewer'; export const PROJECT_CHAT_USER_ROLE_SLUG = 'project:chatUser'; export const PERSONAL_SPACE_PUBLISHING_SETTING = { key: 'security.personalSpacePublishing', - scopes: ['workflow:publish'], + scopes: ['workflow:publish', 'agent:publish'], }; export const PERSONAL_SPACE_SHARING_SETTING = { key: 'security.personalSpaceSharing', diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 162a77dbe50..10897a0f343 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -1,6 +1,15 @@ import type { Scope } from '../../types.ee'; export const GLOBAL_OWNER_SCOPES: Scope[] = [ + 'agent:create', + 'agent:read', + 'agent:update', + 'agent:delete', + 'agent:list', + 'agent:execute', + 'agent:publish', + 'agent:unpublish', + 'agent:manage', 'aiAssistant:manage', 'annotationTag:create', 'annotationTag:read', diff --git a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index ff1328bf91f..f3f49310c5f 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -7,6 +7,14 @@ import type { Scope } from '../../types.ee'; */ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ + 'agent:create', + 'agent:read', + 'agent:update', + 'agent:delete', + 'agent:list', + 'agent:execute', + 'agent:publish', + 'agent:unpublish', 'workflow:create', 'workflow:read', 'workflow:update', @@ -55,6 +63,13 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ ]; export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ + 'agent:create', + 'agent:read', + 'agent:update', + 'agent:delete', + 'agent:list', + 'agent:execute', + 'agent:unpublish', 'workflow:create', 'workflow:read', 'workflow:update', @@ -94,6 +109,14 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ ]; export const PROJECT_EDITOR_SCOPES: Scope[] = [ + 'agent:create', + 'agent:read', + 'agent:update', + 'agent:delete', + 'agent:list', + 'agent:execute', + 'agent:publish', + 'agent:unpublish', 'workflow:create', 'workflow:read', 'workflow:update', @@ -132,6 +155,9 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ ]; export const PROJECT_VIEWER_SCOPES: Scope[] = [ + 'agent:read', + 'agent:list', + 'agent:execute', 'credential:list', 'credential:read', 'project:list', @@ -149,4 +175,4 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'projectVariable:read', ]; -export const PROJECT_CHAT_USER_SCOPES: Scope[] = ['workflow:execute-chat']; +export const PROJECT_CHAT_USER_SCOPES: Scope[] = ['agent:execute', 'workflow:execute-chat']; diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index ff2e3e2b86c..a9d0999a396 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -24,6 +24,38 @@ export const ALL_SCOPES = buildResourceScopes(); export const ALL_API_KEY_SCOPES = buildApiKeyScopes(); export const scopeInformation: Partial> = { + 'agent:create': { + displayName: 'Create Agent', + description: 'Allows creating new agents in a project.', + }, + 'agent:read': { + displayName: 'Read Agent', + description: 'Allows reading agent configuration and history.', + }, + 'agent:update': { + displayName: 'Update Agent', + description: 'Allows updating, building, publishing, and managing integrations of agents.', + }, + 'agent:delete': { + displayName: 'Delete Agent', + description: 'Allows deleting agents.', + }, + 'agent:list': { + displayName: 'List Agents', + description: 'Allows listing agents in a project.', + }, + 'agent:execute': { + displayName: 'Execute Agent', + description: 'Allows running agents in chat.', + }, + 'agent:publish': { + displayName: 'Publish Agent', + description: 'Allows publishing agents.', + }, + 'agent:unpublish': { + displayName: 'Unpublish Agent', + description: 'Allows unpublishing agents.', + }, 'aiAssistant:manage': { displayName: 'Manage AI Usage', description: 'Allows managing AI Usage settings.', diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 93b94600a2a..d51a00576f1 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -6,6 +6,7 @@ import { getResourcePermissions } from '../get-resource-permissions.ee'; describe('permissions', () => { it('getResourcePermissions for empty scopes', () => { expect(getResourcePermissions()).toEqual({ + agent: {}, aiAssistant: {}, annotationTag: {}, auditLogs: {}, @@ -88,6 +89,7 @@ describe('permissions', () => { ]; const permissionRecord: PermissionsRecord = { + agent: {}, aiAssistant: {}, annotationTag: {}, auditLogs: {}, diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts index 25db1bb9a11..81a9c34e350 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts @@ -743,11 +743,12 @@ function mapNestedPropertyTypeInner( } switch (prop.type) { - case 'string': + case 'string': { if (prop.builderHint?.placeholderSupported === false) { return 'string | Expression'; } return 'string | Expression | PlaceholderValue'; + } case 'number': return 'number | Expression'; case 'boolean': @@ -1434,11 +1435,12 @@ function mapPropertyTypeInner( } switch (prop.type) { - case 'string': + case 'string': { if (prop.builderHint?.placeholderSupported === false) { return 'string | Expression'; } return 'string | Expression | PlaceholderValue'; + } case 'number': return 'number | Expression'; diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts index 40775c3e258..e1f84b67ecc 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts @@ -12,7 +12,6 @@ import { isPropertyOptional, mapPropertyToZodSchema, mergeDisplayOptions, - mergePropertiesByName, extractDefaultsForDisplayOptions, } from './generate-zod-schemas'; @@ -1143,6 +1142,206 @@ describe('generateSingleVersionSchemaFile', () => { }); }); +describe('collection sub-fields with typeOptions.multipleValues', () => { + const baseNodeProps = { + group: ['transform'] as string[], + inputs: ['main'] as string[], + outputs: ['main'] as string[], + }; + + it('generates z.array(stringOrExpression) for a string sub-field with multipleValues: true', () => { + const node: NodeTypeDescription = { + ...baseNodeProps, + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'additionalFields', + displayName: 'Additional Fields', + type: 'collection', + default: {}, + options: [ + { + name: 'attendees', + displayName: 'Attendees', + type: 'string', + default: '', + typeOptions: { multipleValues: true }, + } as NodeProperty, + ], + }, + ], + }; + + const code = generateSingleVersionSchemaFile(node, 1); + + expect(code).toContain('z.array(stringOrExpression)'); + // Should NOT just be a plain stringOrExpression for attendees + expect(code).not.toMatch(/attendees:\s*stringOrExpression[^)]/); + }); + + it('generates stringOrExpression (no array) for a string sub-field without multipleValues', () => { + const node: NodeTypeDescription = { + ...baseNodeProps, + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'additionalFields', + displayName: 'Additional Fields', + type: 'collection', + default: {}, + options: [ + { + name: 'description', + displayName: 'Description', + type: 'string', + default: '', + } as NodeProperty, + ], + }, + ], + }; + + const code = generateSingleVersionSchemaFile(node, 1); + + expect(code).toContain('description: stringOrExpression'); + expect(code).not.toContain('z.array(stringOrExpression)'); + }); + + it('generates z.array(numberOrExpression) for a number sub-field with multipleValues: true', () => { + const node: NodeTypeDescription = { + ...baseNodeProps, + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'config', + displayName: 'Config', + type: 'collection', + default: {}, + options: [ + { + name: 'ports', + displayName: 'Ports', + type: 'number', + default: 0, + typeOptions: { multipleValues: true }, + } as NodeProperty, + ], + }, + ], + }; + + const code = generateSingleVersionSchemaFile(node, 1); + + expect(code).toContain('z.array(numberOrExpression)'); + }); + + it('generates z.array(...) for an options sub-field with multipleValues: true', () => { + const node: NodeTypeDescription = { + ...baseNodeProps, + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'additionalFields', + displayName: 'Additional Fields', + type: 'collection', + default: {}, + options: [ + { + name: 'labels', + displayName: 'Labels', + type: 'options', + default: '', + typeOptions: { multipleValues: true }, + options: [ + { name: 'Personal', value: 'personal' }, + { name: 'Work', value: 'work' }, + ], + } as NodeProperty, + ], + }, + ], + }; + + const code = generateSingleVersionSchemaFile(node, 1); + + expect(code).toContain('z.array('); + }); + + it('generates z.array(z.string()) for a string sub-field with multipleValues: true and noDataExpression: true', () => { + const node: NodeTypeDescription = { + ...baseNodeProps, + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'additionalFields', + displayName: 'Additional Fields', + type: 'collection', + default: {}, + options: [ + { + name: 'attendees', + displayName: 'Attendees', + type: 'string', + default: '', + noDataExpression: true, + typeOptions: { multipleValues: true }, + } as NodeProperty, + ], + }, + ], + }; + + const code = generateSingleVersionSchemaFile(node, 1); + + expect(code).toContain('z.array(z.string())'); + // Should not use stringOrExpression inside the array (the bug) + expect(code).not.toContain('z.array(stringOrExpression)'); + }); + + it('generates z.array(z.number()) for a number sub-field with multipleValues: true and noDataExpression: true', () => { + const node: NodeTypeDescription = { + ...baseNodeProps, + name: 'n8n-nodes-base.testNode', + displayName: 'Test Node', + version: 1, + properties: [ + { + name: 'config', + displayName: 'Config', + type: 'collection', + default: {}, + options: [ + { + name: 'ports', + displayName: 'Ports', + type: 'number', + default: 0, + noDataExpression: true, + typeOptions: { multipleValues: true }, + } as NodeProperty, + ], + }, + ], + }; + + const code = generateSingleVersionSchemaFile(node, 1); + + expect(code).toContain('z.array(z.number())'); + // Should not use numberOrExpression inside the array (the bug) + expect(code).not.toContain('z.array(numberOrExpression)'); + }); +}); + describe('generateSubnodeConfigSchemaCode', () => { it('generates static schema when AI inputs have no displayOptions', () => { const aiInputTypes = [ @@ -1533,225 +1732,6 @@ describe('narrowDisplayOptionsByDisabled', () => { }); }); -describe('mergePropertiesByName with disabledOptions', () => { - it('drops the fully-disabled sessionKey variant so only the editable one survives', () => { - const expressionSessionKey: NodeProperty = { - name: 'sessionKey', - displayName: 'Session Key From Previous Node', - type: 'string', - default: '={{ $json.sessionId }}', - displayOptions: { show: { sessionIdType: ['fromInput'] } }, - disabledOptions: { show: { sessionIdType: ['fromInput'] } }, - }; - const editableSessionKey: NodeProperty = { - name: 'sessionKey', - displayName: 'Key', - type: 'string', - default: '', - displayOptions: { show: { sessionIdType: ['customKey'] } }, - }; - - const merged = mergePropertiesByName([expressionSessionKey, editableSessionKey]); - - const variants = merged.get('sessionKey'); - expect(variants).toBeDefined(); - expect(variants).toHaveLength(1); - expect(variants?.[0].displayOptions).toEqual({ show: { sessionIdType: ['customKey'] } }); - }); - - it('produces the same narrowed result regardless of property ordering', () => { - const expressionSessionKey: NodeProperty = { - name: 'sessionKey', - displayName: 'Session Key From Previous Node', - type: 'string', - default: '={{ $json.sessionId }}', - displayOptions: { show: { sessionIdType: ['fromInput'] } }, - disabledOptions: { show: { sessionIdType: ['fromInput'] } }, - }; - const editableSessionKey: NodeProperty = { - name: 'sessionKey', - displayName: 'Key', - type: 'string', - default: '', - displayOptions: { show: { sessionIdType: ['customKey'] } }, - }; - - const mergedA = mergePropertiesByName([expressionSessionKey, editableSessionKey]); - const mergedB = mergePropertiesByName([editableSessionKey, expressionSessionKey]); - - expect(mergedA.get('sessionKey')).toHaveLength(1); - expect(mergedA.get('sessionKey')?.[0].displayOptions).toEqual({ - show: { sessionIdType: ['customKey'] }, - }); - expect(mergedB.get('sessionKey')).toHaveLength(1); - expect(mergedB.get('sessionKey')?.[0].displayOptions).toEqual({ - show: { sessionIdType: ['customKey'] }, - }); - }); - - it('drops a property entirely when every duplicate is fully disabled', () => { - const first: NodeProperty = { - name: 'lockedField', - displayName: 'Locked Field', - type: 'string', - default: 'A', - displayOptions: { show: { mode: ['a'] } }, - disabledOptions: { show: { mode: ['a'] } }, - }; - const second: NodeProperty = { - name: 'lockedField', - displayName: 'Locked Field', - type: 'string', - default: 'B', - displayOptions: { show: { mode: ['b'] } }, - disabledOptions: { show: { mode: ['b'] } }, - }; - - const merged = mergePropertiesByName([first, second]); - - expect(merged.has('lockedField')).toBe(false); - }); - - it('keeps the merged schema emitting only settable states when generating code', () => { - const expressionSessionKey: NodeProperty = { - name: 'sessionKey', - displayName: 'Session Key From Previous Node', - type: 'string', - default: '={{ $json.sessionId }}', - displayOptions: { show: { sessionIdType: ['fromInput'] } }, - disabledOptions: { show: { sessionIdType: ['fromInput'] } }, - }; - const editableSessionKey: NodeProperty = { - name: 'sessionKey', - displayName: 'Key', - type: 'string', - default: '', - displayOptions: { show: { sessionIdType: ['customKey'] } }, - }; - - const merged = mergePropertiesByName([expressionSessionKey, editableSessionKey]); - const line = generateConditionalSchemaLine(merged.get('sessionKey')![0], [ - { - name: 'sessionIdType', - displayName: 'Session ID', - type: 'options', - default: 'fromInput', - }, - ]); - - expect(line).toContain('displayOptions: {"show":{"sessionIdType":["customKey"]}}'); - expect(line).not.toMatch(/"show":\{[^}]*"fromInput"/); - }); -}); - -describe('mergePropertiesByName with UX-fork variants', () => { - it('keeps two BigQuery-shaped sqlQuery declarations as separate variants', () => { - const sqlQueryStandard: NodeProperty = { - name: 'sqlQuery', - displayName: 'Query', - type: 'string', - default: '', - displayOptions: { hide: { '/options.useLegacySql': [true] } }, - }; - const sqlQueryLegacy: NodeProperty = { - name: 'sqlQuery', - displayName: 'Legacy SQL Query', - type: 'string', - default: '', - displayOptions: { show: { '/options.useLegacySql': [true] } }, - }; - - const merged = mergePropertiesByName([sqlQueryStandard, sqlQueryLegacy]); - const variants = merged.get('sqlQuery'); - expect(variants).toHaveLength(2); - expect(variants?.[0].displayOptions).toEqual({ - hide: { '/options.useLegacySql': [true] }, - }); - expect(variants?.[1].displayOptions).toEqual({ - show: { '/options.useLegacySql': [true] }, - }); - }); - - it('collapses true duplicates (deep-equal displayOptions) into one variant with merged options', () => { - const collectionA: NodeProperty = { - name: 'options', - displayName: 'Options', - type: 'collection', - default: {}, - displayOptions: { show: { mode: ['x'] } }, - options: [{ name: 'optA', displayName: 'A' }], - }; - const collectionB: NodeProperty = { - name: 'options', - displayName: 'Options', - type: 'collection', - default: {}, - displayOptions: { show: { mode: ['x'] } }, - options: [{ name: 'optB', displayName: 'B' }], - }; - - const merged = mergePropertiesByName([collectionA, collectionB]); - const variants = merged.get('options'); - expect(variants).toHaveLength(1); - const optionNames = (variants?.[0].options ?? []).map((o) => o.name); - expect(optionNames).toEqual(['optA', 'optB']); - }); -}); - -describe('generateOneOfSchemaLine + generateParameterEntryLine', () => { - it('emits resolveOneOfSchemas for a BigQuery-shaped sqlQuery fork', () => { - const sqlQueryStandard: NodeProperty = { - name: 'sqlQuery', - displayName: 'Query', - type: 'string', - default: '', - displayOptions: { hide: { '/options.useLegacySql': [true] } }, - }; - const sqlQueryLegacy: NodeProperty = { - name: 'sqlQuery', - displayName: 'Legacy SQL Query', - type: 'string', - default: '', - displayOptions: { show: { '/options.useLegacySql': [true] } }, - }; - const useLegacySql: NodeProperty = { - name: 'useLegacySql', - displayName: 'Use Legacy SQL', - type: 'boolean', - default: false, - }; - - const merged = mergePropertiesByName([sqlQueryStandard, sqlQueryLegacy, useLegacySql]); - const allFlat = Array.from(merged.values()).flat(); - // The relevant entry is sqlQuery (multi-variant) - const sqlVariants = merged.get('sqlQuery')!; - // Use the public emit path to confirm a oneOf line is produced - const file = generateSingleVersionSchemaFile( - { - name: 'bigQuery', - displayName: 'BigQuery', - group: ['transform'], - version: 1, - inputs: ['main'], - outputs: ['main'], - properties: [sqlQueryStandard, sqlQueryLegacy, useLegacySql], - }, - 1, - ); - - expect(file).toContain('resolveOneOfSchemas'); - // Both variants serialized into the variants array - expect(file).toMatch(/"hide":\{"\/options\.useLegacySql":\[true\]\}/); - expect(file).toMatch(/"show":\{"\/options\.useLegacySql":\[true\]\}/); - // And the helper is registered - expect(file).toMatch(/function getSchema\(\{[^}]*resolveOneOfSchemas[^}]*\}\)/); - - // Also exercise the merged map directly - expect(sqlVariants).toHaveLength(2); - expect(allFlat).toHaveLength(3); - }); -}); - describe('mapPropertyToZodSchema for fixedCollection with field-count constraints', () => { const buildFilters = (typeOptions: NodeProperty['typeOptions']): NodeProperty => ({ name: 'filters', diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts index 532f5c70099..86a33dc8fec 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts @@ -24,7 +24,6 @@ import { filterPropertiesForVersion, buildDiscriminatorTree, extractAIInputTypesFromBuilderHint, - narrowDisplayOptionsByDisabled, } from './generate-types'; // ============================================================================= @@ -42,27 +41,10 @@ function isCustomApiCall(operation: string): boolean { } /** - * Known values for genericAuthType in HTTP Request node - */ -const GENERIC_AUTH_TYPE_VALUES = [ - 'httpBasicAuth', - 'httpBearerAuth', - 'httpDigestAuth', - 'httpHeaderAuth', - 'httpQueryAuth', - 'httpCustomAuth', - 'oAuth1Api', - 'oAuth2Api', -] as const; - -/** - * Property types that render UI affordances but carry no data in the saved - * workflow JSON. They are skipped entirely from generated schemas so that - * Zod validation does not reject configurations that omit them. + * Property types that don't carry runtime data and are skipped from generated + * Zod schemas — they exist only as UI hints in the workflow JSON. * * Note: `button` and `icon` are not in this list — they can carry data. - * See mapNestedPropertyToZodSchemaInner / mapPropertyToZodSchemaInner for - * their dedicated schemas. */ const DISPLAY_ONLY_PROPERTY_TYPES = new Set(['notice', 'curlImport', 'credentials', 'callout']); @@ -75,13 +57,27 @@ const ICON_ZOD_SCHEMA = /** * Runtime shape for `type: 'workflowSelector'` properties. - * The UI hardcodes two modes (see useWorkflowResourceLocatorModes.ts): `list` and `id`. - * Stored as an `INodeParameterResourceLocator` object, or as an `={{...}}` expression + * The UI hardcodes two modes (`list` and `id`). Stored as an + * `INodeParameterResourceLocator` object, or as an `={{...}}` expression * string when the user enters an expression. */ const WORKFLOW_SELECTOR_ZOD_SCHEMA = "z.union([z.object({ __rl: z.literal(true), mode: z.union([z.literal('list'), z.literal('id')]), value: z.union([z.string(), z.number()]), cachedResultName: z.string().optional(), cachedResultUrl: z.string().optional() }), expressionSchema])"; +/** + * Known values for genericAuthType in HTTP Request node + */ +const GENERIC_AUTH_TYPE_VALUES = [ + 'httpBasicAuth', + 'httpBearerAuth', + 'httpDigestAuth', + 'httpHeaderAuth', + 'httpQueryAuth', + 'httpCustomAuth', + 'oAuth1Api', + 'oAuth2Api', +] as const; + /** * TypeScript reserved words that need quoting */ @@ -205,18 +201,9 @@ const AI_TYPE_TO_SCHEMA_FIELD: Record< // ============================================================================= /** - * Whether a property's `default` value actually satisfies a `required: true` - * constraint at runtime. The runtime check in `getNodeParametersIssues` - * (`n8n-workflow/node-helpers`) rejects the "empty" form of these types even - * when a default is present: - * - * - `string` / `options` / `dateTime`: `''` is treated as missing - * - `multiOptions`: `[]` is treated as missing - * - * The Zod schema must agree — otherwise `required: true, default: ''` (e.g. - * `splitOut.fieldToSplitOut`) silently generates `.optional()`, the LLM-facing - * TS type loses the required signal, and the workflow submits cleanly only to - * fail at execution time with `Parameter "X" is required`. + * A property's default value satisfies its required constraint only when the + * value is non-empty for the property type. Empty strings, empty `multiOptions` + * arrays, etc. do not count as satisfying `required: true`. */ function defaultSatisfiesRequired(prop: NodeProperty): boolean { if (!('default' in prop) || prop.default === undefined) return false; @@ -415,17 +402,48 @@ function generateResourceLocatorZodSchema(prop: NodeProperty): string { } /** - * Map a nested property to its Zod schema code (for collection/fixedCollection inner properties) + * Primitive element schema token for a given base type. + * When `allowExpression` is true, returns the `*OrExpression` helper that accepts + * both the literal value and n8n expression strings; otherwise returns the plain + * Zod primitive. Callers embed this token inside larger schemas (arrays, unions). */ -function mapNestedPropertyToZodSchema(prop: NodeProperty): string { - const result = mapNestedPropertyToZodSchemaInner(prop); - const expressionAwareResult = prop.noDataExpression - ? stripExpressionFromZodSchema(result) - : result; - return wrapMultipleValuesZodSchema(prop, expressionAwareResult); +function primitiveElement(base: 'string' | 'number' | 'boolean', allowExpression: boolean): string { + if (base === 'string') return allowExpression ? 'stringOrExpression' : 'z.string()'; + if (base === 'number') return allowExpression ? 'numberOrExpression' : 'z.number()'; + return allowExpression ? 'booleanOrExpression' : 'z.boolean()'; } -function mapNestedPropertyToZodSchemaInner(prop: NodeProperty): string { +/** + * Wrap an element schema in `z.array(...)` when the property supports + * `typeOptions.multipleValues`. When expressions are allowed the whole array + * can also be replaced by an expression string at runtime, so we emit a union + * with `expressionSchema`. + */ +function wrapMultipleValues( + elementSchema: string, + isMultipleValues: boolean, + _allowExpression: boolean, +): string { + if (!isMultipleValues) return elementSchema; + return `z.array(${elementSchema})`; +} + +/** Wrap a list of literal/object schemas in a union, optionally extended with `expressionSchema`. */ +function literalUnion(literals: string[], allowExpression: boolean): string { + const parts = allowExpression ? [...literals, 'expressionSchema'] : literals; + return `z.union([${parts.join(', ')}])`; +} + +/** + * Map a nested property to its Zod schema code (for collection/fixedCollection inner properties). + * Reads `prop.noDataExpression` once here; all downstream branches build the schema + * directly in its final form instead of post-processing the string. + */ +function mapNestedPropertyToZodSchema(prop: NodeProperty): string { + return mapNestedPropertyToZodSchemaInner(prop, !prop.noDataExpression); +} + +function mapNestedPropertyToZodSchemaInner(prop: NodeProperty, allowExpression: boolean): string { // Skip display-only types if (DISPLAY_ONLY_PROPERTY_TYPES.has(prop.type)) { return ''; @@ -441,12 +459,18 @@ function mapNestedPropertyToZodSchemaInner(prop: NodeProperty): string { return 'resourceMapperValueSchema'; } + const isMultipleValues = prop.typeOptions?.multipleValues === true; + // Handle dynamic options (but not for types with specific structure) if (prop.typeOptions?.loadOptionsMethod || prop.typeOptions?.loadOptionsDependsOn) { if (prop.type === 'multiOptions') { return 'z.array(z.string())'; } - return 'stringOrExpression'; + return wrapMultipleValues( + primitiveElement('string', allowExpression), + isMultipleValues, + allowExpression, + ); } switch (prop.type) { @@ -454,35 +478,42 @@ function mapNestedPropertyToZodSchemaInner(prop: NodeProperty): string { case 'dateTime': case 'color': case 'credentialsSelect': - return 'stringOrExpression'; + return wrapMultipleValues( + primitiveElement('string', allowExpression), + isMultipleValues, + allowExpression, + ); case 'number': - return 'numberOrExpression'; + return wrapMultipleValues( + primitiveElement('number', allowExpression), + isMultipleValues, + allowExpression, + ); case 'boolean': - return 'booleanOrExpression'; + return primitiveElement('boolean', allowExpression); - case 'options': - if (prop.options && prop.options.length > 0) { - const literals = prop.options - .filter((opt) => opt.value !== undefined) - .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); - if (literals.length > 0) { - return `z.union([${literals.join(', ')}, expressionSchema])`; - } - } - return 'stringOrExpression'; + case 'options': { + const literals = (prop.options ?? []) + .filter((opt) => opt.value !== undefined) + .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); + const elementSchema = + literals.length > 0 + ? literalUnion(literals, allowExpression) + : primitiveElement('string', allowExpression); + return wrapMultipleValues(elementSchema, isMultipleValues, allowExpression); + } - case 'multiOptions': - if (prop.options && prop.options.length > 0) { - const literals = prop.options - .filter((opt) => opt.value !== undefined) - .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); - if (literals.length > 0) { - return `z.array(z.union([${literals.join(', ')}]))`; - } + case 'multiOptions': { + const literals = (prop.options ?? []) + .filter((opt) => opt.value !== undefined) + .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); + if (literals.length > 0) { + return `z.array(z.union([${literals.join(', ')}]))`; } return 'z.array(z.string())'; + } case 'json': return 'z.union([iDataObjectSchema, z.string()])'; @@ -500,10 +531,7 @@ function mapNestedPropertyToZodSchemaInner(prop: NodeProperty): string { return generateCollectionZodSchema(prop); case 'button': - // Buttons with `hasInputField: true` (e.g. AiTransform's instructions) - // store the user-typed text; pure-action buttons store the default ''. - // In both cases the value is a string. - return 'stringOrExpression'; + return primitiveElement('string', allowExpression); case 'icon': return ICON_ZOD_SCHEMA; @@ -622,42 +650,31 @@ function generateCollectionZodSchema(prop: NodeProperty): string { * * This function parallels mapPropertyType() but returns Zod schema code * that validates the runtime representation of values (where expressions - * are strings like "={{ $json.field }}"). + * are strings like "={{ $json.field }}"). When `prop.noDataExpression` is set, + * the generated schema omits expression support entirely. */ export function mapPropertyToZodSchema(prop: NodeProperty): string { - const result = mapPropertyToZodSchemaInner(prop); - const expressionAwareResult = prop.noDataExpression - ? stripExpressionFromZodSchema(result) - : result; - return wrapMultipleValuesZodSchema(prop, expressionAwareResult); -} - -/** - * Strip expression support from a Zod schema code string. - * Used when noDataExpression is true. - */ -function stripExpressionFromZodSchema(schema: string): string { - // Replace OrExpression helpers with plain types - if (schema === 'stringOrExpression') return 'z.string()'; - if (schema === 'numberOrExpression') return 'z.number()'; - if (schema === 'booleanOrExpression') return 'z.boolean()'; - // Remove expressionSchema from z.union([..., expressionSchema]) - return schema.replace(/,\s*expressionSchema/g, ''); + const result = mapPropertyToZodSchemaInner(prop, !prop.noDataExpression); + return wrapMultipleValuesZodSchema(prop, result); } function wrapMultipleValuesZodSchema(prop: NodeProperty, schema: string): string { if (!schema || prop.type === 'fixedCollection' || prop.type === 'multiOptions') { return schema; } - return prop.typeOptions?.multipleValues === true ? `z.array(${schema})` : schema; } -function mapPropertyToZodSchemaInner(prop: NodeProperty): string { +function mapPropertyToZodSchemaInner(prop: NodeProperty, allowExpression: boolean): string { + // Skip display-only types (notice, curlImport, credentials, callout) + if (DISPLAY_ONLY_PROPERTY_TYPES.has(prop.type)) { + return ''; + } + // Special handling for known credentialsSelect fields with fixed values if (prop.type === 'credentialsSelect' && prop.name === 'genericAuthType') { const literals = GENERIC_AUTH_TYPE_VALUES.map((v) => `z.literal('${v}')`); - return `z.union([${literals.join(', ')}, expressionSchema])`; + return literalUnion(literals, allowExpression); } // Handle resourceLocator first - it has its own structure regardless of dynamic options @@ -672,47 +689,41 @@ function mapPropertyToZodSchemaInner(prop: NodeProperty): string { // Handle dynamic options (loadOptionsMethod) - but not for types with specific structure if (prop.typeOptions?.loadOptionsMethod || prop.typeOptions?.loadOptionsDependsOn) { - switch (prop.type) { - case 'options': - return 'stringOrExpression'; - case 'multiOptions': - return 'z.array(z.string())'; - default: - return 'stringOrExpression'; - } + if (prop.type === 'multiOptions') return 'z.array(z.string())'; + return primitiveElement('string', allowExpression); } switch (prop.type) { case 'string': - return 'stringOrExpression'; + case 'dateTime': + case 'color': + case 'credentialsSelect': + return primitiveElement('string', allowExpression); case 'number': - return 'numberOrExpression'; + return primitiveElement('number', allowExpression); case 'boolean': - return 'booleanOrExpression'; + return primitiveElement('boolean', allowExpression); - case 'options': - if (prop.options && prop.options.length > 0) { - const literals = prop.options - .filter((opt) => opt.value !== undefined) - .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); - if (literals.length > 0) { - return `z.union([${literals.join(', ')}, expressionSchema])`; - } - } - return 'stringOrExpression'; + case 'options': { + const literals = (prop.options ?? []) + .filter((opt) => opt.value !== undefined) + .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); + return literals.length > 0 + ? literalUnion(literals, allowExpression) + : primitiveElement('string', allowExpression); + } - case 'multiOptions': - if (prop.options && prop.options.length > 0) { - const literals = prop.options - .filter((opt) => opt.value !== undefined) - .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); - if (literals.length > 0) { - return `z.array(z.union([${literals.join(', ')}]))`; - } + case 'multiOptions': { + const literals = (prop.options ?? []) + .filter((opt) => opt.value !== undefined) + .map((opt) => `z.literal(${formatZodLiteral(opt.value)})`); + if (literals.length > 0) { + return `z.array(z.union([${literals.join(', ')}]))`; } return 'z.array(z.string())'; + } case 'json': return 'z.union([iDataObjectSchema, z.string()])'; @@ -729,9 +740,17 @@ function mapPropertyToZodSchemaInner(prop: NodeProperty): string { case 'collection': return generateCollectionZodSchema(prop); - case 'dateTime': - case 'color': - return 'stringOrExpression'; + case 'button': + // Buttons with `hasInputField: true` (e.g. AiTransform's instructions) + // store the user-typed text; pure-action buttons store the default ''. + // In both cases the value is a string. + return primitiveElement('string', allowExpression); + + case 'icon': + return ICON_ZOD_SCHEMA; + + case 'workflowSelector': + return WORKFLOW_SELECTOR_ZOD_SCHEMA; case 'hidden': return 'z.unknown()'; @@ -740,22 +759,7 @@ function mapPropertyToZodSchemaInner(prop: NodeProperty): string { case 'curlImport': case 'credentials': case 'callout': - return ''; // Skip display-only types - - case 'button': - // Buttons with `hasInputField: true` (e.g. AiTransform's instructions) - // store the user-typed text; pure-action buttons store the default ''. - // In both cases the value is a string. - return 'stringOrExpression'; - - case 'icon': - return ICON_ZOD_SCHEMA; - - case 'workflowSelector': - return WORKFLOW_SELECTOR_ZOD_SCHEMA; - - case 'credentialsSelect': - return 'stringOrExpression'; + return ''; // Skip these types default: return 'z.unknown()'; @@ -883,34 +887,16 @@ export function mergeDisplayOptions( return merged; } -function isSameDisplayOptions( - a: NodeProperty['displayOptions'], - b: NodeProperty['displayOptions'], -): boolean { - return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); -} - /** - * Group properties by name, returning each name's variants in declaration order. + * Merge duplicate properties by name, combining displayOptions and nested options. + * When multiple properties have the same name (e.g., multiple 'options' collections + * with different displayOptions), their displayOptions and nested options are merged. * - * Same-named declarations are OR alternatives, not AND constraints — e.g. - * BigQuery's `sqlQuery` is declared once with `hide: useLegacySql=[true]` and - * once with `show: useLegacySql=[true]`. Union-merging show/hide independently - * across these would produce a self-contradicting predicate that rejects every - * value. Forks are kept as separate variants so the runtime can pick the - * matching one via resolveOneOfSchemas. - * - * Two declarations with deep-equal displayOptions are still treated as a true - * duplicate: their nested options arrays are merged into one variant. - * - * Properties whose displayOptions are fully covered by disabledOptions (the - * field is rendered read-only in all its visible states, e.g. the - * expression-prefilled variant of a sessionKey) contribute no settable states - * and are skipped, so the generated schema only accepts values in states where - * the field is actually user-editable. + * @param properties - Array of node properties, possibly with duplicates + * @returns Map of property name to merged property */ -export function mergePropertiesByName(properties: NodeProperty[]): Map { - const variantsByName = new Map(); +export function mergePropertiesByName(properties: NodeProperty[]): Map { + const propsByName = new Map(); for (const prop of properties) { // Skip display-only types @@ -918,88 +904,40 @@ export function mergePropertiesByName(properties: NodeProperty[]): Map - isSameDisplayOptions(v.displayOptions, normalizedProp.displayOptions), - ); - - if (trueDuplicate) { - // True duplicate (deep-equal displayOptions): merge nested options for - // collection/fixedCollection types so additional options remain settable. + // For collection/fixedCollection types, merge nested options if ( - (normalizedProp.type === 'collection' || normalizedProp.type === 'fixedCollection') && - normalizedProp.options && - trueDuplicate.options + (prop.type === 'collection' || prop.type === 'fixedCollection') && + prop.options && + existing.options ) { - const existingOptionNames = new Set(trueDuplicate.options.map((o) => o.name)); - for (const opt of normalizedProp.options) { + const existingOptionNames = new Set(existing.options.map((o) => o.name)); + for (const opt of prop.options) { if (!existingOptionNames.has(opt.name)) { - trueDuplicate.options.push(opt); + existing.options.push(opt); } } } - // Keep the first variant's other attributes (type, required, etc.) + // Keep the first property's other attributes (type, required, etc.) } else { - // Fork: different displayOptions ⇒ keep as a separate variant - existingVariants.push({ - ...normalizedProp, - options: normalizedProp.options ? [...normalizedProp.options] : undefined, + // Create a shallow copy to avoid mutating the original when merging + propsByName.set(prop.name, { + ...prop, + options: prop.options ? [...prop.options] : undefined, }); } } - return variantsByName; -} - -/** - * True when any name in the merged map has more than one variant — used to - * decide whether to register the `resolveOneOfSchemas` helper for the file. - */ -export function hasMultiVariantEntries(variantsByName: Map): boolean { - for (const variants of variantsByName.values()) { - if (variants.length > 1) return true; - } - return false; -} - -/** - * True when at least one name produces a `resolveOneOfSchemas(...)` call after - * the given discriminator keys are stripped from each variant's displayOptions. - * Used to decide whether to register the helper in the emitted factory file. - */ -export function needsResolveOneOfSchemas( - variantsByName: Map, - discriminatorKeys: string[], -): boolean { - for (const variants of variantsByName.values()) { - if (variants.length < 2) continue; - const allRemainConditional = variants.every((v) => { - if (!v.displayOptions) return false; - return ( - stripDiscriminatorKeysFromDisplayOptions(v.displayOptions, discriminatorKeys) !== undefined - ); - }); - if (allRemainConditional) return true; - } - return false; + return propsByName; } /** @@ -1033,86 +971,6 @@ export function generateConditionalSchemaLine( return `${INDENT}${propName}: resolveSchema({ parameters, schema: ${zodSchema}, required: ${required}, displayOptions: ${displayOptionsStr}${defaultsStr} }),`; } -/** - * Generate a `resolveOneOfSchemas(...)` line for a property declared multiple - * times with mutually exclusive displayOptions. Each variant produces one - * entry in the variants array; the runtime picks the first whose displayOptions - * match the current parameters. - */ -export function generateOneOfSchemaLine( - variants: NodeProperty[], - allProperties: NodeProperty[] = [], -): string { - if (variants.length === 0) return ''; - - const propName = quotePropertyName(variants[0].name); - const variantSpecs: string[] = []; - - for (const variant of variants) { - const zodSchema = mapPropertyToZodSchema(variant); - if (!zodSchema) { - // Any unmappable variant aborts emission (mirrors generateConditionalSchemaLine). - return ''; - } - const required = !isPropertyOptional(variant); - const displayOptionsStr = JSON.stringify(variant.displayOptions ?? {}); - const defaults = variant.displayOptions - ? extractDefaultsForDisplayOptions(variant.displayOptions, allProperties) - : {}; - const defaultsStr = - Object.keys(defaults).length > 0 ? `, defaults: ${JSON.stringify(defaults)}` : ''; - variantSpecs.push( - `{ schema: ${zodSchema}, required: ${required}, displayOptions: ${displayOptionsStr}${defaultsStr} }`, - ); - } - - return `${INDENT}${propName}: resolveOneOfSchemas({ parameters, variants: [${variantSpecs.join(', ')}] }),`; -} - -/** - * Emit one parameters-schema line for a name that may have multiple variants. - * After stripping discriminator keys, classifies each variant as conditional - * (still has displayOptions) or unconditional (always visible). If any variant - * is unconditional the field is always visible and a static schema is emitted; - * one conditional variant uses `resolveSchema`, multiple use `resolveOneOfSchemas`. - */ -export function generateParameterEntryLine( - variants: NodeProperty[], - allFlatProperties: NodeProperty[], - discriminatorKeys: string[], -): string { - if (variants.length === 0) return ''; - - type Processed = { variant: NodeProperty; conditional: boolean }; - const processed: Processed[] = variants.map((variant) => { - if (!variant.displayOptions) { - return { variant, conditional: false }; - } - const stripped = stripDiscriminatorKeysFromDisplayOptions( - variant.displayOptions, - discriminatorKeys, - ); - if (!stripped) { - return { variant: { ...variant, displayOptions: undefined }, conditional: false }; - } - return { variant: { ...variant, displayOptions: stripped }, conditional: true }; - }); - - const unconditional = processed.find((p) => !p.conditional); - if (unconditional) { - return generateSchemaPropertyLine( - unconditional.variant, - isPropertyOptional(unconditional.variant), - ); - } - - const conditionalVariants = processed.map((p) => p.variant); - if (conditionalVariants.length === 1) { - return generateConditionalSchemaLine(conditionalVariants[0], allFlatProperties); - } - return generateOneOfSchemaLine(conditionalVariants, allFlatProperties); -} - // ============================================================================= // Schema Generation Result Types // ============================================================================= @@ -1284,14 +1142,10 @@ export function generateSingleVersionSchemaFile( const subnodeSchemaImports = getSubnodeSchemaImports(aiInputTypes); const hasAiInputs = aiInputTypes.length > 0; - // Pre-compute the merged map so we can decide which helpers to register. - const propsByName = mergePropertiesByName(filteredProperties); - // Check if we need resolveSchema (properties with displayOptions or conditional AI inputs) const needsResolveSchema = hasDisplayOptions(filteredProperties) || (hasAiInputs && hasConditionalSubnodeFields(aiInputTypes)); - const needsResolveOneOf = needsResolveOneOfSchemas(propsByName, ['@version']); const lines: string[] = []; @@ -1313,9 +1167,6 @@ export function generateSingleVersionSchemaFile( if (needsResolveSchema) { helpers.push('resolveSchema'); } - if (needsResolveOneOf) { - helpers.push('resolveOneOfSchemas'); - } // Add subnode schema imports if this is an AI node for (const schemaImport of subnodeSchemaImports) { @@ -1411,14 +1262,37 @@ export function generateSingleVersionSchemaFile( lines.push(`${INDENT}// Parameters schema`); lines.push(`${INDENT}const parametersSchema = z.object({`); - // Same-named declarations are kept as separate variants when their - // displayOptions differ (resolved via resolveOneOfSchemas). - const allFlatProperties = Array.from(propsByName.values()).flat(); + // Group properties by name, merging displayOptions and nested options for duplicates + const propsByName = mergePropertiesByName(filteredProperties); + const allPropsArray = Array.from(propsByName.values()); - for (const variants of propsByName.values()) { - const propLine = generateParameterEntryLine(variants, allFlatProperties, ['@version']); - if (propLine) { - lines.push(INDENT + propLine); + for (const prop of allPropsArray) { + if (prop.displayOptions) { + // Strip @version since it's implicit in the file path + const strippedDisplayOptions = stripDiscriminatorKeysFromDisplayOptions(prop.displayOptions, [ + '@version', + ]); + if (strippedDisplayOptions) { + const propWithStripped: NodeProperty = { + ...prop, + displayOptions: strippedDisplayOptions, + }; + const propLine = generateConditionalSchemaLine(propWithStripped, allPropsArray); + if (propLine) { + lines.push(INDENT + propLine); + } + } else { + // No remaining conditions after stripping @version - use static schema + const propLine = generateSchemaPropertyLine(prop, isPropertyOptional(prop)); + if (propLine) { + lines.push(INDENT + propLine); + } + } + } else { + const propLine = generateSchemaPropertyLine(prop, isPropertyOptional(prop)); + if (propLine) { + lines.push(INDENT + propLine); + } } } @@ -1524,10 +1398,6 @@ export function generateDiscriminatorSchemaFile( // Check if AI inputs have conditional fields (displayOptions) const hasConditionalAiInputs = hasAiInputs && hasConditionalSubnodeFields(aiInputTypes); - // Pre-compute the merged map so we can decide which helpers to register. - const propsByName = mergePropertiesByName(props); - const needsResolveOneOf = needsResolveOneOfSchemas(propsByName, discriminatorKeys); - const lines: string[] = []; // Build description from discriminator values @@ -1554,9 +1424,6 @@ export function generateDiscriminatorSchemaFile( if (hasRemainingDisplayOptions || hasConditionalAiInputs) { helpers.push('resolveSchema'); } - if (needsResolveOneOf) { - helpers.push('resolveOneOfSchemas'); - } // Add subnode schema imports if this combo has AI inputs if (hasAiInputs) { @@ -1669,13 +1536,38 @@ export function generateDiscriminatorSchemaFile( } } - // Generate schema for each merged property; same-named declarations with - // differing displayOptions become resolveOneOfSchemas variants. - const allFlatProperties = Array.from(propsByName.values()).flat(); - for (const variants of propsByName.values()) { - const propLine = generateParameterEntryLine(variants, allFlatProperties, discriminatorKeys); - if (propLine) { - lines.push(INDENT.repeat(2) + propLine); + // Group properties by name, merging displayOptions and nested options for duplicates + const propsByName = mergePropertiesByName(props); + + // Generate schema for each merged property + // Convert propsByName to array for extractDefaultsForDisplayOptions + const allPropsArray = Array.from(propsByName.values()); + for (const prop of allPropsArray) { + if (prop.displayOptions) { + const strippedDisplayOptions = stripDiscriminatorKeysFromDisplayOptions( + prop.displayOptions, + discriminatorKeys, + ); + if (strippedDisplayOptions) { + const propWithStrippedOptions: NodeProperty = { + ...prop, + displayOptions: strippedDisplayOptions, + }; + const propLine = generateConditionalSchemaLine(propWithStrippedOptions, allPropsArray); + if (propLine) { + lines.push(INDENT.repeat(2) + propLine); + } + } else { + const propLine = generateSchemaPropertyLine(prop, isPropertyOptional(prop)); + if (propLine) { + lines.push(INDENT.repeat(2) + propLine); + } + } + } else { + const propLine = generateSchemaPropertyLine(prop, isPropertyOptional(prop)); + if (propLine) { + lines.push(INDENT.repeat(2) + propLine); + } } } diff --git a/packages/@n8n/workflow-sdk/src/index.ts b/packages/@n8n/workflow-sdk/src/index.ts index 735bac18887..20c839d5316 100644 --- a/packages/@n8n/workflow-sdk/src/index.ts +++ b/packages/@n8n/workflow-sdk/src/index.ts @@ -157,6 +157,8 @@ export { type ValidationResult, type ValidationOptions, type ValidationErrorCode, + validateNodeConfig, + type SchemaValidationResult, } from './validation'; // Code generation diff --git a/packages/@n8n/workflow-sdk/src/validation.ts b/packages/@n8n/workflow-sdk/src/validation.ts index edc11ca6662..b5422678508 100644 --- a/packages/@n8n/workflow-sdk/src/validation.ts +++ b/packages/@n8n/workflow-sdk/src/validation.ts @@ -8,3 +8,5 @@ export { type ValidationOptions, type ValidationErrorCode, } from './validation/index'; + +export { validateNodeConfig, type SchemaValidationResult } from './validation/schema-validator'; diff --git a/packages/@n8n/workflow-sdk/src/validation/index.ts b/packages/@n8n/workflow-sdk/src/validation/index.ts index fb3309a9063..5f5c2686692 100644 --- a/packages/@n8n/workflow-sdk/src/validation/index.ts +++ b/packages/@n8n/workflow-sdk/src/validation/index.ts @@ -9,7 +9,11 @@ import { validateNodeConfig } from './schema-validator'; import { isStickyNoteType, isHttpRequestType } from '../constants/node-types'; import type { WorkflowBuilder, WorkflowJSON } from '../types/base'; -export { setSchemaBaseDirs } from './schema-validator'; +export { + setSchemaBaseDirs, + validateNodeConfig, + type SchemaValidationResult, +} from './schema-validator'; /** * Validation error codes diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a4bcc4b3b2..f3d9e77d25c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -86,6 +86,7 @@ "@types/yargs-parser": "21.0.0", "@vvo/tzdb": "^6.141.0", "concurrently": "^8.2.0", + "esbuild": "^0.25.0", "ioredis-mock": "^8.8.1", "mjml": "^4.15.3", "n8n-containers": "workspace:*", @@ -99,8 +100,13 @@ "@aws-sdk/client-secrets-manager": "3.808.0", "@azure/identity": "catalog:", "@azure/keyvault-secrets": "4.8.0", + "@chat-adapter/linear": "^4.26.0", + "@chat-adapter/slack": "^4.26.0", + "@chat-adapter/state-memory": "^4.26.0", + "@chat-adapter/telegram": "^4.26.0", "@google-cloud/secret-manager": "5.6.0", "@modelcontextprotocol/sdk": "1.26.0", + "@n8n/agents": "workspace:*", "@n8n/ai-node-sdk": "workspace:*", "@n8n/ai-utilities": "workspace:*", "@n8n/ai-workflow-builder": "workspace:*", @@ -141,10 +147,12 @@ "bull": "4.16.4", "cache-manager": "5.2.3", "change-case": "4.1.2", + "chat": "^4.26.0", "class-transformer": "0.5.1", "class-validator": "0.14.0", "compression": "1.8.1", "convict": "6.2.5", + "cron": "catalog:", "cookie-parser": "1.4.7", "csrf": "3.1.0", "dotenv": "17.2.3", @@ -154,6 +162,7 @@ "express-prom-bundle": "8.0.0", "express-rate-limit": "7.5.0", "fast-glob": "catalog:", + "fast-json-patch": "catalog:", "flat": "5.0.2", "flatted": "catalog:", "formidable": "3.5.4", @@ -163,6 +172,7 @@ "infisical-node": "1.3.0", "ioredis": "5.3.2", "isbot": "3.6.13", + "isolated-vm": "^6.0.2", "jose": "^6.2.2", "json-diff": "1.0.6", "jsonschema": "1.4.1", @@ -187,6 +197,7 @@ "picocolors": "catalog:", "pkce-challenge": "5.0.0", "posthog-node": "3.2.1", + "prettier": "^3.6.2", "prom-client": "15.1.3", "psl": "1.9.0", "raw-body": "3.0.0", @@ -199,6 +210,7 @@ "source-map-support": "0.5.21", "sqlite3": "5.1.7", "sshpk": "1.18.0", + "sucrase": "3.35.0", "swagger-ui-express": "5.0.1", "undici": "^7.16.0", "uuid": "catalog:", @@ -209,6 +221,7 @@ "xss": "catalog:", "yaml": "catalog:", "yargs-parser": "21.1.1", - "zod": "catalog:" + "zod": "catalog:", + "zod-to-json-schema": "catalog:" } } diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 92813f049df..f01049deaf4 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from 'url'; import shell from 'shelljs'; import { rawTimeZones } from '@vvo/tzdb'; import glob from 'fast-glob'; +import { buildAgentLibraryBundle } from './bundle-agent-library.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -16,6 +17,7 @@ const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true'; generateUserManagementEmailTemplates(); generateTimezoneData(); +await buildAgentLibraryBundle(); if (publicApiEnabled) { createPublicApiDirectory(); diff --git a/packages/cli/scripts/bundle-agent-library.mjs b/packages/cli/scripts/bundle-agent-library.mjs new file mode 100644 index 00000000000..60733d2fc06 --- /dev/null +++ b/packages/cli/scripts/bundle-agent-library.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Pre-bundles @n8n/agents (Tool SDK) + zod into a single CJS string consumed + * by the V8 isolate at runtime (see src/modules/agents/runtime/agent-secure-runtime.ts). + * + * Running this at build time means: + * - No esbuild on the hot path at first-request + * - No esbuild native binary needed in the Docker image at runtime + * + * Output: packages/cli/dist/agent-library-bundle.js + */ +import * as esbuild from 'esbuild'; +import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { writeFileSync, mkdirSync } from 'fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = path.resolve(__dirname, '..'); +const OUTPUT_FILE = path.resolve(CLI_ROOT, 'dist', 'agent-library-bundle.js'); + +export async function buildAgentLibraryBundle({ silent = false } = {}) { + // Resolve @n8n/agents from the cli package so we get the workspace-linked + // copy regardless of where this script is invoked from. + const requireFromCli = createRequire(path.join(CLI_ROOT, 'package.json')); + const toSlash = (p) => p.replace(/\\/g, '/'); + const agentsPath = toSlash(requireFromCli.resolve('@n8n/agents')); + const agentsSrcDir = agentsPath.replace(/dist\/index\.js$/, 'dist/'); + + // Import only the Tool builder (needed for describe() + handler execution) + // rather than the full barrel — the runtime pulls in MCP SDK, AI provider + // SDKs, database drivers, etc., none of which are usable inside the isolate. + const shim = ` + const { Tool } = require('${agentsSrcDir}sdk/tool'); + const zod = require('zod'); + + globalThis.__modules = { + '@n8n/agents': { Tool }, + 'zod': zod, + }; + `; + + const result = await esbuild.build({ + stdin: { contents: shim, loader: 'js', resolveDir: CLI_ROOT }, + bundle: true, + format: 'cjs', + target: 'es2022', + platform: 'node', + write: false, + treeShaking: true, + // The shim only pulls in `sdk/tool` (+ zod), which is pure JS with no + // Node built-in or native deps — esbuild currently externalises nothing. + // `node:*` stays as a safety net so that if some future transitive dep + // picks up a `node:` import, esbuild marks it external rather + // than trying to bundle a built-in (which would blow up inside the + // isolate, which has no Node globals anyway). + external: ['node:*'], + define: { + 'process.env.NODE_ENV': '"production"', + }, + }); + + if (result.errors.length > 0) { + throw new Error( + `Failed to bundle agent library: ${result.errors.map((e) => e.text).join('\n')}`, + ); + } + + const bundle = result.outputFiles[0].text; + mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); + writeFileSync(OUTPUT_FILE, bundle, 'utf8'); + + if (!silent) { + const sizeKB = (bundle.length / 1024).toFixed(1); + const sizeMB = (bundle.length / (1024 * 1024)).toFixed(2); + console.log( + `[bundle-agent-library] Wrote ${path.relative(CLI_ROOT, OUTPUT_FILE)} — ${sizeKB} KB (${sizeMB} MB)`, + ); + } + + return OUTPUT_FILE; +} + +// Allow running as a standalone script: `node scripts/bundle-agent-library.mjs` +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + buildAgentLibraryBundle().catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index 987ddafb82e..6bc8bf19944 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -1,4 +1,4 @@ -import { inTest, inDevelopment, Logger } from '@n8n/backend-common'; +import { inDevelopment, inTest, Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { DbConnection } from '@n8n/db'; import { OnShutdown } from '@n8n/decorators'; @@ -9,14 +9,14 @@ import { readFile } from 'fs/promises'; import type { Server } from 'http'; import isbot from 'isbot'; +import { resolveBackendHealthEndpointPath } from './utils/health-endpoint.util'; import config from '@/config'; import { N8N_VERSION, TEMPLATES_DIR } from '@/constants'; import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; import { ExternalHooks } from '@/external-hooks'; -import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; +import { bodyParser, corsMiddleware, rawBodyReader } from '@/middlewares'; import { send, sendErrorResponse } from '@/response-helper'; import { createHandlebarsEngine } from '@/utils/handlebars.util'; -import { resolveBackendHealthEndpointPath } from '@/utils/health-endpoint.util'; import { LiveWebhooks } from '@/webhooks/live-webhooks'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import { WaitingForms } from '@/webhooks/waiting-forms'; @@ -25,6 +25,12 @@ import { createWebhookHandlerFor } from '@/webhooks/webhook-request-handler'; @Service() export abstract class AbstractServer { + /** + * Path patterns that allow bot user agents through the bot filter. + * Populated by ControllerRegistry when routes with { allowBots: true } are registered. + */ + static readonly botAllowedPaths: string[] = []; + protected logger: Logger; protected server: Server; @@ -273,11 +279,21 @@ export abstract class AbstractServer { this.app.all(`/${this.endpointMcpTest}/*path`, createWebhookHandlerFor(testWebhooks, 'mcp')); } - // Block bots from scanning the application + // Block bots from scanning the application. + // Routes with { allowBots: true } are registered in botAllowedPaths + // by the ControllerRegistry and exempted from this filter. const checkIfBot = isbot.spawn(['bot']); this.app.use((req, res, next) => { const userAgent = req.headers['user-agent']; if (userAgent && checkIfBot(userAgent)) { + // Check if this path matches a route with { allowBots: true } + const allowed = AbstractServer.botAllowedPaths.some((pattern) => + new RegExp(`^${pattern}$`).test(req.path), + ); + if (allowed) { + next(); + return; + } this.logger.info(`Blocked ${req.method} ${req.url} for "${userAgent}"`); res.status(204).end(); } else next(); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f5867535961..1a911352cbd 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -45,6 +45,7 @@ import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DeprecationService } from '@/deprecation/deprecation.service'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { WorkflowHistoryCompactionService } from '@/services/pruning/workflow-history-compaction.service'; +import { N8NCheckpointStorage } from '@/modules/agents/integrations/n8n-checkpoint-storage'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const open = require('open'); @@ -383,6 +384,7 @@ export class Start extends BaseCommand> { Container.get(ExecutionsPruningService).init(); Container.get(WorkflowHistoryCompactionService).init(); + Container.get(N8NCheckpointStorage).init(); if (this.globalConfig.executions.mode === 'regular') { await this.runEnqueuedExecutions(); diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index 0559a5292dd..78e9a20195e 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -16,6 +16,7 @@ import { UnexpectedError } from 'n8n-workflow'; import assert from 'node:assert'; import type { ZodClass } from '@n8n/api-types'; +import { AbstractServer } from './abstract-server'; import { NotFoundError } from './errors/response-errors/not-found.error'; import { LastActiveAtService } from './services/last-active-at.service'; import { RateLimitService } from './services/rate-limit.service'; @@ -121,6 +122,16 @@ export class ControllerRegistry { : send(handler); router[route.method](route.path, ...middlewares, finalHandler); + + // Register bot-allowed routes so the global bot filter can exempt them. + // Store the full path pattern (prefix + route path) as a regex so the + // global middleware can match against the actual resolved request path. + if (route.allowBots) { + const fullPattern = (prefix + route.path).replace(/\/+/g, '/'); + // Convert Express params (:name) to regex wildcards + const regexStr = fullPattern.replace(/:[^/]+/g, '[^/]+'); + AbstractServer.botAllowedPaths.push(regexStr); + } } } diff --git a/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts new file mode 100644 index 00000000000..a874c602c45 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-config-composition.test.ts @@ -0,0 +1,68 @@ +import type { Agent } from '../entities/agent.entity'; +import { composeJsonConfig, decomposeJsonConfig } from '../json-config/agent-config-composition'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; + +describe('composeJsonConfig', () => { + it('returns the schema with empty integrations when none are stored', () => { + const agent = { + schema: { name: 'A', model: 'anthropic/claude', instructions: 'x' }, + integrations: [], + } as unknown as Agent; + expect(composeJsonConfig(agent)).toEqual({ + name: 'A', + model: 'anthropic/claude', + instructions: 'x', + integrations: [], + }); + }); + + it('merges integrations from the storage column into the JSON config', () => { + const agent = { + schema: { name: 'A', model: 'anthropic/claude', instructions: 'x' }, + integrations: [{ type: 'slack', credentialId: 'c1', credentialName: 'Acme' }], + } as unknown as Agent; + expect(composeJsonConfig(agent)?.integrations).toEqual([ + { type: 'slack', credentialId: 'c1', credentialName: 'Acme' }, + ]); + }); + + it('returns null when schema is null', () => { + const agent = { schema: null, integrations: [] } as unknown as Agent; + expect(composeJsonConfig(agent)).toBeNull(); + }); + + it('treats a missing integrations column as an empty array', () => { + const agent = { + schema: { name: 'A', model: 'anthropic/claude', instructions: 'x' }, + integrations: undefined, + } as unknown as Agent; + expect(composeJsonConfig(agent)?.integrations).toEqual([]); + }); +}); + +describe('decomposeJsonConfig', () => { + it('splits integrations away from the schema-storage payload', () => { + const input = { + name: 'A', + model: 'anthropic/claude', + instructions: 'x', + integrations: [ + { type: 'schedule', active: true, cronExpression: '0 9 * * *', wakeUpPrompt: 'go' }, + ], + } as unknown as AgentJsonConfig; + const { schemaConfig, integrations } = decomposeJsonConfig(input); + expect(schemaConfig).not.toHaveProperty('integrations'); + expect(schemaConfig.name).toBe('A'); + expect(integrations).toEqual(input.integrations); + }); + + it('defaults to an empty integrations array when missing', () => { + const input = { + name: 'A', + model: 'anthropic/claude', + instructions: 'x', + } as unknown as AgentJsonConfig; + const { integrations } = decomposeJsonConfig(input); + expect(integrations).toEqual([]); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts new file mode 100644 index 00000000000..c2f0997fcbc --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -0,0 +1,92 @@ +import { AgentJsonConfigSchema, isNodeToolsEnabled } from '../json-config/agent-json-config'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; + +const baseConfig: AgentJsonConfig = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', +}; + +describe('AgentJsonConfigSchema — config.nodeTools', () => { + it('accepts a config without nodeTools', () => { + expect(AgentJsonConfigSchema.safeParse({ ...baseConfig, config: {} }).success).toBe(true); + }); + + it('accepts nodeTools: { enabled: true }', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + config: { nodeTools: { enabled: true } }, + }); + expect(parsed.success).toBe(true); + }); + + it('accepts nodeTools: { enabled: false }', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + config: { nodeTools: { enabled: false } }, + }); + expect(parsed.success).toBe(true); + }); + + it('rejects nodeTools without enabled', () => { + expect( + AgentJsonConfigSchema.safeParse({ ...baseConfig, config: { nodeTools: {} } }).success, + ).toBe(false); + }); + + it('rejects nodeTools.enabled of the wrong type', () => { + expect( + AgentJsonConfigSchema.safeParse({ + ...baseConfig, + config: { nodeTools: { enabled: 'yes' } }, + }).success, + ).toBe(false); + }); +}); + +describe('AgentJsonConfigSchema — skill refs', () => { + it('accepts a skill ref with a valid id', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + skills: [{ type: 'skill', id: 'summarize_notes' }], + }); + + expect(parsed.success).toBe(true); + }); + + it('rejects a skill ref with an invalid id', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + skills: [{ type: 'skill', id: 'summarize notes' }], + }); + + expect(parsed.success).toBe(false); + }); + + it('rejects skill refs inside tools', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + tools: [{ type: 'skill', id: 'summarize_notes' }], + }); + + expect(parsed.success).toBe(false); + }); +}); + +describe('isNodeToolsEnabled', () => { + it('returns false when config is undefined', () => { + expect(isNodeToolsEnabled(undefined)).toBe(false); + }); + + it('returns false when config has no nodeTools field', () => { + expect(isNodeToolsEnabled({})).toBe(false); + }); + + it('returns false when nodeTools.enabled is false', () => { + expect(isNodeToolsEnabled({ nodeTools: { enabled: false } })).toBe(false); + }); + + it('returns true only when nodeTools.enabled is explicitly true', () => { + expect(isNodeToolsEnabled({ nodeTools: { enabled: true } })).toBe(true); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-published-version.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent-published-version.repository.test.ts new file mode 100644 index 00000000000..bd4b85801c0 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-published-version.repository.test.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-assignment -- mock-based tests intentionally reference unbound methods and cast mock returns */ +import { mock } from 'jest-mock-extended'; + +import { mockEntityManager } from '@test/mocking'; + +import { AgentPublishedVersion } from '../entities/agent-published-version.entity'; +import { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository'; + +const entityManager = mockEntityManager(AgentPublishedVersion); +const mockDataSource = { manager: entityManager }; + +describe('AgentPublishedVersionRepository', () => { + let repository: AgentPublishedVersionRepository; + + beforeEach(() => { + jest.clearAllMocks(); + repository = new AgentPublishedVersionRepository(mockDataSource as never); + }); + + describe('savePublishedVersion', () => { + const payload = { + agentId: 'agent-1', + schema: null, + tools: null, + skills: null, + publishedFromVersionId: 'v1', + model: 'claude-3', + provider: 'anthropic', + credentialId: 'cred-1', + publishedById: 'user-1', + }; + + it('upserts the snapshot and returns the saved entity', async () => { + const saved = mock(); + jest + .spyOn(repository, 'upsert') + .mockResolvedValue({ raw: {}, generatedMaps: [], identifiers: [] }); + jest.spyOn(repository, 'findOneByOrFail').mockResolvedValue(saved); + + const result = await repository.savePublishedVersion(payload); + + expect(repository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ ...payload, updatedAt: expect.any(Date) }), + ['agentId'], + ); + expect(repository.findOneByOrFail).toHaveBeenCalledWith({ agentId: payload.agentId }); + expect(result).toBe(saved); + }); + + it('uses the transaction-scoped repository when trx is provided', async () => { + const saved = mock(); + const trxRepo = { + upsert: jest.fn().mockResolvedValue({ raw: {}, generatedMaps: [], identifiers: [] }), + findOneByOrFail: jest.fn().mockResolvedValue(saved), + }; + const mockTrx = { getRepository: jest.fn().mockReturnValue(trxRepo) }; + + jest.spyOn(repository, 'upsert'); + + const result = await repository.savePublishedVersion(payload, mockTrx as never); + + // Uses the trx-scoped repo, not `this` + expect(repository.upsert).not.toHaveBeenCalled(); + expect(trxRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ ...payload, updatedAt: expect.any(Date) }), + ['agentId'], + ); + expect(trxRepo.findOneByOrFail).toHaveBeenCalledWith({ agentId: payload.agentId }); + expect(result).toBe(saved); + }); + }); + + describe('deleteByAgentId', () => { + it('calls delete with the correct agentId filter', async () => { + jest.spyOn(repository, 'delete').mockResolvedValue({ affected: 1, raw: {} }); + + await repository.deleteByAgentId('agent-1'); + + expect(repository.delete).toHaveBeenCalledWith({ agentId: 'agent-1' }); + }); + + it('uses the transaction-scoped repository when trx is provided', async () => { + const trxRepo = { delete: jest.fn().mockResolvedValue({ affected: 1, raw: {} }) }; + const mockTrx = { getRepository: jest.fn().mockReturnValue(trxRepo) }; + + jest.spyOn(repository, 'delete'); + + await repository.deleteByAgentId('agent-1', mockTrx as never); + + expect(repository.delete).not.toHaveBeenCalled(); + expect(trxRepo.delete).toHaveBeenCalledWith({ agentId: 'agent-1' }); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-secure-runtime.test.ts b/packages/cli/src/modules/agents/__tests__/agent-secure-runtime.test.ts new file mode 100644 index 00000000000..73411f29f99 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-secure-runtime.test.ts @@ -0,0 +1,378 @@ +import type { Logger } from '@n8n/backend-common'; +import { existsSync } from 'fs'; +import type ivm from 'isolated-vm'; +import { mock } from 'jest-mock-extended'; +import path from 'path'; + +import { + AgentIsolatePool, + AgentIsolateSlot, + PoolDisposedError, + PoolExhaustedError, +} from '../runtime/agent-isolate-pool'; +import { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; + +// The runtime reads the library bundle from packages/cli/dist/agent-library-bundle.js. +// Build it on demand so the test can run without a prior `pnpm build`. +const BUNDLE_PATH = path.resolve(__dirname, '../../../../dist/agent-library-bundle.js'); +async function ensureLibraryBundle() { + if (existsSync(BUNDLE_PATH)) return; + const { buildAgentLibraryBundle } = await import( + path.resolve(__dirname, '../../../../scripts/bundle-agent-library.mjs') + ); + await buildAgentLibraryBundle({ silent: true }); +} + +// No mocking — uses the real isolated-vm V8 isolate. + +const logger = mock(); + +const SIMPLE_TOOL_CODE = ` +import { Tool } from '@n8n/agents'; +import { z } from 'zod'; + +export default new Tool('double') + .description('Doubles a number') + .input(z.object({ value: z.number() })) + .handler(async (input) => ({ result: input.value * 2 })); +`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Disable auto-replenishment on a pool instance so tests can control exactly + * when slots are available. Because `replenish` is private we cast through any. + */ +function disableReplenish(pool: AgentIsolatePool): void { + (pool as unknown as { replenish: () => void }).replenish = () => {}; +} + +// --------------------------------------------------------------------------- +// AgentIsolatePool — unit tests +// --------------------------------------------------------------------------- + +describe('AgentIsolatePool', () => { + let ivmModule: typeof ivm; + const libraryBundle = '"use strict"; globalThis.__modules = {};'; + + beforeAll(async () => { + ivmModule = (await import('isolated-vm')).default; + }); + + function makePool(options?: ConstructorParameters[2]) { + return new AgentIsolatePool(ivmModule, libraryBundle, options); + } + + it('initialize() creates N slots', async () => { + const pool = makePool({ size: 2 }); + disableReplenish(pool); + await pool.initialize(); + + const s1 = await pool.acquire(); + const s2 = await pool.acquire(); + + expect(s1).toBeInstanceOf(AgentIsolateSlot); + expect(s2).toBeInstanceOf(AgentIsolateSlot); + + pool.release(s1); + pool.release(s2); + await pool.dispose(); + }); + + it('acquire() returns a healthy slot', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); + expect(slot.isHealthy).toBe(true); + expect(slot.isolate.isDisposed).toBe(false); + + pool.release(slot); + await pool.dispose(); + }); + + it('acquire() blocks when pool is empty, resolves after release', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); // pool now empty, replenishment disabled + + // Pool is empty — next acquire should return a pending Promise. + let resolved = false; + const pending = pool.acquire().then((s) => { + resolved = true; + return s; + }); + + // Flush microtask queue — should NOT resolve yet (no replenish, no release). + await Promise.resolve(); + expect(resolved).toBe(false); + + // Release the slot — the queued acquire should receive it directly. + pool.release(slot); + const queued = await pending; + expect(resolved).toBe(true); + expect(queued.isHealthy).toBe(true); + + pool.release(queued); + await pool.dispose(); + }); + + it('acquire() rejects when queue depth exceeded', async () => { + const pool = makePool({ size: 1, maxQueueDepth: 2 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); // exhaust pool + + // Fill the wait queue to capacity. + const p1 = pool.acquire(); + const p2 = pool.acquire(); + + // Next acquire exceeds maxQueueDepth. + await expect(pool.acquire()).rejects.toBeInstanceOf(PoolExhaustedError); + + pool.release(slot); + pool.release(await p1); + pool.release(await p2); + await pool.dispose(); + }); + + it('release() returns a healthy slot back to the pool', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); + pool.release(slot); + + const same = await pool.acquire(); + expect(same.isHealthy).toBe(true); + + pool.release(same); + await pool.dispose(); + }); + + it('release() discards an unhealthy slot and triggers replenishment', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); + slot.isolate.dispose(); // simulate OOM + expect(slot.isHealthy).toBe(false); + + // Re-enable real replenishment before releasing. + (pool as unknown as { replenish: (attempt?: number) => void }).replenish = AgentIsolatePool + .prototype['replenish' as keyof AgentIsolatePool] as () => void; + + pool.release(slot); + + // Wait for replenishment microtask to complete. + await new Promise((r) => setTimeout(r, 50)); + + const fresh = pool.tryAcquireSync(); + expect(fresh).not.toBeNull(); + expect(fresh!.isHealthy).toBe(true); + + pool.release(fresh!); + await pool.dispose(); + }); + + it('tryAcquireSync() returns a slot when the pool is non-empty', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = pool.tryAcquireSync(); + expect(slot).not.toBeNull(); + expect(slot!.isHealthy).toBe(true); + + pool.release(slot!); + await pool.dispose(); + }); + + it('tryAcquireSync() returns null when pool is empty', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); // drain pool + expect(pool.tryAcquireSync()).toBeNull(); + + pool.release(slot); + await pool.dispose(); + }); + + it('proactive recycling: high-heap slot is discarded on release', async () => { + // highWaterMarkRatio=0 means any heap usage triggers recycling. + const pool = makePool({ size: 1, highWaterMarkRatio: 0 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); + pool.release(slot); // should be discarded, not returned to pool + + expect(slot.isolate.isDisposed).toBe(true); + // Pool is now empty (slot was discarded, replenish disabled). + expect(pool.tryAcquireSync()).toBeNull(); + + await pool.dispose(); + }); + + it('dispose() cleans up all slots', async () => { + const pool = makePool({ size: 2 }); + disableReplenish(pool); + await pool.initialize(); + + const s1 = await pool.acquire(); + const s2 = await pool.acquire(); + pool.release(s1); + pool.release(s2); + + const iso1 = s1.isolate; + const iso2 = s2.isolate; + + await pool.dispose(); + + expect(iso1.isDisposed).toBe(true); + expect(iso2.isDisposed).toBe(true); + }); + + it('acquire() after dispose() throws PoolDisposedError', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + await pool.dispose(); + + await expect(pool.acquire()).rejects.toBeInstanceOf(PoolDisposedError); + }); + + it('dispose() rejects pending waiters with PoolDisposedError', async () => { + const pool = makePool({ size: 1 }); + disableReplenish(pool); + await pool.initialize(); + + const slot = await pool.acquire(); // drain pool + const pending = pool.acquire(); // will be queued + + await pool.dispose(); // rejects all waiters immediately + pool.release(slot); // cleanup (slot is discarded since pool is disposed) + + await expect(pending).rejects.toBeInstanceOf(PoolDisposedError); + }); + + it('replenish retries on slot creation failure', async () => { + // Use size=2 so acquiring 1 slot triggers replenishment of the 2nd. + const pool = makePool({ size: 2 }); + await pool.initialize(); // starts with 2 real slots + + // Patch createSlot to track calls and fail. + let attempts = 0; + (pool as unknown as { createSlot: () => AgentIsolateSlot }).createSlot = () => { + attempts++; + throw new Error('simulated slot creation failure'); + }; + + // Acquiring a slot triggers replenish() which calls the patched createSlot. + const slot = await pool.acquire(); + + // Wait for the replenish microtask (Promise.resolve().then(...)) to run. + await new Promise((r) => setTimeout(r, 20)); + + expect(attempts).toBeGreaterThan(0); + + pool.release(slot); + await pool.dispose(); + }); +}); + +// --------------------------------------------------------------------------- +// AgentSecureRuntime — describeToolSecurely and executeToolInIsolate +// --------------------------------------------------------------------------- + +describe('AgentSecureRuntime', () => { + let runtime: AgentSecureRuntime; + + beforeAll(async () => { + await ensureLibraryBundle(); + }); + + beforeEach(() => { + runtime = new AgentSecureRuntime(logger); + }); + + afterEach(() => { + runtime.dispose(); + }); + + it('describeToolSecurely returns ToolDescriptor from valid tool code', async () => { + const descriptor = await runtime.describeToolSecurely(SIMPLE_TOOL_CODE); + + expect(descriptor.name).toBe('double'); + expect(descriptor.description).toBe('Doubles a number'); + expect(descriptor.inputSchema).toBeDefined(); + expect(descriptor.outputSchema).toBeNull(); + }); + + it('describeToolSecurely throws on invalid TypeScript', async () => { + const badCode = 'this is not valid typescript @@@ {{{'; + await expect(runtime.describeToolSecurely(badCode)).rejects.toThrow(); + }); + + it('describeToolSecurely throws when no Tool is exported', async () => { + const noTool = 'export const foo = 42;'; + await expect(runtime.describeToolSecurely(noTool)).rejects.toThrow(/No Tool found/); + }); + + it('executeToolInIsolate executes a tool handler', async () => { + const result = await runtime.executeToolInIsolate(SIMPLE_TOOL_CODE, { value: 21 }, {}); + expect(result).toEqual({ result: 42 }); + }); + + it('concurrent describeToolSecurely calls all resolve', async () => { + const results = await Promise.all([ + runtime.describeToolSecurely(SIMPLE_TOOL_CODE), + runtime.describeToolSecurely(SIMPLE_TOOL_CODE), + runtime.describeToolSecurely(SIMPLE_TOOL_CODE), + ]); + + expect(results).toHaveLength(3); + for (const descriptor of results) { + expect(descriptor.name).toBe('double'); + } + }); + + it('libraryBundle is not re-read after an OOM (cached string is reused)', async () => { + await runtime.describeToolSecurely(SIMPLE_TOOL_CODE); // warm up + + const bundleBefore = (runtime as unknown as { libraryBundle: string }).libraryBundle; + expect(bundleBefore).toBeTruthy(); + + // Simulate OOM by disposing a slot's isolate and releasing it. + const pool = (runtime as unknown as { pool: AgentIsolatePool }).pool; + expect(pool).not.toBeNull(); + const slot = pool?.tryAcquireSync(); + expect(slot).not.toBeNull(); + slot?.isolate.dispose(); // mark as unhealthy + if (slot) pool?.release(slot); // pool discards it and starts replenishment + + await new Promise((r) => setTimeout(r, 100)); // wait for replenishment + + await runtime.describeToolSecurely(SIMPLE_TOOL_CODE); + + const bundleAfter = (runtime as unknown as { libraryBundle: string }).libraryBundle; + expect(bundleAfter).toBe(bundleBefore); + }); + + it('dispose() clears the pool — pool reference is null afterwards', async () => { + await runtime.describeToolSecurely(SIMPLE_TOOL_CODE); + runtime.dispose(); + + expect((runtime as unknown as { pool: AgentIsolatePool | null }).pool).toBeNull(); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts new file mode 100644 index 00000000000..635eee1cb81 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method -- async mock stubs and unbound-method references are acceptable test idioms */ +import { mockLogger } from '@n8n/backend-test-utils'; +import { mock } from 'jest-mock-extended'; + +import type { Agent } from '../entities/agent.entity'; +import { AgentSkillsService } from '../agent-skills.service'; +import type { AgentRepository } from '../repositories/agent.repository'; + +const agentId = 'agent-1'; +const projectId = 'project-1'; +const versionId = 'v1'; + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: agentId, + versionId, + schema: null, + publishedVersion: null, + tools: {}, + skills: {}, + updatedAt: new Date(), + ...overrides, + } as unknown as Agent; +} + +describe('AgentSkillsService', () => { + let service: AgentSkillsService; + let agentRepository: jest.Mocked; + + const skill = { + name: 'Summarize Notes', + description: 'Summarizes a meeting transcript', + instructions: 'Extract decisions and action items.', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + agentRepository = mock(); + agentRepository.save.mockImplementation(async (a) => a as Agent); + service = new AgentSkillsService(mockLogger(), agentRepository); + }); + + it('creates a skill without attaching it to the config', async () => { + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + skills: [], + }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.createSkill(agentId, projectId, skill); + + expect(result).toEqual({ + id: expect.stringMatching(/^skill_[A-Za-z0-9]{16}$/), + skill, + versionId: agent.versionId, + }); + expect(agentRepository.save.mock.calls[0][0].skills).toEqual({ + [result.id]: skill, + }); + expect(agent.schema?.skills).toEqual([]); + }); + + it('creates and attaches a skill on the agent when requested', async () => { + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + skills: [], + }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.createAndAttachSkill(agentId, projectId, skill); + + expect(agentRepository.save.mock.calls[0][0].skills).toEqual({ + [result.id]: skill, + }); + expect(agent.schema?.skills).toEqual([{ type: 'skill', id: result.id }]); + }); + + it('loads one skill from the agent', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue( + makeAgent({ skills: { summarize_notes: skill } }), + ); + + await expect(service.getSkill(agentId, projectId, 'summarize_notes')).resolves.toEqual(skill); + }); + + it('updates an existing skill on the agent', async () => { + const agent = makeAgent({ skills: { summarize_notes: skill } }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateSkill(agentId, projectId, 'summarize_notes', { + description: 'Summarizes support notes', + }); + + expect(result).toEqual({ + id: 'summarize_notes', + skill: { + ...skill, + description: 'Summarizes support notes', + }, + versionId: agent.versionId, + }); + expect(agentRepository.save.mock.calls[0][0].skills).toEqual({ + summarize_notes: result.skill, + }); + }); + + it('deletes a skill and removes its config ref', async () => { + const agent = makeAgent({ + skills: { summarize_notes: skill }, + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + tools: [{ type: 'custom', id: 'custom_tool' }], + skills: [{ type: 'skill', id: 'summarize_notes' }], + }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.deleteSkill(agentId, projectId, 'summarize_notes'); + + expect(agentRepository.save.mock.calls[0][0].skills).toEqual({}); + expect(agent.schema?.tools).toEqual([{ type: 'custom', id: 'custom_tool' }]); + expect(agent.schema?.skills).toEqual([]); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts new file mode 100644 index 00000000000..101d20bbdb3 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent.repository.test.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import type { AgentIntegration } from '@n8n/api-types'; +import { mock } from 'jest-mock-extended'; + +import { mockEntityManager } from '@test/mocking'; + +import { Agent } from '../entities/agent.entity'; +import { AgentRepository } from '../repositories/agent.repository'; + +const entityManager = mockEntityManager(Agent); +const mockDataSource = { manager: entityManager }; + +describe('AgentRepository', () => { + let repository: AgentRepository; + + beforeEach(() => { + jest.clearAllMocks(); + repository = new AgentRepository(mockDataSource as never); + }); + + describe('findByIdAndProjectId', () => { + it('calls findOne with id, projectId, and the publishedVersion relation', async () => { + const agent = mock({ id: 'agent-1', projectId: 'project-1' }); + jest.spyOn(repository, 'findOne').mockResolvedValue(agent); + + const result = await repository.findByIdAndProjectId('agent-1', 'project-1'); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: 'agent-1', projectId: 'project-1' }, + relations: { publishedVersion: true }, + }); + expect(result).toBe(agent); + }); + + it('returns null when no agent matches', async () => { + jest.spyOn(repository, 'findOne').mockResolvedValue(null); + + const result = await repository.findByIdAndProjectId('agent-1', 'project-1'); + + expect(result).toBeNull(); + }); + }); + + describe('findByProjectId', () => { + it('calls find ordered by updatedAt descending with the publishedVersion relation', async () => { + const agents = [mock(), mock()]; + jest.spyOn(repository, 'find').mockResolvedValue(agents); + + const result = await repository.findByProjectId('project-1'); + + expect(repository.find).toHaveBeenCalledWith({ + where: { projectId: 'project-1' }, + relations: { publishedVersion: true }, + order: { updatedAt: 'DESC' }, + }); + expect(result).toBe(agents); + }); + + it('returns an empty array when the project has no agents', async () => { + jest.spyOn(repository, 'find').mockResolvedValue([]); + + const result = await repository.findByProjectId('project-1'); + + expect(result).toEqual([]); + }); + }); + + describe('findByIntegrationCredential', () => { + const makeAgent = (id: string, integrations: AgentIntegration[]) => + ({ id, integrations }) as Agent; + + it('returns agents that have a matching type + credentialId, excluding the given agentId', async () => { + const agents = [ + makeAgent('agent-self', [ + { type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' }, + ]), + makeAgent('agent-other', [ + { type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' }, + ]), + makeAgent('agent-slack', [ + { type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' }, + ]), + makeAgent('agent-unrelated', [ + { type: 'telegram', credentialId: 'cred-2', credentialName: 'Telegram cred 2' }, + ]), + makeAgent('agent-empty', []), + ]; + jest.spyOn(repository, 'find').mockResolvedValue(agents); + + const result = await repository.findByIntegrationCredential( + 'telegram', + 'cred-1', + 'project-1', + 'agent-self', + ); + + expect(result.map((a) => a.id)).toEqual(['agent-other']); + }); + + it('returns an empty array when no other agent uses the credential', async () => { + jest + .spyOn(repository, 'find') + .mockResolvedValue([ + makeAgent('agent-self', [ + { type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' }, + ]), + ]); + + const result = await repository.findByIntegrationCredential( + 'telegram', + 'cred-1', + 'project-1', + 'agent-self', + ); + + expect(result).toEqual([]); + }); + + it('handles agents whose integrations column is null / undefined without crashing', async () => { + const agents = [ + makeAgent('agent-a', [ + { type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' }, + ]), + { id: 'agent-null', integrations: null } as unknown as Agent, + { id: 'agent-undef' } as unknown as Agent, + ]; + jest.spyOn(repository, 'find').mockResolvedValue(agents); + + const result = await repository.findByIntegrationCredential( + 'telegram', + 'cred-1', + 'project-1', + 'agent-self', + ); + + expect(result.map((a) => a.id)).toEqual(['agent-a']); + }); + + it('ignores schedule integrations when matching on credentialId', async () => { + const agents = [ + makeAgent('agent-schedule', [ + { + type: 'schedule', + active: true, + cronExpression: '* * * * *', + wakeUpPrompt: 'Automated message', + }, + ]), + makeAgent('agent-match', [ + { type: 'telegram', credentialId: 'cred-1', credentialName: 'Telegram cred 1' }, + ]), + ]; + jest.spyOn(repository, 'find').mockResolvedValue(agents); + + const result = await repository.findByIntegrationCredential( + 'telegram', + 'cred-1', + 'project-1', + 'agent-self', + ); + + expect(result.map((a) => a.id)).toEqual(['agent-match']); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts new file mode 100644 index 00000000000..60f1b74c57c --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -0,0 +1,329 @@ +import type { CredentialProvider } from '@n8n/agents'; +import { AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH } from '@n8n/api-types'; +import type { WorkflowRepository } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; + +import type { AgentsToolsService } from '../agents-tools.service'; +import type { AgentsService } from '../agents.service'; +import { + AgentsBuilderToolsService, + getAgentConfigHash, +} from '../builder/agents-builder-tools.service'; +import { BUILDER_TOOLS } from '../builder/builder-tool-names'; +import type { Agent } from '../entities/agent.entity'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; +import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; + +const ctx = { + resumeData: undefined, + suspend: jest.fn().mockResolvedValue(undefined as never), + parentTelemetry: undefined, +}; + +function makeService() { + const agentsService = mock(); + const secureRuntime = mock(); + const workflowRepository = mock(); + const agentsToolsService = mock(); + agentsToolsService.getSharedTools.mockReturnValue([]); + + const service = new AgentsBuilderToolsService( + agentsService, + secureRuntime, + workflowRepository, + agentsToolsService, + ); + + return { service, agentsService, secureRuntime }; +} + +const baseConfig: AgentJsonConfig = { + name: 'Agent One', + model: 'anthropic/claude-sonnet-4-5', + credential: 'Anthropic Key', + instructions: 'Help the user.', + tools: [], + skills: [], +}; + +function makeAgent(config: AgentJsonConfig = baseConfig): Agent { + return { + schema: config, + integrations: [], + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + versionId: 'v1', + tools: {}, + skills: {}, + } as unknown as Agent; +} + +describe('AgentsBuilderToolsService', () => { + const agentId = 'agent-1'; + const projectId = 'project-1'; + const credentialProvider = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('JSON config tools', () => { + function getJsonTool(service: AgentsBuilderToolsService, name: string) { + return service + .getTools(agentId, projectId, credentialProvider) + .json.find((tool) => tool.name === name)!; + } + + it('read_config returns the current config snapshot metadata', async () => { + const { service, agentsService } = makeService(); + agentsService.findById.mockResolvedValue(makeAgent()); + + const result = await getJsonTool(service, BUILDER_TOOLS.READ_CONFIG).handler!({}, ctx); + + expect(result).toEqual({ + ok: true, + config: { ...baseConfig, integrations: [] }, + configHash: getAgentConfigHash({ ...baseConfig, integrations: [] }), + updatedAt: '2026-01-01T00:00:00.000Z', + versionId: 'v1', + }); + }); + + it('patch_config applies a patch when baseConfigHash matches', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig = { ...currentConfig, description: 'Updated description' }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: updatedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + operations: JSON.stringify([ + { op: 'add', path: '/description', value: 'Updated description' }, + ]), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, updatedConfig); + expect(result).toEqual({ + ok: true, + config: updatedConfig, + configHash: getAgentConfigHash(updatedConfig), + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + }); + + it('patch_config rejects stale baseConfigHash without updating', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + + const result = await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!( + { + baseConfigHash: 'stale-hash', + operations: JSON.stringify([ + { op: 'add', path: '/description', value: 'Updated description' }, + ]), + }, + ctx, + ); + + expect(agentsService.updateConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: false, + stage: 'stale', + errors: expect.arrayContaining([expect.objectContaining({ path: '(root)' })]), + config: currentConfig, + configHash: getAgentConfigHash(currentConfig), + updatedAt: '2026-01-01T00:00:00.000Z', + versionId: 'v1', + }); + }); + + it('write_config applies a full config when baseConfigHash matches', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig = { ...currentConfig, instructions: 'Help with support tickets.' }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: updatedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, updatedConfig); + expect(result).toEqual({ + ok: true, + config: updatedConfig, + configHash: getAgentConfigHash(updatedConfig), + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + }); + + it('write_config rejects stale baseConfigHash without updating', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig = { ...currentConfig, instructions: 'Help with support tickets.' }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + + const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: 'stale-hash', + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(agentsService.updateConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: false, + stage: 'stale', + errors: expect.arrayContaining([expect.objectContaining({ path: '(root)' })]), + config: currentConfig, + configHash: getAgentConfigHash(currentConfig), + updatedAt: '2026-01-01T00:00:00.000Z', + versionId: 'v1', + }); + }); + }); + + describe('build_custom_tool tool', () => { + function getBuildCustomTool(service: AgentsBuilderToolsService) { + return service + .getTools(agentId, projectId, credentialProvider) + .shared.find((tool) => tool.name === BUILDER_TOOLS.BUILD_CUSTOM_TOOL)!; + } + + it('stores a custom tool and returns the generated tool id', async () => { + const { service, agentsService, secureRuntime } = makeService(); + const descriptor = { + name: 'seo_analyzer', + description: 'Analyze SEO issues', + systemInstruction: null, + inputSchema: null, + outputSchema: null, + hasSuspend: false, + hasResume: false, + hasToMessage: false, + requireApproval: false, + providerOptions: null, + }; + secureRuntime.describeToolSecurely.mockResolvedValue(descriptor); + agentsService.buildCustomTool.mockResolvedValue({ + ok: true, + id: 'tool_0Ab9ZkLm3Pq7Xy2N', + descriptor, + }); + + const result = await getBuildCustomTool(service).handler!( + { code: 'export default new Tool("seo_analyzer")' }, + ctx, + ); + + expect(agentsService.buildCustomTool).toHaveBeenCalledWith( + agentId, + projectId, + 'export default new Tool("seo_analyzer")', + descriptor, + ); + expect(result).toEqual({ + ok: true, + id: 'tool_0Ab9ZkLm3Pq7Xy2N', + descriptor, + }); + }); + }); + + describe('create_skill tool', () => { + function getCreateSkillTool(service: AgentsBuilderToolsService) { + return service + .getTools(agentId, projectId, credentialProvider) + .shared.find((tool) => tool.name === BUILDER_TOOLS.CREATE_SKILL)!; + } + + it('is available to the builder with config attachment guidance', () => { + const { service } = makeService(); + + const tool = getCreateSkillTool(service); + + expect(tool).toBeDefined(); + expect(tool.description).toContain('does NOT attach the skill to the agent config'); + expect(tool.description).toContain('patch_config'); + expect(tool.description).toContain('when to load it'); + }); + + it('creates a skill and returns the generated skill id', async () => { + const { service, agentsService } = makeService(); + agentsService.createSkill.mockResolvedValue({ + id: 'skill_0Ab9ZkLm3Pq7Xy2N', + skill: { + name: 'Summarize Meetings', + description: 'Use when summarizing meeting notes', + instructions: 'Extract decisions and action items.', + }, + versionId: 'v2', + }); + + const result = await getCreateSkillTool(service).handler!( + { + name: 'Summarize Meetings', + description: 'Use when summarizing meeting notes', + body: 'Extract decisions and action items.', + }, + ctx, + ); + + expect(agentsService.createSkill).toHaveBeenCalledWith(agentId, projectId, { + name: 'Summarize Meetings', + description: 'Use when summarizing meeting notes', + instructions: 'Extract decisions and action items.', + }); + expect(result).toEqual({ + ok: true, + id: 'skill_0Ab9ZkLm3Pq7Xy2N', + skill: { + name: 'Summarize Meetings', + description: 'Use when summarizing meeting notes', + instructions: 'Extract decisions and action items.', + }, + }); + }); + + it('rejects oversized names and skill bodies before creating the skill', async () => { + const { service, agentsService } = makeService(); + + const result = await getCreateSkillTool(service).handler!( + { + name: 'a'.repeat(129), + description: 'Use when summarizing meeting notes', + body: 'a'.repeat(AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH + 1), + }, + ctx, + ); + + expect(result).toEqual({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ path: 'name' }), + expect.objectContaining({ path: 'instructions' }), + ]), + }); + expect(agentsService.createSkill).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts new file mode 100644 index 00000000000..92c794c6907 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agents-service-reconstruct-gating.test.ts @@ -0,0 +1,136 @@ +import type * as agents from '@n8n/agents'; +import type { CredentialProvider, BuiltTool } from '@n8n/agents'; + +import type { ToolRegistry } from '../tool-registry'; +import type { Logger } from '@n8n/backend-common'; +import type { + ExecutionRepository, + ProjectRelationRepository, + UserRepository, + WorkflowRepository, +} from '@n8n/db'; +import { mock } from 'jest-mock-extended'; + +import type { ActiveExecutions } from '@/active-executions'; +import type { EphemeralNodeExecutor } from '@/node-execution'; +import type { UrlService } from '@/services/url.service'; +import type { WorkflowRunner } from '@/workflow-runner'; +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import type { AgentExecutionService } from '../agent-execution.service'; +import type { AgentSkillsService } from '../agent-skills.service'; +import type { AgentsToolsService } from '../agents-tools.service'; +import { AgentsService } from '../agents.service'; +import type { Agent } from '../entities/agent.entity'; +import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; +import type { N8nMemory } from '../integrations/n8n-memory'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; +import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository'; +import type { AgentRepository } from '../repositories/agent.repository'; +import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; + +// Mock buildFromJson so reconstructFromConfig doesn't try to actually build an agent. +const builtAgent = mock(); +builtAgent.hasCheckpointStorage.mockReturnValue(true); // skip checkpoint injection branch + +jest.mock('../json-config/from-json-config', () => ({ + buildFromJson: jest.fn().mockImplementation(async () => builtAgent), +})); + +// Avoid loading the rich-interaction tool (its import path resolves to runtime code). +jest.mock('../integrations/rich-interaction-tool', () => ({ + createRichInteractionTool: () => ({}) as never, +})); + +function makeService(agentsToolsService: AgentsToolsService): AgentsService { + return new AgentsService( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + agentsToolsService, + mock(), + mock(), + mock(), + mock(), + ); +} + +function makeAgentEntity(schemaConfig?: AgentJsonConfig['config']): Agent { + const schema: AgentJsonConfig = { + name: 'Test', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + ...(schemaConfig !== undefined ? { config: schemaConfig } : {}), + }; + return { + id: 'agent-1', + projectId: 'project-1', + schema, + tools: {}, + } as unknown as Agent; +} + +// reconstructFromConfig is private; cast to invoke directly. +type Reconstructable = { + reconstructFromConfig( + agentEntity: Agent, + credentialProvider: CredentialProvider, + userId?: string, + ): Promise<{ agent: agents.Agent; toolRegistry: ToolRegistry }>; +}; + +describe('AgentsService.reconstructFromConfig — node tools gating', () => { + beforeEach(() => { + jest.clearAllMocks(); + // rebuild the builtAgent mock state (jest.clearAllMocks clears calls, not behavior) + builtAgent.hasCheckpointStorage.mockReturnValue(true); + }); + + function setup() { + const agentsToolsService = mock(); + agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]); + const credentialProvider = mock(); + const service = makeService(agentsToolsService); + return { service, agentsToolsService, credentialProvider }; + } + + it('does not attach node tools when config.nodeTools is absent (default-off)', async () => { + const { service, agentsToolsService, credentialProvider } = setup(); + const entity = makeAgentEntity(); // no config block at all + + await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider); + + expect(agentsToolsService.getRuntimeTools).not.toHaveBeenCalled(); + }); + + it('attaches node tools when config.nodeTools.enabled is true', async () => { + const { service, agentsToolsService, credentialProvider } = setup(); + const entity = makeAgentEntity({ nodeTools: { enabled: true } }); + + await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider); + + expect(agentsToolsService.getRuntimeTools).toHaveBeenCalledWith( + credentialProvider, + 'project-1', + ); + }); + + it('does not attach node tools when config.nodeTools.enabled is false', async () => { + const { service, agentsToolsService, credentialProvider } = setup(); + const entity = makeAgentEntity({ nodeTools: { enabled: false } }); + + await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider); + + expect(agentsToolsService.getRuntimeTools).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts b/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts new file mode 100644 index 00000000000..f88b4330954 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agents-service-sync.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/require-await -- mock implementations kept async for future-proofing */ +import type { Logger } from '@n8n/backend-common'; +import type { + ExecutionRepository, + ProjectRelationRepository, + UserRepository, + WorkflowRepository, +} from '@n8n/db'; +import { mock } from 'jest-mock-extended'; + +import type { ActiveExecutions } from '@/active-executions'; +import type { EphemeralNodeExecutor } from '@/node-execution'; +import type { UrlService } from '@/services/url.service'; +import type { WorkflowRunner } from '@/workflow-runner'; +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import type { AgentExecutionService } from '../agent-execution.service'; +import type { AgentSkillsService } from '../agent-skills.service'; +import { AgentsService } from '../agents.service'; +import type { Agent } from '../entities/agent.entity'; +import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; +import type { N8nMemory } from '../integrations/n8n-memory'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; +import type { AgentRepository } from '../repositories/agent.repository'; +import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-1', + name: 'Old Name', + description: 'Old description', + projectId: 'project-1', + credentialId: null, + provider: null, + model: null, + schema: { + name: 'Old Name', + model: 'anthropic/claude-sonnet-4-5', + credential: 'cred-1', + instructions: 'Be helpful', + } as AgentJsonConfig, + integrations: [], + tools: {}, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, + } as Agent; +} + +describe('AgentsService — updateName / updateDescription schema sync', () => { + let service: AgentsService; + let agentRepository: ReturnType>; + + beforeEach(() => { + agentRepository = mock(); + agentRepository.save.mockImplementation(async (entity) => entity as Agent); + + service = new AgentsService( + mock(), + agentRepository, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), // AgentsToolsService + mock(), + mock(), + mock(), + mock(), + ); + }); + + describe('updateName', () => { + it('updates entity name and schema name together', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateName('agent-1', 'project-1', 'New Name'); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('New Name'); + expect(result!.schema!.name).toBe('New Name'); + }); + + it('preserves other schema fields when updating name', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateName('agent-1', 'project-1', 'New Name'); + + expect(result!.schema!.model).toBe('anthropic/claude-sonnet-4-5'); + expect(result!.schema!.credential).toBe('cred-1'); + expect(result!.schema!.instructions).toBe('Be helpful'); + }); + + it('handles agent with null schema', async () => { + const agent = makeAgent({ schema: null } as unknown as Partial); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateName('agent-1', 'project-1', 'New Name'); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('New Name'); + expect(result!.schema).toBeNull(); + }); + + it('returns null for non-existent agent', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + const result = await service.updateName('missing', 'project-1', 'Name'); + + expect(result).toBeNull(); + }); + }); + + describe('updateDescription', () => { + it('updates entity description and schema description together', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateDescription('agent-1', 'project-1', 'New desc'); + + expect(result).not.toBeNull(); + expect(result!.description).toBe('New desc'); + expect(result!.schema!.description).toBe('New desc'); + }); + + it('preserves other schema fields when updating description', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateDescription('agent-1', 'project-1', 'New desc'); + + expect(result!.schema!.name).toBe('Old Name'); + expect(result!.schema!.model).toBe('anthropic/claude-sonnet-4-5'); + }); + + it('handles agent with null schema', async () => { + const agent = makeAgent({ schema: null } as unknown as Partial); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.updateDescription('agent-1', 'project-1', 'New desc'); + + expect(result).not.toBeNull(); + expect(result!.description).toBe('New desc'); + expect(result!.schema).toBeNull(); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-tools.service.test.ts new file mode 100644 index 00000000000..a7c34b30a59 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agents-tools.service.test.ts @@ -0,0 +1,330 @@ +import { mock } from 'jest-mock-extended'; +import type { CredentialProvider } from '@n8n/agents'; +import type { Logger } from '@n8n/backend-common'; +import { validateNodeConfig } from '@n8n/workflow-sdk'; + +import type { EphemeralNodeExecutor } from '@/node-execution'; +import type { NodeCatalogService } from '@/node-catalog'; + +import { + AgentsToolsService, + isAgentToolNodeType, + isExecutableNodeType, +} from '../agents-tools.service'; + +jest.mock('@n8n/workflow-sdk', () => ({ + validateNodeConfig: jest.fn().mockReturnValue({ valid: true, errors: [] }), +})); + +const ctx = { + resumeData: undefined, + suspend: jest.fn().mockResolvedValue(undefined as never), + parentTelemetry: undefined, +}; + +function makeService() { + const nodeCatalogService = mock(); + nodeCatalogService.searchNodes.mockResolvedValue('search-result'); + nodeCatalogService.getNodeTypes.mockResolvedValue('node-types-string'); + + const ephemeralNodeExecutor = mock(); + + const logger = mock(); + + const service = new AgentsToolsService(logger, nodeCatalogService, ephemeralNodeExecutor); + + return { service, nodeCatalogService, ephemeralNodeExecutor, logger }; +} + +function makeCredentialProvider( + creds: Array<{ id: string; name: string; type: string }> = [], +): CredentialProvider { + const provider = mock(); + provider.list.mockResolvedValue(creds); + return provider; +} + +describe('AgentsToolsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSharedTools()', () => { + it('returns search_nodes, get_node_types, and list_credentials', () => { + const { service } = makeService(); + const names = service.getSharedTools(makeCredentialProvider(), 'hint').map((t) => t.name); + expect(names).toEqual(['search_nodes', 'get_node_types', 'list_credentials']); + }); + }); + + describe('getRuntimeTools()', () => { + it('returns all four tools including run_node_tool', () => { + const { service } = makeService(); + const names = service + .getRuntimeTools(makeCredentialProvider(), 'project-1') + .map((t) => t.name); + expect(names).toEqual([ + 'search_nodes', + 'get_node_types', + 'list_credentials', + 'run_node_tool', + ]); + }); + }); + + describe('list_credentials handler', () => { + function getListTool(service: AgentsToolsService, provider: CredentialProvider) { + return service.getSharedTools(provider, 'hint').find((t) => t.name === 'list_credentials')!; + } + + it('returns all credentials when no types filter is provided', async () => { + const { service } = makeService(); + const provider = makeCredentialProvider([ + { id: '1', name: 'Gmail', type: 'gmailOAuth2' }, + { id: '2', name: 'Header', type: 'httpHeaderAuth' }, + ]); + + const result = await getListTool(service, provider).handler!({}, ctx); + + expect(result).toEqual({ + credentials: [ + { id: '1', name: 'Gmail', type: 'gmailOAuth2' }, + { id: '2', name: 'Header', type: 'httpHeaderAuth' }, + ], + }); + }); + + it('filters by the provided credential types', async () => { + const { service } = makeService(); + const provider = makeCredentialProvider([ + { id: '1', name: 'Gmail', type: 'gmailOAuth2' }, + { id: '2', name: 'Header', type: 'httpHeaderAuth' }, + { id: '3', name: 'Slack', type: 'slackApi' }, + ]); + + const result = await getListTool(service, provider).handler!( + { types: ['gmailOAuth2', 'slackApi'] }, + ctx, + ); + + expect(result).toEqual({ + credentials: [ + { id: '1', name: 'Gmail', type: 'gmailOAuth2' }, + { id: '3', name: 'Slack', type: 'slackApi' }, + ], + }); + }); + + it('returns all credentials when types is an empty array', async () => { + const { service } = makeService(); + const provider = makeCredentialProvider([{ id: '1', name: 'Gmail', type: 'gmailOAuth2' }]); + + const result = await getListTool(service, provider).handler!({ types: [] }, ctx); + + expect(result).toEqual({ + credentials: [{ id: '1', name: 'Gmail', type: 'gmailOAuth2' }], + }); + }); + }); + + describe('search_nodes handler', () => { + function getSearchTool(service: AgentsToolsService) { + return service + .getRuntimeTools(makeCredentialProvider(), 'project-1') + .find((t) => t.name === 'search_nodes')!; + } + + it('delegates to the node catalog with the agent tool-node filter', async () => { + const { service, nodeCatalogService } = makeService(); + + const result = await getSearchTool(service).handler!({ queries: ['gmail'] }, ctx); + + expect(nodeCatalogService.searchNodes).toHaveBeenCalledWith(['gmail'], { + nodeFilter: isAgentToolNodeType, + }); + expect(result).toEqual({ results: 'search-result' }); + }); + }); + + describe('isExecutableNodeType', () => { + it('rejects trigger nodes only', () => { + expect(isExecutableNodeType('n8n-nodes-base.scheduleTrigger')).toBe(false); + expect(isExecutableNodeType('n8n-nodes-base.httpRequest')).toBe(true); + expect(isExecutableNodeType('n8n-nodes-base.httpRequestTool')).toBe(true); + }); + }); + + describe('isAgentToolNodeType', () => { + it('allows tool node IDs and rejects base, trigger, or HITL tool node IDs', () => { + expect(isAgentToolNodeType('n8n-nodes-base.scheduleTrigger')).toBe(false); + expect(isAgentToolNodeType('n8n-nodes-base.httpRequest')).toBe(false); + expect(isAgentToolNodeType('n8n-nodes-base.httpRequestTool')).toBe(true); + expect(isAgentToolNodeType('n8n-nodes-base.slackHitlTool')).toBe(false); + }); + + it('admits whitelisted AI provider nodes (full vendor APIs)', () => { + expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.openAi')).toBe(true); + expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.anthropic')).toBe(true); + // Non-provider langchain nodes stay excluded. + expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.lmChatOpenAi')).toBe(false); + expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.agent')).toBe(false); + }); + }); + + describe('get_node_types handler', () => { + function getTypesTool(service: AgentsToolsService) { + return service + .getRuntimeTools(makeCredentialProvider(), 'project-1') + .find((t) => t.name === 'get_node_types')!; + } + + it('forwards string node IDs unchanged', async () => { + const { service, nodeCatalogService } = makeService(); + + await getTypesTool(service).handler!({ nodeIds: ['n8n-nodes-base.gmail'] }, ctx); + + expect(nodeCatalogService.getNodeTypes).toHaveBeenCalledWith(['n8n-nodes-base.gmail']); + }); + + it('stringifies object-style version before passing to the catalog', async () => { + const { service, nodeCatalogService } = makeService(); + + await getTypesTool(service).handler!( + { nodeIds: [{ nodeId: 'n8n-nodes-base.gmail', version: 2.1, resource: 'message' }] }, + ctx, + ); + + expect(nodeCatalogService.getNodeTypes).toHaveBeenCalledWith([ + { nodeId: 'n8n-nodes-base.gmail', version: '2.1', resource: 'message' }, + ]); + }); + }); + + describe('run_node_tool handler', () => { + function getRunTool(service: AgentsToolsService) { + return service + .getRuntimeTools(makeCredentialProvider(), 'project-1') + .find((t) => t.name === 'run_node_tool')!; + } + + beforeEach(() => { + jest.mocked(validateNodeConfig).mockReturnValue({ valid: true, errors: [] }); + }); + + it('refuses to run trigger nodes', async () => { + const { service, ephemeralNodeExecutor } = makeService(); + + const result = await getRunTool(service).handler!( + { nodeType: 'n8n-nodes-base.scheduleTrigger', nodeTypeVersion: 1 }, + ctx, + ); + + expect(ephemeralNodeExecutor.executeInline).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'error', + message: expect.stringContaining('scheduleTrigger'), + }); + }); + + it('skips validation and calls executeInline when nodeParameters is absent', async () => { + const { service, ephemeralNodeExecutor } = makeService(); + ephemeralNodeExecutor.executeInline.mockResolvedValue({ status: 'success' } as never); + + await getRunTool(service).handler!( + { nodeType: 'n8n-nodes-base.httpRequest', nodeTypeVersion: 4 }, + ctx, + ); + + expect(validateNodeConfig).not.toHaveBeenCalled(); + expect(ephemeralNodeExecutor.executeInline).toHaveBeenCalled(); + }); + + it('validates nodeParameters via validateNodeConfig before executing', async () => { + const { service, ephemeralNodeExecutor } = makeService(); + ephemeralNodeExecutor.executeInline.mockResolvedValue({ status: 'success' } as never); + + await getRunTool(service).handler!( + { + nodeType: 'n8n-nodes-base.httpRequest', + nodeTypeVersion: 4, + nodeParameters: { url: 'https://example.com' }, + }, + ctx, + ); + + expect(validateNodeConfig).toHaveBeenCalledWith( + 'n8n-nodes-base.httpRequest', + 4, + { + parameters: { url: 'https://example.com' }, + }, + { isToolNode: true }, + ); + expect(ephemeralNodeExecutor.executeInline).toHaveBeenCalled(); + }); + + it('returns an error and skips executeInline when validation fails', async () => { + jest.mocked(validateNodeConfig).mockReturnValue({ + valid: false, + errors: [{ path: 'method', message: 'Field "method" has invalid value.' }], + }); + const { service, ephemeralNodeExecutor } = makeService(); + + const result = await getRunTool(service).handler!( + { + nodeType: 'n8n-nodes-base.httpRequest', + nodeTypeVersion: 4, + nodeParameters: { method: 'DELETE' }, + }, + ctx, + ); + + expect(ephemeralNodeExecutor.executeInline).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'error', + message: expect.stringContaining('"method"'), + }); + }); + + it('returns a structured error when executeInline throws', async () => { + const { service, ephemeralNodeExecutor, logger } = makeService(); + ephemeralNodeExecutor.executeInline.mockRejectedValue(new Error('boom')); + + const result = await getRunTool(service).handler!( + { nodeType: 'n8n-nodes-base.httpRequest', nodeTypeVersion: 4 }, + ctx, + ); + + expect(result).toMatchObject({ + status: 'error', + message: expect.stringContaining('boom'), + }); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('maps inputData, passes credentials and projectId through to executeInline', async () => { + const executionResult = { status: 'success', data: [{ json: { ok: true } }] }; + const { service, ephemeralNodeExecutor } = makeService(); + ephemeralNodeExecutor.executeInline.mockResolvedValue(executionResult as never); + + const result = await getRunTool(service).handler!( + { + nodeType: 'n8n-nodes-base.gmail', + nodeTypeVersion: 2, + credentials: { gmailOAuth2: { id: 'cred-1', name: 'My Gmail' } }, + inputData: { to: 'user@example.com' }, + }, + ctx, + ); + + expect(ephemeralNodeExecutor.executeInline).toHaveBeenCalledWith( + expect.objectContaining({ + credentialDetails: { gmailOAuth2: { id: 'cred-1', name: 'My Gmail' } }, + inputData: [{ json: { to: 'user@example.com' } }], + projectId: 'project-1', + }), + ); + expect(result).toEqual(executionResult); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts new file mode 100644 index 00000000000..a73a9bf1b97 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts @@ -0,0 +1,46 @@ +import { ControllerRegistryMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; + +import { AgentsController } from '../agents.controller'; + +// The webhook route is the single exception: it is `skipAuth: true` (no +// req.user) and authenticates inbound third-party callbacks via per-platform +// signature verification inside the handler. +const UNAUTHENTICATED_HANDLERS = new Set(['handleWebhook']); + +const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata( + AgentsController as never, +); + +const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, route]) => ({ + handlerName, + route, +})); + +describe('AgentsController route access scopes', () => { + it.each(routeCases)( + '$handlerName is gated by a project-scoped agent:* check', + ({ handlerName, route }) => { + if (UNAUTHENTICATED_HANDLERS.has(handlerName)) { + expect(route.accessScope).toBeUndefined(); + expect(route.skipAuth).toBe(true); + return; + } + + expect(route.accessScope).toBeDefined(); + expect(route.accessScope?.globalOnly).toBe(false); + expect(route.accessScope?.scope.startsWith('agent:')).toBe(true); + }, + ); + + it.each([ + ['listSkills', 'agent:read'], + ['getSkill', 'agent:read'], + ['createSkill', 'agent:update'], + ['updateSkill', 'agent:update'], + ['deleteSkill', 'agent:update'], + ['revertToPublished', 'agent:update'], + ])('%s uses %s', (handlerName, scope) => { + expect(metadata.routes.get(handlerName)?.accessScope?.scope).toBe(scope); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts new file mode 100644 index 00000000000..19af21d341b --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -0,0 +1,965 @@ +/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method, id-denylist -- async mock stubs, unbound-method references and short `cb` names are acceptable test idioms */ +import { Container } from '@n8n/di'; +import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegration } from '@n8n/api-types'; +import { mockLogger } from '@n8n/backend-test-utils'; +import { mock } from 'jest-mock-extended'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { AgentSkillsService } from '../agent-skills.service'; +import { AgentsService, chatThreadId } from '../agents.service'; +import type { AgentPublishedVersion } from '../entities/agent-published-version.entity'; +import type { Agent } from '../entities/agent.entity'; +import { AgentScheduleService } from '../integrations/agent-schedule.service'; +import { ChatIntegrationService } from '../integrations/chat-integration.service'; +import { + AgentChatIntegration, + ChatIntegrationRegistry, + type AgentChatIntegrationContext, +} from '../integrations/agent-chat-integration'; +import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; +import type { N8nMemory } from '../integrations/n8n-memory'; +import type { AgentExecutionService } from '../agent-execution.service'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; +import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository'; +import type { AgentRepository } from '../repositories/agent.repository'; + +const agentId = 'agent-1'; +const projectId = 'project-1'; +const userId = 'user-1'; +const versionId = 'v1'; + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: agentId, + versionId, + schema: null, + model: 'claude-3', + provider: 'anthropic', + credentialId: 'cred-1', + publishedVersion: null, + integrations: [], + tools: {}, + skills: {}, + updatedAt: new Date(), + ...overrides, + } as unknown as Agent; +} + +function makePublishedVersion( + overrides: Partial = {}, +): AgentPublishedVersion { + return { + agentId, + publishedFromVersionId: versionId, + schema: null, + model: null, + provider: null, + credentialId: null, + tools: null, + skills: null, + publishedById: null, + ...overrides, + } as unknown as AgentPublishedVersion; +} + +describe('AgentsService', () => { + let service: AgentsService; + let agentRepository: jest.Mocked; + let agentPublishedVersionRepository: jest.Mocked; + let n8nMemory: jest.Mocked; + let n8nCheckpointStorage: jest.Mocked; + let agentExecutionService: jest.Mocked; + let scheduleService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + agentRepository = mock(); + agentPublishedVersionRepository = mock(); + n8nMemory = mock(); + n8nCheckpointStorage = mock(); + agentExecutionService = mock(); + agentExecutionService.recordMessage.mockResolvedValue('exec-id'); + scheduleService = mock(); + const logger = mockLogger(); + + service = new AgentsService( + logger, + agentRepository, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + n8nCheckpointStorage, + mock(), + mock(), + mock(), + n8nMemory, + agentExecutionService, + agentPublishedVersionRepository, + new AgentSkillsService(logger, agentRepository), + ); + }); + + afterEach(() => { + Container.reset(); + }); + + describe('validateConfig', () => { + it('rejects inputSchema on node tool configs', async () => { + const result = await service.validateConfig({ + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help the user.', + tools: [ + { + type: 'node', + name: 'http_request', + description: 'Make an HTTP request to any URL', + inputSchema: { + type: 'object', + properties: { url: { type: 'string' } }, + required: ['url'], + }, + node: { + nodeType: 'n8n-nodes-base.httpRequestTool', + nodeTypeVersion: 4, + nodeParameters: { + method: 'GET', + url: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('url', 'The URL to request', 'string') }}", + toolDescription: 'Make an HTTP request to any URL', + }, + }, + }, + ], + }); + + expect(result.valid).toBe(false); + if (result.valid) return; + + expect(result.error).toContain('inputSchema'); + }); + }); + + describe('updateConfig', () => { + const config = { + name: 'Test Agent', + } as unknown as AgentJsonConfig; + + beforeEach(() => { + jest.spyOn(service, 'validateConfig').mockResolvedValue({ valid: true, config }); + agentRepository.save.mockImplementation(async (a) => a as Agent); + }); + + it('does not bump versionId when agent has never been published', async () => { + const agent = makeAgent({ versionId: 'v1', publishedVersion: null }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.updateConfig(agentId, projectId, {}); + + expect(agentRepository.save.mock.calls[0][0].versionId).toBe('v1'); + }); + + it('does not bump versionId when already in a draft (versionId differs from publishedFromVersionId)', async () => { + const agent = makeAgent({ + versionId: 'v2', + publishedVersion: makePublishedVersion({ publishedFromVersionId: 'v1' }), + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.updateConfig(agentId, projectId, {}); + + expect(agentRepository.save.mock.calls[0][0].versionId).toBe('v2'); + }); + + it('bumps versionId on the first save after publish (versionId equals publishedFromVersionId)', async () => { + const agent = makeAgent({ + versionId: 'v1', + publishedVersion: makePublishedVersion({ publishedFromVersionId: 'v1' }), + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.updateConfig(agentId, projectId, {}); + + const savedVersionId = agentRepository.save.mock.calls[0][0].versionId as string; + expect(savedVersionId).not.toBe('v1'); + expect(savedVersionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it('rejects config saves that reference a missing skill body', async () => { + const configWithMissingSkill = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + skills: [{ type: 'skill', id: 'missing_skill' }], + } as AgentJsonConfig; + jest.spyOn(service, 'validateConfig').mockResolvedValue({ + valid: true, + config: configWithMissingSkill, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ skills: {} })); + + await expect( + service.updateConfig(agentId, projectId, configWithMissingSkill), + ).rejects.toThrow('Invalid agent config: Missing skill bodies: missing_skill'); + expect(agentRepository.save).not.toHaveBeenCalled(); + }); + + it('rejects an active schedule integration when the agent is unpublished', async () => { + const configWithActiveSchedule = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + integrations: [ + { + type: 'schedule', + active: true, + cronExpression: '0 9 * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + } as AgentJsonConfig; + jest.spyOn(service, 'validateConfig').mockResolvedValue({ + valid: true, + config: configWithActiveSchedule, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ publishedVersion: null })); + + await expect( + service.updateConfig(agentId, projectId, configWithActiveSchedule), + ).rejects.toThrow( + 'Invalid agent config: schedule integration cannot be active until the agent is published', + ); + expect(agentRepository.save).not.toHaveBeenCalled(); + }); + + it('allows an inactive schedule integration on an unpublished agent', async () => { + const configWithInactiveSchedule = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + integrations: [ + { + type: 'schedule', + active: false, + cronExpression: '0 9 * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + } as AgentJsonConfig; + jest.spyOn(service, 'validateConfig').mockResolvedValue({ + valid: true, + config: configWithInactiveSchedule, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ publishedVersion: null })); + + await expect( + service.updateConfig(agentId, projectId, configWithInactiveSchedule), + ).resolves.toBeDefined(); + }); + }); + + describe('publishAgent', () => { + let mockTrx: { save: jest.Mock }; + let mockTransaction: jest.Mock; + + beforeEach(() => { + mockTrx = { save: jest.fn() }; + mockTransaction = jest.fn( + async (cb: (trx: typeof mockTrx) => Promise) => await cb(mockTrx), + ); + Object.defineProperty(agentRepository, 'manager', { + value: { transaction: mockTransaction }, + configurable: true, + }); + }); + + it('throws NotFoundError when the agent does not exist', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(service.publishAgent(agentId, projectId, userId)).rejects.toThrow(NotFoundError); + }); + + it('calls savePublishedVersion with the correct payload including publishedFromVersionId', async () => { + const agent = makeAgent({ versionId }); + const publishedVersion = makePublishedVersion(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentPublishedVersionRepository.savePublishedVersion.mockResolvedValue(publishedVersion); + + await service.publishAgent(agentId, projectId, userId); + + expect(agentPublishedVersionRepository.savePublishedVersion).toHaveBeenCalledWith( + { + agentId: agent.id, + schema: agent.schema, + tools: null, + skills: null, + publishedFromVersionId: versionId, + model: agent.model, + provider: agent.provider, + credentialId: agent.credentialId, + publishedById: userId, + }, + mockTrx, + ); + }); + + it('snapshots attached skill bodies when publishing', async () => { + const skill = { + name: 'Summarize Notes', + description: 'Use when summarizing notes', + instructions: 'Extract decisions and action items.', + }; + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + skills: [{ type: 'skill', id: 'summarize_notes' }], + }, + skills: { + summarize_notes: skill, + unattached: { + name: 'Unattached', + description: 'Use when unattached', + instructions: 'Do not snapshot.', + }, + }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentPublishedVersionRepository.savePublishedVersion.mockResolvedValue( + makePublishedVersion(), + ); + + await service.publishAgent(agentId, projectId, userId); + + expect(agentPublishedVersionRepository.savePublishedVersion).toHaveBeenCalledWith( + expect.objectContaining({ + skills: { + summarize_notes: skill, + }, + }), + mockTrx, + ); + }); + + it('rejects publishing when a configured skill body is missing', async () => { + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + skills: [{ type: 'skill', id: 'missing_skill' }], + }, + skills: {}, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await expect(service.publishAgent(agentId, projectId, userId)).rejects.toThrow( + 'Cannot publish agent with missing skill bodies: missing_skill', + ); + }); + + it('assigns a new versionId and persists it when the agent has none', async () => { + const agent = makeAgent({ versionId: null }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentPublishedVersionRepository.savePublishedVersion.mockResolvedValue( + makePublishedVersion(), + ); + + await service.publishAgent(agentId, projectId, userId); + + expect(agent.versionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(mockTrx.save).toHaveBeenCalledWith(agent); + }); + + it('returns the agent with publishedVersion set to the saved snapshot', async () => { + const agent = makeAgent(); + const publishedVersion = makePublishedVersion(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentPublishedVersionRepository.savePublishedVersion.mockResolvedValue(publishedVersion); + + const result = await service.publishAgent(agentId, projectId, userId); + + expect(result.publishedVersion).toBe(publishedVersion); + expect(result).toBe(agent); + }); + + it('connects persisted credential integrations after publishing', async () => { + const integrations: AgentIntegration[] = [ + { type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' }, + { + type: 'schedule', + active: false, + cronExpression: '0 9 * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ]; + const agent = makeAgent({ integrations }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentPublishedVersionRepository.savePublishedVersion.mockResolvedValue( + makePublishedVersion(), + ); + + const chatIntegrationService = mock(); + chatIntegrationService.syncToConfig.mockResolvedValue(undefined); + Container.set(ChatIntegrationService, chatIntegrationService); + + await service.publishAgent(agentId, projectId, userId); + + expect(chatIntegrationService.syncToConfig).toHaveBeenCalledWith( + agent, + [], + [{ type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' }], + ); + }); + + it('does not call syncToConfig when no credential integrations are persisted', async () => { + const agent = makeAgent({ + integrations: [ + { + type: 'schedule', + active: false, + cronExpression: '0 9 * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + agentPublishedVersionRepository.savePublishedVersion.mockResolvedValue( + makePublishedVersion(), + ); + + const chatIntegrationService = mock(); + chatIntegrationService.syncToConfig.mockResolvedValue(undefined); + Container.set(ChatIntegrationService, chatIntegrationService); + + await service.publishAgent(agentId, projectId, userId); + + expect(chatIntegrationService.syncToConfig).not.toHaveBeenCalled(); + }); + }); + + describe('executeForChatPublished', () => { + it('reconstructs from the published skill snapshot instead of the draft skill body', async () => { + const publishedSkill = { + name: 'Summarize Notes', + description: 'Use when summarizing notes', + instructions: 'Published instructions.', + }; + const draftSkill = { + ...publishedSkill, + instructions: 'Draft instructions.', + }; + const schema: AgentJsonConfig = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + skills: [{ type: 'skill', id: 'summarize_notes' }], + }; + const agent = makeAgent({ + schema, + skills: { summarize_notes: draftSkill }, + publishedVersion: makePublishedVersion({ + schema, + skills: { summarize_notes: publishedSkill }, + publishedById: userId, + }), + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + jest.spyOn(service as never, 'createCredentialProvider').mockReturnValue(mock()); + const reconstructSpy = jest + .spyOn(service as never, 'reconstructFromConfig') + .mockResolvedValue({ agent: {}, toolRegistry: {} } as never); + jest + .spyOn(service as never, 'streamChatResponse') + .mockImplementation(async function* () {} as never); + + await service + .executeForChatPublished({ + agentId, + projectId, + message: 'hello', + memory: { threadId: 'thread-1', resourceId: 'platform-user-1' }, + }) + .next(); + + expect(reconstructSpy.mock.calls[0][0]).toEqual( + expect.objectContaining({ + skills: { summarize_notes: publishedSkill }, + }), + ); + }); + + it('passes resourceId (not n8n userId) to streamChatResponse', async () => { + const schema: AgentJsonConfig = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + }; + const n8nPublisherId = 'n8n-user-publisher'; + const chatUserId = 'slack-user-abc'; + const agent = makeAgent({ + schema, + publishedVersion: makePublishedVersion({ schema, publishedById: n8nPublisherId }), + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + jest.spyOn(service as never, 'createCredentialProvider').mockReturnValue(mock()); + jest + .spyOn(service as never, 'reconstructFromConfig') + .mockResolvedValue({ agent: {}, toolRegistry: {} } as never); + const streamSpy = jest + .spyOn(service as never, 'streamChatResponse') + .mockImplementation(async function* () {} as never); + + await service + .executeForChatPublished({ + agentId, + projectId, + message: 'hello', + memory: { threadId: 'thread-1', resourceId: chatUserId }, + }) + .next(); + + const streamConfig = (streamSpy.mock.calls[0] as [{ memory: { resourceId: string } }])[0]; + expect(streamConfig.memory.resourceId).toBe(chatUserId); + expect(streamConfig.memory.resourceId).not.toBe(n8nPublisherId); + }); + }); + + describe('unpublishAgent', () => { + let mockTrx: { save: jest.Mock }; + let mockTransaction: jest.Mock; + + beforeEach(() => { + mockTrx = { save: jest.fn() }; + mockTransaction = jest.fn( + async (cb: (trx: typeof mockTrx) => Promise) => await cb(mockTrx), + ); + Object.defineProperty(agentRepository, 'manager', { + value: { transaction: mockTransaction }, + configurable: true, + }); + // unpublishAgent lazy-imports ChatIntegrationService and calls disconnect via the + // DI container — register a mock so Container.get doesn't try to construct the real + // service (which would fail resolving DataSource in unit tests). + Container.set(ChatIntegrationService, mock()); + Container.set(AgentScheduleService, scheduleService); + }); + + it('throws NotFoundError when the agent does not exist', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(service.unpublishAgent(agentId, projectId)).rejects.toThrow(NotFoundError); + }); + + it('deletes the published version row and clears publishedVersion on the entity', async () => { + const agent = makeAgent({ publishedVersion: makePublishedVersion() }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.unpublishAgent(agentId, projectId); + + expect(agentPublishedVersionRepository.deleteByAgentId).toHaveBeenCalledWith( + agentId, + mockTrx, + ); + expect(agent.publishedVersion).toBeNull(); + }); + + it('deactivates the persisted schedule and stops the local cron job when unpublishing', async () => { + const integrations: AgentIntegration[] = [ + { + type: 'schedule', + active: true, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ]; + const agent = makeAgent({ + publishedVersion: makePublishedVersion(), + integrations, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.unpublishAgent(agentId, projectId); + + expect(mockTrx.save).toHaveBeenCalledWith( + expect.objectContaining({ + integrations: [ + expect.objectContaining({ + type: 'schedule', + active: false, + }), + ], + }), + ); + expect(scheduleService.deregister).toHaveBeenCalledWith(agentId); + }); + + it('returns the agent with publishedVersion cleared', async () => { + const agent = makeAgent({ publishedVersion: makePublishedVersion() }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.unpublishAgent(agentId, projectId); + + expect(result.publishedVersion).toBeNull(); + expect(result).toBe(agent); + }); + }); + + describe('revertToPublishedAgent', () => { + let mockTrx: { save: jest.Mock }; + let mockTransaction: jest.Mock; + + beforeEach(() => { + mockTrx = { save: jest.fn() }; + mockTransaction = jest.fn( + async (cb: (trx: typeof mockTrx) => Promise) => await cb(mockTrx), + ); + Object.defineProperty(agentRepository, 'manager', { + value: { transaction: mockTransaction }, + configurable: true, + }); + }); + + it('throws NotFoundError when the agent does not exist', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(service.revertToPublishedAgent(agentId, projectId)).rejects.toThrow( + NotFoundError, + ); + }); + + it('throws ConflictError when the agent is not published', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent({ publishedVersion: null })); + + await expect(service.revertToPublishedAgent(agentId, projectId)).rejects.toThrow( + ConflictError, + ); + }); + + it('restores the draft fields from the published snapshot', async () => { + const publishedSchema: AgentJsonConfig = { + name: 'Published Agent', + description: 'Published description', + model: 'anthropic/claude-sonnet-4-5', + credential: 'cred-published', + instructions: 'Published instructions', + tools: [{ type: 'custom', id: 'published_tool' }], + skills: [{ type: 'skill', id: 'published_skill' }], + }; + const publishedTools = { + published_tool: { + code: 'return "published";', + descriptor: { name: 'published_tool' }, + }, + } as unknown as Agent['tools']; + const publishedSkills = { + published_skill: { + name: 'Published skill', + description: 'Published skill description', + instructions: 'Published skill instructions', + }, + }; + const publishedVersion = makePublishedVersion({ + schema: publishedSchema, + tools: publishedTools, + skills: publishedSkills, + model: 'anthropic/claude-sonnet-4-5', + provider: 'anthropic', + credentialId: 'cred-published', + publishedFromVersionId: 'published-version-id', + }); + const agent = makeAgent({ + name: 'Draft Agent', + description: 'Draft description', + versionId: 'draft-version-id', + schema: { + name: 'Draft Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Draft instructions', + }, + tools: {}, + skills: {}, + publishedVersion, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.revertToPublishedAgent(agentId, projectId); + + expect(agent.schema).toEqual(publishedSchema); + expect(agent.schema).not.toBe(publishedSchema); + expect(agent.tools).toEqual(publishedTools); + expect(agent.skills).toEqual(publishedSkills); + expect(agent.model).toBe('anthropic/claude-sonnet-4-5'); + expect(agent.provider).toBe('anthropic'); + expect(agent.credentialId).toBe('cred-published'); + expect(agent.versionId).toBe('published-version-id'); + expect(agent.name).toBe('Published Agent'); + expect(agent.description).toBe('Published description'); + expect(mockTrx.save).toHaveBeenCalledWith(agent); + expect(result).toBe(agent); + expect(result.publishedVersion).toBe(publishedVersion); + }); + }); + + describe('getTestChatMessages', () => { + it('derives user-scoped fallback test-chat thread ids', () => { + expect(chatThreadId(agentId, 'user-1')).toBe('test-agent-1:user-1'); + expect(chatThreadId(agentId, 'user-2')).toBe('test-agent-1:user-2'); + expect(chatThreadId(agentId, 'user-1')).not.toBe(chatThreadId(agentId, 'user-2')); + }); + + it('scopes the memory lookup to the caller via resourceId', async () => { + n8nMemory.getMessages.mockResolvedValue([]); + + await service.getTestChatMessages(agentId, userId); + + expect(n8nMemory.getMessages).toHaveBeenCalledWith(chatThreadId(agentId, userId), { + resourceId: userId, + }); + }); + + it('returns whatever memory returns for this user', async () => { + const persisted = [{ id: 'm1' }, { id: 'm2' }]; + n8nMemory.getMessages.mockResolvedValue(persisted as never); + + const result = await service.getTestChatMessages(agentId, userId); + + expect(result).toBe(persisted); + }); + }); + + describe('clearTestChatMessages', () => { + it('deletes only the caller’s messages on their test-chat thread', async () => { + await service.clearTestChatMessages(agentId, userId); + + expect(n8nMemory.deleteMessagesByThread).toHaveBeenCalledWith( + chatThreadId(agentId, userId), + userId, + ); + expect(n8nMemory.deleteThread).not.toHaveBeenCalled(); + }); + }); + + describe('clearAllTestChatMessages', () => { + it('deletes every message and the thread row itself', async () => { + await service.clearAllTestChatMessages(agentId); + + expect(n8nMemory.deleteThreadsByPrefix).toHaveBeenCalledWith(chatThreadId(agentId)); + expect(n8nMemory.deleteMessagesByThread).toHaveBeenCalledWith(chatThreadId(agentId)); + // Second arg must be absent — undefined means "all users". + expect(n8nMemory.deleteMessagesByThread.mock.calls[0]).toHaveLength(1); + expect(n8nMemory.deleteThread).toHaveBeenCalledWith(chatThreadId(agentId)); + }); + }); + + describe('listChatIntegrations', () => { + class TestIntegration extends AgentChatIntegration { + readonly type = 'test-platform'; + readonly credentialTypes = ['testApi']; + readonly displayLabel = 'Test Platform'; + readonly displayIcon = 'circle'; + async createAdapter(_ctx: AgentChatIntegrationContext): Promise { + return {}; + } + } + + it('returns one descriptor per registered integration', () => { + const registry = new ChatIntegrationRegistry(); + registry.register(new TestIntegration()); + Container.set(ChatIntegrationRegistry, registry); + + const result = service.listChatIntegrations(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'test-platform', + label: 'Test Platform', + icon: 'circle', + credentialTypes: ['testApi'], + }); + }); + + it('returns an empty array when no integrations are registered', () => { + const registry = new ChatIntegrationRegistry(); + Container.set(ChatIntegrationRegistry, registry); + + expect(service.listChatIntegrations()).toEqual([]); + }); + + afterEach(() => { + Container.reset(); + }); + }); + + describe('validateAgentIsRunnable', () => { + const credentialProvider = mock<{ list: jest.Mock }>({ + list: jest.fn().mockResolvedValue([]), + }); + + beforeEach(() => { + credentialProvider.list.mockResolvedValue([]); + }); + + it('flags config skill refs that have no stored body', async () => { + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Do stuff', + skills: [ + { type: 'skill', id: 'present_skill' }, + { type: 'skill', id: 'missing_skill' }, + ], + } as unknown as AgentJsonConfig, + skills: { + present_skill: { + name: 'Present', + description: 'Use when present', + instructions: 'do', + }, + }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing).toContain('skill:missing_skill'); + expect(result.missing).not.toContain('skill:present_skill'); + }); + + it('does not flag skill refs when every id has a stored body', async () => { + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Do stuff', + skills: [{ type: 'skill', id: 'present_skill' }], + } as unknown as AgentJsonConfig, + skills: { + present_skill: { + name: 'Present', + description: 'Use when present', + instructions: 'do', + }, + }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing.filter((m) => m.startsWith('skill:'))).toEqual([]); + }); + }); + + describe('resumeForChat', () => { + it('does not use n8n userId as resourceId — resume passes no resourceId to agentInstance.resume', async () => { + const n8nPublisherId = 'n8n-user-publisher'; + const runId = 'run-abc'; + const toolCallId = 'tool-xyz'; + const schema: AgentJsonConfig = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', + }; + const agent = makeAgent({ + schema, + publishedVersion: makePublishedVersion({ schema, publishedById: n8nPublisherId }), + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + // Configure the named checkpoint storage mock injected in beforeEach + n8nCheckpointStorage.getStatus.mockResolvedValue({ + status: 'ok', + checkpoint: { persistence: { threadId: 'thread-1', resourceId: 'platform-user-1' } }, + } as never); + + const mockAgentInstance = { + name: 'Test Agent', + resume: jest.fn().mockResolvedValue({ + stream: { + getReader: () => ({ + read: jest.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: jest.fn(), + }), + }, + }), + }; + + jest.spyOn(service as never, 'createCredentialProvider').mockReturnValue(mock()); + jest + .spyOn(service as never, 'reconstructFromConfig') + .mockResolvedValue({ agent: mockAgentInstance, toolRegistry: {} } as never); + + await service + .resumeForChat({ agentId, projectId, runId, toolCallId, resumeData: { value: 'yes' } }) + .next(); + + // resume() takes runId and toolCallId — no resourceId or userId is passed + expect(mockAgentInstance.resume).toHaveBeenCalledWith( + 'stream', + { value: 'yes' }, + { runId, toolCallId }, + ); + // The n8n publisher ID must not appear in the resume args + const resumeArgs = mockAgentInstance.resume.mock.calls[0]; + expect(JSON.stringify(resumeArgs)).not.toContain(n8nPublisherId); + }); + }); + + describe('delete — chat cleanup', () => { + beforeEach(() => { + Container.set(AgentScheduleService, scheduleService); + }); + + it('removes the test-chat thread + messages after removing the agent', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.delete(agentId, projectId); + + expect(agentRepository.remove).toHaveBeenCalledWith(agent); + expect(n8nMemory.deleteThreadsByPrefix).toHaveBeenCalledWith(chatThreadId(agentId)); + expect(n8nMemory.deleteMessagesByThread).toHaveBeenCalledWith(chatThreadId(agentId)); + expect(n8nMemory.deleteThread).toHaveBeenCalledWith(chatThreadId(agentId)); + }); + + it('stops the local schedule when deleting the agent', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.delete(agentId, projectId); + + expect(scheduleService.deregister).toHaveBeenCalledWith(agentId); + }); + + it('still returns true when chat cleanup fails — agent removal is the primary intent', async () => { + const agent = makeAgent(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + n8nMemory.deleteThreadsByPrefix.mockRejectedValueOnce(new Error('db down')); + + await expect(service.delete(agentId, projectId)).resolves.toBe(true); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts new file mode 100644 index 00000000000..251b6e5b59c --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/execution-recorder.test.ts @@ -0,0 +1,474 @@ +import { ExecutionRecorder } from '../execution-recorder'; +import type { BuiltTool, StreamChunk } from '@n8n/agents'; +import { buildToolRegistry } from '../tool-registry'; + +function makeToolCallChunk(toolName: string, input: unknown, toolCallId = 'tc1'): StreamChunk { + return { type: 'tool-call', toolCallId, toolName, input } satisfies StreamChunk; +} + +function makeToolResultChunk(toolName: string, output: unknown, toolCallId = 'tc1'): StreamChunk { + return { type: 'tool-result', toolCallId, toolName, output } satisfies StreamChunk; +} + +describe('ExecutionRecorder', () => { + describe('timeline ordering', () => { + it('captures text → tool call → text in order', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Let me check' }); + recorder.record(makeToolCallChunk('lookup', { query: 'test' })); + recorder.record(makeToolResultChunk('lookup', { found: true })); + recorder.record({ type: 'text-delta', id: 't2', delta: 'Here are the results' }); + recorder.record({ + type: 'finish', + finishReason: 'stop', + } as StreamChunk); + + const record = recorder.getMessageRecord(); + + expect(record.timeline).toHaveLength(3); + expect(record.timeline[0].type).toBe('text'); + expect(record.timeline[1].type).toBe('tool-call'); + expect(record.timeline[2].type).toBe('text'); + + if (record.timeline[0].type === 'text') { + expect(record.timeline[0].content).toBe('Let me check'); + } + if (record.timeline[1].type === 'tool-call') { + expect(record.timeline[1].name).toBe('lookup'); + expect(record.timeline[1].output).toEqual({ found: true }); + expect(record.timeline[1].success).toBe(true); + } + if (record.timeline[2].type === 'text') { + expect(record.timeline[2].content).toBe('Here are the results'); + } + }); + + it('captures multiple tool calls between text segments', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Checking...' }); + recorder.record(makeToolCallChunk('tool_a', {}, 'a1')); + recorder.record(makeToolResultChunk('tool_a', 'result_a', 'a1')); + recorder.record(makeToolCallChunk('tool_b', {}, 'b1')); + recorder.record(makeToolResultChunk('tool_b', 'result_b', 'b1')); + recorder.record({ type: 'text-delta', id: 't2', delta: 'Done' }); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + + expect(record.timeline).toHaveLength(4); + expect(record.timeline.map((e) => e.type)).toEqual([ + 'text', + 'tool-call', + 'tool-call', + 'text', + ]); + }); + + it('does not create empty text events for whitespace-only segments', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: ' ' }); + recorder.record(makeToolCallChunk('lookup', {})); + recorder.record(makeToolResultChunk('lookup', {})); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + const textEvents = record.timeline.filter((e) => e.type === 'text'); + expect(textEvents).toHaveLength(0); + }); + }); + + describe('working memory capture', () => { + it('captures the working-memory tool call as a timeline event', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Hello' }); + recorder.record({ + type: 'tool-call', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + input: { memory: '# Name: Alice' }, + } as StreamChunk); + recorder.record({ + type: 'tool-result', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + output: { success: true }, + } as StreamChunk); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + + expect(record.workingMemory).toBe('# Name: Alice'); + expect(record.toolCalls).toEqual([]); + expect(record.timeline.some((e) => e.type === 'working-memory')).toBe(true); + }); + + it('keeps last working memory when multiple updates occur', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ + type: 'tool-call', + toolCallId: 'wm-1', + toolName: 'update_working_memory', + input: { memory: 'first' }, + } as StreamChunk); + recorder.record({ + type: 'tool-call', + toolCallId: 'wm-2', + toolName: 'update_working_memory', + input: { memory: 'second' }, + } as StreamChunk); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + expect(record.workingMemory).toBe('second'); + }); + }); + + describe('suspension', () => { + it('records suspension as a timeline event', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Choose an option' }); + recorder.record({ + type: 'tool-call-suspended', + toolName: 'rich_interaction', + toolCallId: 'tc1', + } as StreamChunk); + + const record = recorder.getMessageRecord(); + + expect(recorder.suspended).toBe(true); + expect(record.timeline.some((e) => e.type === 'suspension')).toBe(true); + }); + }); + + describe('display-only rich_interaction', () => { + it('records the standard tool-call/tool-result pair with displayOnly marker', () => { + const recorder = new ExecutionRecorder(); + + const cardPayload = { + components: [{ type: 'image', url: 'https://media.giphy.com/x.gif', alt: 'gif' }], + }; + + // Display-only rich_interaction emits no special framework chunk: + // the LLM calls the tool, the handler returns `{ displayOnly: true }` + // (which is what gets recorded), and the bridge handles rendering. + recorder.record(makeToolCallChunk('rich_interaction', cardPayload, 'tc1')); + recorder.record(makeToolResultChunk('rich_interaction', { displayOnly: true }, 'tc1')); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + + const toolEvents = record.timeline.filter((e) => e.type === 'tool-call'); + expect(toolEvents).toHaveLength(1); + if (toolEvents[0].type === 'tool-call') { + expect(toolEvents[0].toolCallId).toBe('tc1'); + expect(toolEvents[0].input).toEqual(cardPayload); + expect(toolEvents[0].output).toEqual({ displayOnly: true }); + expect(toolEvents[0].success).toBe(true); + } + + expect(record.toolCalls).toHaveLength(1); + expect(record.toolCalls[0]).toEqual({ + name: 'rich_interaction', + input: cardPayload, + output: { displayOnly: true }, + }); + }); + }); + + describe('backward compat', () => { + it('still populates flat toolCalls array', () => { + const recorder = new ExecutionRecorder(); + + recorder.record(makeToolCallChunk('my_tool', { x: 1 })); + recorder.record(makeToolResultChunk('my_tool', { y: 2 })); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + + expect(record.toolCalls).toHaveLength(1); + expect(record.toolCalls[0]).toEqual({ + name: 'my_tool', + input: { x: 1 }, + output: { y: 2 }, + }); + }); + + it('still concatenates assistantResponse from all text deltas', () => { + const recorder = new ExecutionRecorder(); + + recorder.record({ type: 'text-delta', id: 't1', delta: 'Hello ' }); + recorder.record(makeToolCallChunk('tool', {})); + recorder.record(makeToolResultChunk('tool', {})); + recorder.record({ type: 'text-delta', id: 't2', delta: 'world' }); + recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = recorder.getMessageRecord(); + expect(record.assistantResponse).toBe('Hello world'); + }); + }); +}); + +function wfTool(name: string, id: string, wfName: string, trigger = 'manual'): BuiltTool { + return { + name, + metadata: { kind: 'workflow', workflowId: id, workflowName: wfName, triggerType: trigger }, + } as unknown as BuiltTool; +} + +function nodeTool( + name: string, + nodeType: string, + nodeParameters: Record, +): BuiltTool { + return { + name, + metadata: { kind: 'node', nodeType, nodeTypeVersion: 1, displayName: name, nodeParameters }, + } as unknown as BuiltTool; +} + +describe('ExecutionRecorder — node-tool $fromAI resolution', () => { + it('substitutes a full-string $fromAI expression with the LLM-provided value', () => { + const registry = buildToolRegistry([ + nodeTool('generate_image', '@n8n/n8n-nodes-langchain.openAi', { + resource: 'image', + operation: 'generate', + prompt: "={{ $fromAI('prompt', 'Image description', 'string') }}", + }), + ]); + const rec = new ExecutionRecorder(registry); + + rec.record({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'generate_image', + input: { prompt: 'a rolling landscape' }, + } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'generate_image', + output: { status: 'success' }, + } as never); + + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect(tc.kind).toBe('node'); + expect(tc.nodeParameters).toEqual({ + resource: 'image', + operation: 'generate', + prompt: 'a rolling landscape', + }); + }); + + it('falls back to the $fromAI default when the LLM did not provide the key', () => { + const registry = buildToolRegistry([ + nodeTool('send_message', 'n8n-nodes-base.slack', { + channel: "={{ $fromAI('channel', 'Channel name', 'string', 'general') }}", + }), + ]); + const rec = new ExecutionRecorder(registry); + + rec.record({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'send_message', + input: {}, + } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'send_message', + output: { ok: true }, + } as never); + + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect((tc.nodeParameters as Record).channel).toBe('general'); + }); + + it('substitutes $fromAI inside an auto-generated-marker template', () => { + const registry = buildToolRegistry([ + nodeTool('search', 'n8n-nodes-base.http', { + url: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('query', 'Search term', 'string') }}", + }), + ]); + const rec = new ExecutionRecorder(registry); + + rec.record({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'search', + input: { query: 'dragons' }, + } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'search', + output: { ok: true }, + } as never); + + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect((tc.nodeParameters as Record).url).toBe('dragons'); + }); + + it('walks nested objects in nodeParameters and resolves each $fromAI', () => { + const registry = buildToolRegistry([ + nodeTool('image', '@n8n/n8n-nodes-langchain.openAi', { + options: { + size: "={{ $fromAI('size', 'Image size', 'string', '1024x1024') }}", + nested: { nested2: "={{ $fromAI('quality', 'Quality', 'string', 'medium') }}" }, + }, + }), + ]); + const rec = new ExecutionRecorder(registry); + + rec.record({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'image', + input: { size: '512x512', quality: 'high' }, + } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'image', + output: { ok: true }, + } as never); + + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + const params = tc.nodeParameters as Record>; + expect(params.options.size).toBe('512x512'); + expect((params.options.nested as Record).nested2).toBe('high'); + }); + + it('leaves the raw template in place when extraction fails', () => { + const registry = buildToolRegistry([ + nodeTool('broken', 'n8n-nodes-base.set', { + field: '={{ $fromAI(unbalanced ', + }), + ]); + const rec = new ExecutionRecorder(registry); + + rec.record({ + type: 'tool-call', + toolCallId: 't1', + toolName: 'broken', + input: {}, + } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'broken', + output: { ok: true }, + } as never); + + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect((tc.nodeParameters as Record).field).toBe('={{ $fromAI(unbalanced '); + }); +}); + +describe('ExecutionRecorder — workflow-tool timeline tags', () => { + it('tags a tool-call entry as kind:tool when no registry is provided', () => { + const rec = new ExecutionRecorder(); + rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'http', input: {} } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'http', + output: { ok: true }, + isError: false, + } as never); + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect(tc.kind).toBe('tool'); + expect(tc.workflowId).toBeUndefined(); + }); + + it('tags a tool-call as kind:workflow and stores workflowId/Name/triggerType when registry matches', () => { + const registry = buildToolRegistry([wfTool('run-wf', 'wf-1', 'Run WF')]); + const rec = new ExecutionRecorder(registry); + rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'run-wf', input: {} } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'run-wf', + output: { executionId: 'e-42', status: 'success' }, + isError: false, + } as never); + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect(tc.kind).toBe('workflow'); + expect(tc.workflowId).toBe('wf-1'); + expect(tc.workflowName).toBe('Run WF'); + expect(tc.triggerType).toBe('manual'); + expect(tc.workflowExecutionId).toBe('e-42'); + }); + + it('leaves workflowExecutionId undefined for form-trigger workflow tools', () => { + const registry = buildToolRegistry([wfTool('fill-form', 'wf-2', 'Fill Form', 'form')]); + const rec = new ExecutionRecorder(registry); + rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'fill-form', input: {} } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'fill-form', + output: { status: 'form_link_sent', formUrl: 'https://x/form' }, + isError: false, + } as never); + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect(tc.kind).toBe('workflow'); + expect(tc.triggerType).toBe('form'); + expect(tc.workflowExecutionId).toBeUndefined(); + }); + + it('synthesizes a tool-call timeline entry when tool-result arrives without a preceding tool-call', () => { + const registry = buildToolRegistry([wfTool('run-wf', 'wf-1', 'Run WF')]); + const rec = new ExecutionRecorder(registry); + + // HITL/approval resume: SDK replays the result without a tool-call chunk + rec.record({ + type: 'tool-result', + toolCallId: 't-resume', + toolName: 'run-wf', + output: { executionId: 'e-99', status: 'success' }, + isError: false, + } as never); + rec.record({ type: 'finish', finishReason: 'stop' } as StreamChunk); + + const record = rec.getMessageRecord(); + const tc = record.timeline.find((e) => e.type === 'tool-call'); + + expect(tc).toBeDefined(); + expect(tc?.kind).toBe('workflow'); + expect(tc?.name).toBe('run-wf'); + expect(tc?.toolCallId).toBe('t-resume'); + expect(tc?.workflowId).toBe('wf-1'); + expect(tc?.workflowName).toBe('Run WF'); + expect(tc?.workflowExecutionId).toBe('e-99'); + expect(tc?.success).toBe(true); + expect(record.toolCalls).toHaveLength(1); + expect(record.toolCalls[0]).toEqual({ + name: 'run-wf', + input: undefined, + output: { executionId: 'e-99', status: 'success' }, + }); + }); + + it('leaves workflowExecutionId undefined when the output is an error with no executionId', () => { + const registry = buildToolRegistry([wfTool('run-wf', 'wf-1', 'Run WF')]); + const rec = new ExecutionRecorder(registry); + rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'run-wf', input: {} } as never); + rec.record({ + type: 'tool-result', + toolCallId: 't1', + toolName: 'run-wf', + output: { error: 'Kaboom' }, + isError: true, + } as never); + const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!; + expect(tc.workflowExecutionId).toBeUndefined(); + expect(tc.success).toBe(false); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts new file mode 100644 index 00000000000..fd332c072d8 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -0,0 +1,761 @@ +import type { AgentSnapshot, ToolDescriptor } from '@n8n/agents'; +import type { JSONSchema7 } from 'json-schema'; + +import type { AgentJsonConfig } from '../json-config/agent-json-config'; +import { AgentJsonConfigSchema } from '../json-config/agent-json-config'; +import { buildFromJson } from '../json-config/from-json-config'; +import type { ToolExecutor } from '../json-config/from-json-config'; + +// --------------------------------------------------------------------------- +// buildFromJson() tests +// --------------------------------------------------------------------------- + +describe('buildFromJson()', () => { + const makeConfig = (overrides: Partial = {}): AgentJsonConfig => ({ + name: 'test-agent', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-anthropic-key', + instructions: 'You are a test agent.', + ...overrides, + }); + + const makeMockToolExecutor = (): ToolExecutor => ({ + executeTool: jest.fn().mockResolvedValue({ result: 'tool result' }), + }); + + const makeMockCredentialProvider = () => ({ + resolve: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }), + list: jest.fn().mockResolvedValue([]), + }); + + const makeToolDescriptor = (overrides: Partial = {}): ToolDescriptor => ({ + name: 'search', + description: 'Search the web', + systemInstruction: null, + inputSchema: { type: 'object', properties: { query: { type: 'string' } } } as JSONSchema7, + outputSchema: null, + hasSuspend: false, + hasResume: false, + hasToMessage: false, + requireApproval: false, + providerOptions: null, + ...overrides, + }); + + const getMemoryConfig = (agent: unknown) => + ( + agent as { + memoryConfig?: { + lastMessages: number; + workingMemory?: { + template: string; + structured: boolean; + scope: 'resource' | 'thread'; + instruction?: string; + }; + }; + } + ).memoryConfig; + + const makeMockMemoryFactory = () => jest.fn(); + + it('sets name, model, and instructions', async () => { + const agent = await buildFromJson( + makeConfig(), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + const snap: AgentSnapshot = agent.snapshot; + + expect(snap.name).toBe('test-agent'); + expect(snap.model.provider).toBe('anthropic'); + expect(snap.model.name).toBe('claude-sonnet-4-5'); + expect(snap.instructions).toBe('You are a test agent.'); + }); + + it('handles multi-slash model string for aggregator providers', async () => { + const agent = await buildFromJson( + makeConfig({ model: 'openrouter/amazon/nova-micro-v1' }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + const snap: AgentSnapshot = agent.snapshot; + // Provider is always everything before the FIRST slash. + expect(snap.model.provider).toBe('openrouter'); + // Model name is everything after the first slash, including any further slashes. + expect(snap.model.name).toBe('amazon/nova-micro-v1'); + }); + + it('handles single-part model string (no slash)', async () => { + const agent = await buildFromJson( + makeConfig({ model: 'claude-sonnet-4-5' }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + const snap: AgentSnapshot = agent.snapshot; + expect(snap.model.provider).toBeNull(); + expect(snap.model.name).toBe('claude-sonnet-4-5'); + }); + + it('wires a custom tool', async () => { + const descriptor = makeToolDescriptor({ name: 'my_search' }); + const config = makeConfig({ tools: [{ type: 'custom', id: 'search_tool' }] }); + + const agent = await buildFromJson( + config, + { search_tool: descriptor }, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(agent.snapshot.tools.some((t) => t.name === 'my_search')).toBe(true); + }); + + it('injects attached skill names, descriptions, and ids into instructions, but not bodies', async () => { + const config = makeConfig({ + skills: [{ type: 'skill', id: 'skill_0Ab9ZkLm3Pq7Xy2N' }], + }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + skills: { + skill_0Ab9ZkLm3Pq7Xy2N: { + name: 'Summarize notes', + description: 'Use for meeting notes and transcripts', + instructions: 'Extract decisions and action items.', + }, + }, + }, + ); + + const instructions = agent.snapshot.instructions ?? ''; + expect(instructions).toContain('Skill loading protocol:'); + expect(instructions).toContain('Skills are optional instruction packs, not execution tools'); + expect(instructions).toContain('Available skills:'); + expect(instructions).toContain('name: Summarize notes'); + expect(instructions).toContain('description: Use for meeting notes and transcripts'); + expect(instructions).toContain('id: skill_0Ab9ZkLm3Pq7Xy2N'); + expect(instructions).toContain("call load_skill once with that skill's id"); + expect(instructions).toContain('do not call load_skill again'); + expect(instructions).toContain('Do not load a skill just because it is listed here'); + expect(instructions).not.toContain('Extract decisions and action items.'); + }); + + it('wires load_skill for attached skills and returns the selected skill body on demand', async () => { + const config = makeConfig({ + skills: [{ type: 'skill', id: 'summarize_notes' }], + }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + skills: { + summarize_notes: { + name: 'Summarize notes', + description: 'Use for meeting notes and transcripts', + instructions: 'Extract decisions and action items.', + }, + unused_skill: { + name: 'Unused skill', + description: 'This is not attached', + instructions: 'This should not load.', + }, + }, + }, + ); + + const loadSkill = agent.declaredTools.find((t) => t.name === 'load_skill'); + expect(loadSkill).toBeDefined(); + expect(loadSkill?.description).not.toContain('Summarize notes'); + expect(loadSkill?.systemInstruction).toBeUndefined(); + + await expect(loadSkill!.handler?.({ skillId: 'summarize_notes' }, {})).resolves.toMatchObject({ + ok: true, + skillId: 'summarize_notes', + instructions: 'Extract decisions and action items.', + }); + + await expect(loadSkill!.handler?.({ skillId: 'unused_skill' }, {})).resolves.toMatchObject({ + ok: false, + }); + }); + + it('throws when a configured skill ref is missing its stored body', async () => { + const config = makeConfig({ + skills: [{ type: 'skill', id: 'missing_skill' }], + }); + + await expect( + buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + skills: {}, + }, + ), + ).rejects.toThrow('Skill "missing_skill" not found in stored skill bodies'); + }); + + it('throws when custom tool id is not found in descriptors', async () => { + const config = makeConfig({ tools: [{ type: 'custom', id: 'missing_tool' }] }); + + await expect( + buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ), + ).rejects.toThrow('Custom tool "missing_tool" not found in tool descriptors'); + }); + + it('resolves workflow tool via resolveTool callback', async () => { + const config = makeConfig({ + tools: [{ type: 'workflow', workflow: 'My Workflow', name: 'run_workflow' }], + }); + + const resolvedTool = { + name: 'run_workflow', + description: 'Run My Workflow', + handler: jest.fn().mockResolvedValue({ done: true }), + }; + const resolveTool = jest.fn().mockResolvedValue(resolvedTool); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + resolveTool, + }, + ); + + expect(agent.snapshot.tools.some((t) => t.name === 'run_workflow')).toBe(true); + expect(resolveTool).toHaveBeenCalledTimes(1); + }); + + it('wraps workflow tool with approval when requireApproval is true', async () => { + const config = makeConfig({ + tools: [ + { type: 'workflow', workflow: 'My Workflow', name: 'run_workflow', requireApproval: true }, + ], + }); + + const resolvedTool = { + name: 'run_workflow', + description: 'Run My Workflow', + handler: jest.fn().mockResolvedValue({ done: true }), + }; + const resolveTool = jest.fn().mockResolvedValue(resolvedTool); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + resolveTool, + }, + ); + + const tool = agent.declaredTools.find((t) => t.name === 'run_workflow'); + expect(tool).toBeDefined(); + expect(tool!.withDefaultApproval).toBe(true); + }); + + it('wraps node tool with approval when requireApproval is true', async () => { + const config = makeConfig({ + tools: [ + { + type: 'node', + name: 'my_node_tool', + description: 'A node tool', + node: { nodeType: 'n8n-nodes-base.httpRequest', nodeTypeVersion: 1, nodeParameters: {} }, + requireApproval: true, + }, + ], + }); + + const resolvedTool = { + name: 'my_node_tool', + description: 'A node tool', + handler: jest.fn().mockResolvedValue({ done: true }), + }; + const resolveTool = jest.fn().mockResolvedValue(resolvedTool); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + resolveTool, + }, + ); + + const tool = agent.declaredTools.find((t) => t.name === 'my_node_tool'); + expect(tool).toBeDefined(); + expect(tool!.withDefaultApproval).toBe(true); + }); + + it('does not wrap workflow tool with approval when requireApproval is not set', async () => { + const config = makeConfig({ + tools: [{ type: 'workflow', workflow: 'My Workflow', name: 'run_workflow' }], + }); + + const resolvedTool = { + name: 'run_workflow', + description: 'Run My Workflow', + handler: jest.fn().mockResolvedValue({ done: true }), + }; + const resolveTool = jest.fn().mockResolvedValue(resolvedTool); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + resolveTool, + }, + ); + + const tool = agent.declaredTools.find((t) => t.name === 'run_workflow'); + expect(tool).toBeDefined(); + expect(tool!.withDefaultApproval).toBeUndefined(); + }); + + it('falls back to marker tool when resolveTool is not provided for workflow tools', async () => { + const config = makeConfig({ tools: [{ type: 'workflow', workflow: 'Test Workflow' }] }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(agent.snapshot.tools.some((t) => t.name === 'Test Workflow')).toBe(true); + }); + + it('sets thinking config', async () => { + const config = makeConfig({ + config: { thinking: { provider: 'anthropic', budgetTokens: 5000 } }, + }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + const snap: AgentSnapshot = agent.snapshot; + + expect(snap.thinking).not.toBeNull(); + expect(snap.thinking).toMatchObject({ budgetTokens: 5000 }); + }); + + it('sets toolCallConcurrency', async () => { + const config = makeConfig({ config: { toolCallConcurrency: 5 } }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(agent.snapshot.toolCallConcurrency).toBe(5); + }); + + it('sets requireToolApproval', async () => { + const config = makeConfig({ config: { requireToolApproval: true } }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(agent.snapshot.requireToolApproval).toBe(true); + }); + + it('configures memory when enabled', async () => { + const mockMemory = { + getThread: jest.fn(), + saveThread: jest.fn(), + deleteThread: jest.fn(), + getMessages: jest.fn().mockResolvedValue([]), + saveMessages: jest.fn(), + deleteMessages: jest.fn(), + describe: jest + .fn() + .mockReturnValue({ name: 'n8n', constructorName: 'N8nMemory', connectionParams: null }), + close: jest.fn(), + }; + + const config = makeConfig({ + memory: { enabled: true, storage: 'n8n', lastMessages: 15 }, + }); + + const memoryFactory = jest.fn().mockReturnValue(mockMemory); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory, + }, + ); + + expect(memoryFactory).toHaveBeenCalledWith(config.memory); + expect(agent.snapshot.hasMemory).toBe(true); + expect(getMemoryConfig(agent)?.lastMessages).toBe(15); + expect(getMemoryConfig(agent)?.workingMemory).toMatchObject({ + structured: false, + scope: 'thread', + }); + expect(getMemoryConfig(agent)?.workingMemory?.template).toContain('Thread memory'); + expect(getMemoryConfig(agent)?.workingMemory?.template).toContain('Current goal/task'); + expect(getMemoryConfig(agent)?.workingMemory?.template).toContain('Key active items'); + expect(getMemoryConfig(agent)?.workingMemory?.template).toContain('Resolved or superseded'); + expect(getMemoryConfig(agent)?.workingMemory?.instruction).toContain('thread-scoped'); + expect(getMemoryConfig(agent)?.workingMemory?.instruction).toContain('current-state snapshot'); + expect(getMemoryConfig(agent)?.workingMemory?.instruction).toContain( + 'primary, secondary, active, resolved, and superseded', + ); + }); + + it('skips memory when memory.enabled is false', async () => { + const config = makeConfig({ memory: { enabled: false, storage: 'n8n' } }); + + const memoryFactory = jest.fn(); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory, + }, + ); + + expect(memoryFactory).not.toHaveBeenCalled(); + expect(agent.snapshot.hasMemory).toBe(false); + expect(getMemoryConfig(agent)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// AgentJsonConfig Zod schema validation +// --------------------------------------------------------------------------- + +describe('AgentJsonConfigSchema', () => { + it('parses a minimal valid config', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: 'Be helpful.', + }; + expect(() => AgentJsonConfigSchema.parse(config)).not.toThrow(); + }); + + it('rejects invalid model format (no slash)', () => { + const config = { + name: 'test', + model: 'invalid-no-slash', + credential: 'my-key', + instructions: 'Be helpful.', + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('accepts multi-slash model format for aggregator providers', () => { + const config = { + name: 'test', + model: 'openrouter/amazon/nova-micro-v1', + credential: 'my-key', + instructions: 'Be helpful.', + }; + expect(() => AgentJsonConfigSchema.parse(config)).not.toThrow(); + }); + + it('accepts deeply-nested model format', () => { + const config = { + name: 'test', + model: 'openrouter/openai/gpt-4o', + credential: 'my-key', + instructions: 'Be helpful.', + }; + const parsed = AgentJsonConfigSchema.parse(config); + expect(parsed.model).toBe('openrouter/openai/gpt-4o'); + }); + + it('rejects model with consecutive slashes', () => { + const config = { + name: 'test', + model: 'openrouter//nova-micro-v1', + credential: 'my-key', + instructions: 'Be helpful.', + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects model with trailing slash', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5/', + credential: 'my-key', + instructions: 'Be helpful.', + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects model with leading slash in model segment', () => { + const config = { + name: 'test', + model: 'anthropic//claude-sonnet-4-5', + credential: 'my-key', + instructions: 'Be helpful.', + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects empty name', () => { + const config = { + name: '', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects name longer than 128 chars', () => { + const config = { + name: 'a'.repeat(129), + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('parses custom tool ref with valid id', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + tools: [{ type: 'custom', id: 'my_tool_1' }], + }; + const parsed = AgentJsonConfigSchema.parse(config); + expect(parsed.tools?.[0]).toMatchObject({ type: 'custom', id: 'my_tool_1' }); + }); + + it('rejects inputSchema on node tool configs', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + tools: [ + { + type: 'node', + name: 'http_request', + description: 'Make an HTTP request', + node: { + nodeType: 'n8n-nodes-base.httpRequestTool', + nodeTypeVersion: 4, + nodeParameters: { + url: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('url', 'The URL to request', 'string') }}", + }, + }, + inputSchema: { + type: 'object', + properties: { url: { type: 'string' } }, + required: ['url'], + }, + }, + ], + }; + + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects id on node tool configs', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + tools: [ + { + type: 'node', + id: 'tool-ref-1', + name: 'http_request', + description: 'Make an HTTP request', + node: { + nodeType: 'n8n-nodes-base.httpRequestTool', + nodeTypeVersion: 4, + nodeParameters: { + url: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('url', 'The URL to request', 'string') }}", + }, + }, + }, + ], + }; + + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects id on workflow tool configs', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'You are helpful', + tools: [{ type: 'workflow', id: 'tool-ref-1', workflow: 'Send Welcome Email' }], + }; + + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('rejects custom tool ref with invalid id characters', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + tools: [{ type: 'custom', id: 'tool id' }], + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('parses an integrations array containing schedule + chat triggers', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [ + { + type: 'schedule', + active: false, + cronExpression: '0 0 * * *', + wakeUpPrompt: 'tick', + }, + { type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' }, + ], + }; + const parsed = AgentJsonConfigSchema.parse(config); + expect(parsed.integrations).toHaveLength(2); + expect(parsed.integrations?.[0]).toMatchObject({ type: 'schedule', active: false }); + expect(parsed.integrations?.[1]).toMatchObject({ + type: 'slack', + credentialId: 'cred-1', + credentialName: 'Acme Slack', + }); + }); + + it('rejects a chat integration missing credentialName at the schema level', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [{ type: 'slack', credentialId: 'cred-1' }], + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); + + it('parses an integrations array containing schedule + chat triggers', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [ + { + type: 'schedule', + active: false, + cronExpression: '0 0 * * *', + wakeUpPrompt: 'tick', + }, + { type: 'slack', credentialId: 'cred-1', credentialName: 'Acme Slack' }, + ], + }; + const parsed = AgentJsonConfigSchema.parse(config); + expect(parsed.integrations).toHaveLength(2); + expect(parsed.integrations?.[0]).toMatchObject({ type: 'schedule', active: false }); + expect(parsed.integrations?.[1]).toMatchObject({ + type: 'slack', + credentialId: 'cred-1', + credentialName: 'Acme Slack', + }); + }); + + it('rejects a chat integration missing credentialName at the schema level', () => { + const config = { + name: 'test', + model: 'anthropic/claude-sonnet-4-5', + credential: 'my-key', + instructions: '', + integrations: [{ type: 'slack', credentialId: 'cred-1' }], + }; + expect(() => AgentJsonConfigSchema.parse(config)).toThrow(); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/integration-config.test.ts b/packages/cli/src/modules/agents/__tests__/integration-config.test.ts new file mode 100644 index 00000000000..17a1dc3992b --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/integration-config.test.ts @@ -0,0 +1,72 @@ +import { AgentIntegrationSchema } from '../json-config/integration-config'; + +describe('AgentIntegrationSchema', () => { + it('accepts a schedule integration', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'schedule', + active: true, + cronExpression: '0 9 * * *', + wakeUpPrompt: 'Daily standup ping', + }); + expect(result.success).toBe(true); + }); + + it('accepts a chat integration with credential name', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'slack', + credentialId: 'cred-123', + credentialName: 'Acme Slack', + }); + expect(result.success).toBe(true); + }); + + it('rejects a chat integration missing credentialName', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'slack', + credentialId: 'cred-123', + }); + expect(result.success).toBe(false); + }); + + it('rejects a schedule integration missing cronExpression', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'schedule', + active: true, + wakeUpPrompt: 'hello', + }); + expect(result.success).toBe(false); + }); + + it('rejects a chat integration with the reserved type "schedule"', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'schedule', + credentialId: 'cred-123', + credentialName: 'Acme', + }); + expect(result.success).toBe(false); + }); + + it('rejects a schedule integration with extra fields', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'schedule', + active: true, + cronExpression: '0 9 * * *', + wakeUpPrompt: 'go', + extra: 'nope', + }); + expect(result.success).toBe(false); + }); + + it('rejects a schedule integration whose cronExpression is malformed', () => { + const malformed = ['not-a-cron', '* * *', '99 99 * * *']; + for (const cron of malformed) { + const result = AgentIntegrationSchema.safeParse({ + type: 'schedule', + active: false, + cronExpression: cron, + wakeUpPrompt: 'go', + }); + expect(result.success).toBe(false); + } + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts b/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts new file mode 100644 index 00000000000..a61e53237bc --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/schema-text-serializer.test.ts @@ -0,0 +1,590 @@ +import type { JSONSchema7 } from 'json-schema'; + +import { jsonSchemaToCompactText } from '../json-config/schema-text-serializer'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Wrap a schema in a top-level object so we can assert on field labels. */ +function wrap(fieldName: string, schema: object, required = false) { + return { + type: 'object' as const, + properties: { [fieldName]: schema }, + ...(required ? { required: [fieldName] } : {}), + }; +} + +function field(fieldName: string, schema: object, required = false): string { + return jsonSchemaToCompactText(wrap(fieldName, schema, required)); +} + +// --------------------------------------------------------------------------- +// Scalar types +// --------------------------------------------------------------------------- + +describe('scalar types', () => { + it('renders a plain string field', () => { + expect(field('name', { type: 'string' }, true)).toBe(' name: string (required)'); + }); + + it('renders an optional string field', () => { + expect(field('name', { type: 'string' })).toBe(' name?: string'); + }); + + it('renders boolean', () => { + expect(field('active', { type: 'boolean' }, true)).toBe(' active: boolean (required)'); + }); + + it('renders integer', () => { + expect(field('count', { type: 'integer' }, true)).toBe(' count: integer (required)'); + }); + + it('renders number', () => { + expect(field('score', { type: 'number' })).toBe(' score?: number'); + }); + + it('renders plain array', () => { + expect(field('items', { type: 'array' })).toBe(' items?: array'); + }); + + it('renders array with typed items', () => { + expect(field('tags', { type: 'array', items: { type: 'string' } })).toBe( + ' tags?: array of ', + ); + }); + + it('renders array whose items are a anyOf union', () => { + expect( + field('vals', { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }), + ).toBe([' vals?: array with items any of:', ' | string', ' | number'].join('\n')); + }); + + it('renders array whose items are a oneOf union', () => { + expect( + field('vals', { + type: 'array', + items: { oneOf: [{ type: 'boolean' }, { type: 'integer' }] }, + }), + ).toBe([' vals?: array with items any of:', ' | boolean', ' | integer'].join('\n')); + }); + + it('renders array whose items are object shapes in an anyOf union', () => { + expect( + field('events', { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { kind: { const: 'click' }, x: { type: 'number' } }, + required: ['kind', 'x'], + }, + { + type: 'object', + properties: { kind: { const: 'key' }, char: { type: 'string' } }, + required: ['kind', 'char'], + }, + ], + }, + }), + ).toBe( + [ + ' events?: array with items any of:', + ' | (kind = "click")', + ' x: number (required)', + ' | (kind = "key")', + ' char: string (required)', + ].join('\n'), + ); + }); + + it('renders unknown for unrecognised type', () => { + expect(field('x', {})).toBe(' x?: unknown'); + }); +}); + +// --------------------------------------------------------------------------- +// String constraints +// --------------------------------------------------------------------------- + +describe('string constraints', () => { + it('renders minLength only', () => { + expect(field('s', { type: 'string', minLength: 1 })).toBe(' s?: string [min 1 chars]'); + }); + + it('renders maxLength only', () => { + expect(field('s', { type: 'string', maxLength: 50 })).toBe(' s?: string [max 50 chars]'); + }); + + it('renders minLength..maxLength range', () => { + expect(field('s', { type: 'string', minLength: 2, maxLength: 20 })).toBe( + ' s?: string [2..20 chars]', + ); + }); + + it('renders pattern', () => { + expect(field('s', { type: 'string', pattern: '^[a-z]+$' })).toBe( + ' s?: string [pattern: ^[a-z]+$]', + ); + }); + + it('renders range and pattern together', () => { + expect(field('s', { type: 'string', minLength: 1, pattern: '^\\w+$' })).toBe( + ' s?: string [min 1 chars, pattern: ^\\w+$]', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Numeric constraints +// --------------------------------------------------------------------------- + +describe('numeric constraints', () => { + it('renders minimum only', () => { + expect(field('n', { type: 'number', minimum: 0 })).toBe(' n?: number [min 0]'); + }); + + it('renders maximum only', () => { + expect(field('n', { type: 'integer', maximum: 100 })).toBe(' n?: integer [max 100]'); + }); + + it('renders minimum..maximum range', () => { + expect(field('n', { type: 'number', minimum: 0, maximum: 1 })).toBe(' n?: number [0..1]'); + }); +}); + +// --------------------------------------------------------------------------- +// Enum and const +// --------------------------------------------------------------------------- + +describe('enum and const', () => { + it('renders an enum as a union of JSON-stringified values', () => { + expect(field('color', { enum: ['red', 'green', 'blue'] })).toBe( + ' color?: "red" | "green" | "blue"', + ); + }); + + it('renders a numeric enum', () => { + expect(field('level', { enum: [1, 2, 3] })).toBe(' level?: 1 | 2 | 3'); + }); + + it('renders a const value', () => { + expect(field('kind', { const: 'event' })).toBe(' kind?: "event"'); + }); +}); + +// --------------------------------------------------------------------------- +// Default values +// --------------------------------------------------------------------------- + +describe('default values', () => { + it('appends the default for a scalar field', () => { + expect(field('count', { type: 'integer', default: 0 })).toBe(' count?: integer (default: 0)'); + }); + + it('appends the default together with required suffix', () => { + expect(field('flag', { type: 'boolean', default: false }, true)).toBe( + ' flag: boolean (required) (default: false)', + ); + }); + + it('JSON-stringifies string defaults', () => { + expect(field('mode', { type: 'string', default: 'auto' })).toBe( + ' mode?: string (default: "auto")', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Object with properties +// --------------------------------------------------------------------------- + +describe('object with properties', () => { + it('does not emit a header line for the top-level object', () => { + const output = jsonSchemaToCompactText({ + type: 'object', + properties: { x: { type: 'number' } }, + required: ['x'], + }); + expect(output).toBe(' x: number (required)'); + }); + + it('emits required and optional properties correctly', () => { + const output = jsonSchemaToCompactText({ + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + }, + required: ['id'], + }); + expect(output).toBe(' id: string (required)\n label?: string'); + }); + + it('marks a named object field as required', () => { + const output = field( + 'address', + { + type: 'object', + properties: { street: { type: 'string' } }, + required: ['street'], + }, + true, + ); + expect(output).toBe(' address: object (required)\n street: string (required)'); + }); + + it('marks a named object field as optional', () => { + const output = field('meta', { + type: 'object', + properties: { key: { type: 'string' } }, + }); + expect(output).toBe(' meta?: object\n key?: string'); + }); + + it('appends an additionalProperties catch-all when properties are also present', () => { + const output = jsonSchemaToCompactText({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: { type: 'number' }, + }); + expect(output).toBe(' name: string (required)\n [key: string]: number'); + }); +}); + +// --------------------------------------------------------------------------- +// Record (object with only additionalProperties) +// --------------------------------------------------------------------------- + +describe('Record type (additionalProperties only)', () => { + it('renders Record for a typed additionalProperties', () => { + expect(field('data', { type: 'object', additionalProperties: { type: 'string' } })).toBe( + ' data?: Record', + ); + }); + + it('renders Record when additionalProperties is true', () => { + expect(field('data', { type: 'object', additionalProperties: true }, true)).toBe( + ' data: Record (required)', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Nested objects +// --------------------------------------------------------------------------- + +describe('nested objects', () => { + it('indents nested properties correctly', () => { + const output = jsonSchemaToCompactText({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + profile: { + type: 'object', + properties: { bio: { type: 'string' } }, + }, + }, + required: ['profile'], + }, + }, + required: ['user'], + }); + expect(output).toBe( + [' user: object (required)', ' profile: object (required)', ' bio?: string'].join( + '\n', + ), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Union types (anyOf / oneOf) +// --------------------------------------------------------------------------- + +describe('union types', () => { + it('detects a discriminator field and labels branches by its const value', () => { + const output = field( + 'action', + { + anyOf: [ + { + type: 'object', + properties: { + type: { const: 'click' }, + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['type', 'x', 'y'], + }, + { + type: 'object', + properties: { + type: { const: 'type' }, + text: { type: 'string' }, + }, + required: ['type', 'text'], + }, + ], + }, + true, + ); + expect(output).toBe( + [ + ' action: one of (required)', + ' | type = "click": { type: "click", x: number, y: number }', + ' | type = "type": { type: "type", text: string }', + ].join('\n'), + ); + }); + + it('falls back to "type" discriminator label when no const property is found', () => { + const output = field('value', { + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }); + expect(output).toBe( + [ + ' value?: one of ', + ' | ?: { a?: string }', + ' | ?: { b?: number }', + ].join('\n'), + ); + }); + + it('treats oneOf the same as anyOf', () => { + const output = field('val', { + oneOf: [ + { type: 'object', properties: { kind: { const: 'A' } }, required: ['kind'] }, + { type: 'object', properties: { kind: { const: 'B' } }, required: ['kind'] }, + ], + }); + expect(output).toContain('discriminated by "kind"'); + expect(output).toContain('kind = "A"'); + expect(output).toContain('kind = "B"'); + }); + + it('marks optional union fields with ?', () => { + const output = field('ev', { + anyOf: [{ type: 'object', properties: { k: { const: 'x' } }, required: ['k'] }], + }); + expect(output).toMatch(/^ {2}ev\?:/); + }); + + it('uses "one of" (not "array of") for anyOf/oneOf union fields', () => { + expect( + field('ev', { + anyOf: [ + { type: 'object', properties: { kind: { const: 'A' } }, required: ['kind'] }, + { type: 'object', properties: { kind: { const: 'B' } }, required: ['kind'] }, + ], + }), + ).toBe( + [ + ' ev?: one of ', + ' | kind = "A": { kind: "A" }', + ' | kind = "B": { kind: "B" }', + ].join('\n'), + ); + + expect(field('items', { type: 'array', items: { type: 'string' } })).toBe( + ' items?: array of ', + ); + }); + + it('emits nothing for a top-level union schema (no fieldName)', () => { + const output = jsonSchemaToCompactText({ + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }); + expect(output).toBe(''); + }); + + it('appends (required) to a required union field', () => { + const output = field( + 'action', + { + anyOf: [ + { type: 'object', properties: { kind: { const: 'A' } }, required: ['kind'] }, + { type: 'object', properties: { kind: { const: 'B' } }, required: ['kind'] }, + ], + }, + true, + ); + expect(output).toMatch(/^ {2}action: one of .* \(required\)$/m); + }); + + it('appends (default: ...) to a union field with a default', () => { + const output = field('mode', { + anyOf: [{ type: 'object', properties: { kind: { const: 'A' } }, required: ['kind'] }], + default: { kind: 'A' }, + }); + expect(output).toMatch(/\(default: \{"kind":"A"\}\)/); + }); + + it('uses a single-value enum as a discriminator const', () => { + const output = field('ev', { + anyOf: [ + { + type: 'object', + properties: { kind: { enum: ['start'] }, ms: { type: 'number' } }, + required: ['kind', 'ms'], + }, + { + type: 'object', + properties: { kind: { enum: ['stop'] } }, + required: ['kind'], + }, + ], + }); + expect(output).toContain('discriminated by "kind"'); + expect(output).toContain('kind = "start"'); + expect(output).toContain('kind = "stop"'); + }); +}); + +// --------------------------------------------------------------------------- +// Array-union field metadata (required / default) +// --------------------------------------------------------------------------- + +describe('array-union field metadata', () => { + it('appends (required) when the array-union field is required', () => { + const output = field( + 'events', + { + type: 'array', + items: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + }, + true, + ); + expect(output).toMatch(/^ {2}events: array with items any of: \(required\)$/m); + }); + + it('appends (default: ...) when the array-union field carries a default', () => { + const output = field('events', { + type: 'array', + items: { anyOf: [{ type: 'string' }] }, + default: [], + }); + expect(output).toMatch(/\(default: \[\]\)/); + }); + + it('appends both (required) and (default: ...) together', () => { + const output = field( + 'events', + { + type: 'array', + items: { anyOf: [{ type: 'string' }] }, + default: [], + }, + true, + ); + expect(output).toMatch(/array with items any of: \(required\) \(default: \[\]\)/); + }); +}); + +// --------------------------------------------------------------------------- +// Full integration: large realistic schema +// --------------------------------------------------------------------------- + +describe('full schema snapshot', () => { + it('serializes a large realistic schema to the expected string', () => { + const schema = { + type: 'object', + required: ['id', 'status', 'priority', 'metadata', 'event'], + properties: { + id: { type: 'string', minLength: 1, maxLength: 36 }, + status: { enum: ['draft', 'active', 'archived'] }, + priority: { type: 'integer', minimum: 1, maximum: 5 }, + enabled: { type: 'boolean', default: true }, + tags: { type: 'array', items: { type: 'string' } }, + metadata: { + type: 'object', + required: ['createdBy'], + properties: { + createdBy: { type: 'string' }, + version: { type: 'integer', default: 1 }, + }, + }, + config: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + scores: { + type: 'object', + required: ['baseline'], + properties: { baseline: { type: 'number', minimum: 0, maximum: 1 } }, + additionalProperties: { type: 'number' }, + }, + event: { + anyOf: [ + { + type: 'object', + required: ['type', 'x', 'y'], + properties: { + type: { const: 'click' }, + x: { type: 'number' }, + y: { type: 'number' }, + }, + }, + { + type: 'object', + required: ['type', 'key'], + properties: { + type: { const: 'keypress' }, + key: { type: 'string' }, + modifiers: { type: 'array', items: { type: 'string' } }, + }, + }, + ], + }, + }, + }; + + const expected = [ + ' id: string [1..36 chars] (required)', + ' status: "draft" | "active" | "archived" (required)', + ' priority: integer [1..5] (required)', + ' enabled?: boolean (default: true)', + ' tags?: array of ', + ' metadata: object (required)', + ' createdBy: string (required)', + ' version?: integer (default: 1)', + ' config?: Record', + ' scores?: object', + ' baseline: number [0..1] (required)', + ' [key: string]: number', + ' event: one of (required)', + ' | type = "click": { type: "click", x: number, y: number }', + ' | type = "keypress": { type: "keypress", key: string, modifiers?: array of }', + ].join('\n'); + + expect(jsonSchemaToCompactText(schema as unknown as JSONSchema7)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Custom indent +// --------------------------------------------------------------------------- + +describe('custom indent', () => { + it('applies the initial indent offset to all lines', () => { + const output = jsonSchemaToCompactText( + { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + }, + 2, + ); + expect(output).toBe(' x: string (required)'); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/tool-registry.test.ts b/packages/cli/src/modules/agents/__tests__/tool-registry.test.ts new file mode 100644 index 00000000000..628b4e8044e --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/tool-registry.test.ts @@ -0,0 +1,76 @@ +import type { BuiltTool } from '@n8n/agents'; +import { buildToolRegistry } from '../tool-registry'; + +function mkTool(name: string, metadata?: Record): BuiltTool { + return { name, metadata } as BuiltTool; +} + +describe('buildToolRegistry', () => { + it('returns kind:tool for tools with no metadata', () => { + const r = buildToolRegistry([mkTool('http')]); + expect(r.get('http')).toEqual({ kind: 'tool' }); + }); + + it('extracts workflow metadata when present', () => { + const r = buildToolRegistry([ + mkTool('run-wf', { + kind: 'workflow', + workflowId: 'wf-1', + workflowName: 'Run WF', + triggerType: 'manual', + }), + ]); + expect(r.get('run-wf')).toEqual({ + kind: 'workflow', + workflowId: 'wf-1', + workflowName: 'Run WF', + triggerType: 'manual', + }); + }); + + it('falls back to kind:tool when metadata lacks workflow fields', () => { + const r = buildToolRegistry([mkTool('weird', { unrelated: 'stuff' })]); + expect(r.get('weird')).toEqual({ kind: 'tool' }); + }); + + it('falls back to kind:tool when workflowId/workflowName are missing', () => { + const r = buildToolRegistry([mkTool('partial', { kind: 'workflow' })]); + expect(r.get('partial')).toEqual({ kind: 'tool' }); + }); + + it('omits triggerType when not a string', () => { + const r = buildToolRegistry([ + mkTool('wf', { kind: 'workflow', workflowId: 'wf-1', workflowName: 'X', triggerType: 42 }), + ]); + expect(r.get('wf')).toEqual({ kind: 'workflow', workflowId: 'wf-1', workflowName: 'X' }); + }); + + it('extracts node metadata when kind is node', () => { + const r = buildToolRegistry([ + mkTool('http-tool', { + kind: 'node', + nodeType: 'n8n-nodes-base.httpRequest', + nodeTypeVersion: 4.2, + displayName: 'HTTP Request', + }), + ]); + expect(r.get('http-tool')).toEqual({ + kind: 'node', + nodeType: 'n8n-nodes-base.httpRequest', + nodeTypeVersion: 4.2, + nodeDisplayName: 'HTTP Request', + }); + }); + + it('falls back to kind:tool when node metadata lacks nodeType', () => { + const r = buildToolRegistry([mkTool('partial-node', { kind: 'node' })]); + expect(r.get('partial-node')).toEqual({ kind: 'tool' }); + }); + + it('keys the registry by tool name', () => { + const r = buildToolRegistry([mkTool('a'), mkTool('b')]); + expect(r.size).toBe(2); + expect(r.has('a')).toBe(true); + expect(r.has('b')).toBe(true); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts b/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts new file mode 100644 index 00000000000..4a243bc6080 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts @@ -0,0 +1,150 @@ +import { mock } from 'jest-mock-extended'; +import type { WorkflowRepository, UserRepository } from '@n8n/db'; +import type { WorkflowEntity } from '@n8n/db'; +import type { INode } from 'n8n-workflow'; + +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import type { WorkflowRunner } from '@/workflow-runner'; +import type { ActiveExecutions } from '@/active-executions'; +import type { ExecutionRepository } from '@n8n/db'; + +import { resolveWorkflowTool } from '../tools/workflow-tool-factory'; +import type { WorkflowToolContext } from '../tools/workflow-tool-factory'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeManualTriggerNode(overrides: Partial = {}): INode { + return { + id: 'trigger-node-id', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + ...overrides, + }; +} + +function makeFormTriggerNode(overrides: Partial = {}): INode { + return { + id: 'trigger-node-id', + name: 'Form Trigger', + type: 'n8n-nodes-base.formTrigger', + typeVersion: 1, + position: [0, 0], + parameters: { path: 'my-form' }, + webhookId: 'webhook-abc', + ...overrides, + }; +} + +function makeWorkflow( + overrides: Partial = {}, + triggerNode: INode = makeManualTriggerNode(), +): WorkflowEntity { + return { + id: 'workflow-123', + name: 'My Test Workflow', + active: false, + nodes: [triggerNode], + connections: {}, + settings: {}, + pinData: {}, + ...overrides, + } as unknown as WorkflowEntity; +} + +function makeContext(workflowForUser: WorkflowEntity | null): WorkflowToolContext { + const workflowRepository = mock(); + const userRepository = mock(); + const workflowFinderService = mock(); + const workflowRunner = mock(); + const activeExecutions = mock(); + const executionRepository = mock(); + + // findOne returns a candidate workflow + workflowRepository.findOne.mockResolvedValue(workflowForUser ?? makeWorkflow()); + + // userRepository returns a dummy user + userRepository.findOne.mockResolvedValue({ id: 'user-1' } as never); + + // workflowFinderService returns the full workflow for the user + workflowFinderService.findWorkflowForUser.mockResolvedValue(workflowForUser); + + return { + workflowRepository, + workflowRunner, + activeExecutions, + executionRepository, + workflowFinderService, + userRepository, + userId: 'user-1', + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('resolveWorkflowTool() — metadata attachment', () => { + it('attaches metadata with triggerType "manual" for a manual trigger workflow', async () => { + const workflow = makeWorkflow( + { id: 'wf-manual-1', name: 'Manual Workflow' }, + makeManualTriggerNode(), + ); + const context = makeContext(workflow); + + const tool = await resolveWorkflowTool( + { type: 'workflow', workflow: 'Manual Workflow' }, + context, + ); + + expect(tool.metadata).toEqual({ + kind: 'workflow', + workflowId: 'wf-manual-1', + workflowName: 'Manual Workflow', + triggerType: 'manual', + }); + }); + + it('attaches metadata with triggerType "form" for a form trigger workflow', async () => { + const workflow = makeWorkflow( + { id: 'wf-form-2', name: 'Form Workflow' }, + makeFormTriggerNode(), + ); + const context = makeContext(workflow); + + const tool = await resolveWorkflowTool( + { type: 'workflow', workflow: 'Form Workflow' }, + context, + ); + + expect(tool.metadata).toEqual({ + kind: 'workflow', + workflowId: 'wf-form-2', + workflowName: 'Form Workflow', + triggerType: 'form', + }); + }); + + it('sets workflowId and workflowName from the resolved workflow (not the descriptor)', async () => { + const workflow = makeWorkflow( + { id: 'wf-id-99', name: 'canonical-name' }, + makeManualTriggerNode(), + ); + const context = makeContext(workflow); + + const tool = await resolveWorkflowTool( + // descriptor.workflow is the lookup key; the built tool should reflect the resolved entity's name + { type: 'workflow', workflow: 'old-lookup-name', name: 'custom_tool_name' }, + context, + ); + + expect(tool.metadata).toMatchObject({ + workflowId: 'wf-id-99', + workflowName: 'canonical-name', + }); + }); +}); diff --git a/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts b/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts new file mode 100644 index 00000000000..e937f0e42a9 --- /dev/null +++ b/packages/cli/src/modules/agents/adapters/agents-credential-provider.ts @@ -0,0 +1,59 @@ +import type { CredentialProvider, ResolvedCredential, CredentialListItem } from '@n8n/agents'; + +import type { CredentialsService } from '@/credentials/credentials.service'; + +/** + * Resolves and lists n8n credentials for use by SDK agents. + * + * This is not a DI-managed singleton — a new instance is created per request, + * scoped to a specific project. Agents always belong to a project, so credential + * access is always project-scoped, matching how workflow execution resolves + * credentials and preventing cross-project credential leakage. + */ +export class AgentsCredentialProvider implements CredentialProvider { + constructor( + private readonly credentialsService: CredentialsService, + private readonly projectId: string, + ) {} + + /** + * Resolve a credential by ID or name, then decrypt and return the raw data. + * + * Only credentials shared with the agent's project are considered. ID is + * tried first, then name (case-insensitive). + */ + async resolve(credentialIdOrName: string): Promise { + const projectCredentials = await this.credentialsService.findAllCredentialIdsForProject( + this.projectId, + ); + + const credential = + projectCredentials.find((c) => c.id === credentialIdOrName) ?? + projectCredentials.find((c) => c.name.toLowerCase() === credentialIdOrName.toLowerCase()) ?? + null; + + if (!credential) { + throw new Error(`Credential "${credentialIdOrName}" not found or not accessible`); + } + + const data = await this.credentialsService.decrypt(credential, true); + const apiKey = typeof data.apiKey === 'string' ? data.apiKey : ''; + + return { ...data, apiKey }; + } + + /** + * List all credentials shared with the agent's project. + */ + async list(): Promise { + const projectCredentials = await this.credentialsService.findAllCredentialIdsForProject( + this.projectId, + ); + + return projectCredentials.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + })); + } +} diff --git a/packages/cli/src/modules/agents/agent-execution.service.ts b/packages/cli/src/modules/agents/agent-execution.service.ts new file mode 100644 index 00000000000..ea6b7bcff3b --- /dev/null +++ b/packages/cli/src/modules/agents/agent-execution.service.ts @@ -0,0 +1,259 @@ +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; + +import { AgentExecution } from './entities/agent-execution.entity'; +import { AgentExecutionThread } from './entities/agent-execution-thread.entity'; +import type { MessageRecord } from './execution-recorder'; +import { N8nMemory } from './integrations/n8n-memory'; +import { AgentExecutionRepository } from './repositories/agent-execution.repository'; +import { AgentExecutionThreadRepository } from './repositories/agent-execution-thread.repository'; + +export interface RecordMessageParams { + threadId: string; + agentId: string; + agentName: string; + projectId: string; + userMessage: string; + record: MessageRecord; + /** Set to 'suspended' or 'resumed' for HITL tool call flows. */ + hitlStatus?: 'suspended' | 'resumed'; + /** Where the message originated from, e.g. 'chat', 'slack'. */ + source?: string; +} + +export interface ThreadDetail { + thread: AgentExecutionThread; + executions: AgentExecution[]; +} + +export interface ThreadListItem extends AgentExecutionThread { + firstMessage: string | null; +} + +@Service() +export class AgentExecutionService { + constructor( + private readonly logger: Logger, + private readonly agentExecutionRepository: AgentExecutionRepository, + private readonly agentExecutionThreadRepository: AgentExecutionThreadRepository, + private readonly n8nMemory: N8nMemory, + ) {} + + /** + * Record a single agent run within a thread. + * Creates or updates the thread, then inserts one row into agent_execution. + */ + async recordMessage(params: RecordMessageParams): Promise { + const { threadId, agentId, agentName, projectId, record, source, hitlStatus } = params; + + // Ensure the thread exists and bump its updatedAt + const { thread, created } = await this.agentExecutionThreadRepository.findOrCreate( + threadId, + agentId, + agentName, + projectId, + ); + if (!created) { + await this.agentExecutionThreadRepository.bumpUpdatedAt(threadId); + // Sync title from the SDK memory thread if we don't have one yet + if (!thread.title) { + await this.syncTitleFromMemory(threadId); + } + } + + // Replace platform mentions (e.g. Slack's <@U0ANB4K6611> or plain @U0ANB4K6611) + const cleanedMessage = params.userMessage + .replace(/<@[A-Z0-9]+>/gi, `@${agentName}`) + .replace(/@[A-Z0-9]{8,}/gi, `@${agentName}`) + .trim(); + + const status: AgentExecution['status'] = record.error ? 'error' : 'success'; + const startedAt = new Date(record.startTime); + const stoppedAt = new Date(record.startTime + record.duration); + + const inserted = await this.agentExecutionRepository.save( + this.agentExecutionRepository.create({ + threadId, + status, + startedAt, + stoppedAt, + duration: record.duration, + userMessage: cleanedMessage, + assistantResponse: record.assistantResponse, + model: record.model, + promptTokens: record.usage?.promptTokens ?? null, + completionTokens: record.usage?.completionTokens ?? null, + totalTokens: record.usage?.totalTokens ?? null, + cost: record.totalCost, + toolCalls: record.toolCalls.length > 0 ? record.toolCalls : null, + timeline: record.timeline.length > 0 ? record.timeline : null, + error: record.error, + hitlStatus: hitlStatus ?? null, + workingMemory: record.workingMemory, + source: source ?? null, + }), + ); + + // When a resumed execution completes with usage data, backfill any + // preceding suspended executions in the same thread that are missing it. + if (hitlStatus === 'resumed' && record.model) { + await this.backfillSuspendedExecutions(threadId, record.model); + } + + // Atomically increment token/cost/duration counters on the thread + if (record.usage) { + await this.agentExecutionThreadRepository.incrementUsage( + threadId, + record.usage.promptTokens, + record.usage.completionTokens, + record.totalCost ?? 0, + record.duration, + ); + } + + this.logger.debug('Recorded agent execution', { + executionId: inserted.id, + threadId, + agentId, + status, + duration: record.duration, + }); + + // Title generation now runs synchronously (sync: true) before the stream + // ends, so by this point the memory thread should have the title. + // Sync it to our execution thread on the first message. + if (created) { + await this.syncTitleFromMemory(threadId); + } + + return inserted.id; + } + + /** + * Backfill `model` on suspended runs in a thread that don't yet have it. + * Called when the resumed run finishes — the model applies to the whole + * suspend/resume cycle but only arrives once the resume completes. + */ + private async backfillSuspendedExecutions(threadId: string, model: string): Promise { + const candidates = await this.agentExecutionRepository.findSuspendedWithoutModel(threadId); + if (candidates.length === 0) return; + await this.agentExecutionRepository.backfillModel( + candidates.map((c) => c.id), + model, + ); + } + + /** + * Try to read the title from the SDK memory thread and sync it to our execution thread. + * The SDK's titleGeneration runs fire-and-forget after the first message, + * so the title is typically available by the second message. + */ + private async syncTitleFromMemory(threadId: string): Promise { + try { + const memoryThread = await this.n8nMemory.getThread(threadId); + if (memoryThread?.title) { + const emoji = + memoryThread.metadata && typeof memoryThread.metadata.emoji === 'string' + ? memoryThread.metadata.emoji + : null; + await this.agentExecutionThreadRepository.update(threadId, { + title: memoryThread.title, + ...(emoji && { emoji }), + }); + } + } catch { + // Memory thread may not exist (agent without memory configured) — ignore + } + } + + /** + * Delete a thread and all its associated runs. The FK on agent_execution + * cascades, so deleting the thread removes the runs in one statement. + */ + async deleteThread(projectId: string, threadId: string): Promise { + // Verify ownership before deleting anything + const thread = await this.agentExecutionThreadRepository.findOneBy({ + id: threadId, + projectId, + }); + if (!thread) return false; + + await this.agentExecutionThreadRepository.delete({ id: threadId }); + return true; + } + + /** + * Get paginated execution threads for a project. + * Optionally filtered by agentId. Each thread is annotated with the + * first non-empty user message for preview. + */ + async getThreads( + projectId: string, + limit: number, + cursor?: string, + agentId?: string, + ): Promise<{ threads: ThreadListItem[]; nextCursor: string | null }> { + const page = await this.agentExecutionThreadRepository.findByProjectIdPaginated( + projectId, + limit, + cursor, + agentId, + ); + + if (page.threads.length === 0) { + return { threads: [], nextCursor: page.nextCursor }; + } + + const messageMap = await this.agentExecutionRepository.findFirstUserMessageByThreadIds( + page.threads.map((t) => t.id), + ); + + return { + ...page, + threads: page.threads.map((t) => ({ + ...t, + firstMessage: messageMap.get(t.id) ?? null, + })), + }; + } + + /** + * Get a thread with all its executions. + * Validates projectId ownership. Optionally validates agentId. + */ + async getThreadDetail( + threadId: string, + projectId: string, + agentId?: string, + ): Promise { + const thread = await this.agentExecutionThreadRepository.findOneBy({ id: threadId }); + if (!thread || !threadBelongsTo(thread, projectId, agentId)) return null; + + const executions = await this.agentExecutionRepository.findByThreadIdOrdered(threadId); + return { thread, executions }; + } + + /** + * Fetch a thread by id without scoping. Callers accepting a client-supplied + * threadId MUST verify ownership with {@link threadBelongsTo} before use to + * prevent IDOR. + */ + async findThreadById(threadId: string): Promise { + return await this.agentExecutionThreadRepository.findOneBy({ id: threadId }); + } +} + +/** + * True if `thread` belongs to the given project (and optionally agent). + * Returns false for threads from a different project/agent so the caller can + * reject the request instead of leaking/modifying unrelated thread data. + */ +export function threadBelongsTo( + thread: AgentExecutionThread, + projectId: string, + agentId?: string, +): boolean { + if (thread.projectId !== projectId) return false; + if (agentId && thread.agentId !== agentId) return false; + return true; +} diff --git a/packages/cli/src/modules/agents/agent-message-mapper.ts b/packages/cli/src/modules/agents/agent-message-mapper.ts new file mode 100644 index 00000000000..5b418bd67df --- /dev/null +++ b/packages/cli/src/modules/agents/agent-message-mapper.ts @@ -0,0 +1,34 @@ +import type { AgentDbMessage, MessageContent } from '@n8n/agents'; +import type { AgentPersistedMessageContentPart, AgentPersistedMessageDto } from '@n8n/api-types'; + +export function contentPartToDto(part: MessageContent): AgentPersistedMessageContentPart { + const dto: AgentPersistedMessageContentPart = { type: part.type }; + if ('text' in part && typeof part.text === 'string') dto.text = part.text; + if ('toolName' in part && typeof part.toolName === 'string') dto.toolName = part.toolName; + if ('toolCallId' in part && typeof part.toolCallId === 'string') { + dto.toolCallId = part.toolCallId; + } + if ('input' in part) dto.input = part.input; + if ('state' in part && typeof part.state === 'string') dto.state = part.state; + if ('output' in part) dto.output = part.output; + if ('error' in part && typeof part.error === 'string') dto.error = part.error; + return dto; +} + +export function messageToDto(msg: AgentDbMessage): AgentPersistedMessageDto | null { + if (!('role' in msg) || !Array.isArray(msg.content)) return null; + return { + id: msg.id, + role: msg.role, + content: msg.content.map(contentPartToDto), + }; +} + +export function messagesToDto(msgs: AgentDbMessage[]): AgentPersistedMessageDto[] { + const out: AgentPersistedMessageDto[] = []; + for (const m of msgs) { + const dto = messageToDto(m); + if (dto) out.push(dto); + } + return out; +} diff --git a/packages/cli/src/modules/agents/agent-skills.service.ts b/packages/cli/src/modules/agents/agent-skills.service.ts new file mode 100644 index 00000000000..3aa48407dce --- /dev/null +++ b/packages/cli/src/modules/agents/agent-skills.service.ts @@ -0,0 +1,202 @@ +import { agentSkillSchema, type AgentSkill, type AgentSkillMutationResponse } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { markAgentDraftDirty } from './utils/agent-draft.utils'; +import { Agent } from './entities/agent.entity'; +import type { AgentJsonConfig } from './json-config/agent-json-config'; +import { AgentRepository } from './repositories/agent.repository'; +import { generateAgentResourceId } from './utils/agent-resource-id'; + +type AgentSkillEntries = Agent['skills']; + +@Service() +export class AgentSkillsService { + constructor( + private readonly logger: Logger, + private readonly agentRepository: AgentRepository, + ) {} + + async listSkills(agentId: string, projectId: string): Promise> { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + return entity.skills ?? {}; + } + + async getSkill(agentId: string, projectId: string, skillId: string): Promise { + const skills = await this.listSkills(agentId, projectId); + const skill = skills[skillId]; + if (!skill) throw new NotFoundError('Skill not found'); + + return skill; + } + + async createSkill( + agentId: string, + projectId: string, + skill: AgentSkill, + ): Promise { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + this.validateSkill(skill); + + const skillId = this.addSkill(entity, skill); + + markAgentDraftDirty(entity); + const saved = await this.agentRepository.save(entity); + + this.logger.debug('Created agent skill', { agentId, projectId, skillId }); + + return { id: skillId, skill, versionId: saved.versionId }; + } + + async createAndAttachSkill( + agentId: string, + projectId: string, + skill: AgentSkill, + ): Promise { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + if (!entity.schema) throw new UserError('Agent has no JSON config yet.'); + + this.validateSkill(skill); + + const skillId = this.addSkill(entity, skill); + this.attachSkillRef(entity, skillId); + + markAgentDraftDirty(entity); + const saved = await this.agentRepository.save(entity); + + this.logger.debug('Created and attached agent skill', { agentId, projectId, skillId }); + + return { id: skillId, skill, versionId: saved.versionId }; + } + + async updateSkill( + agentId: string, + projectId: string, + skillId: string, + updates: Partial, + ): Promise { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + const existing = entity.skills?.[skillId]; + if (!existing) throw new NotFoundError('Skill not found'); + + const updated = { ...existing, ...updates }; + this.validateSkill(updated); + + entity.skills = { + ...(entity.skills ?? {}), + [skillId]: updated, + }; + + markAgentDraftDirty(entity); + const saved = await this.agentRepository.save(entity); + + this.logger.debug('Updated agent skill', { agentId, projectId, skillId }); + + return { id: skillId, skill: updated, versionId: saved.versionId }; + } + + async deleteSkill(agentId: string, projectId: string, skillId: string): Promise { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + const skills = { ...(entity.skills ?? {}) }; + if (!skills[skillId]) throw new NotFoundError('Skill not found'); + + delete skills[skillId]; + entity.skills = skills; + + if (entity.schema?.skills) { + entity.schema.skills = entity.schema.skills.filter((t) => t.id !== skillId); + } + + markAgentDraftDirty(entity); + await this.agentRepository.save(entity); + + this.logger.debug('Deleted agent skill', { agentId, projectId, skillId }); + } + + removeUnreferencedSkills(entity: Agent, config: AgentJsonConfig): void { + const referencedSkillIds = new Set((config.skills ?? []).map((t) => t.id)); + const orphanSkillIds = Object.keys(entity.skills ?? {}).filter( + (id) => !referencedSkillIds.has(id), + ); + if (orphanSkillIds.length === 0) return; + + const skills = { ...(entity.skills ?? {}) }; + for (const id of orphanSkillIds) { + delete skills[id]; + } + entity.skills = skills; + } + + getMissingSkillIds(config: AgentJsonConfig | null, skills: AgentSkillEntries): string[] { + const refs = config?.skills ?? []; + const seen = new Set(); + const missing: string[] = []; + + for (const ref of refs) { + if (seen.has(ref.id)) continue; + seen.add(ref.id); + if (!skills[ref.id]) missing.push(ref.id); + } + + return missing; + } + + snapshotConfiguredSkills( + config: AgentJsonConfig | null, + skills: AgentSkillEntries, + ): AgentSkillEntries | null { + if (!config) return null; + const missing = this.getMissingSkillIds(config, skills); + if (missing.length > 0) { + throw new UserError(`Cannot publish agent with missing skill bodies: ${missing.join(', ')}`); + } + + const snapshot: AgentSkillEntries = {}; + for (const ref of config.skills ?? []) { + const skill = skills[ref.id]; + if (skill) snapshot[ref.id] = { ...skill }; + } + return snapshot; + } + + private validateSkill(skill: AgentSkill): void { + const result = agentSkillSchema.safeParse(skill); + if (!result.success) { + throw new UserError( + `Invalid agent skill: ${result.error.issues[0]?.message ?? 'Invalid skill'}`, + ); + } + } + + private addSkill(entity: Agent, skill: AgentSkill): string { + const skillId = generateAgentResourceId('skill', Object.keys(entity.skills ?? {})); + + entity.skills = { + ...(entity.skills ?? {}), + [skillId]: skill, + }; + + return skillId; + } + + private attachSkillRef(entity: Agent, skillId: string): void { + if (!entity.schema) throw new UserError('Agent has no JSON config yet.'); + + entity.schema.skills = [ + ...(entity.schema.skills ?? []).filter((ref) => ref.id !== skillId), + { type: 'skill', id: skillId }, + ]; + } +} diff --git a/packages/cli/src/modules/agents/agent-sse-stream.ts b/packages/cli/src/modules/agents/agent-sse-stream.ts new file mode 100644 index 00000000000..3a52fab6449 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-sse-stream.ts @@ -0,0 +1,304 @@ +import { UPDATE_WORKING_MEMORY_TOOL_NAME, type AgentMessage, type StreamChunk } from '@n8n/agents'; +import type { + AgentPersistedMessageContentPart, + AgentSseEvent, + AgentSseMessage, + ToolSuspendedPayload, +} from '@n8n/api-types'; +import type { Response } from 'express'; + +export type FlushableResponse = Response & { flush?: () => void }; + +/** + * Side-effect callbacks for the agent builder. Keyed off discrete tool events + * — no more `messageId` turn tracking. `toolInputStart` lets the builder + * remember which tool is currently streaming arguments so it can route + * `toolInputDelta` text into the right side-effect (e.g. `code-delta`). + */ +export interface ToolEventCallbacks { + toolInputStart?: (toolName: string) => void; + toolInputDelta?: (toolCallId: string, delta: string) => void; + toolResult?: (toolName: string) => void; +} + +interface ChunkHandlerCtx { + send: (e: AgentSseEvent) => void; + onToolEvent?: ToolEventCallbacks; + /** + * Tool-call ids belonging to the SDK-internal working-memory tool. The id + * Set is needed because `tool-input-delta` chunks carry only the id, not + * the tool name — we capture the id on `tool-input-start` / `tool-call` + * and use it to drop the matching streamed memory content. + */ + workingMemoryToolCallIds: Set; +} + +/** + * Set up SSE headers and return a typed `send(event)` helper. + */ +export function initSseStream(res: FlushableResponse) { + res.setHeader('Content-Type', 'text/event-stream; charset=UTF-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + (res.socket as { setNoDelay?: (v: boolean) => void })?.setNoDelay?.(true); + + const send = (event: AgentSseEvent) => { + res.write(`data: ${JSON.stringify(event)}\n\n`); + res.flush?.(); + }; + + return { send }; +} + +function toAgentSseMessage(message: AgentMessage): AgentSseMessage | undefined { + if (!('content' in message) || !Array.isArray(message.content)) return undefined; + + const content: AgentPersistedMessageContentPart[] = []; + for (const part of message.content) { + if (part.type === 'text' && 'text' in part) { + content.push({ type: 'text', text: part.text }); + } else if (part.type === 'reasoning' && 'text' in part) { + content.push({ type: 'reasoning', text: part.text }); + } + } + + if (content.length === 0) return undefined; + return { role: message.role, content }; +} + +/** SSE-emit text/reasoning lifecycle chunks. */ +function emitTextLikeChunk( + chunk: Extract< + StreamChunk, + { + type: + | 'text-start' + | 'text-delta' + | 'text-end' + | 'reasoning-start' + | 'reasoning-delta' + | 'reasoning-end'; + } + >, + send: (e: AgentSseEvent) => void, +): void { + switch (chunk.type) { + case 'text-start': + send({ type: 'text-start', id: chunk.id }); + break; + case 'text-delta': + if (chunk.delta) send({ type: 'text-delta', id: chunk.id, delta: chunk.delta }); + break; + case 'text-end': + send({ type: 'text-end', id: chunk.id }); + break; + case 'reasoning-start': + send({ type: 'reasoning-start', id: chunk.id }); + break; + case 'reasoning-delta': + if (chunk.delta) send({ type: 'reasoning-delta', id: chunk.id, delta: chunk.delta }); + break; + case 'reasoning-end': + send({ type: 'reasoning-end', id: chunk.id }); + break; + } +} + +/** + * SSE-emit a tool-* chunk and fire any matching builder side-effect callback. + * Returns `{ suspended: true }` when the chunk was `tool-call-suspended`. + */ +/** + * Working memory is implemented as an SDK tool, but n8n surfaces it as a + * distinct memory event in the chat UI rather than a regular tool step. + * Returns `true` when the chunk was handled and should not flow through the + * regular tool emission path. + */ +function handleWorkingMemoryChunk( + chunk: Extract< + StreamChunk, + { + type: + | 'tool-input-start' + | 'tool-input-delta' + | 'tool-call' + | 'tool-execution-start' + | 'tool-result'; + } + >, + ctx: ChunkHandlerCtx, +): boolean { + const { send, workingMemoryToolCallIds } = ctx; + const isWmName = 'toolName' in chunk && chunk.toolName === UPDATE_WORKING_MEMORY_TOOL_NAME; + + if (chunk.type === 'tool-input-delta') { + return workingMemoryToolCallIds.has(chunk.toolCallId); + } + if (!isWmName) return false; + + if (chunk.type === 'tool-input-start' || chunk.type === 'tool-call') { + workingMemoryToolCallIds.add(chunk.toolCallId); + return true; + } + if (chunk.type === 'tool-execution-start') return true; + if (chunk.type === 'tool-result') { + if (chunk.isError) { + const errMsg = chunk.output instanceof Error ? chunk.output.message : String(chunk.output); + send({ type: 'error', message: `Working memory update failed: ${errMsg}` }); + } else { + send({ type: 'working-memory-update', toolName: chunk.toolName }); + } + return true; + } + return false; +} + +function emitToolChunk( + chunk: Extract< + StreamChunk, + { + type: + | 'tool-input-start' + | 'tool-input-delta' + | 'tool-call' + | 'tool-execution-start' + | 'tool-result' + | 'tool-call-suspended'; + } + >, + ctx: ChunkHandlerCtx, +): { suspended: boolean } { + const { send, onToolEvent } = ctx; + + if (chunk.type !== 'tool-call-suspended' && handleWorkingMemoryChunk(chunk, ctx)) { + return { suspended: false }; + } + + switch (chunk.type) { + case 'tool-input-start': + send({ + type: 'tool-input-start', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + }); + onToolEvent?.toolInputStart?.(chunk.toolName); + break; + case 'tool-input-delta': + if (chunk.delta) { + send({ type: 'tool-input-delta', toolCallId: chunk.toolCallId, delta: chunk.delta }); + onToolEvent?.toolInputDelta?.(chunk.toolCallId, chunk.delta); + } + break; + case 'tool-call': + send({ + type: 'tool-call', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + input: chunk.input, + }); + break; + case 'tool-execution-start': + send({ + type: 'tool-execution-start', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + }); + break; + case 'tool-result': + send({ + type: 'tool-result', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + output: chunk.output, + ...(chunk.isError !== undefined && { isError: chunk.isError }), + }); + onToolEvent?.toolResult?.(chunk.toolName); + break; + case 'tool-call-suspended': { + const payload: ToolSuspendedPayload = { + toolCallId: chunk.toolCallId, + runId: chunk.runId, + toolName: chunk.toolName, + input: chunk.suspendPayload, + }; + send({ type: 'tool-call-suspended', payload }); + return { suspended: true }; + } + } + return { suspended: false }; +} + +/** + * Translate a single chunk into one or more SSE events. + * + * Returns `{ suspended: true }` when the chunk was a `tool-call-suspended` + * — the run pauses and the caller stops pumping. All other chunks return + * `{ suspended: false }`. + */ +function emitChunkEvents(chunk: StreamChunk, ctx: ChunkHandlerCtx): { suspended: boolean } { + switch (chunk.type) { + case 'start-step': + ctx.send({ type: 'start-step' }); + return { suspended: false }; + case 'finish-step': + ctx.send({ type: 'finish-step' }); + return { suspended: false }; + case 'text-start': + case 'text-delta': + case 'text-end': + case 'reasoning-start': + case 'reasoning-delta': + case 'reasoning-end': + emitTextLikeChunk(chunk, ctx.send); + return { suspended: false }; + case 'tool-input-start': + case 'tool-input-delta': + case 'tool-call': + case 'tool-execution-start': + case 'tool-result': + case 'tool-call-suspended': + return emitToolChunk(chunk, ctx); + case 'message': { + const sseMessage = toAgentSseMessage(chunk.message); + if (sseMessage) ctx.send({ type: 'message', message: sseMessage }); + return { suspended: false }; + } + case 'error': { + const errMsg = chunk.error instanceof Error ? chunk.error.message : String(chunk.error); + ctx.send({ type: 'error', message: errMsg }); + return { suspended: false }; + } + default: + return { suspended: false }; + } +} + +/** + * Pump SDK stream chunks through a typed AgentSseEvent stream. + * + * Side-effects (`config-updated` / `tool-updated` / `code-delta`) for the + * agent builder are surfaced via the `onToolEvent` callback so the chat path + * can ignore them. + * + * Returns `true` when a suspension was emitted (the run paused), `false` + * otherwise. + */ +export async function pumpChunks( + chunks: AsyncIterable, + send: (e: AgentSseEvent) => void, + onToolEvent?: ToolEventCallbacks, +): Promise { + const ctx: ChunkHandlerCtx = { + send, + onToolEvent, + workingMemoryToolCallIds: new Set(), + }; + + for await (const chunk of chunks) { + const { suspended } = emitChunkEvents(chunk, ctx); + if (suspended) return true; + } + return false; +} diff --git a/packages/cli/src/modules/agents/agents-tools.service.ts b/packages/cli/src/modules/agents/agents-tools.service.ts new file mode 100644 index 00000000000..f7d1d3a2cf7 --- /dev/null +++ b/packages/cli/src/modules/agents/agents-tools.service.ts @@ -0,0 +1,267 @@ +import { Tool } from '@n8n/agents'; +import type { BuiltTool, CredentialProvider } from '@n8n/agents'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { validateNodeConfig } from '@n8n/workflow-sdk'; +import { isToolType, isTriggerNodeType } from 'n8n-workflow'; +import type { IDataObject, INodeParameters } from 'n8n-workflow'; +import { z } from 'zod'; + +import { EphemeralNodeExecutor, isAgentProviderNode } from '@/node-execution'; +import { NodeCatalogService } from '@/node-catalog'; + +type NodeRequest = + | string + | { + nodeId: string; + version?: number; + resource?: string; + operation?: string; + mode?: string; + }; + +/** + * Nodes the agent runtime can execute directly. Triggers are workflow entry + * points, so they are never valid as standalone agent tools. + */ +export const isExecutableNodeType = (nodeId: string): boolean => !isTriggerNodeType(nodeId); + +/** + * Node IDs the agent builder should surface when configuring node-backed + * tools. For regular nodes marked `usableAsTool`, the loader creates a + * mirrored `*Tool` node type; native tool nodes already follow this shape. + * HITL tools are excluded because the builder wires regular executable tools, + * not approval-gated workflow steps. Provider nodes (OpenAI etc.) are + * admitted via the explicit whitelist — they ship the full vendor API + * (image, audio, …) but lack the `usableAsTool` flag. + * + * Exported as a stable reference so the catalog service can cache its + * filtered search tool per filter identity. + */ +export const isAgentToolNodeType = (nodeId: string): boolean => + isExecutableNodeType(nodeId) && + (isToolType(nodeId, { includeHitl: false }) || isAgentProviderNode(nodeId)); + +const searchNodesInputSchema = z.object({ + queries: z.array(z.string()).min(1).describe('Search queries (e.g., ["gmail", "slack", "http"])'), +}); + +const nodeVersionSchema = z.number().describe('Tool node type version from search_nodes'); + +const getNodeTypesInputSchema = z.object({ + nodeIds: z + .array( + z.union([ + z.string(), + z.object({ + nodeId: z.string(), + version: nodeVersionSchema.optional(), + resource: z.string().optional(), + operation: z.string().optional(), + mode: z.string().optional(), + }), + ]), + ) + .min(1) + .describe('Tool node IDs from search_nodes (e.g., ["n8n-nodes-base.gmailTool"])'), +}); + +const listCredentialsInputSchema = z.object({ + types: z + .array(z.string()) + .optional() + .describe( + 'Optional credential types to filter by (e.g., ["gmailOAuth2", "httpHeaderAuth"]). ' + + 'When omitted, returns all credentials. Use the credential types declared in the ' + + 'node schema from get_node_types to narrow the results.', + ), +}); + +const runNodeInputSchema = z.object({ + nodeType: z.string().describe('Tool node type identifier from search_nodes'), + nodeTypeVersion: nodeVersionSchema, + nodeParameters: z + .record(z.unknown()) + .optional() + .describe( + 'Static node config. Use expressions like ={{ $json.url }} to reference inputData fields.', + ), + credentials: z + .record(z.object({ id: z.string(), name: z.string() })) + .optional() + .describe('Credential slot → { id, name }. Copy from list_credentials results.'), + inputData: z + .record(z.unknown()) + .optional() + .describe('Runtime input, available as $json inside nodeParameters expressions.'), +}); + +@Service() +export class AgentsToolsService { + constructor( + private readonly logger: Logger, + private readonly nodeCatalogService: NodeCatalogService, + private readonly ephemeralNodeExecutor: EphemeralNodeExecutor, + ) {} + + /** + * Tools usable from both the builder and the agent runtime. + * + * `listCredentialsUsageHint` lets each caller tailor the `list_credentials` + * description to its flow — the runtime points at `run_node_tool`, while the + * builder points at code generation. + */ + getSharedTools( + credentialProvider: CredentialProvider, + listCredentialsUsageHint: string, + ): BuiltTool[] { + return [ + this.buildSearchNodesTool(), + this.buildGetNodeTypesTool(), + this.buildListCredentialsTool(credentialProvider, listCredentialsUsageHint), + ]; + } + + /** Shared tools plus the runtime-only `run_node_tool` which binds to a project. */ + getRuntimeTools(credentialProvider: CredentialProvider, projectId: string): BuiltTool[] { + return [ + ...this.getSharedTools( + credentialProvider, + 'Call this before run_node_tool to know which credential to pass.', + ), + this.buildRunNodeTool(projectId), + ]; + } + + private buildSearchNodesTool(): BuiltTool { + return new Tool('search_nodes') + .description( + 'Search for n8n nodes by name or service. Use this to find nodes that can be executed. ' + + 'Returns tool node IDs, display names, versions, and descriptions. ' + + 'After finding a node, call get_node_types to get its parameter schema.', + ) + .input(searchNodesInputSchema) + .handler(async ({ queries }: { queries: string[] }) => { + const results = await this.nodeCatalogService.searchNodes(queries, { + nodeFilter: isAgentToolNodeType, + }); + return { results }; + }) + .build(); + } + + private buildGetNodeTypesTool(): BuiltTool { + return new Tool('get_node_types') + .description( + 'Get detailed parameter schema for specific n8n nodes. Use the node IDs returned ' + + 'by search_nodes. Returns parameter definitions needed to configure a node for execution. ' + + 'Use the tool node IDs from search_nodes. ' + + 'You can optionally filter by resource/operation/mode.', + ) + .input(getNodeTypesInputSchema) + .handler(async ({ nodeIds }: { nodeIds: NodeRequest[] }) => { + const results = await this.nodeCatalogService.getNodeTypes( + nodeIds.map(normalizeNodeRequestForCatalog), + ); + return { results }; + }) + .build(); + } + + private buildListCredentialsTool( + credentialProvider: CredentialProvider, + usageHint: string, + ): BuiltTool { + return new Tool('list_credentials') + .description( + 'List the credentials available to the user. Returns an array of credential names and types. ' + + 'Accepts an optional `types` filter to return only credentials matching the given types. ' + + usageHint, + ) + .input(listCredentialsInputSchema) + .handler(async ({ types }) => { + const creds = await credentialProvider.list(); + if (!types || types.length === 0) return { credentials: creds }; + const allowed = new Set(types); + return { credentials: creds.filter((c) => allowed.has(c.type)) }; + }) + .build(); + } + + private buildRunNodeTool(projectId: string): BuiltTool { + return new Tool('run_node_tool') + .description( + 'Execute an n8n node for the current request. ' + + 'Use the tool nodeType and nodeTypeVersion from search_nodes. ' + + 'Call get_node_types first to understand what nodeParameters the node accepts. ' + + 'nodeParameters holds static node config; use n8n expressions like ={{ $json.url }} to map inputData fields. ' + + 'credentials maps slot names to { id, name } — copy from the list_credentials results. ' + + 'inputData is the runtime payload available as $json inside expressions. ' + + 'Parameters are validated against the node schema before execution.', + ) + .input(runNodeInputSchema) + .handler(async ({ nodeType, nodeTypeVersion, nodeParameters, credentials, inputData }) => { + if (!isExecutableNodeType(nodeType)) { + return { + status: 'error', + message: `Node type "${nodeType}" cannot be executed directly — trigger nodes are not supported here.`, + }; + } + + if (nodeParameters) { + const { valid, errors } = validateNodeConfig( + nodeType, + nodeTypeVersion, + { + parameters: nodeParameters, + }, + { isToolNode: true }, + ); + if (!valid) { + return { + status: 'error', + message: `Invalid nodeParameters: ${errors.map((e) => e.message).join('; ')}`, + }; + } + } + + try { + return await this.ephemeralNodeExecutor.executeInline({ + nodeType, + nodeTypeVersion, + nodeParameters: (nodeParameters ?? {}) as INodeParameters, + credentialDetails: credentials, + inputData: [{ json: (inputData ?? {}) as IDataObject }], + projectId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn('run_node_tool execution failed', { nodeType, error }); + return { + status: 'error', + message: `Node execution failed: ${message}`, + }; + } + }) + .build(); + } +} + +/** + * The catalog's `getNodeTypes` signature expects `version` as a string (matching the + * code-builder tool's wire format). Our public schema uses `number` for consistency + * with `run_node_tool`; adapt at the boundary. + */ +function normalizeNodeRequestForCatalog(req: NodeRequest): + | string + | { + nodeId: string; + version?: string; + resource?: string; + operation?: string; + mode?: string; + } { + 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.controller.ts b/packages/cli/src/modules/agents/agents.controller.ts new file mode 100644 index 00000000000..ab4963cf9bf --- /dev/null +++ b/packages/cli/src/modules/agents/agents.controller.ts @@ -0,0 +1,865 @@ +import { + AGENT_SCHEDULE_TRIGGER_TYPE, + type AgentBuilderMessagesResponse, + type AgentIntegrationStatusResponse, + type AgentPersistedMessageDto, + type AgentSkill, + type AgentScheduleConfig, + type AgentSseEvent, + type ChatIntegrationDescriptor, + AgentBuildResumeDto, + AgentChatMessageDto, + CreateAgentSkillDto, + AgentIntegrationDto, + CreateAgentDto, + UpdateAgentSkillDto, + UpdateAgentConfigDto, + UpdateAgentScheduleDto, + UpdateAgentDto, + isAgentCredentialIntegration, +} from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { + Body, + Delete, + Get, + Param, + Patch, + Post, + ProjectScope, + Put, + RestController, +} from '@n8n/decorators'; +import { randomUUID } from 'crypto'; +import type { Request, Response } from 'express'; + +import { CredentialsService } from '@/credentials/credentials.service'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; +import { AgentExecutionService, threadBelongsTo } from './agent-execution.service'; +import { messagesToDto } from './agent-message-mapper'; +import { + type FlushableResponse, + initSseStream, + pumpChunks, + type ToolEventCallbacks, +} from './agent-sse-stream'; +import { AgentsService } from './agents.service'; +import { AgentsBuilderService } from './builder/agents-builder.service'; +import { BUILDER_TOOLS } from './builder/builder-tool-names'; +import { AgentScheduleService } from './integrations/agent-schedule.service'; +import { ChatIntegrationService } from './integrations/chat-integration.service'; +import { AgentRepository } from './repositories/agent.repository'; + +/** + * Builder side-effects: when the LLM streams arguments for `build_custom_tool` + * we re-emit each delta as a `code-delta` event so the FE editor can render + * incrementally; on tool completion we emit `config-updated` / `tool-updated` + * so the FE refreshes the corresponding panel. State is local to one request: + * `streamingToolName` tracks the tool whose arguments are currently streaming + * (replaces the old per-message-id heuristic). + */ +function makeBuilderToolEvents(send: (e: AgentSseEvent) => void): ToolEventCallbacks { + let streamingToolName: string | undefined; + return { + toolInputStart: (name) => { + streamingToolName = name; + }, + toolInputDelta: (_toolCallId, delta) => { + if (streamingToolName === BUILDER_TOOLS.BUILD_CUSTOM_TOOL) { + send({ type: 'code-delta', delta }); + } + }, + toolResult: (name) => { + if (name === BUILDER_TOOLS.WRITE_CONFIG || name === BUILDER_TOOLS.PATCH_CONFIG) { + send({ type: 'config-updated' }); + streamingToolName = undefined; + } + if (name === BUILDER_TOOLS.CREATE_SKILL) { + send({ type: 'config-updated' }); + streamingToolName = undefined; + } + if (name === BUILDER_TOOLS.BUILD_CUSTOM_TOOL) { + send({ type: 'tool-updated' }); + streamingToolName = undefined; + } + }, + }; +} + +@RestController('/projects/:projectId/agents/v2') +export class AgentsController { + constructor( + private readonly agentsService: AgentsService, + private readonly agentsBuilderService: AgentsBuilderService, + private readonly credentialsService: CredentialsService, + private readonly chatIntegrationService: ChatIntegrationService, + private readonly agentScheduleService: AgentScheduleService, + private readonly agentRepository: AgentRepository, + private readonly agentExecutionService: AgentExecutionService, + ) {} + + @Post('/') + @ProjectScope('agent:create') + async create( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Body payload: CreateAgentDto, + ) { + const { projectId } = req.params; + + return await this.agentsService.create(projectId, payload.name); + } + + @Get('/') + @ProjectScope('agent:list') + async list(req: AuthenticatedRequest<{ projectId: string }, unknown, unknown, { all?: string }>) { + // ?all=true returns all agents for this user (cross-project, for Instance AI switcher) + if (req.query.all === 'true') { + return await this.agentsService.findByUser(req.user.id); + } + return await this.agentsService.findByProjectId(req.params.projectId); + } + + @Get('/:agentId/config') + @ProjectScope('agent:read') + async getConfig(req: AuthenticatedRequest<{ projectId: string; agentId: string }>) { + const { projectId, agentId } = req.params; + return await this.agentsService.getConfig(agentId, projectId); + } + + @Put('/:agentId/config') + @ProjectScope('agent:update') + async putConfig( + req: AuthenticatedRequest<{ projectId: string; agentId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: UpdateAgentConfigDto, + ) { + const { projectId } = req.params; + const { config } = payload; + return await this.agentsService.updateConfig(agentId, projectId, config); + } + + @Delete('/:agentId/tools/:toolId') + @ProjectScope('agent:update') + async deleteTool( + req: AuthenticatedRequest<{ projectId: string; agentId: string; toolId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Param('toolId') toolId: string, + ) { + const { projectId } = req.params; + await this.agentsService.deleteCustomTool(agentId, projectId, toolId); + return { ok: true }; + } + + @Get('/:agentId/skills') + @ProjectScope('agent:read') + async listSkills(req: AuthenticatedRequest<{ projectId: string; agentId: string }>) { + const { projectId, agentId } = req.params; + return await this.agentsService.listSkills(agentId, projectId); + } + + @Get('/:agentId/skills/:skillId') + @ProjectScope('agent:read') + async getSkill( + req: AuthenticatedRequest<{ projectId: string; agentId: string; skillId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Param('skillId') skillId: string, + ) { + const { projectId } = req.params; + return await this.agentsService.getSkill(agentId, projectId, skillId); + } + + @Post('/:agentId/skills') + @ProjectScope('agent:update') + async createSkill( + req: AuthenticatedRequest<{ projectId: string; agentId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: CreateAgentSkillDto, + ) { + const { projectId } = req.params; + const skill: AgentSkill = { + name: payload.name, + description: payload.description, + instructions: payload.instructions, + }; + + return await this.agentsService.createAndAttachSkill(agentId, projectId, skill); + } + + @Patch('/:agentId/skills/:skillId') + @ProjectScope('agent:update') + async updateSkill( + req: AuthenticatedRequest<{ projectId: string; agentId: string; skillId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Param('skillId') skillId: string, + @Body payload: UpdateAgentSkillDto, + ) { + const { projectId } = req.params; + return await this.agentsService.updateSkill(agentId, projectId, skillId, payload); + } + + @Delete('/:agentId/skills/:skillId') + @ProjectScope('agent:update') + async deleteSkill( + req: AuthenticatedRequest<{ projectId: string; agentId: string; skillId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Param('skillId') skillId: string, + ) { + const { projectId } = req.params; + await this.agentsService.deleteSkill(agentId, projectId, skillId); + return { ok: true }; + } + + @Get('/:agentId/credentials') + @ProjectScope('agent:read') + async listCredentials(req: AuthenticatedRequest<{ projectId: string; agentId: string }>) { + const { projectId } = req.params; + const credentialProvider = new AgentsCredentialProvider(this.credentialsService, projectId); + return await credentialProvider.list(); + } + + @Get('/catalog/models') + @ProjectScope('agent:read') + async getModelCatalog() { + const { fetchProviderCatalog } = await import('@n8n/agents'); + return await fetchProviderCatalog(); + } + + @Get('/catalog/integrations') + @ProjectScope('agent:read') + listIntegrations(): ChatIntegrationDescriptor[] { + return this.agentsService.listChatIntegrations(); + } + + @Get('/threads') + @ProjectScope('agent:read') + async listThreads( + req: AuthenticatedRequest< + { projectId: string }, + {}, + {}, + { cursor?: string; limit?: string; agentId?: string } + >, + ) { + const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100); + return await this.agentExecutionService.getThreads( + req.params.projectId, + limit, + req.query.cursor, + req.query.agentId, + ); + } + + @Get('/threads/:threadId') + @ProjectScope('agent:read') + async getThread( + req: AuthenticatedRequest< + { projectId: string; threadId: string }, + {}, + {}, + { agentId?: string } + >, + ) { + const result = await this.agentExecutionService.getThreadDetail( + req.params.threadId, + req.params.projectId, + req.query.agentId, + ); + if (!result) { + throw new NotFoundError(`Thread "${req.params.threadId}" not found`); + } + return result; + } + + @Delete('/threads/:threadId') + @ProjectScope('agent:update') + async deleteThread(req: AuthenticatedRequest<{ projectId: string; threadId: string }>) { + const { projectId, threadId } = req.params; + const deleted = await this.agentExecutionService.deleteThread(projectId, threadId); + if (!deleted) { + throw new NotFoundError(`Thread "${threadId}" not found`); + } + return { success: true }; + } + + @Get('/:agentId') + @ProjectScope('agent:read') + async get( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ) { + const agent = await this.agentsService.findById(agentId, req.params.projectId); + + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + return agent; + } + + @Patch('/:agentId') + @ProjectScope('agent:update') + async update( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: UpdateAgentDto, + ) { + const { name, description } = payload; + let agent = await this.agentsService.findById(agentId, req.params.projectId); + + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + if (name !== undefined) { + agent = await this.agentsService.updateName(agentId, req.params.projectId, name); + } + + if (description !== undefined && agent) { + // Use the latest updatedAt from previous saves (name), not the original + // request updatedAt, to avoid false optimistic-lock conflicts. + const latestUpdatedAt = + agent.updatedAt instanceof Date ? agent.updatedAt.toISOString() : agent.updatedAt; + agent = await this.agentsService.updateDescription( + agentId, + req.params.projectId, + description, + latestUpdatedAt, + ); + } + + return agent; + } + + @Delete('/:agentId') + @ProjectScope('agent:delete') + async delete( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ) { + const deleted = await this.agentsService.delete(agentId, req.params.projectId); + + if (!deleted) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + return { success: true }; + } + + @Post('/:agentId/publish') + @ProjectScope('agent:publish') + async publish( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ) { + return await this.agentsService.publishAgent(agentId, req.params.projectId, req.user.id); + } + + @Post('/:agentId/unpublish') + @ProjectScope('agent:unpublish') + async unpublish( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ) { + return await this.agentsService.unpublishAgent(agentId, req.params.projectId); + } + + @Post('/:agentId/revert-to-published') + @ProjectScope('agent:update') + async revertToPublished( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ) { + return await this.agentsService.revertToPublishedAgent(agentId, req.params.projectId); + } + + @Post('/:agentId/chat', { usesTemplates: true }) + @ProjectScope('agent:execute') + async chat( + req: AuthenticatedRequest<{ projectId: string }>, + res: FlushableResponse, + @Param('agentId') agentId: string, + @Body payload: AgentChatMessageDto, + ) { + const { projectId } = req.params; + const { message, sessionId } = payload; + + const credentialProvider = new AgentsCredentialProvider(this.credentialsService, projectId); + + const { send } = initSseStream(res); + + // If the client supplied a sessionId and a thread already exists under that id, + // the thread must belong to this (project, agent). Otherwise a caller could + // append messages to another user's thread. A non-existent id is fine — + // executeForChat will create the thread on first persisted message. + if (sessionId) { + const existing = await this.agentExecutionService.findThreadById(sessionId); + if (existing && !threadBelongsTo(existing, projectId, agentId)) { + send({ type: 'error', message: 'Session not found' }); + res.end(); + return; + } + } + + const threadId = sessionId ?? randomUUID(); + + const { missing } = await this.agentsService.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider, + ); + if (missing.length > 0) { + send({ + type: 'error', + message: 'This agent is not ready to run yet.', + errorCode: 'agent_misconfigured', + missing, + }); + res.end(); + return; + } + + try { + await pumpChunks( + this.agentsService.executeForChat({ + agentId, + projectId, + message, + userId: req.user.id, + memory: { threadId, resourceId: req.user.id }, + }), + send, + ); + send({ type: 'done', sessionId: threadId }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Chat failed'; + send({ type: 'error', message: errorMessage }); + } + + res.end(); + } + + @Get('/:agentId/chat/:threadId/messages') + @ProjectScope('agent:read') + async getChatMessages( + req: AuthenticatedRequest<{ projectId: string; agentId: string; threadId: string }>, + ) { + const { projectId, agentId, threadId } = req.params; + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + const thread = await this.agentExecutionService.findThreadById(threadId); + if (!thread || !threadBelongsTo(thread, projectId, agentId)) { + throw new NotFoundError(`Thread "${threadId}" not found`); + } + return await this.agentsService.getChatMessages(threadId); + } + + @Get('/:agentId/build/messages') + @ProjectScope('agent:read') + async getBuilderMessages( + req: AuthenticatedRequest<{ projectId: string; agentId: string }>, + ): Promise { + const { projectId, agentId } = req.params; + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + // Merge persisted thread memory with any open suspension's checkpoint + // so a refresh during a suspended turn still returns the suspended + // assistant message (the SDK only saveToMemory's on completion). + const memory = await this.agentsBuilderService.getBuilderMessages(agentId); + const checkpoint = await this.agentsBuilderService.findOpenCheckpoint(agentId); + const openSuspensions = Object.values(checkpoint?.pendingToolCalls ?? {}) + .filter((tc) => tc.suspended) + .map((tc) => ({ + toolCallId: tc.toolCallId, + runId: tc.runId, + })); + + let messages: AgentPersistedMessageDto[]; + if (!checkpoint) { + messages = messagesToDto(memory); + } else { + const memoryIds = new Set(memory.map((m) => m.id)); + const newFromCheckpoint = checkpoint.messageList.messages.filter((m) => !memoryIds.has(m.id)); + messages = messagesToDto([...memory, ...newFromCheckpoint]); + } + + return { messages, openSuspensions }; + } + + @Delete('/:agentId/build/messages') + @ProjectScope('agent:update') + async clearBuilderMessages(req: AuthenticatedRequest<{ projectId: string; agentId: string }>) { + const { projectId, agentId } = req.params; + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + await this.agentsBuilderService.clearBuilderMessages(agentId); + return { ok: true }; + } + + @Get('/:agentId/chat/messages') + @ProjectScope('agent:read') + async getTestChatMessages( + req: AuthenticatedRequest<{ projectId: string; agentId: string }>, + ): Promise { + const { projectId, agentId } = req.params; + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + const messages = await this.agentsService.getTestChatMessages(agentId, req.user.id); + return messagesToDto(messages); + } + + @Delete('/:agentId/chat/messages') + @ProjectScope('agent:update') + async clearTestChatMessages(req: AuthenticatedRequest<{ projectId: string; agentId: string }>) { + const { projectId, agentId } = req.params; + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + await this.agentsService.clearTestChatMessages(agentId, req.user.id); + return { ok: true }; + } + + @Post('/:agentId/build', { usesTemplates: true }) + @ProjectScope('agent:update') + async build( + req: AuthenticatedRequest<{ projectId: string }>, + res: FlushableResponse, + @Param('agentId') agentId: string, + @Body payload: AgentChatMessageDto, + ) { + const { projectId } = req.params; + const { message } = payload; + + // Validate the agent exists before opening the SSE stream so a malformed + // id surfaces as a typed 404 instead of a generic 500 from the builder + // service's internal lookup. + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + const credentialProvider = new AgentsCredentialProvider(this.credentialsService, projectId); + + const { send } = initSseStream(res); + + try { + const suspended = await pumpChunks( + this.agentsBuilderService.buildAgent( + agentId, + projectId, + message, + credentialProvider, + req.user, + ), + send, + makeBuilderToolEvents(send), + ); + + if (!suspended) { + send({ type: 'done' }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Build failed'; + const errorCode = + error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined; + send({ + type: 'error', + message: errorMessage, + ...(typeof errorCode === 'string' ? { code: errorCode } : {}), + }); + } + + res.end(); + } + + @Post('/:agentId/build/resume', { usesTemplates: true }) + @ProjectScope('agent:update') + async buildResume( + req: AuthenticatedRequest<{ projectId: string }>, + res: FlushableResponse, + @Param('agentId') agentId: string, + @Body payload: AgentBuildResumeDto, + ) { + const { projectId } = req.params; + const { runId, toolCallId, resumeData } = payload; + + // Validate the agent exists before opening the SSE stream so a malformed + // id surfaces as a typed 404 instead of a generic 500 from the builder + // service's internal lookup. + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + const credentialProvider = new AgentsCredentialProvider(this.credentialsService, projectId); + + const { send } = initSseStream(res); + + try { + const suspended = await pumpChunks( + this.agentsBuilderService.resumeBuild( + agentId, + projectId, + runId, + toolCallId, + resumeData, + credentialProvider, + req.user, + ), + send, + makeBuilderToolEvents(send), + ); + + if (!suspended) { + send({ type: 'done' }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Resume failed'; + send({ type: 'error', message: errorMessage }); + } + + res.end(); + } + + @Post('/:agentId/integrations/connect') + @ProjectScope('agent:update') + async connectIntegration( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: AgentIntegrationDto, + ) { + const { type, credentialId } = payload; + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + if (!agent.publishedVersion) + throw new ConflictError( + `Agent "${agentId}" must be published before connecting an integration`, + ); + + await this.chatIntegrationService.connect( + agentId, + credentialId, + type, + req.user.id, + agent.projectId, + ); + + // Persist the integration reference on the agent + const existing = agent.integrations ?? []; + const alreadyExists = existing.some( + (i) => isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId, + ); + if (!alreadyExists) { + const credential = await this.credentialsService.getOne(req.user, credentialId, false); + agent.integrations = [...existing, { type, credentialId, credentialName: credential.name }]; + await this.agentRepository.save(agent); + } + + return { status: 'connected' }; + } + + @Post('/:agentId/integrations/disconnect') + @ProjectScope('agent:update') + async disconnectIntegration( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: AgentIntegrationDto, + ) { + const { type, credentialId } = payload; + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + await this.chatIntegrationService.disconnect(agentId, type, credentialId); + + // Remove the integration reference from the agent + agent.integrations = (agent.integrations ?? []).filter( + (i) => !isAgentCredentialIntegration(i) || i.type !== type || i.credentialId !== credentialId, + ); + await this.agentRepository.save(agent); + + return { status: 'disconnected' }; + } + + @Get('/:agentId/integrations/schedule') + @ProjectScope('agent:read') + async getScheduleIntegration( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + return this.agentScheduleService.getConfig(agent); + } + + @Put('/:agentId/integrations/schedule') + @ProjectScope('agent:update') + async updateScheduleIntegration( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: UpdateAgentScheduleDto, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + return await this.agentScheduleService.saveConfig( + agent, + payload.cronExpression, + payload.wakeUpPrompt, + ); + } + + @Post('/:agentId/integrations/schedule/activate') + @ProjectScope('agent:update') + async activateScheduleIntegration( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + return await this.agentScheduleService.activate(agent); + } + + @Post('/:agentId/integrations/schedule/deactivate') + @ProjectScope('agent:update') + async deactivateScheduleIntegration( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + return await this.agentScheduleService.deactivate(agent); + } + + @Get('/:agentId/integrations/status') + @ProjectScope('agent:read') + async integrationStatus( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); + if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); + + const chatStatus = this.chatIntegrationService.getStatus(agentId); + const schedule = this.agentScheduleService.getConfig(agent); + const scheduleIntegrations = schedule.active ? [{ type: AGENT_SCHEDULE_TRIGGER_TYPE }] : []; + const connectedIntegrations = [...chatStatus.integrations, ...scheduleIntegrations]; + + return { + status: connectedIntegrations.length > 0 ? 'connected' : 'disconnected', + integrations: connectedIntegrations, + }; + } + + // Third-party webhook callback: do not add @ProjectScope. Auth happens + // via per-platform signature verification inside webhookHandler. + @Post('/:agentId/webhooks/:platform', { skipAuth: true, allowBots: true }) + async handleWebhook( + req: Request<{ projectId: string; agentId: string; platform: string }>, + res: Response, + ) { + const { agentId, platform } = req.params; + const webhookHandler = this.chatIntegrationService.getWebhookHandler(agentId, platform); + + if (!webhookHandler) { + res.status(404).json({ error: `No active ${platform} integration for agent "${agentId}"` }); + return; + } + + // Chat SDK webhook handlers accept a Web API Request and return a Web API Response. + // Convert Express req → Web Request. We must preserve the raw body exactly as + // received because the Slack adapter verifies the request signature against it. + // Using JSON.stringify(req.body) would break signature verification (→ 401). + const forwardedProto = req.headers['x-forwarded-proto']; + const protocol = + (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto) ?? req.protocol; + const forwardedHost = req.headers['x-forwarded-host']; + const host = + (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? + req.headers.host ?? + 'localhost'; + const url = `${protocol}://${host}${req.originalUrl}`; + + // Get the raw body — Express may have parsed it already. + // If rawBody is available (from rawBodyReader middleware), use it. + // Otherwise re-encode based on content-type. + let requestBody: string | undefined; + if (req.method !== 'GET' && req.method !== 'HEAD') { + // Express augments Request with rawBody via middleware + interface RequestWithRawBody { + rawBody?: Buffer; + } + + const rawBody = (req as RequestWithRawBody).rawBody; + if (rawBody) { + requestBody = rawBody.toString('utf-8'); + } else if (req.headers['content-type']?.includes('application/json')) { + requestBody = JSON.stringify(req.body); + } else if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) { + requestBody = new URLSearchParams(req.body as Record).toString(); + } else { + requestBody = JSON.stringify(req.body); + } + } + + const sanitizedHeaders: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + sanitizedHeaders[key] = value; + } else if (Array.isArray(value)) { + sanitizedHeaders[key] = value.join(', '); + } + } + + const webRequest = new globalThis.Request(url, { + method: req.method, + headers: sanitizedHeaders, + body: requestBody, + }); + + // In Express, background tasks just need to not be garbage collected. + // We hold references to keep them alive for the lifetime of the process. + const backgroundTasks: Array> = []; + const waitUntil = (task: Promise) => { + backgroundTasks.push( + task.catch((error: unknown) => { + console.warn( + '[AgentsController] Background task failed:', + error instanceof Error ? error.message : String(error), + ); + }), + ); + }; + + const webResponse = await webhookHandler(webRequest, { waitUntil }); + + res.status(webResponse.status); + webResponse.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + const body = await webResponse.text(); + res.send(body); + } +} diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts new file mode 100644 index 00000000000..dbf82c1bbac --- /dev/null +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -0,0 +1,114 @@ +import { Logger } from '@n8n/backend-common'; +import { AgentsConfig } from '@n8n/config'; +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; + +@BackendModule({ name: 'agents' }) +export class AgentsModule implements ModuleInterface { + async init() { + await import('./agents.controller'); + await import('./builder/agents-builder-settings.controller'); + + const { AgentsService } = await import('./agents.service'); + Container.get(AgentsService); + + const { AgentsBuilderSettingsService } = await import( + './builder/agents-builder-settings.service' + ); + Container.get(AgentsBuilderSettingsService); + + const { AgentExecutionService } = await import('./agent-execution.service'); + Container.get(AgentExecutionService); + + const { AgentPublishedVersionRepository } = await import( + './repositories/agent-published-version.repository' + ); + Container.get(AgentPublishedVersionRepository); + + // Register the sandboxed runtime service (lazy — the V8 isolate is only + // created on first use, so this import has negligible startup cost). + const { AgentSecureRuntime } = await import('./runtime/agent-secure-runtime'); + Container.get(AgentSecureRuntime); + + // Populate the integration registry with supported chat platforms. + // Adding a new platform is adding one subclass + one register() call. + const { ChatIntegrationRegistry } = await import('./integrations/agent-chat-integration'); + const { SlackIntegration } = await import('./integrations/platforms/slack-integration'); + const { TelegramIntegration } = await import('./integrations/platforms/telegram-integration'); + const { LinearIntegration } = await import('./integrations/platforms/linear-integration'); + const registry = Container.get(ChatIntegrationRegistry); + registry.register(Container.get(SlackIntegration)); + registry.register(Container.get(TelegramIntegration)); + registry.register(Container.get(LinearIntegration)); + + // Warm the node catalog so the agent runtime can attach search/execute tools + // synchronously on each agent reconstruction. The underlying init is idempotent. + const { NodeCatalogService } = await import('@/node-catalog'); + await Container.get(NodeCatalogService).initialize(); + + // Register Chat and Schedule services. Reconnect active integrations and + // schedules only on the leader main — followers wait for @OnLeaderTakeover + // (in multi-main mode). Importing the services here also registers any + // @OnLeaderTakeover/@OnLeaderStepdown decorators with MultiMainMetadata + // before start.ts:295 wires up the listeners. + const { AgentScheduleService } = await import('./integrations/agent-schedule.service'); + const { ChatIntegrationService } = await import('./integrations/chat-integration.service'); + const scheduleService = Container.get(AgentScheduleService); + const chatService = Container.get(ChatIntegrationService); + const logger = Container.get(Logger); + const instanceSettings = Container.get(InstanceSettings); + if (instanceSettings.isLeader) { + void chatService.reconnectAll().catch((error) => { + logger.error('[Agents] Failed to reconnect integrations on startup', { + error: error instanceof Error ? error.message : String(error), + }); + }); + void scheduleService.reconnectAll().catch((error) => { + logger.error('[Agents] Failed to reconnect schedules on startup', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } else { + logger.debug('[Agents] Skipping integration and schedule reconnect on startup — not leader'); + } + } + + // eslint-disable-next-line @typescript-eslint/require-await -- module contract requires async + async settings() { + const config = Container.get(AgentsConfig); + return { + enabled: true, + modules: [...config.modules], + }; + } + + async entities() { + const { Agent } = await import('./entities/agent.entity'); + const { AgentCheckpoint } = await import('./entities/agent-checkpoint.entity'); + const { AgentResourceEntity } = await import('./entities/agent-resource.entity'); + const { AgentThreadEntity } = await import('./entities/agent-thread.entity'); + const { AgentMessageEntity } = await import('./entities/agent-message.entity'); + const { AgentExecutionThread } = await import('./entities/agent-execution-thread.entity'); + const { AgentExecution } = await import('./entities/agent-execution.entity'); + const { AgentPublishedVersion } = await import('./entities/agent-published-version.entity'); + + return [ + Agent, + AgentCheckpoint, + AgentResourceEntity, + AgentThreadEntity, + AgentMessageEntity, + AgentExecutionThread, + AgentExecution, + AgentPublishedVersion, + ]; + } + + async context() { + const { AgentsService } = await import('./agents.service'); + + return { agentsService: Container.get(AgentsService) }; + } +} diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts new file mode 100644 index 00000000000..b88c1ecbfef --- /dev/null +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -0,0 +1,1572 @@ +import type { + BuiltAgent, + BuiltTool, + CredentialProvider, + GenerateResult, + StreamChunk, + ToolDescriptor, +} from '@n8n/agents'; +import { + AGENT_SCHEDULE_TRIGGER_TYPE, + isAgentCredentialIntegration, + isAgentScheduleIntegration, + type AgentSkill, + type AgentSkillMutationResponse, + type ChatIntegrationDescriptor, +} from '@n8n/api-types'; +import * as agents from '@n8n/agents'; +import { extractFromAIParameters } from '@n8n/ai-utilities'; +import { Logger } from '@n8n/backend-common'; +import { Time } from '@n8n/constants'; +import { + CredentialsRepository, + ExecutionRepository, + ProjectRelationRepository, + UserRepository, + WorkflowRepository, +} from '@n8n/db'; +import { Container, Service } from '@n8n/di'; +import { In } from '@n8n/typeorm'; +import { + deepCopy, + OperationalError, + UserError, + type ExecuteAgentData, + type INodeParameters, +} from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; + +import { ActiveExecutions } from '@/active-executions'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { resolveBuiltinNodeDefinitionDirs } from '@/modules/instance-ai/node-definition-resolver'; +import { EphemeralNodeExecutor } from '@/node-execution'; +import { UrlService } from '@/services/url.service'; +import { TtlMap } from '@/utils/ttl-map'; +import { WorkflowRunner } from '@/workflow-runner'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; +import { markAgentDraftDirty } from './utils/agent-draft.utils'; +import { generateAgentResourceId } from './utils/agent-resource-id'; +import { AgentExecutionService } from './agent-execution.service'; +import { AgentSkillsService } from './agent-skills.service'; +import { AgentsToolsService } from './agents-tools.service'; +import { AGENT_THREAD_PREFIX } from './builder/builder-tool-names'; +import { Agent } from './entities/agent.entity'; +import { ExecutionRecorder } from './execution-recorder'; +import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; +import { syncAgentIntegrations } from './integrations/integrations-sync'; +import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage'; +import { N8nMemory } from './integrations/n8n-memory'; +import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition'; +import { AgentJsonConfigSchema, isNodeToolsEnabled } from './json-config/agent-json-config'; +import type { + AgentJsonConfig, + AgentJsonConfigRef, + AgentJsonMemoryConfig, + AgentJsonToolConfig, +} from './json-config/agent-json-config'; +import { + buildFromJson, + type MemoryFactory, + type ToolResolver, +} from './json-config/from-json-config'; +import { AgentPublishedVersionRepository } from './repositories/agent-published-version.repository'; +import { AgentRepository } from './repositories/agent.repository'; +import { AgentSecureRuntime } from './runtime/agent-secure-runtime'; +import { buildToolRegistry, type ToolRegistry } from './tool-registry'; + +type AgentToolEntries = Agent['tools']; + +interface InjectRuntimeDependenciesParams { + agent: agents.Agent; + agentId: string; + projectId: string; + credentialProvider: CredentialProvider; + nodeToolsEnabled: boolean; + /** Chat platform the runtime is being reconstructed for — drives the rich_interaction tool's capability profile. */ + integrationType?: string; +} + +/** Derive a stable thread ID for the test-chat of a given agent and user. */ +export function chatThreadId(agentId: string, userId?: string): string { + const baseThreadId = `${AGENT_THREAD_PREFIX.TEST}${agentId}`; + return userId ? `${baseThreadId}:${userId}` : baseThreadId; +} + +/** Scopes the agent's memory to a specific conversation context. */ +export interface AgentMemoryScope { + threadId: string; + resourceId: string; +} + +export interface ExecuteForChatConfig { + agentId: string; + projectId: string; + message: string; + /** n8n user ID — used for RBAC / credential resolution. */ + userId: string; + /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ + memory: AgentMemoryScope; +} + +export interface ExecuteForChatPublishedConfig { + agentId: string; + projectId: string; + message: string; + /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ + memory: AgentMemoryScope; + integrationType?: string; +} + +export interface ResumeForChatConfig { + agentId: string; + projectId: string; + runId: string; + toolCallId: string; + resumeData: unknown; + /** + * Required when the suspended turn invoked a platform-injected tool + * (e.g. `rich_interaction`). Without it, `getRuntime` rebuilds the agent + * with only its configured tools, and `runtime.resume` throws because the + * persisted tool call references a tool the rebuilt runtime doesn't know. + */ + integrationType?: string; +} + +export interface ExecuteForSchedulePublishedConfig { + agentId: string; + projectId: string; + message: string; + /** Memory scope — resourceId isolates per-run memory. */ + memory: AgentMemoryScope; +} + +interface StreamChatResponseConfig { + agentInstance: agents.Agent; + toolRegistry: ToolRegistry; + agentId: string; + message: string; + memory: AgentMemoryScope; + projectId: string; + source?: string; +} + +interface GetRuntimeParams { + agentId: string; + projectId: string; + n8nUserId?: string; + integrationType?: string; + /** When true, load the published snapshot; n8nUserId is derived from publishedById when omitted. */ + usePublishedVersion?: boolean; +} + +@Service() +export class AgentsService { + /** + * Cached agent runtimes. Keys follow the pattern: + * Draft: `{agentId}:draft:{n8nUserId}` + * Published: `{agentId}:published[:{integrationType}]` + * + * TTL = 30 minutes — entries are evicted when the agent is idle so that + * memory is freed without requiring an explicit shutdown step. + * + * Separating draft and published with explicit prefixes prevents a draft + * runtime from being mistakenly returned to a published-agent execution. + */ + private readonly runtimes = new TtlMap< + string, + { agent: agents.Agent; agentId: string; toolRegistry: ToolRegistry; projectId: string } + >(30 * Time.minutes.toMilliseconds); + + /** + * Stash of user messages for suspended tool calls. + * When executeForChat suspends, we store the original message here so + * resumeForChat can record it against the execution. + */ + private readonly pendingUserMessages = new Map(); + + private computeRuntimeCacheKey(params: GetRuntimeParams): string { + if (params.usePublishedVersion) { + const parts = [params.agentId, 'published']; + if (params.integrationType) parts.push(params.integrationType); + return parts.join(':'); + } + const parts = [params.agentId, 'draft']; + if (params.n8nUserId) parts.push(params.n8nUserId); + return parts.join(':'); + } + + /** Remove all cached draft runtimes for an agent (all users). */ + private clearRuntimes(agentId: string): void { + for (const key of this.runtimes.keys()) { + if (key === agentId || key.startsWith(`${agentId}:`)) { + this.runtimes.delete(key); + } + } + } + + constructor( + private readonly logger: Logger, + private readonly agentRepository: AgentRepository, + private readonly projectRelationRepository: ProjectRelationRepository, + private readonly workflowRunner: WorkflowRunner, + private readonly activeExecutions: ActiveExecutions, + private readonly executionRepository: ExecutionRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly userRepository: UserRepository, + private readonly workflowFinderService: WorkflowFinderService, + private readonly urlService: UrlService, + private readonly n8nCheckpointStorage: N8NCheckpointStorage, + private readonly secureRuntime: AgentSecureRuntime, + private readonly ephemeralNodeExecutor: EphemeralNodeExecutor, + private readonly agentsToolsService: AgentsToolsService, + private readonly n8nMemory: N8nMemory, + private readonly agentExecutionService: AgentExecutionService, + private readonly agentPublishedVersionRepository: AgentPublishedVersionRepository, + private readonly agentSkillsService: AgentSkillsService, + ) {} + + /** + * Return the list of registered chat platform integrations with their + * FE display metadata. Used by `GET /agents/integrations`. + */ + listChatIntegrations(): ChatIntegrationDescriptor[] { + return Container.get(ChatIntegrationRegistry) + .list() + .map((i) => ({ + type: i.type, + label: i.displayLabel, + icon: i.displayIcon, + credentialTypes: i.credentialTypes, + })); + } + + async create(projectId: string, name: string): Promise { + // New agents start with no instructions so the home screen routes the + // first user message to the builder (/build) instead of to the chat + // endpoint. The builder fills in instructions and credentials. + const defaultConfig: AgentJsonConfig = { + name, + model: 'anthropic/claude-sonnet-4-5', + credential: '', + instructions: '', + tools: [], + skills: [], + }; + + const agent = this.agentRepository.create({ + name, + projectId, + schema: defaultConfig, + versionId: uuid(), + }); + + const saved = await this.agentRepository.save(agent); + + this.logger.debug('Created SDK agent', { agentId: saved.id, projectId }); + + return saved; + } + + async findByProjectId(projectId: string): Promise { + return await this.agentRepository.findByProjectId(projectId); + } + + async findById(agentId: string, projectId: string): Promise { + return await this.agentRepository.findByIdAndProjectId(agentId, projectId); + } + + async updateName(agentId: string, projectId: string, name: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + + if (!agent) { + return null; + } + + agent.name = name; + // Keep the JSON config name in sync so a subsequent config save doesn't + // revert the entity name back to the stale config value. + if (agent.schema) { + agent.schema = { ...agent.schema, name }; + } + markAgentDraftDirty(agent); + const saved = await this.agentRepository.save(agent); + this.logger.debug('Updated SDK agent name', { agentId, projectId, name }); + return saved; + } + + async updateDescription( + agentId: string, + projectId: string, + description: string, + updatedAt?: string, + ): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + + if (!agent) { + return null; + } + + if (updatedAt && agent.updatedAt.toISOString() !== updatedAt) { + throw new ConflictError('Agent has been modified'); + } + + agent.description = description; + if (agent.schema) { + agent.schema = { ...agent.schema, description }; + } + markAgentDraftDirty(agent); + const saved = await this.agentRepository.save(agent); + this.logger.debug('Updated SDK agent description', { agentId, projectId }); + return saved; + } + + async findByUser(userId: string): Promise { + const projectRelations = await this.projectRelationRepository.findAllByUser(userId); + const projectIds = projectRelations.map((pr) => pr.projectId); + + if (projectIds.length === 0) return []; + + return await this.agentRepository.find({ + where: { projectId: In(projectIds) }, + order: { updatedAt: 'DESC' }, + }); + } + + async publishAgent(agentId: string, projectId: string, userId: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + await this.agentRepository.manager.transaction(async (trx) => { + // publishedFromVersionId is non-null — ensure the agent has a versionId before snapshotting. + if (!agent.versionId) { + agent.versionId = uuid(); + await trx.save(agent); + } + + agent.publishedVersion = await this.agentPublishedVersionRepository.savePublishedVersion( + { + agentId: agent.id, + schema: agent.schema, + tools: this.snapshotConfiguredTools(agent.schema, agent.tools ?? {}), + skills: this.agentSkillsService.snapshotConfiguredSkills( + agent.schema, + agent.skills ?? {}, + ), + publishedFromVersionId: agent.versionId, + model: agent.model, + provider: agent.provider, + credentialId: agent.credentialId, + publishedById: userId, + }, + trx, + ); + }); + + // Evict any cached draft runtime so integration executions pick up + // the new published snapshot on their next request. + this.clearRuntimes(agentId); + + // Wake up any chat integrations that were persisted while the agent + // was a draft. ChatIntegrationService.syncToConfig gates connect on + // publish, so the entries sat dormant on agent.integrations; passing + // previous=[] makes every persisted integration an addition. + const credentialIntegrations = (agent.integrations ?? []).filter(isAgentCredentialIntegration); + if (credentialIntegrations.length > 0) { + // eslint-disable-next-line import-x/no-cycle + const { ChatIntegrationService } = await import('./integrations/chat-integration.service'); + await Container.get(ChatIntegrationService) + .syncToConfig(agent, [], credentialIntegrations) + .catch((error) => + this.logger.warn('Failed to connect integrations on publish', { + agentId, + error, + }), + ); + } + + this.logger.debug('Published SDK agent', { agentId, projectId, userId }); + + return agent; + } + + async unpublishAgent(agentId: string, projectId: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + await this.agentRepository.manager.transaction(async (trx) => { + await this.agentPublishedVersionRepository.deleteByAgentId(agentId, trx); + agent.publishedVersion = null; + + const hasActiveSchedule = (agent.integrations ?? []).some( + (integration) => isAgentScheduleIntegration(integration) && integration.active, + ); + + if (hasActiveSchedule) { + agent.integrations = (agent.integrations ?? []).map((integration) => + isAgentScheduleIntegration(integration) ? { ...integration, active: false } : integration, + ); + await trx.save(agent); + } + }); + + this.clearRuntimes(agentId); + + // Drop any live chat-integration connections so webhook endpoints stop + // accepting events immediately — before the 30-minute TTL would have expired. + // Lazy import avoids the circular DI dependency (ChatIntegrationService → AgentsService). + // eslint-disable-next-line import-x/no-cycle + const { ChatIntegrationService } = await import('./integrations/chat-integration.service'); + await Container.get(ChatIntegrationService).disconnect(agentId); + + const { AgentScheduleService } = await import('./integrations/agent-schedule.service'); + Container.get(AgentScheduleService).deregister(agentId); + + this.logger.debug('Unpublished SDK agent', { agentId, projectId }); + return agent; + } + + async revertToPublishedAgent(agentId: string, projectId: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + const publishedVersion = agent.publishedVersion; + if (!publishedVersion) { + throw new ConflictError(`Agent "${agentId}" is not published`); + } + + await this.agentRepository.manager.transaction(async (trx) => { + agent.schema = publishedVersion.schema ? deepCopy(publishedVersion.schema) : null; + agent.tools = deepCopy(publishedVersion.tools ?? {}); + agent.skills = deepCopy(publishedVersion.skills ?? {}); + agent.model = publishedVersion.model; + agent.provider = publishedVersion.provider; + agent.credentialId = publishedVersion.credentialId; + agent.versionId = publishedVersion.publishedFromVersionId; + + if (agent.schema) { + agent.name = agent.schema.name; + agent.description = agent.schema.description ?? null; + } + + await trx.save(agent); + }); + + this.clearRuntimes(agentId); + + this.logger.debug('Reverted SDK agent to published version', { agentId, projectId }); + return agent; + } + + async delete(agentId: string, projectId: string): Promise { + const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + + if (!agent) { + return false; + } + + await this.agentRepository.remove(agent); + + this.clearRuntimes(agentId); + + try { + const { AgentScheduleService } = await import('./integrations/agent-schedule.service'); + Container.get(AgentScheduleService).deregister(agentId); + } catch (error) { + this.logger.warn('Failed to stop schedule on agent delete', { + agentId, + error: error instanceof Error ? error.message : error, + }); + } + + // Remove the test-chat thread + its messages so deleting an agent + // doesn't leave orphaned rows in agents_threads / agents_messages. + // Swallow errors — the agent is already gone; best-effort cleanup. + // Log at warn level so orphaned rows are observable in production. + try { + await this.clearAllTestChatMessages(agentId); + } catch (error) { + this.logger.warn('Failed to clear test chat on agent delete', { + agentId, + error: error instanceof Error ? error.message : error, + }); + } + + this.logger.debug('Deleted SDK agent', { agentId, projectId }); + + return true; + } + + /** Return persisted chat messages for a given session/thread. */ + async getChatMessages(threadId: string) { + return await this.n8nMemory.getMessages(threadId); + } + + private getMemoryFactory(): MemoryFactory { + return (params: AgentJsonMemoryConfig) => { + if (params.storage === 'n8n') { + return this.n8nMemory; + } + if (params.storage === 'sqlite') { + return new agents.SqliteMemory(agents.SqliteMemoryConfigSchema.parse(params)); + } + throw new Error(`Unsupported memory storage: ${params.storage}`); + }; + } + + /** Create a credential provider scoped to a project. */ + private createCredentialProvider(projectId: string): AgentsCredentialProvider { + return new AgentsCredentialProvider(Container.get(CredentialsService), projectId); + } + + /** + * Return a cached runtime, or reconstruct one from the DB. + */ + private async getRuntime(params: GetRuntimeParams): Promise<{ + agent: agents.Agent; + agentId: string; + toolRegistry: ToolRegistry; + projectId: string; + }> { + const { agentId, projectId, integrationType, usePublishedVersion } = params; + + const cacheKey = this.computeRuntimeCacheKey(params); + + const cached = this.runtimes.get(cacheKey); + if (cached) return cached; + + const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agentEntity) throw new NotFoundError(`Agent ${agentId} not found`); + + let n8nUserId = params.n8nUserId; + let agentData: Agent = agentEntity; + + if (usePublishedVersion) { + const publishedSchema = agentEntity.publishedVersion?.schema; + if (!publishedSchema) { + throw new NotFoundError(`Agent ${agentId} is not published`); + } + agentData = { + ...agentEntity, + schema: publishedSchema, + tools: agentEntity.publishedVersion?.tools ?? agentEntity.tools ?? {}, + skills: agentEntity.publishedVersion?.skills ?? agentEntity.skills ?? {}, + } as Agent; + + // Resolve n8n user from publishedById when not provided by the caller. + n8nUserId ??= agentEntity.publishedVersion?.publishedById ?? undefined; + } + + if (!n8nUserId) { + throw new UserError('Agent user owner id is required'); + } + + const credentialProvider = this.createCredentialProvider(projectId); + const { agent: agentInstance, toolRegistry } = await this.reconstructFromConfig( + agentData, + credentialProvider, + n8nUserId, + integrationType, + ); + + this.runtimes.set(cacheKey, { agent: agentInstance, agentId, toolRegistry, projectId }); + const runtime = this.runtimes.get(cacheKey); + if (!runtime) throw new Error(`Agent ${agentId} failed to reconstruct`); + return runtime; + } + + /** + * Returns a `resolveTool` callback for `Agent.fromSchema()` that converts + * non-editable tool schema entries into functional `BuiltTool` implementations. + * + * Detects the tool type via `metadata.workflowTool` / `metadata.nodeTool` and + * delegates to the appropriate factory. Returns `null` for unknown types so that + * `fromSchema` falls back to a passthrough marker. + */ + private makeToolResolver(projectId: string, userId?: string): ToolResolver { + return async (ref: AgentJsonToolConfig) => { + if (ref.type === 'workflow') { + if (!userId) { + throw new UserError('userId is required when agent uses workflow tools'); + } + const { resolveWorkflowTool } = await import('./tools/workflow-tool-factory'); + return await resolveWorkflowTool(ref, { + workflowRepository: this.workflowRepository, + workflowRunner: this.workflowRunner, + activeExecutions: this.activeExecutions, + executionRepository: this.executionRepository, + workflowFinderService: this.workflowFinderService, + userRepository: this.userRepository, + userId, + projectId, + webhookBaseUrl: this.urlService.getWebhookBaseUrl(), + }); + } + + if (ref.type === 'node') { + const { resolveNodeTool } = await import('./tools/node-tool-factory'); + return await resolveNodeTool(ref, { + executor: this.ephemeralNodeExecutor, + projectId, + }); + } + + return null; + }; + } + + /** + * Inject platform-level tools and storage into an agent instance. + * Workflow and node tools are resolved earlier via `makeToolResolver()` inside + * `fromSchema()`, so this method only handles host-side singletons. + * + * `nodeToolsEnabled` comes from the agent's `config.nodeTools.enabled` flag + * (opt-in, defaults to false) — see {@link isNodeToolsEnabled}. + */ + private async injectRuntimeDependencies(params: InjectRuntimeDependenciesParams): Promise { + const { agent, agentId, projectId, credentialProvider, nodeToolsEnabled, integrationType } = + params; + + // Inject the rich_interaction tool only for platforms that can actually + // render its suspend/resume HITL cards. Two gates: + // - A registered integration in ChatIntegrationRegistry. The in-app + // test chat uses `integrationType = 'chat'`, which isn't registered, + // and the compile/validate path passes no integrationType at all — + // neither has a bridge to render the card or resume the suspended + // turn, so letting the model call the tool there would hang the + // agent. + // - The integration must declare `supportedComponents`. Platforms + // that omit it (e.g. Linear) have explicitly opted out of + // rich_interaction. + const integration = integrationType + ? Container.get(ChatIntegrationRegistry).get(integrationType) + : undefined; + if (integration?.supportedComponents !== undefined) { + try { + const { createRichInteractionTool } = await import('./integrations/rich-interaction-tool'); + agent.tool(createRichInteractionTool(integrationType)); + } catch (toolError) { + this.logger.warn('Failed to inject rich_interaction tool', { + agentId, + error: toolError instanceof Error ? toolError.message : String(toolError), + }); + } + } + + if (nodeToolsEnabled) { + this.attachNodeToolChain(agent, credentialProvider, projectId); + } + + // Inject checkpoint storage + if (!agent.hasCheckpointStorage()) { + agent.checkpoint(this.n8nCheckpointStorage); + } + } + + /** + * Attaches the built-in node tool chain (search_nodes, get_node_types, + * list_credentials, run_node_tool) so the agent can discover and execute + * n8n nodes on demand. Sourced from {@link AgentsToolsService}, which in + * turn delegates to `NodeCatalogService`. + */ + private attachNodeToolChain( + agent: agents.Agent, + credentialProvider: CredentialProvider, + projectId: string, + ): void { + agent.tool(this.agentsToolsService.getRuntimeTools(credentialProvider, projectId)); + } + + /** + * Resume a suspended tool call and yield the resulting stream chunks. + * Used by chat integration handlers to continue an agent run after + * a human-in-the-loop action (button click, modal submission). + */ + async *resumeForChat(config: ResumeForChatConfig): AsyncGenerator { + const { agentId, projectId, runId, toolCallId, resumeData, integrationType } = config; + + const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId); + if (checkpointStatus.status === 'expired') { + throw new UserError(`Checkpoint ${runId} is expired and cannot be resumed`); + } + + if (checkpointStatus.status === 'not-found') { + throw new UserError(`Checkpoint ${runId} not found and cannot be resumed`); + } + + const memoryScope = checkpointStatus.checkpoint?.persistence; + if (!memoryScope) { + throw new UserError(`Checkpoint ${runId} has no memory data and cannot be resumed`); + } + + const threadId = memoryScope.threadId; + + const runtime = await this.getRuntime({ + agentId, + projectId, + usePublishedVersion: true, + integrationType, + }); + + const { agent: agentInstance, toolRegistry } = runtime; + const recorder = new ExecutionRecorder(toolRegistry); + + const resultStream = await agentInstance.resume('stream', resumeData, { + runId, + toolCallId, + }); + + const reader = resultStream.stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + recorder.record(value); + yield value; + } + } finally { + reader.releaseLock(); + } + + // Always record resumed executions — even if they suspend again (chained HITL). + // Don't repeat the original user message — the pre-suspension execution already has it. + if (!recorder.suspended) { + this.pendingUserMessages.delete(agentId); + } + const messageRecord = recorder.getMessageRecord(); + void this.agentExecutionService + .recordMessage({ + threadId, + agentId, + agentName: agentInstance.name, + projectId, + userMessage: '', + record: messageRecord, + hitlStatus: 'resumed', + }) + .catch((error) => { + this.logger.warn('Failed to record resumed agent execution', { + agentId, + threadId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + /** + * Check whether an agent has the minimum config it needs to be run. + * Returns the list of missing/invalid fields, if any. + * + * `missing` items correspond to user-facing concepts: + * - "instructions": empty or whitespace-only instructions string + * - "model": missing model or one that fails the provider/model regex + * - "credential": credential name is set in config but doesn't resolve to + * a real credential in the project + * - "skill:": config references a skill id with no stored body + */ + async validateAgentIsRunnable( + agentId: string, + projectId: string, + credentialProvider: CredentialProvider, + ): Promise<{ missing: string[] }> { + const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agentEntity) { + return { missing: ['agent'] }; + } + // Schema is persisted as JSON — double-cast rehydrates to the typed config. + const config = agentEntity.schema as unknown as AgentJsonConfig | null; + const missing: string[] = []; + + if (!config) { + return { missing: ['instructions', 'model'] }; + } + + if (!config.instructions?.trim()) { + missing.push('instructions'); + } + + const modelSchema = AgentJsonConfigSchema.shape.model; + if (!config.model || !modelSchema.safeParse(config.model).success) { + missing.push('model'); + } + + if (config.credential) { + try { + const credentialName = config.credential; + const creds = await credentialProvider.list(); + const exists = creds.some( + (c) => c.id === credentialName || c.name.toLowerCase() === credentialName.toLowerCase(), + ); + if (!exists) missing.push('credential'); + } catch { + // If listing fails (e.g. permissions), don't flag as misconfigured — + // the runtime will surface the real error path on execute. + } + } + + missing.push( + ...this.agentSkillsService + .getMissingSkillIds(config, agentEntity.skills ?? {}) + .map((skillId) => `skill:${skillId}`), + ); + + return { missing }; + } + + /** + * Execute an agent for the in-app test chat and yield stream chunks. + * + * `userId` is the authenticated n8n user (used for RBAC / credential + * resolution). `memory.resourceId` scopes the agent's memory so each + * user sees their own conversation; for test-chat both equal userId. + * + */ + async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator { + const { agentId, projectId, message, userId, memory } = config; + + const runtime = await this.getRuntime({ agentId, projectId, n8nUserId: userId }); + + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + message, + memory, + projectId: runtime.projectId, + }); + } + + /** + * Return persisted test-chat messages for an agent scoped to the current + * user. Test-chat threads are keyed by agent and user so thread-scoped + * working memory stays isolated. + */ + async getTestChatMessages(agentId: string, userId: string) { + return await this.n8nMemory.getMessages(chatThreadId(agentId, userId), { + resourceId: userId, + }); + } + + /** + * Clear the current user's test-chat messages for an agent. + */ + async clearTestChatMessages(agentId: string, userId: string) { + await this.n8nMemory.deleteMessagesByThread(chatThreadId(agentId, userId), userId); + } + + /** Delete all test-chat messages + the thread row — used when the agent itself is deleted. */ + async clearAllTestChatMessages(agentId: string) { + const threadId = chatThreadId(agentId); + await this.n8nMemory.deleteThreadsByPrefix(threadId); + await this.n8nMemory.deleteMessagesByThread(threadId); + await this.n8nMemory.deleteThread(threadId); + } + + /** + * Execute a published agent for a chat integration (Slack, Telegram, …). + * + * Loads the published snapshot — never the draft. + */ + async *executeForChatPublished( + config: ExecuteForChatPublishedConfig, + ): AsyncGenerator { + const { agentId, projectId, message, memory, integrationType } = config; + + const runtime = await this.getRuntime({ + agentId, + projectId, + integrationType, + usePublishedVersion: true, + }); + + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + message, + memory, + projectId: runtime.projectId, + source: integrationType, + }); + } + + /** + * Execute a published agent for the local schedule trigger. + * + * The n8n user identity for RBAC is resolved from + * `publishedVersion.publishedById`. Each scheduled run uses its own + * memory scope so no conversation history is shared across runs. + * `projectId` is resolved from the agent entity. + */ + async *executeForSchedulePublished( + config: ExecuteForSchedulePublishedConfig, + ): AsyncGenerator { + const { agentId, projectId, message, memory } = config; + + // One shared compiled runtime per agent for all schedule runs. + const runtime = await this.getRuntime({ + agentId, + projectId, + integrationType: AGENT_SCHEDULE_TRIGGER_TYPE, + usePublishedVersion: true, + }); + + yield* this.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + message, + memory, + projectId: runtime.projectId, + source: AGENT_SCHEDULE_TRIGGER_TYPE, + }); + } + + /** + * Stream an agent response, record it, and yield each chunk. + * + * `config.memory.resourceId` is passed as `persistence.resourceId` to + * `agentInstance.stream()` to scope memory per chat user — it is + * deliberately distinct from the n8n user ID used for RBAC. + */ + private async *streamChatResponse(config: StreamChatResponseConfig): AsyncGenerator { + const { agentInstance, toolRegistry, agentId, message, memory, projectId, source } = config; + const { threadId, resourceId } = memory; + + const recorder = new ExecutionRecorder(toolRegistry); + + const resultStream = await agentInstance.stream(message, { + persistence: { threadId, resourceId }, + }); + + const reader = resultStream.stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + recorder.record(value); + if (value.type === 'tool-call-suspended') { + this.logger.info('Chat: tool-call-suspended chunk received', { + agentId, + toolCallId: value.toolCallId, + toolName: value.toolName, + }); + } + yield value; + } + } finally { + reader.releaseLock(); + } + + // Always record — even if suspended, the pre-suspension response text + // and tool calls are valuable. Usage/model will be null for suspended runs. + if (recorder.suspended) { + this.pendingUserMessages.set(agentId, message); + } + + const messageRecord = recorder.getMessageRecord(); + void this.agentExecutionService + .recordMessage({ + threadId, + agentId, + agentName: agentInstance.name, + projectId, + userMessage: message, + record: messageRecord, + hitlStatus: recorder.suspended ? 'suspended' : undefined, + source, + }) + .catch((error) => { + this.logger.warn('Failed to record agent execution', { + agentId, + threadId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + /** + * Compile an agent in isolation without writing to the shared runtime cache. + * Used by executeForWorkflow so that concurrent Slack / chat executions + * are not affected. + */ + private async compileIsolated( + agentEntity: Agent, + credentialProvider: CredentialProvider, + userId: string, + ): Promise<{ ok: boolean; agent?: BuiltAgent; error?: string }> { + if (!agentEntity.schema) { + return { ok: false, error: 'Agent has no JSON config. Create a config first.' }; + } + + try { + const { agent: reconstructed } = await this.reconstructFromConfig( + agentEntity, + credentialProvider, + userId, + ); + return { ok: true, agent: reconstructed as BuiltAgent }; + } catch (e) { + return { + ok: false, + error: e instanceof Error ? e.message : 'Unknown compilation error', + }; + } + } + + /** + * Execute an SDK agent within a workflow execution context. + * Compiles a fresh isolated agent per call for credential isolation + * (does not use or affect the shared runtime cache). + */ + async executeForWorkflow( + agentId: string, + message: string, + executionId: string, + threadId: string, + userId: string, + projectId: string, + ): Promise { + const agentEntity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!agentEntity) { + throw new OperationalError('Agent not found or not accessible.'); + } + + if (!agentEntity.publishedVersion) { + throw new OperationalError( + 'Agent is not published. Publish the agent before using it in a workflow.', + ); + } + + const credentialProvider = new AgentsCredentialProvider( + Container.get(CredentialsService), + projectId, + ); + + const compiled = await this.compileIsolated(agentEntity, credentialProvider, userId); + if (!compiled.ok || !compiled.agent) { + throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`); + } + + const result = await compiled.agent.generate(message, { + persistence: { + resourceId: executionId, + threadId, + }, + }); + + // Check for errors + if (result.error) { + const errorMessage = + result.error instanceof Error + ? result.error.message + : typeof result.error === 'string' + ? result.error + : JSON.stringify(result.error); + throw new OperationalError(`Agent execution failed: ${errorMessage}`); + } + + if (result.finishReason === 'error') { + throw new OperationalError('Agent execution finished with an error.'); + } + + if (result.pendingSuspend && result.pendingSuspend.length > 0) { + const toolNames = result.pendingSuspend + .map((s: { toolName: string }) => s.toolName) + .join(', '); + throw new OperationalError( + `Agent execution suspended waiting for tool approval: ${toolNames}. ` + + 'Suspend/resume is not supported in workflow execution context.', + ); + } + + return { + response: this.extractTextResponse(result), + structuredOutput: result.structuredOutput ?? null, + usage: result.usage + ? { + promptTokens: result.usage.promptTokens, + completionTokens: result.usage.completionTokens, + totalTokens: result.usage.totalTokens, + } + : null, + toolCalls: (result.toolCalls ?? []).map( + (tc: { tool: string; input: unknown; output: unknown }) => ({ + toolName: tc.tool, + input: tc.input, + result: tc.output, + }), + ), + finishReason: result.finishReason ?? 'stop', + }; + } + + /** + * Extract the text response from the last assistant message in a GenerateResult. + */ + private extractTextResponse(result: GenerateResult): string { + for (let i = result.messages.length - 1; i >= 0; i--) { + const msg = result.messages[i]; + if (msg.type !== 'custom' && msg.role === 'assistant' && Array.isArray(msg.content)) { + const textParts = (msg.content as Array<{ type: string; text?: string }>) + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text); + if (textParts.length > 0) { + return textParts.join(''); + } + } + } + return ''; + } + + /** + * 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 config = composeJsonConfig(entity); + if (!config) { + throw new UserError('Agent has no JSON config yet.'); + } + return config; + } + + /** + * Validate an AgentJsonConfig: runs Zod schema validation and checks any + * node tool configurations against their JSON-Schema definitions. + * + * Returns `{ valid: true, config }` on success, or `{ valid: false, error }` + * with a human-readable message on failure. + */ + async validateConfig( + raw: unknown, + ): Promise<{ valid: true; config: AgentJsonConfig } | { valid: false; error: string }> { + const parsed = AgentJsonConfigSchema.safeParse(raw); + if (!parsed.success) { + return { valid: false, error: parsed.error.message }; + } + + const config = parsed.data; + + try { + this.validateNodeToolExpressions(config); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + valid: false, + error: `Invalid $fromAI expression in node tool config: ${message}`, + }; + } + + const nodeError = await this.validateNodeToolConfigs(config); + if (nodeError) { + return { valid: false, error: nodeError }; + } + + return { valid: true, config }; + } + + private validateNodeToolExpressions(config: AgentJsonConfig): void { + for (const tool of config.tools ?? []) { + if (tool.type !== 'node') continue; + + extractFromAIParameters((tool.node.nodeParameters ?? {}) as INodeParameters); + } + } + + /** + * Backfill `credentialName` on credential integrations that were created + * before the field was required. Looks up the name by `credentialId` and + * splices it into the config; integrations that already have a name, or + * aren't credential integrations at all, pass through untouched. + * + * If a credential id no longer resolves, the integration is left as-is — + * validation will then fail with a clear "credentialName required" error + * pointing at the orphaned integration, which is the correct outcome. + */ + private async healIntegrationCredentialNames(rawConfig: unknown): Promise { + if (!rawConfig || typeof rawConfig !== 'object') return rawConfig; + const cfg = rawConfig as { integrations?: unknown }; + if (!Array.isArray(cfg.integrations)) return rawConfig; + + const missingIds = new Set(); + for (const integration of cfg.integrations) { + if (!integration || typeof integration !== 'object') continue; + const i = integration as { credentialId?: unknown; credentialName?: unknown }; + if (typeof i.credentialId === 'string' && i.credentialName === undefined) { + missingIds.add(i.credentialId); + } + } + if (missingIds.size === 0) return rawConfig; + + const credentials = await Container.get(CredentialsRepository).findBy({ + id: In(Array.from(missingIds)), + }); + const namesById = new Map(credentials.map((c) => [c.id, c.name])); + + const integrations: unknown[] = cfg.integrations; + return { + ...cfg, + integrations: integrations.map((integration: unknown): unknown => { + if (!integration || typeof integration !== 'object') return integration; + const i = integration as { credentialId?: unknown; credentialName?: unknown }; + if (typeof i.credentialId !== 'string' || i.credentialName !== undefined) { + return integration; + } + const name = namesById.get(i.credentialId); + return name ? { ...integration, credentialName: name } : integration; + }), + }; + } + + /** + * Persist a new AgentJsonConfig (full replace). + */ + async updateConfig( + agentId: string, + projectId: string, + config: unknown, + ): Promise<{ config: AgentJsonConfig; updatedAt: string; versionId: string | null }> { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + // Repair integrations missing `credentialName`. Agents created before + // the field was added to the schema return integrations of the form + // `{ type, credentialId }` from `findById`; the UI sends them back + // unchanged on save and validation rejects them as invalid. Look the + // names up by id once here so the next save persists the full shape. + const healedConfig = await this.healIntegrationCredentialNames(config); + + const result = await this.validateConfig(healedConfig); + if (!result.valid) { + throw new UserError(`Invalid agent config: ${result.error}`); + } + + this.validateConfigRefs(result.config, entity); + + const previousIntegrations = entity.integrations ?? []; + const { schemaConfig, integrations: nextIntegrations } = decomposeJsonConfig(result.config); + + entity.schema = schemaConfig as AgentJsonConfig; + entity.name = result.config.name; + entity.description = result.config.description ?? null; + entity.integrations = nextIntegrations; + markAgentDraftDirty(entity); + + // Remove tool entries that are no longer referenced in the config + const referencedIds = new Set( + (result.config.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; + } + + this.agentSkillsService.removeUnreferencedSkills(entity, result.config); + + // Invalidate runtime caches + this.clearRuntimes(agentId); + + const saved = await this.agentRepository.save(entity); + this.logger.debug('Updated agent JSON config', { agentId, projectId }); + + await syncAgentIntegrations(saved, previousIntegrations, nextIntegrations, this.logger); + + return { + config: composeJsonConfig(saved) ?? result.config, + updatedAt: saved.updatedAt.toISOString(), + versionId: saved.versionId, + }; + } + + /** + * Validate and persist a custom tool for an agent. + * The tool code is described in an isolate, and the descriptor + code + * are stored in the agent's `tools` column. + */ + async buildCustomTool( + agentId: string, + projectId: string, + code: string, + descriptor: ToolDescriptor, + ): Promise<{ ok: boolean; id: string; descriptor: ToolDescriptor }> { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + const toolId = generateAgentResourceId('tool', Object.keys(entity.tools ?? {})); + + // Store tool code + descriptor. Registering the tool in the agent config + // (adding `{ type: "custom", id }` to `schema.tools`) is the caller's + // responsibility — typically via a follow-up patch_config / write_config — + // so this method does not touch `entity.schema`. + entity.tools = { + ...entity.tools, + [toolId]: { code, descriptor }, + }; + + markAgentDraftDirty(entity); + this.clearRuntimes(agentId); + await this.agentRepository.save(entity); + + this.logger.debug('Built custom tool', { agentId, projectId, toolId }); + + return { ok: true, id: toolId, descriptor }; + } + + async listSkills(agentId: string, projectId: string): Promise> { + return await this.agentSkillsService.listSkills(agentId, projectId); + } + + async getSkill(agentId: string, projectId: string, skillId: string): Promise { + return await this.agentSkillsService.getSkill(agentId, projectId, skillId); + } + + async createSkill( + agentId: string, + projectId: string, + skill: AgentSkill, + ): Promise { + const result = await this.agentSkillsService.createSkill(agentId, projectId, skill); + this.clearRuntimes(agentId); + return result; + } + + async createAndAttachSkill( + agentId: string, + projectId: string, + skill: AgentSkill, + ): Promise { + const result = await this.agentSkillsService.createAndAttachSkill(agentId, projectId, skill); + this.clearRuntimes(agentId); + return result; + } + + async updateSkill( + agentId: string, + projectId: string, + skillId: string, + updates: Partial, + ): Promise { + const result = await this.agentSkillsService.updateSkill(agentId, projectId, skillId, updates); + this.clearRuntimes(agentId); + return result; + } + + /** + * Remove a custom tool from an agent. + */ + async deleteCustomTool(agentId: string, projectId: string, toolId: string): Promise { + const entity = await this.agentRepository.findByIdAndProjectId(agentId, projectId); + if (!entity) throw new NotFoundError('Agent not found'); + + const tools = { ...entity.tools }; + delete tools[toolId]; + entity.tools = tools; + + // Remove from config tools array + if (entity.schema?.tools) { + entity.schema.tools = entity.schema.tools.filter( + (t: AgentJsonConfigRef) => !(t.type === 'custom' && 'id' in t && t.id === toolId), + ); + } + + markAgentDraftDirty(entity); + this.clearRuntimes(agentId); + await this.agentRepository.save(entity); + + this.logger.debug('Deleted custom tool', { agentId, projectId, toolId }); + } + + async deleteSkill(agentId: string, projectId: string, skillId: string): Promise { + await this.agentSkillsService.deleteSkill(agentId, projectId, skillId); + this.clearRuntimes(agentId); + } + + /** + * Validate the node configurations for any node-type tools in an AgentJsonConfig. + * + * Node tool schemas are validated on the host (not in the sandbox) because the + * real Zod schemas are only available in the host process. + * + * @returns A formatted error string if any node config is invalid, or null if all pass. + */ + private async validateNodeToolConfigs(config: AgentJsonConfig): Promise { + const nodeTools = (config.tools ?? []).filter( + (t): t is Extract => t.type === 'node', + ); + + if (nodeTools.length === 0) return null; + + const { setSchemaBaseDirs, validateNodeConfig } = await import('@n8n/workflow-sdk'); + + const dirs = resolveBuiltinNodeDefinitionDirs(); + if (dirs.length > 0) { + setSchemaBaseDirs(dirs); + } + + const errors: string[] = []; + + for (const tool of nodeTools) { + const nodeType: string = tool.node.nodeType; + const nodeTypeVersion: number = tool.node.nodeTypeVersion; + const nodeParameters = tool.node.nodeParameters ?? {}; + + const result = validateNodeConfig( + nodeType, + nodeTypeVersion, + { parameters: nodeParameters }, + { isToolNode: true }, + ); + + if (!result.valid) { + const messages = result.errors + .map((e: { path: string; message: string }) => e.message) + .join('; '); + errors.push(`Node tool "${tool.name}" (${nodeType}@${nodeTypeVersion}): ${messages}`); + } + } + + return errors.length > 0 ? errors.join('\n') : null; + } + + private validateConfigRefs(config: AgentJsonConfig, entity: Agent) { + const missingSkillIds = this.agentSkillsService.getMissingSkillIds(config, entity.skills ?? {}); + if (missingSkillIds.length > 0) { + throw new UserError( + `Invalid agent config: Missing skill bodies: ${missingSkillIds.join(', ')}`, + ); + } + + const missingToolIds = this.getMissingCustomToolIds(config, entity.tools ?? {}); + if (missingToolIds.length > 0) { + throw new UserError( + `Invalid agent config: Missing custom tool definitions: ${missingToolIds.join(', ')}`, + ); + } + + // Mirror AgentScheduleService.activate(): a schedule integration cannot be + // active until the agent has a published version. Otherwise the persisted + // config can claim active=true while the cron registration silently + // refuses to register. + const activeUnpublishedSchedule = (config.integrations ?? []).some( + (integration) => isAgentScheduleIntegration(integration) && integration.active, + ); + if (activeUnpublishedSchedule && !entity.publishedVersion) { + throw new UserError( + 'Invalid agent config: schedule integration cannot be active until the agent is published', + ); + } + } + + private getMissingCustomToolIds( + config: AgentJsonConfig | null, + tools: AgentToolEntries, + ): string[] { + const refs = (config?.tools ?? []).filter( + (ref): ref is Extract => ref.type === 'custom', + ); + const seen = new Set(); + const missing: string[] = []; + + for (const ref of refs) { + if (seen.has(ref.id)) continue; + seen.add(ref.id); + if (!tools[ref.id]) missing.push(ref.id); + } + + return missing; + } + + private snapshotConfiguredTools( + config: AgentJsonConfig | null, + tools: AgentToolEntries, + ): AgentToolEntries | null { + if (!config) return null; + const missing = this.getMissingCustomToolIds(config, tools); + if (missing.length > 0) { + throw new UserError(`Cannot publish agent with missing custom tools: ${missing.join(', ')}`); + } + + const snapshot: AgentToolEntries = {}; + for (const ref of config.tools ?? []) { + if (ref.type !== 'custom') continue; + const tool = tools[ref.id]; + if (tool) snapshot[ref.id] = tool; + } + return snapshot; + } + + /** + * Reconstruct an agent from its JSON config using buildFromJson(). + * This is the new execution path for JSON-config agents. + */ + private async reconstructFromConfig( + agentEntity: Agent, + credentialProvider: CredentialProvider, + userId: string, + integrationType?: string, + ): Promise<{ agent: agents.Agent; toolRegistry: ToolRegistry }> { + const config = agentEntity.schema; + if (!config) { + throw new UserError('Agent has no JSON config.'); + } + + // Build toolsByName map: { toolName -> code } + const toolsByName: Record = {}; + for (const [_toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) { + toolsByName[toolEntry.descriptor.name] = toolEntry.code; + } + + // Build toolDescriptors map: { toolId -> descriptor } + const toolDescriptors: Record = {}; + for (const [toolId, toolEntry] of Object.entries(agentEntity.tools ?? {})) { + toolDescriptors[toolId] = toolEntry.descriptor; + } + + const toolExecutor = this.secureRuntime.createToolExecutor(toolsByName); + + const toolResolver = this.makeToolResolver(agentEntity.projectId, userId); + + const resolvedTools: BuiltTool[] = []; + + const reconstructed = await buildFromJson(config, toolDescriptors, { + toolExecutor, + credentialProvider, + resolveTool: async (ref) => { + const resolved = await toolResolver(ref); + if (resolved) resolvedTools.push(resolved); + return resolved; + }, + skills: agentEntity.skills ?? {}, + memoryFactory: this.getMemoryFactory(), + }); + + await this.injectRuntimeDependencies({ + agent: reconstructed, + agentId: agentEntity.id, + projectId: agentEntity.projectId, + credentialProvider, + nodeToolsEnabled: isNodeToolsEnabled(config.config), + integrationType, + }); + + const toolRegistry = buildToolRegistry(resolvedTools); + return { agent: reconstructed, toolRegistry }; + } +} diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-settings.service.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-settings.service.test.ts new file mode 100644 index 00000000000..32973a508c6 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-settings.service.test.ts @@ -0,0 +1,374 @@ +import type { Logger } from '@n8n/backend-common'; +import type { CredentialsEntity, SettingsRepository, User } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; + +import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import type { AiService } from '@/services/ai.service'; +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import type { CredentialsService } from '@/credentials/credentials.service'; + +import { AgentsBuilderSettingsService } from '../agents-builder-settings.service'; +import { BUILDER_NOT_CONFIGURED_CODE, BuilderNotConfiguredError } from '../errors'; + +const ENV_KEYS = ['N8N_AI_ANTHROPIC_KEY', 'ANTHROPIC_API_KEY'] as const; + +function clearEnvKeys() { + for (const key of ENV_KEYS) delete process.env[key]; +} + +describe('AgentsBuilderSettingsService', () => { + const logger = mock(); + const settingsRepository = mock(); + const aiService = mock(); + const credentialsService = mock(); + const credentialsFinderService = mock(); + const user = mock({ id: 'user-1' }); + + let service: AgentsBuilderSettingsService; + + beforeEach(() => { + jest.clearAllMocks(); + clearEnvKeys(); + service = new AgentsBuilderSettingsService( + logger, + settingsRepository, + aiService, + credentialsService, + credentialsFinderService, + ); + }); + + afterAll(() => { + clearEnvKeys(); + }); + + function mockPersistedSettings(value: unknown) { + settingsRepository.findByKey.mockResolvedValue( + value === null + ? null + : ({ key: 'agentBuilder.settings', value: JSON.stringify(value) } as never), + ); + } + + function mockCustomCredential(opts: { + credentialType?: string; + apiKey?: string; + url?: string; + }) { + const credential = mock({ + id: 'cred-1', + type: opts.credentialType ?? 'anthropicApi', + }); + credentialsFinderService.findCredentialById.mockResolvedValue(credential); + credentialsService.decrypt.mockReturnValue({ + apiKey: opts.apiKey ?? 'sk-test', + ...(opts.url ? { url: opts.url } : {}), + } as never); + return credential; + } + + describe('resolveModelConfig', () => { + it('mode=default + proxy enabled → returns proxy LanguageModel', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(true); + aiService.getClient.mockResolvedValue({ + getApiProxyBaseUrl: () => 'https://proxy.example/api', + getBuilderApiProxyToken: jest + .fn() + .mockResolvedValue({ accessToken: 'tok', tokenType: 'Bearer' }), + } as never); + + const result = await service.resolveModelConfig(user); + + expect(result.isProxied).toBe(true); + expect(result.config).toBeDefined(); + }); + + it('mode=default + proxy disabled + env set → returns env-var anthropic config', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(false); + process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env'; + + const result = await service.resolveModelConfig(user); + + expect(result).toEqual({ + config: { + id: 'anthropic/claude-sonnet-4-5', + apiKey: 'sk-env', + }, + isProxied: false, + }); + }); + + it('mode=default + proxy disabled + env empty → throws BuilderNotConfiguredError', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(false); + + await expect(service.resolveModelConfig(user)).rejects.toBeInstanceOf( + BuilderNotConfiguredError, + ); + await expect(service.resolveModelConfig(user)).rejects.toMatchObject({ + code: BUILDER_NOT_CONFIGURED_CODE, + }); + }); + + it('mode=custom with valid credential → returns provider/model config', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }); + aiService.isProxyEnabled.mockReturnValue(false); + mockCustomCredential({ apiKey: 'sk-user' }); + + const result = await service.resolveModelConfig(user); + + expect(result).toEqual({ + config: { + id: 'anthropic/claude-3-5-sonnet', + apiKey: 'sk-user', + }, + isProxied: false, + }); + }); + + it('mode=custom with base URL → returns config with baseURL field', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'openai', + credentialId: 'cred-1', + modelName: 'gpt-4o', + }); + aiService.isProxyEnabled.mockReturnValue(false); + mockCustomCredential({ + credentialType: 'openAiApi', + apiKey: 'sk-user', + url: 'https://custom.example/v1', + }); + + const result = await service.resolveModelConfig(user); + + expect(result).toEqual({ + config: { + id: 'openai/gpt-4o', + apiKey: 'sk-user', + baseURL: 'https://custom.example/v1', + }, + isProxied: false, + }); + }); + + it('mode=custom for aws-bedrock → maps region + access keys', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'aws-bedrock', + credentialId: 'cred-1', + modelName: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }); + aiService.isProxyEnabled.mockReturnValue(false); + credentialsFinderService.findCredentialById.mockResolvedValue( + mock({ id: 'cred-1', type: 'aws' }), + ); + credentialsService.decrypt.mockReturnValue({ + region: 'us-east-1', + accessKeyId: 'AKIA...', + secretAccessKey: 'secret', + } as never); + + const result = await service.resolveModelConfig(user); + + expect(result).toEqual({ + config: { + id: 'aws-bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0', + region: 'us-east-1', + accessKeyId: 'AKIA...', + secretAccessKey: 'secret', + }, + isProxied: false, + }); + }); + + it('mode=custom with unsupported provider id → falls through to env-var fallback', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'definitely-not-a-provider', + credentialId: 'cred-1', + modelName: 'foo', + }); + aiService.isProxyEnabled.mockReturnValue(false); + process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env'; + + const result = await service.resolveModelConfig(user); + + expect(logger.warn).toHaveBeenCalled(); + expect(result).toEqual({ + config: { id: 'anthropic/claude-sonnet-4-5', apiKey: 'sk-env' }, + isProxied: false, + }); + }); + + it('mode=custom with deleted credential → falls through to env-var fallback and warns', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-deleted', + modelName: 'claude-3-5-sonnet', + }); + credentialsFinderService.findCredentialById.mockResolvedValue(null); + aiService.isProxyEnabled.mockReturnValue(false); + process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env'; + + const result = await service.resolveModelConfig(user); + + expect(logger.warn).toHaveBeenCalled(); + expect(result).toEqual({ + config: { id: 'anthropic/claude-sonnet-4-5', apiKey: 'sk-env' }, + isProxied: false, + }); + }); + }); + + describe('getStatus', () => { + it('mode=custom + resolvable credential → isConfigured: true', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }); + credentialsFinderService.findCredentialById.mockResolvedValue( + mock({ id: 'cred-1', type: 'anthropicApi' }), + ); + + await expect(service.getStatus()).resolves.toEqual({ isConfigured: true }); + }); + + it('mode=custom + missing credential → isConfigured: false', async () => { + mockPersistedSettings({ + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }); + credentialsFinderService.findCredentialById.mockResolvedValue(null); + + await expect(service.getStatus()).resolves.toEqual({ isConfigured: false }); + }); + + it('mode=default + proxy enabled → isConfigured: true', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(true); + + await expect(service.getStatus()).resolves.toEqual({ isConfigured: true }); + }); + + it('mode=default + proxy disabled + env set → isConfigured: true (env-var backstop counts)', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(false); + process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env'; + + await expect(service.getStatus()).resolves.toEqual({ isConfigured: true }); + }); + + it('mode=default + proxy disabled + ANTHROPIC_API_KEY set → isConfigured: true', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(false); + process.env.ANTHROPIC_API_KEY = 'sk-env'; + + await expect(service.getStatus()).resolves.toEqual({ isConfigured: true }); + }); + + it('mode=default + proxy disabled + env empty → isConfigured: false', async () => { + mockPersistedSettings({ mode: 'default' }); + aiService.isProxyEnabled.mockReturnValue(false); + + await expect(service.getStatus()).resolves.toEqual({ isConfigured: false }); + }); + + it('no persisted settings → defaults to mode=default', async () => { + mockPersistedSettings(null); + aiService.isProxyEnabled.mockReturnValue(true); + + const status = await service.getStatus(); + expect(status).toEqual({ isConfigured: true }); + }); + }); + + describe('updateAdminSettings', () => { + beforeEach(() => { + settingsRepository.upsert.mockResolvedValue(undefined as never); + }); + + it('persists mode=default', async () => { + await service.updateAdminSettings({ mode: 'default' }); + + expect(settingsRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'agentBuilder.settings', + value: JSON.stringify({ mode: 'default' }), + }), + ['key'], + ); + }); + + it('persists mode=custom with required fields', async () => { + await service.updateAdminSettings({ + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }); + + expect(settingsRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'agentBuilder.settings', + value: JSON.stringify({ + mode: 'custom', + provider: 'anthropic', + credentialId: 'cred-1', + modelName: 'claude-3-5-sonnet', + }), + }), + ['key'], + ); + }); + + it('accepts mode=custom for cohere (broad provider support)', async () => { + await expect( + service.updateAdminSettings({ + mode: 'custom', + provider: 'cohere', + credentialId: 'cred-1', + modelName: 'command-r', + }), + ).resolves.not.toThrow(); + }); + + it('accepts mode=custom for azure-openai', async () => { + await expect( + service.updateAdminSettings({ + mode: 'custom', + provider: 'azure-openai', + credentialId: 'cred-1', + modelName: 'gpt-4o', + }), + ).resolves.not.toThrow(); + }); + + it('rejects mode=custom with unknown provider', async () => { + // Provider shape (non-empty string) is enforced upstream by the Zod + // schema on the controller. The service only checks the runtime + // mapper compatibility, since api-types intentionally doesn't know + // the runtime's supported provider list. + await expect( + service.updateAdminSettings({ + mode: 'custom', + provider: 'definitely-not-a-provider', + credentialId: 'cred-1', + modelName: 'foo', + }), + ).rejects.toThrow(UnprocessableRequestError); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts new file mode 100644 index 00000000000..12b8afba4f3 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -0,0 +1,589 @@ +import type { JSONSchema7 } from 'json-schema'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +import { AgentJsonConfigSchema } from '../json-config/agent-json-config'; +import { jsonSchemaToCompactText } from '../json-config/schema-text-serializer'; + +// --------------------------------------------------------------------------- +// Context sections — dynamic, injected at runtime +// --------------------------------------------------------------------------- + +export function getAgentStateSection( + configJson: string, + configHash: string | null, + configUpdatedAt: string | null, + toolList: string, +): string { + return `\ +## Current agent config + +configHash: \`${configHash ?? 'null'}\` +updatedAt: \`${configUpdatedAt ?? 'null'}\` + +\`\`\`json +${configJson} +\`\`\` + +Treat this config as a starting snapshot only. Before any \`write_config\` or +\`patch_config\` call, call \`read_config\` in the same turn and use the returned +\`config\` plus \`configHash\` as the write base. Do not pass the prompt +\`configHash\` to a write tool. + +## Custom tools + +${toolList}`; +} + +// --------------------------------------------------------------------------- +// Reference sections — static +// --------------------------------------------------------------------------- + +export const TOOL_TYPES_SECTION = `\ +## Tool types + +### Workflow tools (preferred) +Reference existing n8n workflows by name. Call list_workflows to see available ones. +\`\`\`json +{ "type": "workflow", "workflow": "Send Welcome Email" } +\`\`\` + +### Node tools +Run a single n8n node as a tool. Use search_nodes to find available nodes, then +get_node_types to see their parameters. Add the node to the config with nodeType, +nodeTypeVersion, and nodeParameters. + +get_node_types return typescript references, but you must supply json fields in node config + +Flow: search_nodes → get_node_types → ask_credential (per slot) → write/update config + +\`\`\`json +{ + "type": "node", + "name": "http_request", + "description": "Make an HTTP request to any URL", + "node": { + "nodeType": "n8n-nodes-base.httpRequestTool", + "nodeTypeVersion": 4, + "nodeParameters": { + "method": "GET", + "url": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('url', 'The URL to request', 'string') }}" + } + } +} +\`\`\` + +Rules for node tools: +- \`nodeType\` and \`nodeTypeVersion\` come from get_node_types results. Use the tool node ID from search_nodes (usually ending in \`Tool\`, e.g. \`n8n-nodes-base.httpRequestTool\`), not the base node ID. +- \`nodeParameters\` sets fixed parameters (resource, operation, etc.). For any value the AI should choose at runtime, use \`$fromAI\`: \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('key', 'description', 'type') }}\`. +- Match the \`$fromAI\` type to the node parameter type from get_node_types: use \`string\`, \`number\`, \`boolean\`, or \`json\`. +- Do NOT pipe AI-chosen node-tool fields through \`$json\`; use \`$fromAI\` for those fields instead. +- Do NOT include \`inputSchema\` for node tools. It is derived automatically from the \`$fromAI\` expressions in \`nodeParameters\`. +- Do NOT include \`toolDescription\` in \`nodeParameters\`. Use the top-level tool \`description\` only. +- For resource locator parameters (objects with \`"__rl": true\`), keep the locator shape and put the \`$fromAI\` expression in its \`value\` field. +- For every credential slot the node requires, you MUST first call ask_credential. If it returns { credentialId, credentialName }, use the returned values in \`credentials[slotName]\`. Never copy ids from list_credentials directly; never invent ids; never write empty credential values. +- Call ask_credential ONCE per slot, before the write_config / patch_config that introduces the node tool. If it returns { skipped: true }, DO NOT abort and DO NOT refuse to add the tool. Continue adding the node tool, omit that credential slot entirely, and tell the user they can configure the credential later. +- Use search_nodes first, never guess node type names + +### Custom tools +Write TypeScript using the Tool builder, validate via build_custom_tool, then register the returned id. +\`\`\`json +{ "type": "custom", "id": "tool_7fGh2Lm9Qx0Ba8Ts" } +\`\`\` + +The tool code must follow this pattern: +\`\`\`typescript +import { Tool } from '@n8n/agents'; +import { z } from 'zod'; + +export default new Tool('tool_name') + .description('What the tool does') + .input(z.object({ query: z.string() })) + .handler(async ({ query }) => { + return { result: query.toUpperCase() }; + }); +\`\`\` + +Custom tools run inside a V8 isolate sandbox. Treat every handler as a pure +function: take \`input\`, compute, return a JSON-serialisable value. + +- Must use \`export default new Tool(...)\` pattern. +- Imports at the top of the file: only '@n8n/agents' and 'zod'. No other + modules resolve. +- No I/O of any kind — no network, no filesystem, no waiting for wall-clock + time. Host globals like \`crypto\`, \`process\`, \`Buffer\`, \`fetch\`, \`atob\`, + \`XMLHttpRequest\` are not present and will throw \`ReferenceError\` at runtime. +- Some web APIs appear defined but are no-op stubs (\`setTimeout\` fires + synchronously, \`console.log\` goes nowhere, \`TextEncoder.encode\` returns + its input unchanged). Don't rely on their real behaviour. +- Free to use: \`Math\`, \`Date\`, \`JSON\`, \`RegExp\`, \`Array\`, \`Object\`, \`Map\`, + \`Set\`, \`Promise\`, typed arrays, and any method on values you already have. +- The handler is async and receives \`(input, ctx)\`. + - \`input\` is already validated against your zod schema. + - \`ctx.suspend(payload)\` pauses the tool until the caller resumes it — + use it for human-in-the-loop flows that need to ask the user something. + Otherwise ignore \`ctx\`. +- Return a JSON-serialisable value. Execution is capped at 5 seconds and + ~32 MB of memory. +- If something fails at runtime, the error message is handed back to you on + the next turn — fix the code and try again. +- Do NOT call \`.build()\` — the engine handles it. + +### Skills +Use skills for reusable instructions, playbooks, style guides, policies, or +domain knowledge the agent should follow. Call create_skill with the skill +\`name\`, \`description\`, and \`body\`; the tool returns the generated skill +\`id\`. Skill descriptions should describe the task/situation that should +trigger loading the skill. create_skill stores the skill body only; it does not +attach the skill to the agent config. After create_skill, call read_config and +use patch_config (or write_config) to add +\`{ "type": "skill", "id": "" }\` to \`skills\`.`; + +export const INTERACTIVE_TOOLS_SECTION = `\ +## Interactive tools (user-facing) + +These tools render a UI card in the chat and SUSPEND your run until the user +responds. Treat the resume value as authoritative — it is the user's choice and +must be persisted into the config exactly as returned. + +### ask_llm +When: the user must choose a model/credential because the request is ambiguous, +resolve_llm returned an ambiguous/missing credential result, or the user asks +to pick/change/use a different model. Call AT MOST ONCE per build turn unless +the user changes their mind. +Returns: { provider, model, credentialId, credentialName }. +After: set \`model = "{provider}/{model}"\` and \`credential = credentialName\` +via write_config or patch_config. + +### ask_credential +When: about to add (or change) a node tool whose node requires credentials. +Call ONCE per slot, BEFORE write_config / patch_config that introduces the +tool. Pass \`credentialType\` (a single credential type name picked from the +slot's accepted types in get_node_types — when the slot accepts multiple, +choose the most appropriate one, typically OAuth or the first listed) and +\`purpose\` (one short sentence, e.g. "Slack credential for posting messages"). +Returns: { credentialId, credentialName } or { skipped: true }. +After (success): set \`tools[i].node.credentials. = { id: credentialId, +name: credentialName }\`. After (skipped): DO NOT abort and DO NOT refuse to +add the tool. Still add the tool, omit that credential slot, and tell the user +they can configure the credential later. + +### ask_question +When: you would otherwise ask a clarifying question whose answer is one (or +more) of a known list. Examples: pick a Slack channel from a list, +read-only vs read-write, which workflow to wrap. +Inputs: \`question\`, \`options[{label,value,description?}]\`, \`allowMultiple?\`. +Returns: { values: string[] }. Do NOT call ask_question for free-text input; +ask in prose for that. + +### Rules +- Never call two interactive tools in parallel. The run suspends on the first. +- Never re-ask a question the user already answered in this thread. +- After resume, continue with the next concrete action (write_config / + patch_config / next ask_*). Do not narrate the answer back to the user. +- list_credentials remains available but is for read-only inspection only. + Never copy ids from it into the config.`; + +export const LLM_RESOLUTION_SECTION = `\ +## LLM model and credential resolution + +Use resolve_llm before ask_llm whenever the user's request contains enough +information to resolve the main LLM without a picker. + +### resolve_llm +When: the user explicitly names a provider/model, or a fresh agent needs a +default LLM and the user did not ask to choose. + +Inputs: optional \`provider\`, optional \`model\`. +- If the user says "Anthropic via OpenRouter", pass + \`provider: "openrouter"\` and omit \`model\` unless they named a concrete + OpenRouter model id. +- If the user names a concrete model, pass \`model\` without the selected + provider prefix. For OpenRouter, use the routed model id, e.g. + \`"anthropic/claude-sonnet-4.6"\`. + +On \`{ ok: true, provider, model, credentialId, credentialName }\`: set +\`model = "{provider}/{model}"\` and \`credential = credentialName\`. + +On \`ok: false\`: use ask_llm only when the user needs to choose/configure a +credential or model. Do not guess credential names from list_credentials. + +Rules: +- Explicit provider/model request → resolve_llm first, not ask_llm. +- User asks to pick/change/use a different model → ask_llm. +- No provider specified and resolve_llm reports ambiguity → ask_llm.`; + +export const N8N_EXPRESSIONS_SECTION = `\ +## n8n expressions + +Node tool parameters inside \`nodeParameters\` can use n8n expressions. +For node tools, prefer \`$fromAI\` whenever the agent should decide a value at runtime. + +- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('fieldName', 'What value to provide', 'string') }}\` — let the AI provide a string +- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('count', 'How many items', 'number') }}\` — let the AI provide a number +- \`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('enabled', 'Whether to enable this option', 'boolean') }}\` — let the AI provide a boolean +- \`={{ $now.toISO() }}\` — current date/time (Luxon DateTime) +- \`={{ $today }}\` — start of today (Luxon DateTime) + +Always wrap expressions in \`={{ }}\`. Never use bare JS variables outside the braces.`; + +export const PROVIDER_TOOLS_SECTION = `\ +## Provider tools + +Built-in capabilities offered by the model provider. Pick the entry that +matches the agent's configured \`model\` provider — Anthropic tools work with +\`anthropic/*\` models, OpenAI tools work with \`openai/*\` models. + +Anthropic web search: +\`\`\`json +{ "providerTools": { "anthropic.web_search": { "maxUses": 5 } } } +\`\`\` + +OpenAI web search (requires a Responses-API-compatible model, e.g. \`openai/gpt-4o\`): +\`\`\`json +{ "providerTools": { "openai.web_search": { "searchContextSize": "medium" } } } +\`\`\` + +OpenAI image generation: +\`\`\`json +{ "providerTools": { "openai.image_generation": {} } } +\`\`\``; + +export const CONVERSATION_MODE_SECTION = `\ +## When to build vs when to converse + +Not every user message is a build request. Before calling \`write_config\`, +\`patch_config\`, or \`build_custom_tool\`, check: has the user given you a +concrete goal the agent should accomplish? + +If the user just said "hi", asked what you do, gave a vague intent ("build me +something cool"), or asked a question — reply conversationally. Ask what they +want the agent to do, what systems it needs to touch, what triggers it. Only +start building once you have a real goal. + +Never call \`write_config\` with empty, placeholder, or guessed \`instructions\`. +An agent without real instructions is broken and can't chat. If you don't have +enough detail to write meaningful instructions, ask the user first.`; + +export const RESEARCH_SECTION = `\ +## Research + +You have access to Anthropic's web search tool. Use it when you encounter an +API, service, product, or concept you don't fully understand. Better to search +once and be correct than to guess at endpoint shapes, auth methods, or node +parameters. + +Good reasons to search: +- The user named an API or service you're unsure about +- You're unsure of an endpoint's URL shape, auth method, or request format +- The user referenced a recent or external product, standard, or spec + +Don't search for things you already know (n8n internals, common JS/TS +patterns, widely-known public APIs you've configured many times).`; + +export const MEMORY_PRESETS_SECTION = `\ +## Memory presets + +| Storage | Description | +|----------|------------------------------------------------------| +| n8n | Default. Persists in n8n database. No config needed. | +| sqlite | Local SQLite file. Needs connection.path | +| postgres | PostgreSQL. Needs connection.credential |`; + +export const INTEGRATIONS_SECTION = `\ +## Integrations (triggers) + +The \`integrations\` array on the agent config defines how the agent gets triggered. +Two kinds: + +1. **Schedule trigger** — runs the agent on a cron schedule. One per agent. + Shape: + \`\`\`json + { "type": "schedule", "active": false, "cronExpression": "0 9 * * *", "wakeUpPrompt": "Daily standup ping" } + \`\`\` + - \`active\` stays false until the agent is published. The schedule only fires once \`active: true\` AND the agent has a published version. + - \`cronExpression\` is standard 5-field cron. + - \`wakeUpPrompt\` is the message the agent receives when it fires. + +2. **Chat integrations** — connect the agent to a messaging platform. Multiple allowed. + Shape: + \`\`\`json + { "type": "slack", "credentialId": "", "credentialName": "" } + \`\`\` + +### Workflow for adding integrations + +1. Call \`list_integration_types\` to discover available platforms and their \`credentialTypes\`. +2. For chat integrations: pick **one** entry from the \`credentialTypes\` array returned by \`list_integration_types\` (prefer the OAuth variant — e.g. \`slackOAuth2Api\` over \`slackApi\`) and pass it to \`ask_credential\` as the singular \`credentialType\` arg. It returns \`{ credentialId, credentialName }\`. +3. Use \`patch_config\` (or \`write_config\`) to add an entry to \`integrations\`. For chat integrations, both \`credentialId\` and \`credentialName\` are required and must come from the \`ask_credential\` result. For schedule, write the cron expression directly. + +Never invent credential IDs or names. Always go through \`ask_credential\`.`; + +export const WRITE_CONFIG_SECTION = `\ +## write_config — full replace + +Before calling write_config, call \`read_config\` and build the full replacement +from the returned \`config\`. Call write_config with the complete agent +configuration as a JSON string and the \`baseConfigHash\` from that same +\`read_config\` result: +\`\`\`json +{ + "baseConfigHash": "", + "json": "{ \\"name\\": \\"My Agent\\", \\"model\\": \\"anthropic/claude-sonnet-4-5\\", \\"credential\\": \\"My Anthropic Key\\", \\"instructions\\": \\"You are a helpful assistant.\\", \\"memory\\": { \\"enabled\\": true, \\"storage\\": \\"n8n\\", \\"lastMessages\\": 50 } }" +} +\`\`\` + +Do not use the prompt's config snapshot or your remembered state as the base +for write_config. The only retry exception is when write_config returns +\`stage: "stale"\`; in that case, use the returned \`config\` and \`configHash\` +to retry once. Do not retry from memory.`; + +export const PATCH_CONFIG_SECTION = `\ +## patch_config — RFC 6902 JSON Patch + +Before calling patch_config, call \`read_config\` and derive the patch from the +returned \`config\`. Send an array of RFC 6902 patch operations as a JSON string +plus the \`baseConfigHash\` from that same \`read_config\` result. Each operation +targets a field by its JSON Pointer path. + +| op | description | +|---------|------------------------------------------| +| add | Add or set a value at path | +| remove | Remove the value at path | +| replace | Replace the value at path | +| move | Move value from \`from\` path to \`path\` | +| copy | Copy value from \`from\` path to \`path\` | +| test | Assert a value at path (aborts if wrong) | + +Examples: +\`\`\`json +{ + "baseConfigHash": "", + "operations": "[{ \\"op\\": \\"replace\\", \\"path\\": \\"/model\\", \\"value\\": \\"anthropic/claude-sonnet-4-5\\" }]" +} +\`\`\` +\`\`\`json +{ + "baseConfigHash": "", + "operations": "[{ \\"op\\": \\"replace\\", \\"path\\": \\"/memory/lastMessages\\", \\"value\\": 50 }, { \\"op\\": \\"add\\", \\"path\\": \\"/tools/-\\", \\"value\\": { \\"type\\": \\"workflow\\", \\"workflow\\": \\"Send Email\\" } }]" +} +\`\`\` +\`\`\`json +{ + "baseConfigHash": "", + "operations": "[{ \\"op\\": \\"remove\\", \\"path\\": \\"/description\\" }]" +} +\`\`\` + +Path syntax: \`/field\` for top-level fields, \`/nested/field\` for nested, \`/array/0\` for index, \`/array/-\` to append. + +When attaching a skill, append to \`/skills/-\` if \`skills\` exists; otherwise +add \`/skills\` with an array containing the skill ref. + +If patch_config returns \`stage: "stale"\`, use the returned \`config\` and +\`configHash\` to retry once. Do not retry from memory. + +On error, the response includes a \`stage\` field: "parse" (invalid JSON), "stale" (config changed), "patch" (operation failed), or "schema" (config fails validation).`; + +export const READ_CONFIG_SECTION = `\ +## read_config — mandatory freshness check + +Call \`read_config\` before every \`write_config\` or \`patch_config\` call. Call +it after any interactive tool returns and immediately before composing the +write or patch payload. + +Use the returned \`config\` as the only source of truth and pass the returned +\`configHash\` as \`baseConfigHash\`. Do not patch from memory, the conversation, +or the prompt snapshot. Do not skip this just because the prompt already +contains a \`configHash\`. + +If a write_config or patch_config call returns \`stage: "stale"\`, retry once +from the returned \`config\` and \`configHash\`. For any later independent config +change, call \`read_config\` again. + +\`create_skill\` stores a skill body but does not attach it. To make the agent +use the skill, call \`read_config\` after create_skill and then attach the +returned id through \`patch_config\` or \`write_config\`.`; + +export const WORKFLOW_SECTION = `\ +## Workflow + +1. If the agent has no \`instructions\` and \`credential\` yet (fresh agent), FIRST call resolve_llm + when the user specified a provider/model or did not ask to choose. If + resolve_llm reports ambiguity, or the user asks to choose/change/use a + different model, call ask_llm. Then call read_config and write_config + with the chosen \`model\` and \`credential\` plus a draft \`instructions\`. +2. Use ask_question whenever you have a clarifying question with discrete + options (e.g. "Which Slack channel?" → list channels, "Read-only or + read-write?"). Never put the question in plain text if options are known. +3. Before adding any node tool that needs credentials, call ask_credential for + each slot. +4. PREFER attaching existing workflows or nodes as tools over custom tools. +5. Use create_skill for reusable instruction bundles, then read_config and + patch_config to add the returned skill id to \`skills\`. +6. Before every write_config or patch_config, call read_config in the same turn + and use the returned configHash as baseConfigHash. +7. Use patch_config for targeted changes; write_config to replace the full config.`; + +export const FEW_SHOT_FLOWS_SECTION = `\ +## Example flows + +### New agent (no instructions yet), user says "Build me a Slack triage agent" +1. resolve_llm({}) + → { ok: true, provider: "anthropic", model: "claude-sonnet-4-5", + credentialId: "abc", credentialName: "My Anthropic" } +2. search_nodes({ query: "slack" }) → ... +3. get_node_types({ nodeType: "n8n-nodes-base.slackTool" }) → ... +4. ask_credential({ purpose: "Slack workspace to read/post messages", + nodeType: "n8n-nodes-base.slackTool", credentialType: "slackApi", + slot: "slackApi" }) + → { credentialId: "xyz", credentialName: "Acme Slack" } +5. read_config() → { configHash: "hash1", config: { ... } } +6. write_config({ baseConfigHash: "hash1", json: "{ ...complete config with model, credential, instructions, and Slack tool... }" }) +7. Reply: "Done." + +### New agent, user says "Use Anthropic via OpenRouter" +1. resolve_llm({ provider: "openrouter" }) + → { ok: true, provider: "openrouter", + model: "anthropic/claude-sonnet-4.6", + credentialId: "or1", credentialName: "OpenRouter" } +2. read_config() → { configHash: "hash1", config: { ... } } +3. write_config({ baseConfigHash: "hash1", json: "{ ...complete config with model: \\"openrouter/anthropic/claude-sonnet-4.6\\", credential: \\"OpenRouter\\", and the requested instructions... }" }) + +### User says "Use a different OpenRouter model" +1. ask_llm({ purpose: "Choose a different OpenRouter model" }) +2. read_config() → { configHash: "hash1", config: { ... } } +3. patch_config with \`{ baseConfigHash: "hash1", operations: "[{ \\"op\\": \\"replace\\", \\"path\\": \\"/model\\", \\"value\\": \\"{provider}/{model}\\" }, { \\"op\\": \\"replace\\", \\"path\\": \\"/credential\\", \\"value\\": \\"\\" }]" }\`. + +### Adding a new node tool to an existing agent +1. (skip ask_llm — already set) +2. search_nodes / get_node_types +3. ask_credential per required slot +4. read_config() → { configHash: "hash1", config: { ... } } +5. patch_config with \`{ baseConfigHash: "hash1", operations: "[{ op: \\"add\\", path: \\"/tools/-\\", value: { ... credentials: {...} } }]" }\` + +### Adding a node tool when credential setup is skipped +1. search_nodes / get_node_types +2. ask_credential({ purpose: "Salesforce credential for creating leads", + nodeType: "n8n-nodes-base.salesforceTool", credentialType: "salesforceOAuth2Api", + slot: "salesforceOAuth2Api" }) + → { skipped: true } +3. read_config() → { configHash: "hash1", config: { ... } } +4. patch_config with \`{ baseConfigHash: "hash1", operations: "[{ op: \\"add\\", path: \\"/tools/-\\", value: { type: \\"node\\", + name: "salesforce_create_lead", description: "...", node: { + nodeType: "n8n-nodes-base.salesforceTool", nodeTypeVersion: 1, + nodeParameters: { ... } } } }]" }\` + IMPORTANT: omit \`node.credentials\` or omit only the skipped credential slot. + Do not stop. Do not say you will not add the tool. +5. Reply: "Done. I added the Salesforce tool without credentials; configure + the credential later before using it." + +### Adding a skill to an existing agent +1. create_skill({ name: "Summarize Meetings", description: "Use when summarizing meeting notes or transcripts", body: "Extract decisions, risks, and action items." }) + → { id: "skill_0Ab9ZkLm3Pq7Xy2N", ... } +2. read_config() → { configHash: "hash1", config: { ... } } +3. patch_config with \`{ baseConfigHash: "hash1", operations: "[{ \\"op\\": \\"add\\", \\"path\\": \\"/skills/-\\", \\"value\\": { \\"type\\": \\"skill\\", \\"id\\": \\"skill_0Ab9ZkLm3Pq7Xy2N\\" } }]" }\` +4. Reply: "Done. I added the skill." + +### Ambiguous request: "Make it post somewhere" +1. ask_question({ question: "Where should the agent post?", + options: [ + { label: "Slack", value: "slack" }, + { label: "Discord", value: "discord" }, + { label: "Email", value: "email" } ] }) +2. Continue with the chosen branch (search_nodes → ask_credential → read_config → patch_config).`; + +export const IMPORTANT_SECTION = `\ +## Important + +- Credentials are user-controlled. ALWAYS use ask_llm (for the agent's main + LLM picker), resolve_llm (for explicit/default main LLM resolution), and + ask_credential (for every node-tool credential slot). + Never read credential ids from list_credentials into the config. +- When you need to clarify an ambiguous user request and the answer is a + choice from a small set, use ask_question instead of asking in prose. +- Use search_nodes + get_node_types to discover nodes before adding node tools +- Prefer workflow tools and node tools over custom tools for real-world interactions +- Memory with storage "n8n" is the default -- always enable it unless told otherwise +- \`build_custom_tool\` generates an opaque custom tool id, then compiles and stores the tool code. Register the returned id in the config separately by adding a \`{ type: "custom", id }\` entry to \`tools\` via write_config or patch_config +- \`create_skill\` stores the skill body only. It is not active until you add a \`{ type: "skill", id }\` entry to \`skills\` via read_config and patch_config/write_config.`; + +export const RESPONSE_STYLE_SECTION = `\ +## Response style + +Be concise but informative. + +- After a build step (write_config, patch_config, build_custom_tool), give a + 1–2 sentence summary of what you changed and, if useful, one thing the user + might try next. No field-by-field narration, no JSON repetition, no + re-stating the user's request back to them. +- Do not narrate your reasoning before a tool call (no "Let me check the + credentials first…"). Just do it, then summarise the result. +- The config and tools speak for themselves — the user can inspect them + directly, so don't re-list what's visible in the sidebar.`; + +// --------------------------------------------------------------------------- +// Dynamic sections — depend on runtime values +// --------------------------------------------------------------------------- + +export function getConfigRulesSection(builderModel: string): string { + return `\ +## Agent config rules + +- \`model\` must be "provider/model-name" format (e.g. "anthropic/claude-sonnet-4-5") +- \`credential\` must be the \`credentialName\` returned by a prior resolve_llm or ask_llm tool call. Do not guess. +- \`memory.storage\` is a preset: "n8n" (recommended, persists in n8n DB), "sqlite", or "postgres" +- \`memory.lastMessages\` default: 50 +- Use "n8n" as the default memory storage for all agents +- If the agent has no \`model\`/\`credential\` yet, call resolve_llm or ask_llm before defaulting; only fall back to '${builderModel}' as the in-config placeholder string when the user explicitly declines to pick.`; +} + +export function getSchemaReferenceSection(): string { + const jsonSchemaText = jsonSchemaToCompactText( + zodToJsonSchema(AgentJsonConfigSchema) as JSONSchema7, + ); + return `\ +## Config schema reference + +\`\`\` +${jsonSchemaText} +\`\`\``; +} + +// --------------------------------------------------------------------------- +// Prompt assembler +// --------------------------------------------------------------------------- + +export interface BuilderPromptContext { + configJson: string; + configHash: string | null; + configUpdatedAt: string | null; + toolList: string; + builderModel: string; +} + +export function buildBuilderPrompt(ctx: BuilderPromptContext): string { + const { configJson, configHash, configUpdatedAt, toolList, builderModel } = ctx; + + return [ + 'You are an expert agent builder. You help users create and configure AI agents by writing raw JSON configuration and building custom tools.', + getAgentStateSection(configJson, configHash, configUpdatedAt, toolList), + READ_CONFIG_SECTION, + CONVERSATION_MODE_SECTION, + TOOL_TYPES_SECTION, + LLM_RESOLUTION_SECTION, + INTERACTIVE_TOOLS_SECTION, + N8N_EXPRESSIONS_SECTION, + PROVIDER_TOOLS_SECTION, + MEMORY_PRESETS_SECTION, + INTEGRATIONS_SECTION, + RESEARCH_SECTION, + getConfigRulesSection(builderModel), + getSchemaReferenceSection(), + WORKFLOW_SECTION, + WRITE_CONFIG_SECTION, + PATCH_CONFIG_SECTION, + FEW_SHOT_FLOWS_SECTION, + IMPORTANT_SECTION, + RESPONSE_STYLE_SECTION, + ].join('\n\n'); +} diff --git a/packages/cli/src/modules/agents/builder/agents-builder-settings.controller.ts b/packages/cli/src/modules/agents/builder/agents-builder-settings.controller.ts new file mode 100644 index 00000000000..f94db8b1f40 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/agents-builder-settings.controller.ts @@ -0,0 +1,46 @@ +import { + AgentBuilderAdminSettingsUpdateDto, + type AgentBuilderAdminSettingsResponse, + type AgentBuilderStatusResponse, +} from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Get, GlobalScope, Patch, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +import { AgentsBuilderSettingsService } from './agents-builder-settings.service'; + +@RestController('/agent-builder') +export class AgentsBuilderSettingsController { + constructor(private readonly settingsService: AgentsBuilderSettingsService) {} + + @Get('/settings') + @GlobalScope('agent:manage') + async getAdminSettings(_req: AuthenticatedRequest): Promise { + return await this.settingsService.getAdminSettings(); + } + + @Patch('/settings') + @GlobalScope('agent:manage') + async updateAdminSettings( + req: AuthenticatedRequest, + _res: Response, + ): Promise { + // Manually validate using the discriminated union schema since + // TypeScript reflection doesn't work with plain Zod schemas, and + // `Z.class` doesn't support discriminated unions directly. (Same + // pattern as `CreateDestinationDto` in the log-streaming module.) + const parseResult = AgentBuilderAdminSettingsUpdateDto.safeParse(req.body); + if (!parseResult.success) { + throw new BadRequestError(parseResult.error.errors[0]?.message ?? 'Invalid request body'); + } + await this.settingsService.updateAdminSettings(parseResult.data); + return await this.settingsService.getAdminSettings(); + } + + @Get('/status') + async getStatus(): Promise { + return await this.settingsService.getStatus(); + } +} diff --git a/packages/cli/src/modules/agents/builder/agents-builder-settings.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-settings.service.ts new file mode 100644 index 00000000000..9a54d11a267 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/agents-builder-settings.service.ts @@ -0,0 +1,240 @@ +import { proxyFetch } from '@n8n/ai-utilities'; +import { + AGENT_BUILDER_DEFAULT_MODEL, + agentBuilderAdminSettingsSchema, + type AgentBuilderAdminSettings, + type AgentBuilderAdminSettingsResponse, + type AgentBuilderAdminSettingsUpdateRequest, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import type { ModelConfig, ResolvedCredential } from '@n8n/agents'; +import type { User } from '@n8n/db'; +import { SettingsRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { AiService } from '@/services/ai.service'; +import { ProxyTokenManager } from '@/services/proxy-token-manager'; + +import { + isSupportedAgentProvider, + mapCredentialForProvider, + SUPPORTED_AGENT_PROVIDERS, +} from '../json-config/credential-field-mapping'; +import { BuilderNotConfiguredError } from './errors'; + +const SETTINGS_KEY = 'agentBuilder.settings'; + +const DEFAULT_SETTINGS: AgentBuilderAdminSettings = { mode: 'default' }; + +const PROXY_HEADERS = { + 'x-n8n-feature': 'agent-builder', +}; + +/** Read an Anthropic key from env, preferring the n8n-specific variable. */ +function readEnvAnthropicKey(): string | null { + const key = process.env.N8N_AI_ANTHROPIC_KEY ?? process.env.ANTHROPIC_API_KEY; + return key && key.length > 0 ? key : null; +} + +/** + * Admin-instance-wide settings for the agent builder. Decides which model + * the builder LLM runs on: + * 1. `mode: 'custom'` — admin-picked credential. Wins regardless of platform. + * 2. `mode: 'default'` + AI proxy enabled — runs through the n8n AI assistant proxy. + * 3. `mode: 'default'` + env-var backstop set (`N8N_AI_ANTHROPIC_KEY` / + * `ANTHROPIC_API_KEY`) — direct Anthropic API calls. + */ +@Service() +export class AgentsBuilderSettingsService { + /** In-memory cache of the persisted settings. */ + private cached: AgentBuilderAdminSettings | null = null; + + constructor( + private readonly logger: Logger, + private readonly settingsRepository: SettingsRepository, + private readonly aiService: AiService, + private readonly credentialsService: CredentialsService, + private readonly credentialsFinderService: CredentialsFinderService, + ) {} + + /** Load and cache the persisted admin settings (defaults to `{ mode: 'default' }`). */ + private async loadSettings(): Promise { + if (this.cached) return this.cached; + const row = await this.settingsRepository.findByKey(SETTINGS_KEY); + if (!row) { + this.cached = DEFAULT_SETTINGS; + return this.cached; + } + // Tolerate corrupt persisted JSON or stale shapes by falling back to + // defaults — the admin can re-save from the UI to recover. + const raw = jsonParse(row.value, { fallbackValue: undefined }); + const parseResult = agentBuilderAdminSettingsSchema.safeParse(raw); + this.cached = parseResult.success ? parseResult.data : DEFAULT_SETTINGS; + return this.cached; + } + + private async persist(settings: AgentBuilderAdminSettings): Promise { + await this.settingsRepository.upsert( + { + key: SETTINGS_KEY, + value: JSON.stringify(settings), + loadOnStartup: true, + }, + ['key'], + ); + this.cached = settings; + } + + /** Get the persisted admin settings + the derived `isConfigured` flag. */ + async getAdminSettings(): Promise { + const settings = await this.loadSettings(); + const isConfigured = await this.computeIsConfigured(settings); + return { settings, isConfigured }; + } + + /** Lightweight readiness check used by the builder UI to gate the input box. */ + async getStatus(): Promise<{ isConfigured: boolean }> { + const settings = await this.loadSettings(); + const isConfigured = await this.computeIsConfigured(settings); + return { isConfigured }; + } + + private async computeIsConfigured(settings: AgentBuilderAdminSettings): Promise { + if (settings.mode === 'custom') { + const credential = await this.credentialsFinderService.findCredentialById( + settings.credentialId, + ); + return !!credential; + } + // mode === 'default' — true if any of the runtime resolution branches + // can succeed: AI proxy enabled, or the env-var backstop is set. + return this.aiService.isProxyEnabled() || !!readEnvAnthropicKey(); + } + + /** + * Update and persist the admin settings. + * + * The shape of `payload` is already enforced by the controller via the + * shared `AgentBuilderAdminSettingsUpdateDto` Zod schema. The only + * additional check needed here is the runtime constraint that `provider` + * must be one the agents runtime can actually map a credential for — + * something the api-types schema deliberately doesn't encode. + */ + async updateAdminSettings(payload: AgentBuilderAdminSettingsUpdateRequest): Promise { + if (payload.mode === 'custom' && !isSupportedAgentProvider(payload.provider)) { + throw new UnprocessableRequestError( + `Unsupported provider "${payload.provider}". Supported: ${SUPPORTED_AGENT_PROVIDERS.join(', ')}`, + ); + } + await this.persist(payload); + } + + /** + * Resolve the `ModelConfig` the builder agent should run on. + * + * Priority: + * 1. mode 'custom' with a still-resolvable credential + * 2. mode 'default' + AI proxy enabled + * 3. env-var backstop (dev/CI only) + * + * Throws `BuilderNotConfiguredError` when none resolve. + */ + async resolveModelConfig(user: User): Promise<{ config: ModelConfig; isProxied: boolean }> { + const settings = await this.loadSettings(); + + if (settings.mode === 'custom') { + const fromCredential = await this.tryResolveCustomCredential(settings); + if (fromCredential) return { config: fromCredential, isProxied: false }; + this.logger.warn( + 'Agent builder custom credential could not be resolved; falling back to default', + { credentialId: settings.credentialId }, + ); + } + + if (this.aiService.isProxyEnabled()) { + return { config: await this.resolveProxyModel(user), isProxied: true }; + } + + const envKey = readEnvAnthropicKey(); + if (envKey) { + return { + config: { + id: `anthropic/${AGENT_BUILDER_DEFAULT_MODEL}`, + apiKey: envKey, + }, + isProxied: false, + }; + } + + throw new BuilderNotConfiguredError(); + } + + private async tryResolveCustomCredential( + settings: Extract, + ): Promise { + if (!isSupportedAgentProvider(settings.provider)) { + this.logger.warn('Agent builder provider is not supported by the runtime', { + provider: settings.provider, + credentialId: settings.credentialId, + }); + return null; + } + + const credential = await this.credentialsFinderService.findCredentialById( + settings.credentialId, + ); + if (!credential) return null; + + const data = await this.credentialsService.decrypt(credential, true); + // Reuse the shared credential mapper used by the agent runtime + // (`from-json-config.ts`). It normalises field names per provider so + // Azure OpenAI, AWS Bedrock, OpenAI-compatible base URLs, etc. all work. + const mapped = mapCredentialForProvider(settings.provider, data as ResolvedCredential); + + const id = `${settings.provider}/${settings.modelName}`; + return { id, ...mapped } as ModelConfig; + } + + /** + * Build a native Anthropic `LanguageModel` pointed at the proxy. Auth + * headers are injected via a `fetch` wrapper backed by `ProxyTokenManager` + * so each request gets a fresh-or-cached token. + */ + private async resolveProxyModel(user: User): Promise { + const client = await this.aiService.getClient(); + const baseURL = client.getApiProxyBaseUrl().replace(/\/$/, '') + '/anthropic/v1'; + + const tokenManager = new ProxyTokenManager(async () => { + return await client.getBuilderApiProxyToken({ id: user.id }, { userMessageId: nanoid() }); + }); + + const { createAnthropic } = await import('@ai-sdk/anthropic'); + + const provider = createAnthropic({ + baseURL, + apiKey: 'proxy-managed', + fetch: async (input, init) => { + const headers = new Headers(init?.headers); + const auth = await tokenManager.getAuthHeaders(); + for (const [k, v] of Object.entries(auth)) { + headers.set(k, v); + } + for (const [k, v] of Object.entries(PROXY_HEADERS)) { + headers.set(k, v); + } + return await proxyFetch(input as string, { ...init, headers }); + }, + }); + const model = provider(AGENT_BUILDER_DEFAULT_MODEL); + // `LanguageModel` from the AI SDK is structurally compatible with ModelConfig. + if (!model) { + throw new UnexpectedError('Failed to instantiate Anthropic proxy model'); + } + return model as ModelConfig; + } +} 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 new file mode 100644 index 00000000000..fc0c1327752 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -0,0 +1,499 @@ +import { Tool } from '@n8n/agents'; +import type { BuiltTool, CredentialProvider } from '@n8n/agents'; +import { agentSkillSchema } from '@n8n/api-types'; +import { WorkflowRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +import type { Operation } from 'fast-json-patch'; +import { createHash } from 'node:crypto'; +import { z } from 'zod'; + +import { AgentsToolsService } from '../agents-tools.service'; +import { AgentsService } from '../agents.service'; +import { composeJsonConfig } from '../json-config/agent-config-composition'; +import type { AgentJsonConfig, ConfigValidationError } from '../json-config/agent-json-config'; +import { + AgentJsonConfigSchema, + formatZodErrors, + tryParseConfigJson, +} from '../json-config/agent-json-config'; +import { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; +import { + buildAskCredentialTool, + buildAskLlmTool, + buildAskQuestionTool, + buildResolveLlmTool, +} from './interactive'; +import { BUILDER_TOOLS } from './builder-tool-names'; + +const EMPTY_INSTRUCTIONS_ERROR: ConfigValidationError = { + path: '/instructions', + message: + 'Refusing to write an agent with empty instructions. Ask the user what the agent should do before calling write_config or patch_config again.', +}; + +const STALE_CONFIG_ERROR: ConfigValidationError = { + path: '(root)', + message: + 'Agent config changed since you last read it. Call read_config and retry with the returned configHash.', +}; + +export interface AgentConfigSnapshot { + config: AgentJsonConfig | null; + configHash: string | null; + updatedAt: string | null; + versionId: string | null; +} + +function rejectIfEmptyInstructions( + config: AgentJsonConfig, +): { errors: ConfigValidationError[] } | null { + if (!config.instructions.trim()) { + return { errors: [EMPTY_INSTRUCTIONS_ERROR] }; + } + return null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function canonicalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalizeJson(item)); + } + + if (!isRecord(value)) return value; + + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = canonicalizeJson(value[key]); + } + return sorted; +} + +export function getAgentConfigHash(config: AgentJsonConfig | null): string | null { + if (!config) return null; + return createHash('sha256') + .update(JSON.stringify(canonicalizeJson(config))) + .digest('hex'); +} + +function snapshotFromConfig( + config: AgentJsonConfig | null, + updatedAt: string | null, + versionId: string | null, +): AgentConfigSnapshot { + return { + config, + configHash: getAgentConfigHash(config), + updatedAt, + versionId, + }; +} + +export interface BuilderTools { + json: BuiltTool[]; + shared: BuiltTool[]; +} + +@Service() +export class AgentsBuilderToolsService { + constructor( + private readonly agentsService: AgentsService, + private readonly secureRuntime: AgentSecureRuntime, + private readonly workflowRepository: WorkflowRepository, + private readonly agentsToolsService: AgentsToolsService, + ) {} + + getTools( + agentId: string, + projectId: string, + credentialProvider: CredentialProvider, + ): BuilderTools { + return { + json: this.getJsonTools(agentId, projectId, credentialProvider), + shared: this.getSharedTools(agentId, projectId, credentialProvider), + }; + } + + private getJsonTools( + agentId: string, + projectId: string, + credentialProvider: CredentialProvider, + ): BuiltTool[] { + const readConfigTool = new Tool(BUILDER_TOOLS.READ_CONFIG) + .description( + 'Read the latest persisted agent configuration and freshness metadata. ' + + 'Returns { ok: true, config, configHash, updatedAt, versionId }. ' + + 'Call this before every write_config or patch_config and use configHash as baseConfigHash.', + ) + .input(z.object({})) + .handler(async () => { + try { + return { ok: true, ...(await this.getConfigSnapshot(agentId, projectId)) }; + } catch (e) { + return { + ok: false, + errors: [{ path: '(root)', 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, or from a stale retry response. ' + + 'Do not use a configHash copied from the prompt snapshot. ' + + 'Returns { ok: true, config, configHash, updatedAt, versionId } on success or ' + + '{ ok: false, stage, errors } with path, message, expected, received fields on failure.', + ) + .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], ...snapshot }; + } + const zodResult = AgentJsonConfigSchema.safeParse(parsed.data); + if (!zodResult.success) { + return { ok: false, errors: formatZodErrors(zodResult.error) }; + } + const emptyInstructions = rejectIfEmptyInstructions(zodResult.data); + if (emptyInstructions) { + return { ok: false, errors: emptyInstructions.errors }; + } + try { + const result = await this.agentsService.updateConfig( + agentId, + projectId, + zodResult.data, + ); + return { + ok: true, + ...snapshotFromConfig(result.config, result.updatedAt, result.versionId), + }; + } catch (e) { + return { + ok: false, + stage: '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, or from a stale retry response. ' + + 'Do not use a configHash copied from the prompt snapshot. ' + + 'Supported ops: add, remove, replace, move, copy, test. ' + + 'Returns { ok: true, config, configHash, updatedAt, versionId } on success or ' + + '{ ok: false, stage, errors } on failure. ' + + 'stage is "parse", "stale", "patch", or "schema".', + ) + .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], ...snapshot }; + } + 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 = AgentJsonConfigSchema.safeParse(patched); + if (!zodResult.success) { + return { ok: false, stage: 'schema', errors: formatZodErrors(zodResult.error) }; + } + const emptyInstructions = rejectIfEmptyInstructions(zodResult.data); + if (emptyInstructions) { + return { ok: false, stage: 'schema', errors: emptyInstructions.errors }; + } + + try { + const result = await this.agentsService.updateConfig( + agentId, + projectId, + zodResult.data, + ); + return { + ok: true, + ...snapshotFromConfig(result.config, result.updatedAt, result.versionId), + }; + } catch (e) { + return { + ok: false, + stage: 'schema', + errors: [{ path: '(root)', message: e instanceof Error ? e.message : String(e) }], + }; + } + }, + ) + .build(); + + const listIntegrationTypesTool = new Tool(BUILDER_TOOLS.LIST_INTEGRATION_TYPES) + .description( + "List trigger / integration types that can be added to the agent's `integrations` array. " + + 'Returns the schedule trigger plus every connected chat platform with the list of ' + + 'credential types it supports (`credentialTypes: string[]`). ' + + 'Call this BEFORE asking the user for a credential. Then pick ONE entry from the ' + + 'returned `credentialTypes` (prefer the OAuth variant if present, e.g. `slackOAuth2Api` ' + + 'over `slackApi`) and pass it to `ask_credential` as the singular `credentialType` arg.', + ) + .input(z.object({})) + .handler(async () => { + const chat = this.agentsService.listChatIntegrations(); + return [ + { type: 'schedule', label: 'Schedule', icon: 'clock', credentialTypes: [] }, + ...chat, + ]; + }) + .build(); + + return [ + readConfigTool, + writeConfigTool, + patchConfigTool, + listIntegrationTypesTool, + buildResolveLlmTool({ credentialProvider }), + buildAskCredentialTool({ credentialProvider }), + buildAskLlmTool(), + buildAskQuestionTool(), + ]; + } + + private getSharedTools( + agentId: string, + projectId: string, + credentialProvider: CredentialProvider, + ): BuiltTool[] { + const buildCustomToolTool = 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, but this does NOT register the tool in the ' + + 'agent config — follow up with patch_config (or write_config) to add a ' + + '`{ type: "custom", id }` entry to `tools` so the agent actually uses it. ' + + 'Returns { ok: true, id, descriptor } or { ok: false, errors }.', + ) + .input( + z.object({ + code: z + .string() + .describe('Complete TypeScript source using export default new Tool(...)'), + }), + ) + .handler(async ({ code }: { code: string }) => { + try { + const descriptor = await this.secureRuntime.describeToolSecurely(code); + const built = await this.agentsService.buildCustomTool( + agentId, + projectId, + code, + descriptor, + ); + return { ok: true, id: built.id, descriptor }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }) + .build(); + + const createSkillTool = new Tool(BUILDER_TOOLS.CREATE_SKILL) + .description( + 'Create and store an agent skill. Pass the skill name, a short description, and the full skill body. ' + + 'The description should help the runtime decide when to load it. ' + + 'The body is stored as the skill instructions, but this does NOT attach the skill to the agent config. ' + + 'Follow up with read_config and patch_config (or write_config) to add a `{ type: "skill", id }` entry to `skills`. ' + + 'Returns { ok: true, id, skill } or { ok: false, errors }.', + ) + .input( + z.object({ + name: agentSkillSchema.shape.name.describe('Human-readable skill name'), + description: agentSkillSchema.shape.description.describe( + 'Short description of when to load the skill.', + ), + body: agentSkillSchema.shape.instructions.describe('Full skill instructions/body'), + }), + ) + .handler( + async ({ + name, + description, + body, + }: { + name: string; + description: string; + body: string; + }) => { + const skill = { name, description, instructions: body }; + const validation = agentSkillSchema.safeParse(skill); + if (!validation.success) { + return { ok: false, errors: formatZodErrors(validation.error) }; + } + + try { + const created = await this.agentsService.createSkill(agentId, projectId, skill); + return { ok: true, id: created.id, skill: created.skill }; + } catch (e) { + return { + ok: false, + errors: [{ message: e instanceof Error ? e.message : String(e) }], + }; + } + }, + ) + .build(); + + const listWorkflowsTool = new Tool('list_workflows') + .description( + 'List the n8n workflows that can be attached as tools via `type: "workflow"` in the agent config. ' + + 'ALWAYS call this at the start — workflows are the preferred way to give agents real capabilities ' + + '(sending emails, creating calendar events, querying databases, calling APIs, etc.). ' + + 'Only returns workflows with supported trigger types.', + ) + .input(z.object({})) + .handler(async () => { + const workflows = await this.workflowRepository.find({ + select: ['id', 'name', 'nodes', 'active', 'updatedAt'], + where: { shared: { projectId } }, + relations: ['shared'], + order: { updatedAt: 'DESC' }, + take: 100, + }); + + // Keys are n8n node type IDs, which use the dotted "package.nodeName" + // format — the naming-convention rule doesn't apply to those. + /* eslint-disable @typescript-eslint/naming-convention */ + const SUPPORTED_TRIGGERS: Record = { + 'n8n-nodes-base.manualTrigger': 'manual', + 'n8n-nodes-base.executeWorkflowTrigger': 'executeWorkflow', + 'n8n-nodes-base.chatTrigger': 'chat', + 'n8n-nodes-base.scheduleTrigger': 'schedule', + 'n8n-nodes-base.formTrigger': 'form', + }; + /* eslint-enable @typescript-eslint/naming-convention */ + + const compatible = workflows + .map((w) => { + const triggerNode = (w.nodes ?? []).find( + (n: { type: string }) => SUPPORTED_TRIGGERS[n.type], + ); + if (!triggerNode) return null; + return { + name: w.name, + active: w.active, + triggerType: SUPPORTED_TRIGGERS[triggerNode.type], + }; + }) + .filter(Boolean); + + return { workflows: compatible }; + }) + .build(); + + return [ + buildCustomToolTool, + createSkillTool, + listWorkflowsTool, + ...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 async getConfigSnapshot( + agentId: string, + projectId: string, + ): Promise { + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) throw new Error('Agent not found'); + + const config = composeJsonConfig(agent); + return snapshotFromConfig(config, agent.updatedAt.toISOString(), agent.versionId); + } +} diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts new file mode 100644 index 00000000000..8ebccff541b --- /dev/null +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -0,0 +1,237 @@ +import type { + CredentialProvider, + SerializableAgentState, + StreamChunk, + StreamResult, +} from '@n8n/agents'; +import { Agent, Memory } from '@n8n/agents'; +import { Logger } from '@n8n/backend-common'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { jsonParse, UserError } from 'n8n-workflow'; + +import { AgentsService } from '../agents.service'; +import { composeJsonConfig } from '../json-config/agent-config-composition'; +import { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage'; +import { N8nMemory } from '../integrations/n8n-memory'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; +import { AgentCheckpointRepository } from '../repositories/agent-checkpoint.repository'; +import { buildBuilderPrompt } from './agents-builder-prompts'; +import { AgentsBuilderToolsService, getAgentConfigHash } from './agents-builder-tools.service'; +import { AGENT_THREAD_PREFIX } from './builder-tool-names'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { AgentsBuilderSettingsService } from './agents-builder-settings.service'; + +const BUILDER_MODEL = 'anthropic/claude-sonnet-4-5'; + +/** Derive a stable thread ID for the builder chat of a given agent. */ +function builderThreadId(agentId: string): string { + return `${AGENT_THREAD_PREFIX.BUILDER}${agentId}`; +} + +@Service() +export class AgentsBuilderService { + constructor( + private readonly logger: Logger, + private readonly agentsService: AgentsService, + private readonly agentsBuilderToolsService: AgentsBuilderToolsService, + private readonly n8nMemory: N8nMemory, + private readonly builderSettings: AgentsBuilderSettingsService, + private readonly n8nCheckpointStorage: N8NCheckpointStorage, + private readonly agentCheckpointRepository: AgentCheckpointRepository, + ) {} + + // --------------------------------------------------------------------------- + // Public — message storage + // --------------------------------------------------------------------------- + + /** + * Return persisted builder chat messages for an agent. + */ + async getBuilderMessages(agentId: string) { + const threadId = builderThreadId(agentId); + return await this.n8nMemory.getMessages(threadId); + } + + /** + * Clear persisted builder chat messages for an agent. + */ + async clearBuilderMessages(agentId: string) { + const threadId = builderThreadId(agentId); + await this.n8nMemory.deleteMessagesByThread(threadId); + await this.n8nMemory.deleteThread(threadId); + } + // --------------------------------------------------------------------------- + // Public — streaming + // --------------------------------------------------------------------------- + + async *buildAgent( + agentId: string, + projectId: string, + message: string, + credentialProvider: CredentialProvider, + user: User, + ): AsyncGenerator { + const builder = await this.createBuilderAgent(agentId, projectId, credentialProvider, user); + + this.logger.debug('Starting builder agent stream', { agentId, projectId }); + + const resourceId = user.id; + const resultStream = await builder.stream(message, { + persistence: { threadId: builderThreadId(agentId), resourceId }, + }); + + yield* this.streamFromAgent(resultStream); + } + + /** + * Resume a suspended builder tool call and yield the resulting stream chunks. + * + * The `runId` is supplied by the caller — it originates either from the + * `tool-call-suspended` chunk the FE just received (live) or from the + * `openSuspensions` sidecar returned by `GET /build/messages` (history + * reload). A fresh builder agent is reconstructed every time; the SDK's + * `agent.resume(...)` rehydrates the suspended state from the persisted + * checkpoint, so the new instance picks up where the old one left off. + */ + async *resumeBuild( + agentId: string, + projectId: string, + runId: string, + toolCallId: string, + resumeData: unknown, + credentialProvider: CredentialProvider, + user: User, + ): AsyncGenerator { + const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId); + if (checkpointStatus.status === 'expired') { + throw new UserError(`Builder checkpoint ${runId} has expired and cannot be resumed`); + } + if (checkpointStatus.status === 'not-found') { + throw new UserError(`Builder checkpoint ${runId} not found`); + } + + const builder = await this.createBuilderAgent(agentId, projectId, credentialProvider, user); + + this.logger.debug('Resuming builder agent', { agentId, runId, toolCallId }); + + const resultStream = await builder.resume('stream', resumeData, { + runId, + toolCallId, + }); + + yield* this.streamFromAgent(resultStream); + } + + // --------------------------------------------------------------------------- + // Private — builder agent construction + // --------------------------------------------------------------------------- + + /** + * Build a fresh builder `Agent` instance for the given target agent. + * + * Encapsulates: env-key validation, prompt assembly from current config, + * tool registration, memory storage, and checkpoint wiring. Called on + * every `buildAgent` / `resumeBuild` — resume rehydrates state from the + * persisted checkpoint via the SDK, so the new instance picks up where + * the previous one left off. + */ + private async createBuilderAgent( + agentId: string, + projectId: string, + credentialProvider: CredentialProvider, + user: User, + ): Promise { + const agent = await this.agentsService.findById(agentId, projectId); + if (!agent) { + throw new NotFoundError(`Agent "${agentId}" not found`); + } + + // Resolve the model the builder should run on. Throws + // `BuilderNotConfiguredError` when none of custom-credential / proxy / + // env-var fallback is available. + const { config: modelConfig } = await this.builderSettings.resolveModelConfig(user); + + const currentConfig = composeJsonConfig(agent) as unknown as AgentJsonConfig | null; + const currentToolsMap = agent.tools ?? {}; + const toolList = + Object.entries(currentToolsMap) + .map(([id, t]) => `- ${id}: ${t.descriptor.name} -- ${t.descriptor.description}`) + .join('\n') || '(none)'; + + const configJson = currentConfig ? JSON.stringify(currentConfig, null, 2) : '(no config yet)'; + const instructions = buildBuilderPrompt({ + configJson, + configHash: getAgentConfigHash(currentConfig), + configUpdatedAt: agent.updatedAt.toISOString(), + toolList, + builderModel: BUILDER_MODEL, + }); + + const tools = this.agentsBuilderToolsService.getTools(agentId, projectId, credentialProvider); + + const builderMemory = new Memory().storage(this.n8nMemory).lastMessages(40); + + // Be careful with provider specific options, since user can change model to openai, grok, etc. + const builder = new Agent('agent-builder') + .model(modelConfig) + .instructions(instructions) + .memory(builderMemory) + .checkpoint(this.n8nCheckpointStorage.getStorage(agentId)); + + for (const tool of [...tools.json, ...tools.shared]) { + builder.tool(tool); + } + + return builder; + } + + /** + * Pump SDK stream chunks through to the caller. The runId is now carried + * on each `tool-call-suspended` chunk by the SDK, so this is just a + * plain reader→generator adapter. + */ + private async *streamFromAgent(resultStream: StreamResult): AsyncGenerator { + const reader = resultStream.stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + } finally { + reader.releaseLock(); + } + } + + // --------------------------------------------------------------------------- + // Private — open-suspension lookup + // --------------------------------------------------------------------------- + + /** + * Return the parsed state of the most recent non-expired suspended + * checkpoint for this agent, or `null` if there isn't one. Each pending + * tool call inside the state already carries its own `runId`, so callers + * don't need a separate runId from this helper. + */ + async findOpenCheckpoint(agentId: string): Promise { + const rows = await this.agentCheckpointRepository.find({ + where: { agentId, expired: false }, + order: { updatedAt: 'DESC' }, + take: 5, + }); + for (const row of rows) { + if (!row.state) continue; + let parsed: SerializableAgentState; + try { + parsed = jsonParse(row.state); + } catch { + continue; + } + if (parsed.status === 'suspended') { + return parsed; + } + } + return null; + } +} diff --git a/packages/cli/src/modules/agents/builder/builder-tool-names.ts b/packages/cli/src/modules/agents/builder/builder-tool-names.ts new file mode 100644 index 00000000000..0b3e7f2a6c1 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/builder-tool-names.ts @@ -0,0 +1,21 @@ +/** + * Tool names used by the agent builder. Centralised so prompts, the SSE event + * routing, and tests can't drift on string typos. + */ +export const BUILDER_TOOLS = { + READ_CONFIG: 'read_config', + WRITE_CONFIG: 'write_config', + PATCH_CONFIG: 'patch_config', + BUILD_CUSTOM_TOOL: 'build_custom_tool', + CREATE_SKILL: 'create_skill', + LIST_INTEGRATION_TYPES: 'list_integration_types', + RESOLVE_LLM: 'resolve_llm', +} as const; + +export type BuilderToolName = (typeof BUILDER_TOOLS)[keyof typeof BUILDER_TOOLS]; + +/** Thread-id prefixes scoping different chat surfaces of the same agent. */ +export const AGENT_THREAD_PREFIX = { + TEST: 'test-', + BUILDER: 'builder:', +} as const; diff --git a/packages/cli/src/modules/agents/builder/errors.ts b/packages/cli/src/modules/agents/builder/errors.ts new file mode 100644 index 00000000000..1e9c04b7643 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/errors.ts @@ -0,0 +1,18 @@ +import { UserError } from 'n8n-workflow'; + +/** + * Stable code on `BuilderNotConfiguredError` so the SSE stream / FE can + * detect the unconfigured state and render the "go to settings" empty + * state without parsing the human-readable message. + */ +export const BUILDER_NOT_CONFIGURED_CODE = 'BUILDER_NOT_CONFIGURED'; + +export class BuilderNotConfiguredError extends UserError { + readonly code = BUILDER_NOT_CONFIGURED_CODE; + + constructor() { + super( + 'Agent builder is not configured. An admin must select a provider and credential, or configure the n8n AI assistant proxy.', + ); + } +} 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 new file mode 100644 index 00000000000..1aee20b5a20 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts @@ -0,0 +1,80 @@ +import type { CredentialListItem, CredentialProvider } from '@n8n/agents'; +import { buildAskCredentialTool } from '../ask-credential.tool'; + +interface TestCtx { + resumeData?: unknown; + suspend: jest.Mock; +} + +function makeCtx(overrides?: { resumeData?: unknown }): TestCtx { + return { resumeData: overrides?.resumeData, suspend: jest.fn(async (x: unknown) => x) }; +} + +function makeProvider(creds: CredentialListItem[]): CredentialProvider { + return { + list: jest.fn(async () => creds), + resolve: jest.fn(async () => ({})), + }; +} + +describe('ask_credential tool', () => { + it('auto-resolves when exactly one credential of the requested type exists', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'My Slack', type: 'slackApi' }, + { id: 'c2', name: 'OpenAI', type: 'openAiApi' }, + ]); + const tool = buildAskCredentialTool({ credentialProvider }); + const ctx = makeCtx(); + const result = await tool.handler!( + { purpose: 'Slack', credentialType: 'slackApi' }, + ctx as never, + ); + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(result).toEqual({ credentialId: 'c1', credentialName: 'My Slack' }); + }); + + it('suspends when multiple credentials of the type exist', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'Personal Slack', type: 'slackApi' }, + { id: 'c2', name: 'Workspace Slack', type: 'slackApi' }, + ]); + const tool = buildAskCredentialTool({ credentialProvider }); + const ctx = makeCtx(); + await tool.handler!({ purpose: 'Slack', credentialType: 'slackApi' }, ctx as never); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + }); + + it('suspends when no credentials of the type exist', async () => { + const credentialProvider = makeProvider([{ id: 'c2', name: 'OpenAI', type: 'openAiApi' }]); + const tool = buildAskCredentialTool({ credentialProvider }); + const ctx = makeCtx(); + await tool.handler!({ purpose: 'Slack', credentialType: 'slackApi' }, ctx as never); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + }); + + it('returns resumeData verbatim after resume without consulting the provider', async () => { + const credentialProvider = makeProvider([]); + const tool = buildAskCredentialTool({ credentialProvider }); + const ctx = makeCtx({ resumeData: { credentialId: 'c9', credentialName: 'Picked' } }); + const result = await tool.handler!( + { purpose: 'Slack', credentialType: 'slackApi' }, + ctx as never, + ); + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(credentialProvider.list).not.toHaveBeenCalled(); + expect(result).toEqual({ credentialId: 'c9', credentialName: 'Picked' }); + }); + + it('returns skipped resumeData so the builder can continue without credentials', async () => { + const credentialProvider = makeProvider([]); + const tool = buildAskCredentialTool({ credentialProvider }); + const ctx = makeCtx({ resumeData: { skipped: true } }); + const result = await tool.handler!( + { purpose: 'Slack', credentialType: 'slackApi' }, + ctx as never, + ); + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(credentialProvider.list).not.toHaveBeenCalled(); + expect(result).toEqual({ skipped: true }); + }); +}); diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts new file mode 100644 index 00000000000..ce0a7883678 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts @@ -0,0 +1,39 @@ +import { buildAskLlmTool } from '../ask-llm.tool'; + +interface TestCtx { + resumeData?: unknown; + suspend: jest.Mock; +} + +function makeCtx(overrides?: { resumeData?: unknown }): TestCtx { + return { resumeData: overrides?.resumeData, suspend: jest.fn(async (x: unknown) => x) }; +} + +describe('ask_llm tool', () => { + it('suspends on first invocation so the user can choose', async () => { + const tool = buildAskLlmTool(); + const ctx = makeCtx(); + await tool.handler!({ purpose: 'Main LLM' }, ctx as never); + expect(ctx.suspend).toHaveBeenCalledWith({ purpose: 'Main LLM' }); + }); + + it('returns resumeData verbatim after resume', async () => { + const tool = buildAskLlmTool(); + const ctx = makeCtx({ + resumeData: { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + credentialId: 'cX', + credentialName: 'Picked', + }, + }); + const result = await tool.handler!({ purpose: 'Main LLM' }, ctx as never); + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: 'anthropic', + model: 'claude-sonnet-4-6', + credentialId: 'cX', + credentialName: 'Picked', + }); + }); +}); diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts new file mode 100644 index 00000000000..00cc7f05d5d --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-question.tool.test.ts @@ -0,0 +1,58 @@ +import { buildAskQuestionTool } from '../ask-question.tool'; + +interface TestCtx { + resumeData?: unknown; + suspend: jest.Mock; +} + +function makeCtx(overrides?: { resumeData?: unknown }): TestCtx { + return { + resumeData: overrides?.resumeData, + suspend: jest.fn(async (x: unknown) => x), + }; +} + +describe('ask_question tool', () => { + const tool = buildAskQuestionTool(); + + it('auto-resolves to the only option when options.length === 1', async () => { + const ctx = makeCtx(); + const result = await tool.handler!( + { question: 'Pick one', options: [{ label: 'Slack', value: 'slack' }] }, + ctx as never, + ); + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(result).toEqual({ values: ['slack'] }); + }); + + it('suspends when there are multiple options', async () => { + const ctx = makeCtx(); + await tool.handler!( + { + question: 'Pick one', + options: [ + { label: 'Slack', value: 'slack' }, + { label: 'Discord', value: 'discord' }, + ], + }, + ctx as never, + ); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + }); + + it('returns resumeData verbatim after resume', async () => { + const ctx = makeCtx({ resumeData: { values: ['discord'] } }); + const result = await tool.handler!( + { + question: 'Pick', + options: [ + { label: 'Slack', value: 'slack' }, + { label: 'Discord', value: 'discord' }, + ], + }, + ctx as never, + ); + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(result).toEqual({ values: ['discord'] }); + }); +}); diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts new file mode 100644 index 00000000000..27efe271a29 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/resolve-llm.tool.test.ts @@ -0,0 +1,118 @@ +import type { CredentialListItem, CredentialProvider } from '@n8n/agents'; +import { buildResolveLlmTool } from '../resolve-llm.tool'; + +function makeProvider(creds: CredentialListItem[]): CredentialProvider { + return { + list: jest.fn(async () => creds), + resolve: jest.fn(async () => ({})), + }; +} + +describe('resolve_llm tool', () => { + it('auto-resolves when exactly one LLM-provider credential exists', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'My Anthropic', type: 'anthropicApi' }, + { id: 'c2', name: 'My Slack', type: 'slackApi' }, + ]); + const tool = buildResolveLlmTool({ credentialProvider }); + const result = await tool.handler!({}, {}); + + expect(result).toEqual({ + ok: true, + provider: 'anthropic', + model: 'claude-sonnet-4-6', + credentialId: 'c1', + credentialName: 'My Anthropic', + }); + }); + + it('auto-resolves the requested provider when multiple LLM-provider credentials exist', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'My Anthropic', type: 'anthropicApi' }, + { id: 'c2', name: 'My OpenRouter', type: 'openRouterApi' }, + ]); + const tool = buildResolveLlmTool({ credentialProvider }); + const result = await tool.handler!({ provider: 'openrouter' }, {}); + + expect(result).toEqual({ + ok: true, + provider: 'openrouter', + model: 'anthropic/claude-sonnet-4.6', + credentialId: 'c2', + credentialName: 'My OpenRouter', + }); + }); + + it('uses the requested model for the requested provider', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'My OpenRouter', type: 'openRouterApi' }, + ]); + const tool = buildResolveLlmTool({ credentialProvider }); + const result = await tool.handler!( + { provider: 'openrouter', model: 'meta-llama/llama-3.1-70b-instruct' }, + {}, + ); + + expect(result).toEqual({ + ok: true, + provider: 'openrouter', + model: 'meta-llama/llama-3.1-70b-instruct', + credentialId: 'c1', + credentialName: 'My OpenRouter', + }); + }); + + it('returns missing_credential when the requested provider has no credentials', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'My Anthropic', type: 'anthropicApi' }, + ]); + const tool = buildResolveLlmTool({ credentialProvider }); + const result = await tool.handler!({ provider: 'openrouter' }, {}); + + expect(result).toEqual({ + ok: false, + reason: 'missing_credential', + provider: 'openrouter', + credentialType: 'openRouterApi', + credentials: [], + }); + }); + + it('returns ambiguous_credential when the requested provider has multiple credentials', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'Personal OpenRouter', type: 'openRouterApi' }, + { id: 'c2', name: 'Work OpenRouter', type: 'openRouterApi' }, + ]); + const tool = buildResolveLlmTool({ credentialProvider }); + const result = await tool.handler!({ provider: 'openrouter' }, {}); + + expect(result).toEqual({ + ok: false, + reason: 'ambiguous_credential', + provider: 'openrouter', + credentialType: 'openRouterApi', + credentials: [ + { id: 'c1', name: 'Personal OpenRouter' }, + { id: 'c2', name: 'Work OpenRouter' }, + ], + }); + }); + + it('returns ambiguous_provider_or_credential when no provider is requested and multiple credentials exist', async () => { + const credentialProvider = makeProvider([ + { id: 'c1', name: 'My Anthropic', type: 'anthropicApi' }, + { id: 'c2', name: 'My OpenAI', type: 'openAiApi' }, + ]); + const tool = buildResolveLlmTool({ credentialProvider }); + const result = await tool.handler!({}, {}); + + expect(result).toEqual({ + ok: false, + reason: 'ambiguous_provider_or_credential', + credentials: [ + { id: 'c1', name: 'My Anthropic', type: 'anthropicApi', provider: 'anthropic' }, + { id: 'c2', name: 'My OpenAI', type: 'openAiApi', provider: 'openai' }, + ], + }); + }); +}); 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 new file mode 100644 index 00000000000..addae9fe858 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts @@ -0,0 +1,50 @@ +import { Tool } from '@n8n/agents'; +import type { BuiltTool, CredentialProvider, InterruptibleToolContext } from '@n8n/agents'; +import { + ASK_CREDENTIAL_TOOL_NAME, + askCredentialInputSchema, + askCredentialResumeSchema, + type AskCredentialInput, + type AskCredentialResume, +} from '@n8n/api-types'; + +export interface AskCredentialToolDeps { + credentialProvider: CredentialProvider; +} + +export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool { + return ( + new Tool(ASK_CREDENTIAL_TOOL_NAME) + .description( + 'Show a credential picker card in the chat UI and suspend until the user selects ' + + 'a credential. Call ONCE per credential slot, BEFORE the write_config / patch_config ' + + 'that introduces the node tool. Returns { credentialId, credentialName } on success ' + + 'or { skipped: true } if the user skips credential setup so the tool can be added ' + + 'without credentials. Auto-resolves without ' + + 'rendering a card when the user has exactly one credential of the requested type.', + ) + .input(askCredentialInputSchema) + // Suspend payload mirrors the input — the discriminator on the wire is + // the tool name, not a separate `interactionType` field. + .suspend(askCredentialInputSchema) + .resume(askCredentialResumeSchema) + .handler( + async ( + input: AskCredentialInput, + ctx: InterruptibleToolContext, + ) => { + if (ctx.resumeData !== undefined) return ctx.resumeData; + // If the user has exactly one credential of the requested type the + // picker has nothing to ask — auto-resolve so the LLM doesn't render + // a card the user can only confirm. + const all = await deps.credentialProvider.list(); + const matching = all.filter((c) => c.type === input.credentialType); + if (matching.length === 1) { + return { credentialId: matching[0].id, credentialName: matching[0].name }; + } + return await ctx.suspend(input); + }, + ) + .build() + ); +} diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts new file mode 100644 index 00000000000..0e10cb98586 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts @@ -0,0 +1,31 @@ +import { Tool } from '@n8n/agents'; +import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents'; +import { + ASK_LLM_TOOL_NAME, + askLlmInputSchema, + askLlmResumeSchema, + type AskLlmInput, + type AskLlmResume, +} from '@n8n/api-types'; + +export function buildAskLlmTool(): BuiltTool { + return new Tool(ASK_LLM_TOOL_NAME) + .description( + 'Show a model + credential picker card in the chat UI and suspend until the user ' + + 'selects a provider, model and credential. Use only when resolve_llm returns an ' + + 'ambiguous result, when credentials are missing and the user must choose/configure one, ' + + 'or when the user explicitly asks to pick/change the model. ' + + 'After resume: set model = "{provider}/{model}" and credential = credentialName ' + + 'via write_config or patch_config.', + ) + .input(askLlmInputSchema) + .suspend(askLlmInputSchema) + .resume(askLlmResumeSchema) + .handler( + async (input: AskLlmInput, ctx: InterruptibleToolContext) => { + if (ctx.resumeData !== undefined) return ctx.resumeData; + return await ctx.suspend(input); + }, + ) + .build(); +} diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts new file mode 100644 index 00000000000..c49f0915d09 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts @@ -0,0 +1,37 @@ +import { Tool } from '@n8n/agents'; +import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents'; +import { + ASK_QUESTION_TOOL_NAME, + askQuestionInputSchema, + askQuestionResumeSchema, + type AskQuestionInput, + type AskQuestionResume, +} from '@n8n/api-types'; + +export function buildAskQuestionTool(): BuiltTool { + return new Tool(ASK_QUESTION_TOOL_NAME) + .description( + 'Show a multiple-choice card in the chat UI and suspend until the user picks an ' + + 'answer. Use when the request is ambiguous and the answer is one (or more) of a ' + + 'known list of options. Do NOT use for free-text input — ask in prose for that. ' + + 'Returns { values: string[] } with the selected values.', + ) + .input(askQuestionInputSchema) + .suspend(askQuestionInputSchema) + .resume(askQuestionResumeSchema) + .handler( + async ( + input: AskQuestionInput, + ctx: InterruptibleToolContext, + ) => { + if (ctx.resumeData !== undefined) return ctx.resumeData; + // Single-option questions have no actual choice — auto-pick so the + // LLM doesn't render a card the user can only confirm. + if (input.options.length === 1) { + return { values: [input.options[0].value] }; + } + return await ctx.suspend(input); + }, + ) + .build(); +} diff --git a/packages/cli/src/modules/agents/builder/interactive/index.ts b/packages/cli/src/modules/agents/builder/interactive/index.ts new file mode 100644 index 00000000000..7c624b75d18 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/index.ts @@ -0,0 +1,4 @@ +export { buildAskCredentialTool } from './ask-credential.tool'; +export { buildAskLlmTool } from './ask-llm.tool'; +export { buildAskQuestionTool } from './ask-question.tool'; +export { buildResolveLlmTool } from './resolve-llm.tool'; diff --git a/packages/cli/src/modules/agents/builder/interactive/llm-provider-defaults.ts b/packages/cli/src/modules/agents/builder/interactive/llm-provider-defaults.ts new file mode 100644 index 00000000000..656dacb3f27 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/llm-provider-defaults.ts @@ -0,0 +1,29 @@ +/** + * Canonical "if you have one of THIS credential type, this is the LLM provider + * + safe default model to seed the agent with." Used by the ask_llm tool to + * auto-resolve when there's exactly one LLM-provider credential available. + * + * Provider strings match the catalog IDs used by `@n8n/agents`'s + * `.model(provider, model)` call (see editor-ui's provider-mapping.ts for the + * other side of this contract). + * + * Keep this list narrow — when the canonical default is unclear (e.g. Bedrock, + * Azure variants), omit the entry so the tool falls through to suspending and + * lets the user pick explicitly. + */ +export interface LlmProviderDefault { + provider: string; + defaultModel: string; +} + +export const LLM_PROVIDER_DEFAULTS: Record = { + anthropicApi: { provider: 'anthropic', defaultModel: 'claude-sonnet-4-6' }, + openAiApi: { provider: 'openai', defaultModel: 'gpt-5' }, + googlePalmApi: { provider: 'google', defaultModel: 'gemini-2.5-pro' }, + xAiApi: { provider: 'xai', defaultModel: 'grok-4' }, + groqApi: { provider: 'groq', defaultModel: 'llama-3.1-70b-versatile' }, + mistralCloudApi: { provider: 'mistral', defaultModel: 'mistral-large-latest' }, + deepSeekApi: { provider: 'deepseek', defaultModel: 'deepseek-chat' }, + cohereApi: { provider: 'cohere', defaultModel: 'command-r-plus' }, + openRouterApi: { provider: 'openrouter', defaultModel: 'anthropic/claude-sonnet-4.6' }, +}; 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 new file mode 100644 index 00000000000..1f1bdeaea74 --- /dev/null +++ b/packages/cli/src/modules/agents/builder/interactive/resolve-llm.tool.ts @@ -0,0 +1,124 @@ +import { Tool } from '@n8n/agents'; +import type { BuiltTool, CredentialListItem, CredentialProvider } from '@n8n/agents'; +import { z } from 'zod'; + +import { BUILDER_TOOLS } from '../builder-tool-names'; +import { LLM_PROVIDER_DEFAULTS, type LlmProviderDefault } from './llm-provider-defaults'; + +export interface ResolveLlmToolDeps { + credentialProvider: CredentialProvider; +} + +type LlmCredentialEntry = [credentialType: string, defaults: LlmProviderDefault]; + +function findProviderDefault(provider: string): LlmCredentialEntry | undefined { + const requestedProvider = provider.trim(); + return Object.entries(LLM_PROVIDER_DEFAULTS).find( + ([, defaults]) => defaults.provider === requestedProvider, + ); +} + +function toLlmResolution( + credential: CredentialListItem, + defaults: LlmProviderDefault, + model?: string, +) { + return { + ok: true as const, + provider: defaults.provider, + model: model?.trim() || defaults.defaultModel, + credentialId: credential.id, + credentialName: credential.name, + }; +} + +export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool { + return new Tool(BUILDER_TOOLS.RESOLVE_LLM) + .description( + 'Resolve the agent main LLM without showing a picker. Use this when the user ' + + 'explicitly requests a provider/model, or when a fresh agent needs a default LLM. ' + + 'If provider is given, resolves only that provider; if model is omitted, uses the ' + + 'provider default model. For "Anthropic via OpenRouter", pass provider="openrouter" ' + + 'and omit model unless the user named a concrete OpenRouter model id. Returns ok=false ' + + 'when credentials are missing, unsupported, or ambiguous; use ask_llm only when the ' + + 'user must choose.', + ) + .input( + z.object({ + provider: z + .string() + .optional() + .describe('Requested provider, e.g. "anthropic", "openai", or "openrouter".'), + model: z + .string() + .optional() + .describe( + 'Requested model without the selected provider prefix. For OpenRouter use the routed id, e.g. "anthropic/claude-sonnet-4.6".', + ), + }), + ) + .handler(async ({ provider, model }: { provider?: string; model?: string }) => { + const all = await deps.credentialProvider.list(); + const llmCredentials = all.filter((credential) => LLM_PROVIDER_DEFAULTS[credential.type]); + + if (provider) { + const providerEntry = findProviderDefault(provider); + if (!providerEntry) { + return { + ok: false as const, + reason: 'unsupported_provider' as const, + provider, + supportedProviders: Object.values(LLM_PROVIDER_DEFAULTS).map( + (defaults) => defaults.provider, + ), + }; + } + + const [credentialType, defaults] = providerEntry; + const matchingCredentials = llmCredentials.filter( + (credential) => credential.type === credentialType, + ); + + if (matchingCredentials.length === 1) { + return toLlmResolution(matchingCredentials[0], defaults, model); + } + + return { + ok: false as const, + reason: + matchingCredentials.length === 0 + ? ('missing_credential' as const) + : ('ambiguous_credential' as const), + provider: defaults.provider, + credentialType, + credentials: matchingCredentials.map((credential) => ({ + id: credential.id, + name: credential.name, + })), + }; + } + + if (llmCredentials.length === 1) { + const credential = llmCredentials[0]; + return toLlmResolution(credential, LLM_PROVIDER_DEFAULTS[credential.type], model); + } + + return { + ok: false as const, + reason: + llmCredentials.length === 0 + ? ('missing_credential' as const) + : ('ambiguous_provider_or_credential' as const), + credentials: llmCredentials.map((credential) => { + const defaults = LLM_PROVIDER_DEFAULTS[credential.type]; + return { + id: credential.id, + name: credential.name, + type: credential.type, + provider: defaults.provider, + }; + }), + }; + }) + .build(); +} diff --git a/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts b/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts new file mode 100644 index 00000000000..cd4e0b04649 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-checkpoint.entity.ts @@ -0,0 +1,23 @@ +import { WithTimestamps } from '@n8n/db'; +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; + +import { Agent } from './agent.entity'; + +@Entity({ name: 'agent_checkpoints' }) +export class AgentCheckpoint extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 255 }) + runId: string; + + @ManyToOne(() => Agent, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'agentId' }) + agent: Agent | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + agentId: string | null; + + @Column({ type: 'text', nullable: true }) + state: string | null; + + @Column({ type: 'boolean', default: false }) + expired: boolean; +} diff --git a/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts new file mode 100644 index 00000000000..969e57b5814 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-execution-thread.entity.ts @@ -0,0 +1,65 @@ +import { Project, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm'; + +import { Agent } from './agent.entity'; + +/** + * One conversation between a user and an agent. Aggregates per-session + * counters (token usage, cost, duration) so the sessions list can render + * without scanning every message. + * + * Replaces the unreleased `ExecutionThread` entity (`execution_threads` + * table). Per-message records live in `AgentExecution` (`agent_execution` + * table) — see {@link AgentExecution}. + * + * Distinct from the SDK memory `AgentThreadEntity` (`agents_threads`), + * which stores chat-history state owned by the n8n-memory integration. + * Both use the same `threadId` value but serve different layers. + */ +@Entity({ name: 'agent_execution_threads' }) +export class AgentExecutionThread extends WithTimestampsAndStringId { + @ManyToOne(() => Agent, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'agentId' }) + agent: Agent; + + @Index() + @Column({ type: 'varchar', length: 36 }) + agentId: string; + + /** Denormalized for display — avoids joining agents table when listing threads. */ + @Column({ type: 'varchar', length: 255 }) + agentName: string; + + /** LLM-generated summary of the first user message. */ + @Column({ type: 'varchar', length: 255, nullable: true }) + title: string | null; + + /** Emoji representing the session topic. */ + @Column({ type: 'varchar', length: 8, nullable: true }) + emoji: string | null; + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project: Project; + + @Index() + @Column({ type: 'varchar', length: 255 }) + projectId: string; + + /** Stable, project-scoped incrementing counter assigned at creation. */ + @Column({ type: 'int', default: 0 }) + sessionNumber: number; + + @Column({ type: 'int', default: 0 }) + totalPromptTokens: number; + + @Column({ type: 'int', default: 0 }) + totalCompletionTokens: number; + + @Column({ type: 'double precision', default: 0 }) + totalCost: number; + + /** Total generation time across all messages, in milliseconds. */ + @Column({ type: 'int', default: 0 }) + totalDuration: number; +} diff --git a/packages/cli/src/modules/agents/entities/agent-execution.entity.ts b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts new file mode 100644 index 00000000000..37ec0d95a36 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-execution.entity.ts @@ -0,0 +1,87 @@ +import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm'; + +import { AgentExecutionThread } from './agent-execution-thread.entity'; +import type { RecordedToolCall, TimelineEvent } from '../execution-recorder'; + +export type AgentExecutionStatus = 'success' | 'error'; +export type AgentExecutionHitlStatus = 'suspended' | 'resumed'; + +/** + * One agent run within a thread — the unit recorded for each user/agent + * exchange. Replaces the per-agent rows that used to live in + * `execution_entity` (with a fan-out of free-form key/value rows in + * `execution_metadata`). + * + * Storing typed columns instead of metadata key/value pairs lets queries + * filter and aggregate directly (e.g. "first userMessage in thread", + * "suspended runs missing model"), without the index-unfriendly + * `WHERE key = '...' AND value != ''` predicates the old schema needed. + */ +@Entity({ name: 'agent_execution' }) +@Index(['threadId', 'createdAt']) +export class AgentExecution extends WithTimestampsAndStringId { + @ManyToOne(() => AgentExecutionThread, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'threadId' }) + thread: AgentExecutionThread; + + @Column({ type: 'varchar', length: 36 }) + threadId: string; + + @Column({ type: 'varchar', length: 16 }) + status: AgentExecutionStatus; + + @DateTimeColumn({ precision: 3, nullable: true }) + startedAt: Date | null; + + @DateTimeColumn({ precision: 3, nullable: true }) + stoppedAt: Date | null; + + /** Wall-clock generation time in milliseconds. */ + @Column({ type: 'int', default: 0 }) + duration: number; + + /** + * Cleaned user input. Empty for resumed runs (HITL continuations) where + * the user input belongs to an earlier suspended run in the same thread. + */ + @Column({ type: 'text' }) + userMessage: string; + + @Column({ type: 'text' }) + assistantResponse: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + model: string | null; + + @Column({ type: 'int', nullable: true }) + promptTokens: number | null; + + @Column({ type: 'int', nullable: true }) + completionTokens: number | null; + + @Column({ type: 'int', nullable: true }) + totalTokens: number | null; + + @Column({ type: 'double precision', nullable: true }) + cost: number | null; + + @JsonColumn({ nullable: true }) + toolCalls: RecordedToolCall[] | null; + + @JsonColumn({ nullable: true }) + timeline: TimelineEvent[] | null; + + @Column({ type: 'text', nullable: true }) + error: string | null; + + @Column({ type: 'varchar', length: 16, nullable: true }) + hitlStatus: AgentExecutionHitlStatus | null; + + @Column({ type: 'text', nullable: true }) + workingMemory: string | null; + + /** Where the run originated, e.g. 'chat', 'slack'. */ + @Column({ type: 'varchar', length: 32, nullable: true }) + source: string | null; +} diff --git a/packages/cli/src/modules/agents/entities/agent-message.entity.ts b/packages/cli/src/modules/agents/entities/agent-message.entity.ts new file mode 100644 index 00000000000..7cf16ae6000 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-message.entity.ts @@ -0,0 +1,26 @@ +import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, JoinColumn, ManyToOne } from '@n8n/typeorm'; + +import { AgentThreadEntity } from './agent-thread.entity'; + +@Entity({ name: 'agents_messages' }) +export class AgentMessageEntity extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 255 }) + threadId: string; + + @Column({ type: 'varchar', length: 255 }) + resourceId: string; + + @Column({ type: 'varchar', length: 36 }) + role: string; + + @Column({ type: 'varchar', length: 36, nullable: true }) + type: string | null; + + @JsonColumn() + content: Record; + + @ManyToOne(() => AgentThreadEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'threadId' }) + thread: AgentThreadEntity; +} diff --git a/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts b/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts new file mode 100644 index 00000000000..1e0e9dbdd99 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-published-version.entity.ts @@ -0,0 +1,63 @@ +import type { ToolDescriptor } from '@n8n/agents'; +import type { AgentSkill } from '@n8n/api-types'; +import { JsonColumn, User, WithTimestamps } from '@n8n/db'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryColumn, + type Relation, +} from '@n8n/typeorm'; + +import type { Agent } from './agent.entity'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; + +@Entity({ name: 'agent_published_version' }) +export class AgentPublishedVersion extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + agentId: string; + + @OneToOne('Agent', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'agentId' }) + agent: Relation; + + @JsonColumn({ nullable: true, default: null }) + schema: AgentJsonConfig | null; + + @JsonColumn({ nullable: true, default: null }) + tools: Record< + string, + { + code: string; + descriptor: ToolDescriptor; + } + > | null; + + @JsonColumn({ nullable: true, default: null }) + skills: Record | null; + + /** + * The agent's draft versionId at the time this snapshot was published. + * Compared against Agent.versionId to detect draft divergence from the published version. + */ + @Column({ type: 'varchar', length: 36 }) + publishedFromVersionId: string; + + @Column({ type: 'varchar', length: 128, nullable: true }) + model: string | null; + + @Column({ type: 'varchar', length: 128, nullable: true }) + provider: string | null; + + @Column({ type: 'varchar', length: 36, nullable: true }) + credentialId: string | null; + + @Column({ type: 'uuid', nullable: true }) + publishedById: string | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'publishedById' }) + publishedBy?: User | null; +} diff --git a/packages/cli/src/modules/agents/entities/agent-resource.entity.ts b/packages/cli/src/modules/agents/entities/agent-resource.entity.ts new file mode 100644 index 00000000000..d66805942e9 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-resource.entity.ts @@ -0,0 +1,11 @@ +import { WithTimestamps } from '@n8n/db'; +import { Column, Entity, PrimaryColumn } from '@n8n/typeorm'; + +@Entity({ name: 'agents_resources' }) +export class AgentResourceEntity extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 255 }) + id: string; + + @Column({ type: 'text', nullable: true }) + metadata: string | null; +} diff --git a/packages/cli/src/modules/agents/entities/agent-thread.entity.ts b/packages/cli/src/modules/agents/entities/agent-thread.entity.ts new file mode 100644 index 00000000000..0785cfe2239 --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent-thread.entity.ts @@ -0,0 +1,14 @@ +import { WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity } from '@n8n/typeorm'; + +@Entity({ name: 'agents_threads' }) +export class AgentThreadEntity extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 255 }) + resourceId: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + title: string | null; + + @Column({ type: 'text', nullable: true }) + metadata: string | null; +} diff --git a/packages/cli/src/modules/agents/entities/agent.entity.ts b/packages/cli/src/modules/agents/entities/agent.entity.ts new file mode 100644 index 00000000000..11e052f2f5c --- /dev/null +++ b/packages/cli/src/modules/agents/entities/agent.entity.ts @@ -0,0 +1,57 @@ +import type { AgentIntegration, AgentSkill } from '@n8n/api-types'; +import type { ToolDescriptor } from '@n8n/agents'; +import { JsonColumn, Project, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, ManyToOne, JoinColumn, OneToOne, type Relation } from '@n8n/typeorm'; + +import type { AgentPublishedVersion } from './agent-published-version.entity'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; + +@Entity({ name: 'agents' }) +export class Agent extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 128 }) + name: string; + + @Column({ type: 'varchar', length: 512, nullable: true }) + description: string | null; + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project: Project; + + @Column() + projectId: string; + + @Column({ type: 'varchar', nullable: true }) + credentialId: string | null; + + @Column({ type: 'varchar', nullable: true }) + provider: string | null; + + @Column({ type: 'varchar', nullable: true }) + model: string | null; + + @JsonColumn({ nullable: true, default: null }) + schema: AgentJsonConfig | null; + + @JsonColumn({ default: '[]' }) + integrations: AgentIntegration[]; + + @JsonColumn({ default: '{}' }) + tools: Record< + string, + { + code: string; + descriptor: ToolDescriptor; + } + >; + + @JsonColumn({ default: '{}' }) + skills: Record; + + /** UUID identifying the current draft; bumped on the first config save after each publish. */ + @Column({ type: 'varchar', length: 36, nullable: true }) + versionId: string | null; + + @OneToOne('AgentPublishedVersion', 'agent', { nullable: true }) + publishedVersion?: Relation | null; +} diff --git a/packages/cli/src/modules/agents/execution-recorder.ts b/packages/cli/src/modules/agents/execution-recorder.ts new file mode 100644 index 00000000000..44f92559ceb --- /dev/null +++ b/packages/cli/src/modules/agents/execution-recorder.ts @@ -0,0 +1,410 @@ +import { UPDATE_WORKING_MEMORY_TOOL_NAME, type StreamChunk } from '@n8n/agents'; +import { extractFromAICalls, isFromAIOnlyExpression } from 'n8n-workflow'; + +import type { ToolRegistry } from './tool-registry'; + +/** Pull the human-readable working-memory content out of the WM tool's input. */ +function workingMemoryContentFromInput(input: unknown): string { + if (input && typeof input === 'object' && !Array.isArray(input)) { + const maybe = (input as Record).memory; + if (typeof maybe === 'string') return maybe; + } + return JSON.stringify(input, null, 2); +} + +/** + * Walk a nodeParameters tree and substitute every `$fromAI('key', ...)` + * expression with the value the LLM passed for that key (or the call's + * default when the LLM didn't provide one). Used when recording a + * `kind: 'node'` tool call so the timeline shows the resolved values the + * node would have run with — not the raw template strings the user + * configured. + * + * Pure best-effort: parsing failures fall through to the raw string. The + * goal is a clearer log entry, not exact expression-engine fidelity. + */ +function resolveFromAIInValue(value: unknown, llmArgs: Record): unknown { + if (typeof value === 'string') return resolveFromAIInString(value, llmArgs); + if (Array.isArray(value)) return value.map((v) => resolveFromAIInValue(v, llmArgs)); + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = resolveFromAIInValue(v, llmArgs); + } + return out; + } + return value; +} + +function resolveFromAIInString(str: string, llmArgs: Record): unknown { + if (!str.includes('$fromAI')) return str; + + let calls: ReturnType; + try { + calls = extractFromAICalls(str); + } catch { + return str; + } + if (calls.length === 0) return str; + + // Full-string `$fromAI(...)` — replace the entire value with the resolved + // arg so the timeline shows e.g. the literal prompt text, not `={{ ... }}`. + if (isFromAIOnlyExpression(str)) { + const call = calls[0]; + if (call.key in llmArgs) return llmArgs[call.key]; + if (call.defaultValue !== undefined) return call.defaultValue; + return str; + } + + // Mixed-content expression — substitute each `$fromAI(...)` call inline. + // We re-scan with a forgiving pattern; precise expression-engine rules + // (e.g. nested calls) aren't supported, but the common case of a single + // call inside `={{ ... }}` works. + const pattern = /\$fromAI\s*\([^)]*\)/g; + return str.replace(pattern, (match) => { + try { + const inner = extractFromAICalls(match); + if (inner.length === 0) return match; + const call = inner[0]; + const resolved = + call.key in llmArgs + ? llmArgs[call.key] + : call.defaultValue !== undefined + ? call.defaultValue + : undefined; + if (resolved === undefined) return match; + if (typeof resolved === 'object') return JSON.stringify(resolved); + return String(resolved); + } catch { + return match; + } + }); +} + +export interface RecordedUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +export interface RecordedToolCall { + name: string; + input: unknown; + output: unknown; +} + +export type TimelineEvent = + | { type: 'text'; content: string; timestamp: number; endTime?: number } + | { + type: 'tool-call'; + kind: 'tool' | 'workflow' | 'node'; + name: string; + toolCallId: string; + input: unknown; + output: unknown; + startTime: number; + endTime: number; + success: boolean; + workflowId?: string; + workflowName?: string; + workflowExecutionId?: string; + triggerType?: string; + nodeType?: string; + nodeTypeVersion?: number; + nodeDisplayName?: string; + /** + * Configured node parameters from the agent's JSON config (only set + * for `kind: 'node'`). The LLM's runtime args go into `input`; this + * field carries the node's actual configuration so the session- + * detail viewer can show what the node was set up to do. + */ + nodeParameters?: Record; + } + | { type: 'working-memory'; content: string; timestamp: number } + | { type: 'suspension'; toolName: string; toolCallId: string; timestamp: number }; + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} + +/** + * Collects execution data from agent stream chunks. + * Used to build an execution record after a message cycle completes. + */ +export interface MessageRecord { + assistantResponse: string; + model: string | null; + finishReason: string; + usage: RecordedUsage | null; + totalCost: number | null; + toolCalls: RecordedToolCall[]; + timeline: TimelineEvent[]; + startTime: number; + duration: number; + error: string | null; + workingMemory: string | null; +} + +export class ExecutionRecorder { + private readonly registry: ToolRegistry; + + constructor(registry?: ToolRegistry) { + this.registry = registry ?? new Map(); + } + + private textParts: string[] = []; + + /** Text buffer for the current segment (flushed to timeline on boundaries). */ + private textBuffer: string[] = []; + + private model: string | null = null; + + private finishReason = 'unknown'; + + private usage: RecordedUsage | null = null; + + private totalCost: number | null = null; + + private toolCalls: RecordedToolCall[] = []; + + private timeline: TimelineEvent[] = []; + + /** Wall-clock when the first text-delta of the current segment arrived. */ + private textStartTime: number | null = null; + + private _suspended = false; + + private error: string | null = null; + + private workingMemory: string | null = null; + + private readonly startTime = Date.now(); + + /** Feed a stream chunk into the recorder. */ + record(chunk: StreamChunk): void { + switch (chunk.type) { + case 'text-delta': + if (this.textStartTime === null) this.textStartTime = Date.now(); + this.textParts.push(chunk.delta); + this.textBuffer.push(chunk.delta); + break; + case 'tool-call': + if (chunk.toolName === UPDATE_WORKING_MEMORY_TOOL_NAME) { + this.recordWorkingMemoryUpdate(workingMemoryContentFromInput(chunk.input)); + } else { + this.recordToolCall(chunk.toolCallId, chunk.toolName, chunk.input); + } + break; + case 'tool-result': + if (chunk.toolName === UPDATE_WORKING_MEMORY_TOOL_NAME) { + // WM tool-result is already represented by the timeline entry + // pushed at tool-call time; nothing more to do here. + break; + } + this.recordToolResult( + chunk.toolCallId, + chunk.toolName, + chunk.output, + chunk.isError === true, + ); + break; + case 'finish': + this.flushTextBuffer(); + this.finishReason = chunk.finishReason; + if (chunk.usage) { + this.usage = { + promptTokens: chunk.usage.promptTokens, + completionTokens: chunk.usage.completionTokens, + totalTokens: chunk.usage.totalTokens, + }; + } + this.model = chunk.model ?? null; + this.totalCost = chunk.totalCost ?? chunk.usage?.cost ?? null; + break; + case 'tool-call-suspended': + this.flushTextBuffer(); + this._suspended = true; + this.timeline.push({ + type: 'suspension', + toolName: chunk.toolName ?? '', + toolCallId: chunk.toolCallId ?? '', + timestamp: Date.now(), + }); + break; + case 'error': { + const errMsg = chunk.error instanceof Error ? chunk.error.message : String(chunk.error); + this.error = errMsg; + break; + } + } + } + + /** Whether the stream ended with a tool-call suspension (incomplete cycle). */ + get suspended(): boolean { + return this._suspended; + } + + /** Build the final message record after the stream has ended. */ + getMessageRecord(): MessageRecord { + this.flushTextBuffer(); + return { + assistantResponse: this.textParts.join(''), + model: this.model, + finishReason: this.finishReason, + usage: this.usage, + totalCost: this.totalCost, + toolCalls: this.toolCalls, + timeline: this.timeline, + startTime: this.startTime, + duration: Date.now() - this.startTime, + error: this.error, + workingMemory: this.workingMemory, + }; + } + + /** Flush accumulated text into a timeline event. */ + private flushTextBuffer(): void { + if (this.textBuffer.length === 0) return; + const content = this.textBuffer.join(''); + if (content.trim()) { + const now = Date.now(); + this.timeline.push({ + type: 'text', + content, + // Generation start (first text-delta) → end (now). Falls back to `now` + // if no start was recorded (defensive: empty segment shouldn't reach here). + timestamp: this.textStartTime ?? now, + endTime: now, + }); + } + this.textBuffer = []; + this.textStartTime = null; + } + + private recordWorkingMemoryUpdate(content: string): void { + this.flushTextBuffer(); + this.workingMemory = content; + this.timeline.push({ + type: 'working-memory', + content, + timestamp: Date.now(), + }); + } + + /** + * Record a discrete `tool-call` chunk from the stream. Maintains both the + * flat `toolCalls` array (backward compat) and the ordered timeline. The + * matching `tool-result` chunk closes the timeline entry. + */ + private recordToolCall(toolCallId: string, name: string, input: unknown): void { + this.flushTextBuffer(); + + this.toolCalls.push({ name, input, output: undefined }); + + const entry = this.registry.get(name); + // Resolve `$fromAI(...)` expressions in nodeParameters using the LLM's + // args so the timeline shows the values the node would have run with + // (e.g. the actual prompt text) rather than raw template strings. + const llmArgs = + input !== null && typeof input === 'object' ? (input as Record) : {}; + const resolvedNodeParameters = + entry?.nodeParameters !== undefined + ? (resolveFromAIInValue(entry.nodeParameters, llmArgs) as Record) + : undefined; + this.timeline.push({ + type: 'tool-call', + kind: entry?.kind ?? 'tool', + name, + toolCallId, + input, + output: undefined as unknown, + startTime: Date.now(), + endTime: 0, + success: false, + workflowId: entry?.workflowId, + workflowName: entry?.workflowName, + triggerType: entry?.triggerType, + nodeType: entry?.nodeType, + nodeTypeVersion: entry?.nodeTypeVersion, + nodeDisplayName: entry?.nodeDisplayName, + nodeParameters: resolvedNodeParameters, + }); + } + + /** + * Record a discrete `tool-result` chunk from the stream. Closes the + * matching open timeline entry by `toolCallId` (preferred) or by name as + * a fallback. + * + * On HITL/approval resume, the SDK replays the `tool-result` for the + * pending call without a preceding `tool-call`, so there is no open + * timeline entry to close. In that case we synthesize one from the + * registry so workflow rows and execution-log links still render. + */ + private recordToolResult( + toolCallId: string, + name: string, + output: unknown, + isError: boolean, + ): void { + const pendingFlat = [...this.toolCalls] + .reverse() + .find((tc) => tc.name === name && tc.output === undefined); + if (pendingFlat) { + pendingFlat.output = output; + } else { + this.toolCalls.push({ name, input: undefined, output }); + } + + const pendingTimeline = [...this.timeline] + .reverse() + .find( + (e): e is TimelineEvent & { type: 'tool-call' } => + e.type === 'tool-call' && + (toolCallId ? e.toolCallId === toolCallId : e.name === name) && + e.endTime === 0, + ); + if (pendingTimeline) { + pendingTimeline.output = output; + pendingTimeline.endTime = Date.now(); + pendingTimeline.success = !isError; + + if (pendingTimeline.kind === 'workflow' && isRecord(output)) { + const execId = output.executionId; + if (typeof execId === 'string') { + pendingTimeline.workflowExecutionId = execId; + } + } + return; + } + + this.flushTextBuffer(); + const entry = this.registry.get(name); + const now = Date.now(); + const synthesized: TimelineEvent = { + type: 'tool-call', + kind: entry?.kind ?? 'tool', + name, + toolCallId, + input: undefined, + output, + startTime: now, + endTime: now, + success: !isError, + workflowId: entry?.workflowId, + workflowName: entry?.workflowName, + triggerType: entry?.triggerType, + nodeType: entry?.nodeType, + nodeTypeVersion: entry?.nodeTypeVersion, + nodeDisplayName: entry?.nodeDisplayName, + nodeParameters: entry?.nodeParameters, + }; + if (synthesized.kind === 'workflow' && isRecord(output)) { + const execId = output.executionId; + if (typeof execId === 'string') { + synthesized.workflowExecutionId = execId; + } + } + this.timeline.push(synthesized); + } +} diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts new file mode 100644 index 00000000000..61c2d8185c4 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -0,0 +1,230 @@ +import type { StreamChunk } from '@n8n/agents'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import type { Logger } from 'n8n-workflow'; + +import { AgentChatBridge } from '../agent-chat-bridge'; +import { + AgentChatIntegration, + ChatIntegrationRegistry, + type AgentChatIntegrationContext, +} from '../agent-chat-integration'; +import type { ComponentMapper } from '../component-mapper'; + +type ChatBotLike = ConstructorParameters[0]; + +interface FakeThread { + id: string; + subscribe: jest.Mock; + post: jest.Mock; +} + +function makeBot() { + const handlers: { + mention?: (thread: unknown, message: unknown) => Promise; + subscribed?: (thread: unknown, message: unknown) => Promise; + action?: (event: unknown) => Promise; + } = {}; + const bot = { + onNewMention: (h: typeof handlers.mention) => { + handlers.mention = h; + }, + onSubscribedMessage: (h: typeof handlers.subscribed) => { + handlers.subscribed = h; + }, + onAction: (h: typeof handlers.action) => { + handlers.action = h; + }, + }; + return { bot, handlers }; +} + +function makeThread(): FakeThread { + return { + id: 'thread-1', + subscribe: jest.fn().mockResolvedValue(undefined), + post: jest.fn().mockResolvedValue(undefined), + }; +} + +async function* toStream(chunks: StreamChunk[]): AsyncGenerator { + for (const c of chunks) yield c; +} + +async function drainIterable(value: unknown): Promise { + if (typeof value === 'string') return value; + if ( + value && + typeof value === 'object' && + Symbol.asyncIterator in (value as Record) + ) { + let out = ''; + for await (const part of value as AsyncIterable) out += part; + return out; + } + throw new Error(`post() received unexpected argument: ${JSON.stringify(value)}`); +} + +class BufferingTestIntegration extends AgentChatIntegration { + readonly type = 'test-buffered'; + readonly credentialTypes: string[] = []; + readonly supportedComponents: string[] = []; + readonly description = ''; + readonly displayLabel = 'Test Buffered'; + readonly displayIcon = 'circle'; + readonly disableStreaming = true; + async createAdapter(_ctx: AgentChatIntegrationContext): Promise { + return {}; + } +} + +class StreamingTestIntegration extends AgentChatIntegration { + readonly type = 'test-streaming'; + readonly credentialTypes: string[] = []; + readonly supportedComponents: string[] = []; + readonly description = ''; + readonly displayLabel = 'Test Streaming'; + readonly displayIcon = 'circle'; + async createAdapter(_ctx: AgentChatIntegrationContext): Promise { + return {}; + } +} + +describe('AgentChatBridge — consumeStream', () => { + let registry: ChatIntegrationRegistry; + const componentMapper = mock(); + const logger = mock(); + + beforeEach(() => { + registry = new ChatIntegrationRegistry(); + registry.register(new BufferingTestIntegration()); + registry.register(new StreamingTestIntegration()); + Container.set(ChatIntegrationRegistry, registry); + }); + + afterEach(() => { + Container.reset(); + jest.clearAllMocks(); + }); + + function makeAgentExecutor(chunks: StreamChunk[]) { + return { + executeForChatPublished: () => toStream(chunks), + resumeForChat: () => toStream(chunks), + }; + } + + describe('when integration disables streaming', () => { + it('posts a single collected string for a run that only has text deltas', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = makeAgentExecutor([ + { type: 'text-delta', id: 't1', delta: 'Hello ' }, + { type: 'text-delta', id: 't1', delta: 'world' }, + { type: 'finish', finishReason: 'stop' }, + ]); + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'test-buffered', + ); + + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + + expect(thread.post).toHaveBeenCalledTimes(1); + expect(thread.post).toHaveBeenCalledWith({ markdown: 'Hello world' }); + }); + + it('flushes the buffer before posting a suspension card, then continues buffering', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + componentMapper.toCard.mockResolvedValue({ kind: 'card' } as never); + + const agentExecutor = makeAgentExecutor([ + { type: 'text-delta', id: 't1', delta: 'Before suspend. ' }, + { + type: 'tool-call-suspended', + runId: 'run-1', + toolCallId: 'tool-1', + toolName: 'approval', + suspendPayload: { message: 'Approve?' }, + }, + { type: 'text-delta', id: 't2', delta: 'After resume.' }, + { type: 'finish', finishReason: 'stop' }, + ]); + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'test-buffered', + ); + + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + + expect(thread.post).toHaveBeenCalledTimes(3); + expect(thread.post).toHaveBeenNthCalledWith(1, { markdown: 'Before suspend. ' }); + expect(thread.post).toHaveBeenNthCalledWith(2, { card: { kind: 'card' } }); + expect(thread.post).toHaveBeenNthCalledWith(3, { markdown: 'After resume.' }); + }); + + it('does not post when the buffer is only whitespace', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = makeAgentExecutor([ + { type: 'text-delta', id: 't1', delta: ' ' }, + { type: 'finish', finishReason: 'stop' }, + ]); + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'test-buffered', + ); + + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + + expect(thread.post).not.toHaveBeenCalled(); + }); + }); + + describe('when integration keeps streaming enabled', () => { + it('posts an AsyncIterable whose drained content equals the concatenated deltas', async () => { + const { bot, handlers } = makeBot(); + const thread = makeThread(); + const agentExecutor = makeAgentExecutor([ + { type: 'text-delta', id: 't1', delta: 'Hello ' }, + { type: 'text-delta', id: 't1', delta: 'world' }, + { type: 'finish', finishReason: 'stop' }, + ]); + + new AgentChatBridge( + bot as unknown as ChatBotLike, + 'agent-1', + agentExecutor as never, + componentMapper, + logger, + 'project-1', + 'test-streaming', + ); + + await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1' } }); + + expect(thread.post).toHaveBeenCalledTimes(1); + const received = await drainIterable(thread.post.mock.calls[0][0]); + expect(received).toBe('Hello world'); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts new file mode 100644 index 00000000000..4da6b7fa9db --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-schedule.service.test.ts @@ -0,0 +1,268 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import { DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, type AgentIntegration } from '@n8n/api-types'; +import { mockLogger } from '@n8n/backend-test-utils'; +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'jest-mock-extended'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; + +import type { AgentsService } from '../../agents.service'; +import type { Agent } from '../../entities/agent.entity'; +import { AgentScheduleService } from '../agent-schedule.service'; + +function makePublishedAgent( + integrations: AgentIntegration[] = [], + overrides: Partial = {}, +): Agent { + return { + id: 'agent-1', + projectId: 'project-1', + name: 'Scheduled Agent', + integrations, + publishedVersion: { + publishedById: 'user-1', + schema: null, + }, + ...overrides, + } as unknown as Agent; +} + +async function* emptyStream() {} + +describe('AgentScheduleService', () => { + let service: AgentScheduleService; + let agentRepository: { + save: jest.Mock; + findPublished: jest.Mock; + findOne: jest.Mock; + }; + let projectRelationRepository: { findUserIdsByProjectId: jest.Mock }; + let agentsService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2026-05-04T13:00:00Z')); + + agentRepository = { + save: jest.fn(async (agent: Agent) => agent), + findPublished: jest.fn(), + findOne: jest.fn(), + }; + projectRelationRepository = { + findUserIdsByProjectId: jest.fn(), + }; + agentsService = mock(); + agentsService.executeForSchedulePublished.mockReturnValue(emptyStream() as never); + + service = new AgentScheduleService( + mockLogger(), + mock({ + generic: { timezone: 'Europe/Berlin' }, + }), + agentRepository as never, + agentsService, + projectRelationRepository as never, + ); + }); + + afterEach(() => { + service.stopAll(); + jest.useRealTimers(); + }); + + it('saveConfig upserts the schedule integration alongside credential-backed integrations', async () => { + const agent = makePublishedAgent([ + { type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' }, + ]); + + const result = await service.saveConfig(agent, '* * * * *'); + + expect(agentRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + integrations: [ + { type: 'slack', credentialId: 'cred-1', credentialName: 'Slack cred 1' }, + { + type: 'schedule', + active: false, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + }), + ); + expect(result).toEqual({ + active: false, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }); + }); + + it('rejects invalid cron expressions on save and activation', async () => { + await expect(service.saveConfig(makePublishedAgent(), 'not-a-cron')).rejects.toBeInstanceOf( + BadRequestError, + ); + await expect( + service.activate( + makePublishedAgent([ + { + type: 'schedule', + active: false, + cronExpression: 'not-a-cron', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ]), + ), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + it('activate rejects unpublished agents', async () => { + const agent = makePublishedAgent( + [ + { + type: 'schedule', + active: false, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + { publishedVersion: null }, + ); + + await expect(service.activate(agent)).rejects.toBeInstanceOf(ConflictError); + }); + + it('activate registers a cron job and deactivate removes it', async () => { + const agent = makePublishedAgent([ + { + type: 'schedule', + active: false, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ]); + agentRepository.findOne.mockResolvedValue({ + ...agent, + integrations: [ + { + type: 'schedule', + active: true, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + }); + projectRelationRepository.findUserIdsByProjectId.mockResolvedValue(['user-1']); + + const activeConfig = await service.activate(agent); + + expect(activeConfig.active).toBe(true); + expect(agentsService.executeForSchedulePublished).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(agentsService.executeForSchedulePublished).toHaveBeenCalledTimes(1); + + const inactiveConfig = await service.deactivate(agent); + + expect(inactiveConfig.active).toBe(false); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(agentsService.executeForSchedulePublished).toHaveBeenCalledTimes(1); + }); + + it('saveConfig refreshes an already active schedule job', async () => { + const agent = makePublishedAgent([ + { + type: 'schedule', + active: true, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ]); + + await service.registerOrRefresh(agent); + await service.saveConfig(agent, '*/5 * * * *'); + + expect(agentRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + integrations: [ + expect.objectContaining({ + type: 'schedule', + active: true, + cronExpression: '*/5 * * * *', + }), + ], + }), + ); + }); + + it('runScheduled appends the timestamp and uses a fresh thread/resource id', async () => { + const agent = makePublishedAgent([ + { + type: 'schedule', + active: true, + cronExpression: '* * * * *', + wakeUpPrompt: 'Wake up and check the queue.', + }, + ]); + agentRepository.findOne.mockResolvedValue(agent); + projectRelationRepository.findUserIdsByProjectId.mockResolvedValue(['user-1']); + + await (service as unknown as { runScheduled: (agentId: string) => Promise }).runScheduled( + 'agent-1', + ); + + expect(agentsService.executeForSchedulePublished).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-1', + projectId: 'project-1', + message: expect.stringContaining('Wake up and check the queue.'), + memory: { + threadId: expect.stringMatching(/^schedule-agent-1-/), + resourceId: expect.stringMatching(/^user-1/), + }, + }), + ); + + const config = agentsService.executeForSchedulePublished.mock.calls[0][0]; + expect(config.message).toContain('Current date and time:'); + expect(config.message).toContain('(timezone: Europe/Berlin)'); + }); + + it('reconnectAll restores only active published schedules', async () => { + const activeAgent = makePublishedAgent([ + { + type: 'schedule', + active: true, + cronExpression: '* * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ]); + const inactiveAgent = makePublishedAgent( + [ + { + type: 'schedule', + active: false, + cronExpression: '*/5 * * * *', + wakeUpPrompt: DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }, + ], + { id: 'agent-2' }, + ); + agentRepository.findPublished.mockResolvedValue([activeAgent, inactiveAgent]); + agentRepository.findOne.mockImplementation(async ({ where }: { where: { id: string } }) => { + if (where.id === activeAgent.id) return activeAgent; + if (where.id === inactiveAgent.id) return inactiveAgent; + return null; + }); + projectRelationRepository.findUserIdsByProjectId.mockResolvedValue(['user-1']); + + await service.reconnectAll(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(agentsService.executeForSchedulePublished).toHaveBeenCalledTimes(1); + }); +}); 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 new file mode 100644 index 00000000000..bbc3cc324d1 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/chat-integration.service.test.ts @@ -0,0 +1,149 @@ +import type { AgentCredentialIntegration } from '@n8n/api-types'; +import type { Logger } from '@n8n/backend-common'; +import { mockLogger } from '@n8n/backend-test-utils'; +import { mock } from 'jest-mock-extended'; + +import type { Agent } from '../../entities/agent.entity'; +import { ChatIntegrationService } from '../chat-integration.service'; + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-1', + projectId: 'project-1', + integrations: [], + publishedVersion: null, + ...overrides, + } as unknown as Agent; +} + +const slackIntegration: AgentCredentialIntegration = { + type: 'slack', + credentialId: 'cred-1', + credentialName: 'Acme Slack', +}; + +describe('ChatIntegrationService.syncToConfig — publish gate', () => { + let service: ChatIntegrationService; + let connectSpy: jest.SpyInstance; + let disconnectSpy: jest.SpyInstance; + + beforeEach(() => { + service = new ChatIntegrationService(mockLogger(), mock(), mock(), mock(), mock(), mock()); + connectSpy = jest.spyOn(service, 'connect').mockResolvedValue(); + disconnectSpy = jest.spyOn(service, 'disconnect').mockResolvedValue(); + }); + + it('skips connect when the agent is not published', async () => { + const agent = makeAgent({ publishedVersion: null }); + + await service.syncToConfig(agent, [], [slackIntegration]); + + expect(connectSpy).not.toHaveBeenCalled(); + }); + + it('still disconnects removed integrations even when the agent is not published', async () => { + const agent = makeAgent({ publishedVersion: null }); + + await service.syncToConfig(agent, [slackIntegration], []); + + expect(disconnectSpy).toHaveBeenCalledWith('agent-1', 'slack', 'cred-1'); + expect(connectSpy).not.toHaveBeenCalled(); + }); +}); + +describe('ChatIntegrationService', () => { + const logger = mock(); + + const buildService = () => + new ChatIntegrationService(logger, mock(), mock(), mock(), mock(), mock()); + + describe('disconnectAll', () => { + it('shuts down every active connection and empties the connection map', async () => { + const service = buildService(); + const shutdownA = jest.fn().mockResolvedValue(undefined); + const shutdownB = jest.fn().mockResolvedValue(undefined); + const disposeA = jest.fn(); + const disposeB = jest.fn(); + + // Seed two connections via the private map. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internal = service as any; + internal.connections.set('agent-1:slack:cred-1', { + chat: { + shutdown: shutdownA, + webhooks: {}, + onAction: jest.fn(), + onNewMention: jest.fn(), + onSubscribedMessage: jest.fn(), + initialize: jest.fn(), + }, + bridge: { dispose: disposeA }, + }); + internal.connections.set('agent-2:telegram:cred-2', { + chat: { + shutdown: shutdownB, + webhooks: {}, + onAction: jest.fn(), + onNewMention: jest.fn(), + onSubscribedMessage: jest.fn(), + initialize: jest.fn(), + }, + bridge: { dispose: disposeB }, + }); + + await service.disconnectAll(); + + expect(shutdownA).toHaveBeenCalledTimes(1); + expect(shutdownB).toHaveBeenCalledTimes(1); + expect(disposeA).toHaveBeenCalledTimes(1); + expect(disposeB).toHaveBeenCalledTimes(1); + expect(internal.connections.size).toBe(0); + }); + + it('does not throw when there are no active connections', async () => { + const service = buildService(); + await expect(service.disconnectAll()).resolves.toBeUndefined(); + }); + + it('continues disconnecting remaining connections when one shutdown rejects', async () => { + const service = buildService(); + const shutdownA = jest.fn().mockRejectedValue(new Error('boom')); + const shutdownB = jest.fn().mockResolvedValue(undefined); + const disposeA = jest.fn(); + const disposeB = jest.fn(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internal = service as any; + internal.connections.set('agent-1:slack:cred-1', { + chat: { + shutdown: shutdownA, + webhooks: {}, + onAction: jest.fn(), + onNewMention: jest.fn(), + onSubscribedMessage: jest.fn(), + initialize: jest.fn(), + }, + bridge: { dispose: disposeA }, + }); + internal.connections.set('agent-2:telegram:cred-2', { + chat: { + shutdown: shutdownB, + webhooks: {}, + onAction: jest.fn(), + onNewMention: jest.fn(), + onSubscribedMessage: jest.fn(), + initialize: jest.fn(), + }, + bridge: { dispose: disposeB }, + }); + + await expect(service.disconnectAll()).resolves.toBeUndefined(); + + expect(shutdownA).toHaveBeenCalledTimes(1); + expect(shutdownB).toHaveBeenCalledTimes(1); + expect(disposeA).toHaveBeenCalledTimes(1); + expect(disposeB).toHaveBeenCalledTimes(1); + expect(internal.connections.size).toBe(0); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts new file mode 100644 index 00000000000..aaa9fea8451 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts @@ -0,0 +1,438 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/naming-convention -- mocks the Slack-style SDK (PascalCase components) and intentionally uses any-based factory wrappers */ +// Define mocks inline inside the factory to avoid jest.mock hoisting issues +type MockFn = jest.Mock; +const mockButton: MockFn = jest.fn((opts) => ({ type: 'button', ...opts })); +const mockCard: MockFn = jest.fn((opts) => ({ type: 'card', ...opts })); +const mockActions: MockFn = jest.fn((children) => ({ type: 'actions', children })); +const mockCardText: MockFn = jest.fn((content) => ({ type: 'text', content })); +const mockSection: MockFn = jest.fn((children) => ({ type: 'section', children })); +const mockDivider: MockFn = jest.fn(() => ({ type: 'divider' })); +const mockImage: MockFn = jest.fn((opts) => ({ type: 'image', ...opts })); +const mockSelect: MockFn = jest.fn((opts) => ({ type: 'select', ...opts })); +const mockSelectOption: MockFn = jest.fn((opts) => ({ type: 'select_option', ...opts })); +const mockRadioSelect: MockFn = jest.fn((opts) => ({ type: 'radio_select', ...opts })); +const mockFields: MockFn = jest.fn((children) => ({ type: 'fields', children })); +const mockField: MockFn = jest.fn((opts) => ({ type: 'field', ...opts })); + +jest.mock('../esm-loader', () => { + // These reference the hoisted variables above via closure + + const wrap = + (fn: MockFn) => + (...args: any[]) => + fn(...args); + const Button = wrap(mockButton); + const Card = wrap(mockCard); + const Actions = wrap(mockActions); + const CardText = wrap(mockCardText); + const Section = wrap(mockSection); + const Divider = wrap(mockDivider); + const Image = wrap(mockImage); + const Select = wrap(mockSelect); + const SelectOption = wrap(mockSelectOption); + const RadioSelect = wrap(mockRadioSelect); + const Fields = wrap(mockFields); + const Field = wrap(mockField); + + return { + loadChatSdk: jest.fn().mockResolvedValue({ + Button, + Card, + Actions, + CardText, + Section, + Divider, + Image, + Select, + SelectOption, + RadioSelect, + Fields, + Field, + }), + }; +}); + +import { ComponentMapper } from '../component-mapper'; + +describe('ComponentMapper', () => { + let mapper: ComponentMapper; + + beforeEach(() => { + jest.clearAllMocks(); + mapper = new ComponentMapper(); + }); + + describe('toCard', () => { + const runId = 'run-123'; + const toolCallId = 'tc-456'; + + it('should map buttons with correct resume ID encoding', async () => { + const payload = { + title: 'Approval Required', + components: [ + { type: 'button', label: 'Approve', style: 'primary', value: 'yes' }, + { type: 'button', label: 'Reject', style: 'danger', value: 'no' }, + ], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockButton).toHaveBeenCalledTimes(2); + // Button values are JSON-encoded resume payloads. + // With no resumeSchema, wrapValueForSchema falls back to { value: rawValue } + expect(mockButton).toHaveBeenCalledWith({ + id: `resume:${runId}:${toolCallId}:0`, + label: 'Approve', + style: 'primary', + value: JSON.stringify({ value: 'yes' }), + }); + expect(mockButton).toHaveBeenCalledWith({ + id: `resume:${runId}:${toolCallId}:1`, + label: 'Reject', + style: 'danger', + value: JSON.stringify({ value: 'no' }), + }); + expect(mockActions).toHaveBeenCalledTimes(1); + expect(mockCard).toHaveBeenCalledWith({ + title: 'Approval Required', + children: expect.arrayContaining([expect.objectContaining({ type: 'actions' })]), + }); + }); + + it('should map section components with text wrapped in CardText', async () => { + const payload = { + components: [{ type: 'section', text: 'Some section text' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockCardText).toHaveBeenCalledWith('Some section text'); + expect(mockSection).toHaveBeenCalledWith([ + expect.objectContaining({ type: 'text', content: 'Some section text' }), + ]); + }); + + it('should map divider components', async () => { + const payload = { + components: [{ type: 'divider' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockDivider).toHaveBeenCalledTimes(1); + expect(mockCard).toHaveBeenCalledWith({ + title: undefined, + children: [expect.objectContaining({ type: 'divider' })], + }); + }); + + it('should map image components using the Image builder', async () => { + const payload = { + components: [{ type: 'image', url: 'https://example.com/img.png', altText: 'A photo' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockImage).toHaveBeenCalledWith({ + url: 'https://example.com/img.png', + alt: 'A photo', + }); + }); + + it('should map context components with plain text', async () => { + const payload = { + components: [{ type: 'context', text: 'Additional info' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockCardText).toHaveBeenCalledWith('Additional info'); + }); + + it('should map context components with elements array', async () => { + const payload = { + components: [ + { + type: 'context', + elements: [ + { type: 'text', text: 'Timezone info' }, + { type: 'image', url: 'https://example.com/icon.png', altText: 'icon' }, + ], + }, + ], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockCardText).toHaveBeenCalledWith('Timezone info'); + expect(mockImage).toHaveBeenCalledWith({ url: 'https://example.com/icon.png', alt: 'icon' }); + }); + + it('should skip context components without text or elements', async () => { + const payload = { + components: [{ type: 'context' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockCardText).not.toHaveBeenCalled(); + }); + + it('should map section with accessory button as Section + Actions', async () => { + const payload = { + components: [ + { + type: 'section', + text: 'Friday evening', + button: { label: 'Choose', value: 'date_0' }, + }, + ], + }; + + await mapper.toCard(payload, runId, toolCallId); + + // Section contains only text + expect(mockSection).toHaveBeenCalledWith([ + expect.objectContaining({ type: 'text', content: 'Friday evening' }), + ]); + // Button is in a separate Actions block + expect(mockButton).toHaveBeenCalledWith({ + id: `resume:${runId}:${toolCallId}:0`, + label: 'Choose', + style: 'primary', + value: JSON.stringify({ value: 'date_0' }), + }); + expect(mockActions).toHaveBeenCalledTimes(1); + }); + + it('should use message as title fallback', async () => { + const payload = { + message: 'Schedule session', + components: [{ type: 'section', text: 'Pick a date' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ title: 'Schedule session' })); + }); + + it('should wrap button values using resume schema with approved property', async () => { + const payload = { + components: [{ type: 'button', label: 'Approve', value: 'true', style: 'primary' }], + }; + const resumeSchema = { + type: 'object', + properties: { approved: { type: 'boolean' } }, + }; + + await mapper.toCard(payload, runId, toolCallId, resumeSchema); + + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ value: JSON.stringify({ approved: true }) }), + ); + }); + + it('should wrap button values with type+value schema as { type: button, value }', async () => { + const payload = { + components: [{ type: 'button' as const, label: 'Go', value: 'go' }], + }; + const resumeSchema = { + type: 'object', + properties: { + type: { type: 'string' }, + value: { type: 'string' }, + }, + }; + + await mapper.toCard(payload, runId, toolCallId, resumeSchema); + + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ + value: JSON.stringify({ type: 'button', value: 'go' }), + }), + ); + }); + + it('should wrap button values using resume schema with values property', async () => { + const payload = { + components: [{ type: 'button', label: 'Choose', value: 'date_0' }], + }; + const resumeSchema = { + type: 'object', + properties: { values: { type: 'object' } }, + }; + + await mapper.toCard(payload, runId, toolCallId, resumeSchema); + + expect(mockButton).toHaveBeenCalledWith( + expect.objectContaining({ value: JSON.stringify({ values: { action: 'date_0' } }) }), + ); + }); + + it('should silently drop unsupported component types', async () => { + const payload = { + components: [ + { type: 'text-input', label: 'Name', id: 'name' }, + { type: 'select-input', label: 'Priority', id: 'prio' }, + { type: 'modal', title: 'Form' }, + { type: 'unknown-thing' }, + ], + }; + + await mapper.toCard(payload, runId, toolCallId); + + // No child-building functions should have been called + expect(mockButton).not.toHaveBeenCalled(); + expect(mockSection).not.toHaveBeenCalled(); + expect(mockDivider).not.toHaveBeenCalled(); + expect(mockImage).not.toHaveBeenCalled(); + expect(mockCardText).not.toHaveBeenCalled(); + expect(mockActions).not.toHaveBeenCalled(); + + // Card should still be created with an empty children array + expect(mockCard).toHaveBeenCalledWith({ + title: undefined, + children: [], + }); + }); + + it('should default button style to primary when not danger', async () => { + const payload = { + components: [{ type: 'button', label: 'Go', style: 'secondary', value: 'go' }], + }; + + await mapper.toCard(payload, runId, toolCallId); + + expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({ style: 'primary' })); + }); + + it('should map select components into Actions', async () => { + const payload = { + components: [ + { + type: 'select' as const, + id: 'difficulty', + label: 'Difficulty', + placeholder: 'Choose...', + options: [ + { label: 'Easy', value: 'easy' }, + { label: 'Hard', value: 'hard', description: 'For veterans' }, + ], + }, + ], + }; + await mapper.toCard(payload, runId, toolCallId); + expect(mockSelect).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ label: 'Easy', value: 'easy' }), + expect.objectContaining({ label: 'Hard', value: 'hard' }), + ]), + }), + ); + expect(mockActions).toHaveBeenCalled(); + }); + + it('should map radio_select components wrapped in Actions', async () => { + const payload = { + components: [ + { + type: 'radio_select' as const, + id: 'rating', + label: 'Rating', + options: [ + { label: 'Good', value: 'good' }, + { label: 'Bad', value: 'bad' }, + ], + }, + ], + }; + await mapper.toCard(payload, runId, toolCallId); + expect(mockRadioSelect).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ label: 'Good', value: 'good' }), + ]), + }), + ); + expect(mockActions).toHaveBeenCalled(); + }); + + it('should map fields components', async () => { + const payload = { + components: [ + { + type: 'fields' as const, + fields: [ + { label: 'Name', value: 'Gandalf' }, + { label: 'Class', value: 'Wizard' }, + ], + }, + ], + }; + await mapper.toCard(payload, runId, toolCallId); + expect(mockField).toHaveBeenCalledTimes(2); + expect(mockFields).toHaveBeenCalled(); + }); + }); + + describe('toCardOrMarkdown', () => { + it('should return strings as-is', async () => { + const result = await mapper.toCardOrMarkdown('Hello world'); + + expect(result).toBe('Hello world'); + expect(mockCard).not.toHaveBeenCalled(); + }); + + it('should convert structured messages with components to a Card', async () => { + const message = { + components: [ + { type: 'section', text: 'Section content' }, + { type: 'context', text: 'Context content' }, + ], + }; + + await mapper.toCardOrMarkdown(message); + + expect(mockCard).toHaveBeenCalledTimes(1); + expect(mockSection).toHaveBeenCalledWith([ + expect.objectContaining({ type: 'text', content: 'Section content' }), + ]); + expect(mockCardText).toHaveBeenCalledWith('Context content'); + }); + + it('should render dividers in toCardOrMarkdown', async () => { + const message = { + components: [{ type: 'section', text: 'Has text' }, { type: 'divider' }], + }; + + await mapper.toCardOrMarkdown(message); + + expect(mockDivider).toHaveBeenCalledTimes(1); + expect(mockCard).toHaveBeenCalledWith({ + children: [ + expect.objectContaining({ type: 'section' }), + expect.objectContaining({ type: 'divider' }), + ], + }); + }); + + it('should fall back to String() for non-string, non-structured values', async () => { + const result = await mapper.toCardOrMarkdown(42); + + expect(result).toBe('42'); + }); + + it('should fall back to String() for null', async () => { + const result = await mapper.toCardOrMarkdown(null); + + expect(result).toBe('null'); + }); + + it('should fall back to String() for objects without components', async () => { + const result = await mapper.toCardOrMarkdown({ foo: 'bar' }); + + expect(result).toBe('[object Object]'); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts new file mode 100644 index 00000000000..3d2ed46d29c --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/linear-integration.test.ts @@ -0,0 +1,101 @@ +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'jest-mock-extended'; + +import type { AgentChatIntegrationContext } from '../agent-chat-integration'; +import { LinearIntegration } from '../platforms/linear-integration'; + +jest.mock('../esm-loader', () => ({ + loadLinearAdapter: jest.fn(), +})); + +import { loadLinearAdapter } from '../esm-loader'; + +const mockedLoadLinearAdapter = loadLinearAdapter as jest.MockedFunction; + +describe('LinearIntegration', () => { + const logger = mock(); + let integration: LinearIntegration; + let fetchSpy: jest.SpyInstance; + const createLinearAdapter = jest.fn(); + + beforeEach(() => { + integration = new LinearIntegration(logger); + createLinearAdapter.mockReset(); + createLinearAdapter.mockReturnValue({ marker: 'adapter' }); + mockedLoadLinearAdapter.mockReset(); + mockedLoadLinearAdapter.mockResolvedValue({ + createLinearAdapter, + } as unknown as Awaited>); + + fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ data: { viewer: { displayName: 'AgentName' } } }), + } as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + const ctx = (credential: Record): AgentChatIntegrationContext => ({ + agentId: 'agent-1', + projectId: 'project-1', + credentialId: 'credential-1', + credential, + webhookUrlFor: () => 'https://example.test/webhook', + }); + + it('builds the adapter with an apiKey from a linearApi credential', async () => { + await integration.createAdapter(ctx({ apiKey: 'lin_api_xyz', signingSecret: 'sec' })); + + expect(createLinearAdapter).toHaveBeenCalledWith({ + apiKey: 'lin_api_xyz', + webhookSecret: 'sec', + userName: 'AgentName', + }); + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ + headers: { Authorization: 'lin_api_xyz' }, + }); + }); + + it('builds the adapter with an accessToken from a linearOAuth2Api credential', async () => { + await integration.createAdapter( + ctx({ + oauthTokenData: { access_token: 'oauth_token' }, + signingSecret: 'sec', + }), + ); + + expect(createLinearAdapter).toHaveBeenCalledWith({ + accessToken: 'oauth_token', + webhookSecret: 'sec', + userName: 'AgentName', + }); + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ + headers: { Authorization: 'Bearer oauth_token' }, + }); + }); + + it('omits userName when the viewer lookup fails', async () => { + fetchSpy.mockResolvedValue({ ok: false } as Response); + + await integration.createAdapter(ctx({ apiKey: 'lin_api_xyz', signingSecret: 'sec' })); + + expect(createLinearAdapter).toHaveBeenCalledWith({ + apiKey: 'lin_api_xyz', + webhookSecret: 'sec', + }); + }); + + it('throws when the credential has no token', async () => { + await expect(integration.createAdapter(ctx({ signingSecret: 'sec' }))).rejects.toThrow( + /Could not extract an API token/, + ); + }); + + it('throws when the credential has no signing secret', async () => { + await expect(integration.createAdapter(ctx({ apiKey: 'lin_api_xyz' }))).rejects.toThrow( + /missing a signing secret/, + ); + }); +}); 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 new file mode 100644 index 00000000000..5d048c01232 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/n8n-memory.test.ts @@ -0,0 +1,308 @@ +import { LessThan } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; + +import type { AgentMessageEntity } from '../../entities/agent-message.entity'; +import type { AgentThreadEntity } from '../../entities/agent-thread.entity'; +import type { AgentMessageRepository } from '../../repositories/agent-message.repository'; +import type { AgentResourceRepository } from '../../repositories/agent-resource.repository'; +import type { AgentThreadRepository } from '../../repositories/agent-thread.repository'; +import { N8nMemory } from '../n8n-memory'; + +describe('N8nMemory', () => { + let memory: N8nMemory; + let messageRepository: jest.Mocked; + let threadRepository: jest.Mocked; + let resourceRepository: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + messageRepository = mock(); + threadRepository = mock(); + resourceRepository = mock(); + + memory = new N8nMemory(threadRepository, messageRepository, resourceRepository); + }); + + function makeMessageEntity(id: string, createdAt: Date, text: string): AgentMessageEntity { + return { + id, + threadId: 'thread-1', + resourceId: 'user-1', + role: 'user', + type: null, + content: { + id, + createdAt, + role: 'user', + content: [{ type: 'text', text }], + }, + createdAt, + updatedAt: createdAt, + } as unknown as AgentMessageEntity; + } + + describe('getMessages — resourceId filter', () => { + beforeEach(() => { + messageRepository.find.mockResolvedValue([]); + }); + + it('includes resourceId in the where clause when provided', async () => { + await memory.getMessages('thread-1', { resourceId: 'user-1' }); + + expect(messageRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { threadId: 'thread-1', resourceId: 'user-1' }, + }), + ); + }); + + it('omits resourceId from the where clause when not provided', async () => { + await memory.getMessages('thread-1'); + + expect(messageRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { threadId: 'thread-1' }, + }), + ); + }); + + it('preserves an empty-string resourceId in the filter instead of dropping it', async () => { + // Guard against the previous `...(opts?.resourceId && ...)` bug: a + // falsy userId must NOT widen the query to every user's messages + // on a shared thread. + await memory.getMessages('thread-1', { resourceId: '' }); + + expect(messageRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { threadId: 'thread-1', resourceId: '' }, + }), + ); + }); + + it('combines resourceId with the `before` filter', async () => { + const before = new Date('2026-01-01'); + + await memory.getMessages('thread-1', { resourceId: 'user-1', before }); + + const call = messageRepository.find.mock.calls[0][0]; + expect(call?.where).toMatchObject({ + threadId: 'thread-1', + resourceId: 'user-1', + createdAt: LessThan(before), + }); + }); + }); + + describe('getMessages — limit window', () => { + it('loads the newest limited messages and returns them chronologically', async () => { + const middle = makeMessageEntity('m2', new Date('2026-01-01T00:00:02.000Z'), 'middle'); + const newest = makeMessageEntity('m3', new Date('2026-01-01T00:00:03.000Z'), 'newest'); + messageRepository.find.mockResolvedValue([newest, middle]); + + const result = await memory.getMessages('thread-1', { limit: 2 }); + + expect(messageRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { threadId: 'thread-1' }, + order: { createdAt: 'DESC' }, + take: 2, + }), + ); + expect(result.map((m) => m.id)).toEqual(['m2', 'm3']); + }); + + it('combines limit with before and resourceId while still asking for newest first', async () => { + const before = new Date('2026-01-01T00:00:04.000Z'); + messageRepository.find.mockResolvedValue([ + makeMessageEntity('m3', new Date('2026-01-01T00:00:03.000Z'), 'newest'), + makeMessageEntity('m2', new Date('2026-01-01T00:00:02.000Z'), 'middle'), + ]); + + const result = await memory.getMessages('thread-1', { + before, + limit: 2, + resourceId: 'user-1', + }); + + const call = messageRepository.find.mock.calls[0][0]; + expect(call?.where).toMatchObject({ + threadId: 'thread-1', + resourceId: 'user-1', + createdAt: LessThan(before), + }); + expect(call?.order).toEqual({ createdAt: 'DESC' }); + expect(call?.take).toBe(2); + expect(result.map((m) => m.id)).toEqual(['m2', 'm3']); + }); + }); + + describe('saveThread — existing row', () => { + it('preserves the original resourceId instead of overwriting with the caller’s', async () => { + // Shared threads (e.g. the test-chat thread keyed by agentId) are + // written to by multiple users. The first writer owns the row; + // subsequent saves must not re-stamp it with the current caller. + const existing = { + id: 'thread-1', + resourceId: 'original-user', + title: null, + metadata: null, + } as unknown as AgentThreadEntity; + threadRepository.findOneBy.mockResolvedValue(existing); + threadRepository.save.mockImplementation(async (e) => e as AgentThreadEntity); + resourceRepository.existsBy.mockResolvedValue(true); + + await memory.saveThread({ + id: 'thread-1', + resourceId: 'different-user', + title: undefined, + metadata: undefined, + }); + + expect(threadRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 'thread-1', resourceId: 'original-user' }), + ); + }); + + it('still ensures the caller’s resource row exists (for message-level writes)', async () => { + // resourceId on the thread is preserved, but messages saved afterwards + // carry the current user's resourceId — that row must exist. + const existing = { + id: 'thread-1', + resourceId: 'original-user', + title: null, + metadata: null, + } as unknown as AgentThreadEntity; + threadRepository.findOneBy.mockResolvedValue(existing); + threadRepository.save.mockImplementation(async (e) => e as AgentThreadEntity); + resourceRepository.existsBy.mockResolvedValue(false); + + await memory.saveThread({ + id: 'thread-1', + resourceId: 'different-user', + title: undefined, + metadata: undefined, + }); + + expect(resourceRepository.existsBy).toHaveBeenCalledWith({ id: 'different-user' }); + expect(resourceRepository.save).toHaveBeenCalled(); + }); + }); + + describe('deleteMessagesByThread', () => { + it('deletes only the caller’s messages when resourceId is provided', async () => { + await memory.deleteMessagesByThread('thread-1', 'user-1'); + + expect(messageRepository.delete).toHaveBeenCalledWith({ + threadId: 'thread-1', + resourceId: 'user-1', + }); + }); + + it('deletes every message on the thread when resourceId is omitted', async () => { + await memory.deleteMessagesByThread('thread-1'); + + expect(messageRepository.delete).toHaveBeenCalledWith({ threadId: 'thread-1' }); + }); + + it('does not widen the delete to all users when resourceId is an empty string', async () => { + // Same security invariant as getMessages: an accidental falsy userId + // must never fall through to "delete everyone's messages". + await memory.deleteMessagesByThread('thread-1', ''); + + expect(messageRepository.delete).toHaveBeenCalledWith({ + threadId: 'thread-1', + resourceId: '', + }); + }); + }); + + describe('working memory — thread scope', () => { + it('stores thread-scoped working memory on thread metadata', async () => { + const existing = { + id: 'thread-1', + resourceId: 'user-1', + title: null, + metadata: JSON.stringify({ other: true }), + } as unknown as AgentThreadEntity; + threadRepository.findOneBy.mockResolvedValue(existing); + threadRepository.save.mockImplementation(async (entity) => entity as AgentThreadEntity); + + await memory.saveWorkingMemory( + { threadId: 'thread-1', resourceId: 'user-1', scope: 'thread' }, + '# Thread memory', + ); + + expect(threadRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: JSON.stringify({ other: true, workingMemory: '# Thread memory' }), + }), + ); + expect(resourceRepository.save).not.toHaveBeenCalled(); + }); + + it('creates the thread row when working memory is saved before messages', async () => { + threadRepository.findOneBy.mockResolvedValue(null); + threadRepository.create.mockImplementation((entity) => entity as AgentThreadEntity); + threadRepository.save.mockImplementation(async (entity) => entity as AgentThreadEntity); + resourceRepository.existsBy.mockResolvedValue(true); + + await memory.saveWorkingMemory( + { threadId: 'thread-1', resourceId: 'user-1', scope: 'thread' }, + '# Thread memory', + ); + + expect(resourceRepository.existsBy).toHaveBeenCalledWith({ id: 'user-1' }); + expect(threadRepository.create).toHaveBeenCalledWith({ + id: 'thread-1', + resourceId: 'user-1', + title: null, + metadata: JSON.stringify({ workingMemory: '# Thread memory' }), + }); + expect(threadRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'thread-1', + resourceId: 'user-1', + metadata: JSON.stringify({ workingMemory: '# Thread memory' }), + }), + ); + }); + + it('isolates thread-scoped working memory by user-scoped test-chat thread id', async () => { + const threads = new Map([ + [ + 'test-agent-1:user-1', + { + id: 'test-agent-1:user-1', + metadata: JSON.stringify({ workingMemory: 'alice notes' }), + } as unknown as AgentThreadEntity, + ], + [ + 'test-agent-1:user-2', + { + id: 'test-agent-1:user-2', + metadata: JSON.stringify({ workingMemory: 'bob notes' }), + } as unknown as AgentThreadEntity, + ], + ]); + threadRepository.findOneBy.mockImplementation( + async ({ id }: { id: string }) => threads.get(id) ?? null, + ); + + await expect( + memory.getWorkingMemory({ + threadId: 'test-agent-1:user-1', + resourceId: 'user-1', + scope: 'thread', + }), + ).resolves.toBe('alice notes'); + await expect( + memory.getWorkingMemory({ + threadId: 'test-agent-1:user-2', + resourceId: 'user-2', + scope: 'thread', + }), + ).resolves.toBe('bob notes'); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/rich-interaction-tool.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/rich-interaction-tool.test.ts new file mode 100644 index 00000000000..1a8f714fa73 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/rich-interaction-tool.test.ts @@ -0,0 +1,125 @@ +import { createRichInteractionTool } from '../rich-interaction-tool'; + +describe('createRichInteractionTool', () => { + it('should build a tool with the correct name', () => { + const tool = createRichInteractionTool().build(); + + expect(tool.name).toBe('rich_interaction'); + }); + + it('should have suspend and resume schemas', () => { + const tool = createRichInteractionTool().build(); + + expect(tool.suspendSchema).toBeDefined(); + expect(tool.resumeSchema).toBeDefined(); + }); + + function makeCtx() { + return { + resumeData: undefined, + suspend: jest.fn().mockResolvedValue(undefined as never), + parentTelemetry: undefined, + }; + } + + it('should return resumeData when present', async () => { + const tool = createRichInteractionTool().build(); + const resumeData = { type: 'button', value: 'ok' }; + const ctx = { ...makeCtx(), resumeData }; + + const result = await tool.handler!( + { components: [{ type: 'button', label: 'OK', value: 'ok' }] }, + ctx, + ); + + expect(result).toEqual(resumeData); + expect(ctx.suspend).not.toHaveBeenCalled(); + }); + + it('should suspend when actionable components exist and no resumeData', async () => { + const tool = createRichInteractionTool().build(); + const input = { + title: 'Choose', + components: [ + { type: 'button' as const, label: 'Yes', value: 'yes', style: 'primary' as const }, + { type: 'button' as const, label: 'No', value: 'no', style: 'danger' as const }, + ], + }; + const ctx = makeCtx(); + + await tool.handler!(input, ctx); + + expect(ctx.suspend).toHaveBeenCalledWith(input); + }); + + it('should return displayOnly marker (not suspend) when no actionable components are present', async () => { + const tool = createRichInteractionTool().build(); + const input = { + title: 'Info Card', + message: 'Some details', + components: [{ type: 'section' as const, text: 'Hello world' }, { type: 'divider' as const }], + }; + const ctx = makeCtx(); + + const result = await tool.handler!(input, ctx); + + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(result).toEqual({ displayOnly: true }); + }); + + it('should return displayOnly marker when only an image component is present', async () => { + const tool = createRichInteractionTool('slack').build(); + const input = { + components: [{ type: 'image' as const, url: 'https://media.giphy.com/x.gif', alt: 'gif' }], + }; + const ctx = makeCtx(); + + const result = await tool.handler!(input, ctx); + + expect(ctx.suspend).not.toHaveBeenCalled(); + expect(result).toEqual({ displayOnly: true }); + }); + + it('should suspend for select components', async () => { + const tool = createRichInteractionTool().build(); + const input = { + components: [ + { + type: 'select' as const, + id: 'priority', + placeholder: 'Choose priority', + options: [ + { label: 'High', value: 'high' }, + { label: 'Low', value: 'low' }, + ], + }, + ], + }; + const ctx = makeCtx(); + + await tool.handler!(input, ctx); + + expect(ctx.suspend).toHaveBeenCalledWith(input); + }); + + it('should suspend for radio_select components', async () => { + const tool = createRichInteractionTool().build(); + const input = { + components: [ + { + type: 'radio_select' as const, + id: 'size', + options: [ + { label: 'Small', value: 'sm' }, + { label: 'Large', value: 'lg' }, + ], + }, + ], + }; + const ctx = makeCtx(); + + await tool.handler!(input, ctx); + + expect(ctx.suspend).toHaveBeenCalledWith(input); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts new file mode 100644 index 00000000000..7910d3f2009 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/agent-chat-bridge.ts @@ -0,0 +1,889 @@ +import type { AgentMessage, StreamChunk } from '@n8n/agents'; +import { Container } from '@n8n/di'; +import type { ActionEvent, Chat, Message, Thread } from 'chat'; +import type { Logger } from 'n8n-workflow'; + +import type { AgentsService } from '../agents.service'; +import type { RichSuspendPayload } from '../types'; +import type { AgentChatIntegration } from './agent-chat-integration'; +import { ChatIntegrationRegistry } from './agent-chat-integration'; +import { CallbackStore } from './callback-store'; +import type { ComponentMapper } from './component-mapper'; +import { type TextEndFn, type TextYieldFn, type InternalThread, toInternalThreadId } from './types'; + +interface AgentExecutor { + executeForChatPublished(config: { + agentId: string; + projectId: string; + message: string; + memory: { threadId: InternalThread; resourceId: string }; + integrationType?: string; + }): AsyncGenerator; + + resumeForChat(config: { + agentId: string; + projectId: string; + runId: string; + toolCallId: string; + resumeData: unknown; + integrationType?: string; + }): AsyncGenerator; +} + +/** + * Bridges Chat SDK events to the agent execution pipeline. + * + * Registers three handlers on a Chat SDK `Bot` instance: + * 1. `onNewMention` — new @mentions and DMs → subscribe + execute + * 2. `onSubscribedMessage` — follow-up messages in subscribed threads + * 3. `onAction` — button clicks for HITL resume flow + * + * Stream consumption has two strategies, selected per integration via the + * `disableStreaming` flag on `AgentChatIntegration`: + * • streaming (default, e.g. Slack): text deltas are piped as an + * AsyncIterable into `thread.post()` so Chat SDK can render + * incrementally (post-and-edit). + * • buffered (Telegram): deltas accumulate into a string and are posted as + * a single message per flush event, so the platform adapter only ever + * sees well-formed Markdown (streaming edits ship half-formed markup). + * + * In both strategies, non-text chunks (`tool-call-suspended`, `message`, + * `error`) flush any pending text before being handled, preserving ordering. + */ +export class AgentChatBridge { + /** Short-lived set of run IDs that have been resumed to prevent double resumption */ + private readonly activeResumedRuns = new Set(); + + /** Store for shortening callback data on platforms with size limits (Telegram) */ + private readonly callbackStore?: CallbackStore; + + /** When true, buffer deltas and post as a single message (see integration flag). */ + private readonly disableStreaming: boolean; + + /** Resolved integration for this platform (may be undefined for unknown types). */ + private readonly integration: AgentChatIntegration | undefined; + + /** + * In-flight `rich_interaction` tool inputs keyed by toolCallId. Populated on + * the `tool-call` chunk; consumed on the matching `tool-result` chunk when + * the result carries `displayOnly: true` (display-only render path) or + * cleared on `tool-call-suspended` (interactive HITL path, where the + * suspendPayload itself carries the components). + * + * Storing the input here is the cleanest way to render a display-only + * card from the bridge without leaking chat-SDK semantics into the agent + * framework: the framework just emits tool-call/tool-result; the bridge + * decides which results trigger a card post. + */ + private readonly richInteractionInputs = new Map(); + + constructor( + private readonly chat: Chat, + private readonly agentId: string, + private readonly agentService: AgentExecutor, + private readonly componentMapper: ComponentMapper, + private readonly logger: Logger, + private readonly n8nProjectId: string, + private readonly integrationType: string, + ) { + this.integration = Container.get(ChatIntegrationRegistry).get(integrationType); + if (this.integration?.needsShortCallbackData) { + this.callbackStore = new CallbackStore(); + } + this.disableStreaming = this.integration?.disableStreaming ?? false; + this.registerHandlers(); + } + + // --------------------------------------------------------------------------- + // Static factory + // --------------------------------------------------------------------------- + + static create( + chat: Chat, + agentId: string, + agentService: AgentsService, + componentMapper: ComponentMapper, + logger: Logger, + n8nProjectId: string, + integrationType: string, + ): AgentChatBridge { + const agentExecutor: AgentExecutor = { + async *executeForChatPublished({ memory, agentId: aid, message, integrationType }) { + yield* agentService.executeForChatPublished({ + agentId: aid, + projectId: n8nProjectId, + message, + memory: { threadId: memory.threadId.id, resourceId: memory.resourceId }, + integrationType, + }); + }, + async *resumeForChat(config) { + yield* agentService.resumeForChat(config); + }, + }; + return new AgentChatBridge( + chat, + agentId, + agentExecutor, + componentMapper, + logger, + n8nProjectId, + integrationType, + ); + } + + // --------------------------------------------------------------------------- + // Handler registration + // --------------------------------------------------------------------------- + + private registerHandlers(): void { + this.chat.onNewMention(async (thread, message) => { + try { + await thread.subscribe(); + await this.executeAndStream(thread, message); + } catch (error) { + await this.postErrorToThread(thread, error); + } + }); + + this.chat.onSubscribedMessage(async (thread, message) => { + try { + await this.executeAndStream(thread, message); + } catch (error) { + await this.postErrorToThread(thread, error); + } + }); + + this.chat.onAction(async (event) => { + try { + await this.handleAction(event); + } catch (error) { + await this.postErrorToThread(event.thread, error); + } + }); + } + + /** Release long-lived resources (callback store timer). */ + dispose(): void { + this.callbackStore?.dispose(); + } + + // --------------------------------------------------------------------------- + // Thread ID resolution — single place to apply per-platform formatting + // --------------------------------------------------------------------------- + + /** + * Resolve the thread ID to pass to the agents service. + * + * Delegates to `integration.formatThreadId.fromSdk` when the platform + * provides one (e.g. Slack encodes channel + ts), otherwise falls back + * to the raw Chat SDK `thread.id`. + * + * Every call site that hands a threadId to `AgentExecutor` MUST use this + * helper so platform-specific formatting is never accidentally skipped. + */ + private resolveThreadId(thread: Thread) { + return toInternalThreadId(this.integration?.formatThreadId?.fromSdk(thread) ?? thread.id); + } + + /** + * Returns a callback shortener function for platforms with short callback + * data limits (Telegram). Returns undefined for other platforms. + */ + private getShortenCallback(): + | ((actionId: string, value: string) => Promise<{ id: string; value: string }>) + | undefined { + if (!this.callbackStore) return undefined; + const store = this.callbackStore; + return async (actionId: string, value: string) => { + const key = await store.store(actionId, value); + return { id: key, value: '' }; + }; + } + + // --------------------------------------------------------------------------- + // Core execution pipeline + // --------------------------------------------------------------------------- + + private async executeAndStream(thread: Thread, message: Message): Promise { + const text = message.text?.trim(); + if (!text) return; + + const threadId = this.resolveThreadId(thread); + // threadId.id already encodes platform + user identity (e.g. Telegram: + // "chat:botId-userId") so it serves as a per-chat-user resourceId that + // scopes memory correctly without leaking the n8n user identity. + // Always run the published snapshot — integrations are production traffic. + const stream = this.agentService.executeForChatPublished({ + agentId: this.agentId, + projectId: this.n8nProjectId, + message: text, + memory: { threadId, resourceId: message.author.userId }, + integrationType: this.integrationType, + }); + + await this.consumeStream(stream, thread); + } + + // --------------------------------------------------------------------------- + // Stream consumer + // --------------------------------------------------------------------------- + + /** + * Consume the agent stream and post to the thread. + * + * Default: pipe text deltas as an AsyncIterable to `thread.post()` + * so Chat SDK can render incrementally (post-and-edit). Integrations that + * set `disableStreaming` short-circuit to `consumeStreamBuffered`, which + * accumulates deltas into a string and posts them as a single message per + * flush event (used by Telegram to avoid Markdown streaming issues). + * + * In both strategies, non-text chunks (`tool-call-suspended`, `message`, + * `error`) flush any pending text first, then get handled in order. + */ + private async consumeStream(stream: AsyncGenerator, thread: Thread): Promise { + if (this.disableStreaming) { + await this.consumeStreamBuffered(stream, thread); + return; + } + + // Controller for the text stream iterable that Chat SDK consumes. + // These are reassigned inside `createTextIterable()` (called transitively + // by `ensureStreamingPost()`). TypeScript cannot track mutations through + // closures, so it incorrectly narrows these to `never` after the + // assignment. We use a wrapper object to avoid the TS closure analysis issue. + const textStream: { yield: TextYieldFn | null; end: TextEndFn | null } = { + yield: null, + end: null, + }; + let streamingPost: Promise | null = null; + + const createTextIterable = (): AsyncIterable => { + const queue: string[] = []; + let done = false; + let waiting: ((result: IteratorResult) => void) | null = null; + + textStream.yield = (text: string) => { + if (waiting) { + const resolve = waiting; + waiting = null; + resolve({ value: text, done: false }); + } else { + queue.push(text); + } + }; + + textStream.end = () => { + done = true; + if (waiting) { + const resolve = waiting; + waiting = null; + resolve({ value: '', done: true }); + } + }; + + return { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (queue.length > 0) { + return { value: queue.shift()!, done: false }; + } + if (done) { + return { value: '', done: true }; + } + return await new Promise((resolve) => { + waiting = resolve; + }); + }, + }; + }, + }; + }; + + const startStreamingPost = () => { + const iterable = createTextIterable(); + streamingPost = thread.post(iterable).catch((postError: unknown) => { + this.logger.error('[AgentChatBridge] Streaming post failed', { + error: postError instanceof Error ? postError.message : String(postError), + }); + }); + }; + + const endStreamingPost = async () => { + if (textStream.end) { + textStream.end(); + textStream.end = null; + textStream.yield = null; + } + if (streamingPost) { + await streamingPost; + streamingPost = null; + } + }; + + // Don't start streaming post eagerly — wait for first text delta + const ensureStreamingPost = () => { + if (!streamingPost) startStreamingPost(); + }; + + for await (const chunk of stream) { + switch (chunk.type) { + case 'text-delta': { + const { delta } = chunk; + ensureStreamingPost(); + textStream.yield?.(delta); + break; + } + case 'reasoning-delta': { + const { delta } = chunk; + ensureStreamingPost(); + textStream.yield?.(`_${delta}_`); + break; + } + case 'tool-call': + this.stashRichInteractionInput(chunk); + break; + case 'tool-call-suspended': + this.richInteractionInputs.delete(chunk.toolCallId); + await endStreamingPost(); + await this.handleSuspension(chunk, thread); + // Don't start new streaming post — wait for next text delta + break; + case 'tool-result': + if (this.isRichInteractionDisplayOnly(chunk)) { + await endStreamingPost(); + await this.handleDisplayOnly(chunk, thread); + } else { + this.richInteractionInputs.delete(chunk.toolCallId); + } + break; + case 'message': + await endStreamingPost(); + await this.handleMessage(chunk, thread); + break; + case 'error': + await endStreamingPost(); + await this.postErrorToThread(thread, chunk.error); + break; + default: + // Ignore other chunk types (finish, tool-input-*, + // start-step, finish-step, etc.) + break; + } + } + + await endStreamingPost(); + } + + /** + * Buffered consumer — accumulates text/reasoning deltas and posts them as a + * single message per flush. Used when the integration disables streaming + * (e.g. Telegram). + */ + private async consumeStreamBuffered( + stream: AsyncGenerator, + thread: Thread, + ): Promise { + let buffer = ''; + + const flushBuffer = async () => { + const text = buffer; + buffer = ''; + if (!text.trim()) return; + try { + // Chat SDK's streaming path wraps accumulated deltas as `{ markdown }` + // so the platform adapter applies its markdown parse-mode (Telegram: + // sendMessage with parse_mode=Markdown). A raw string bypasses that + // and renders as plain text, so we post the buffered message the same + // shape the streaming path uses under the hood. + await thread.post({ markdown: text }); + } catch (postError: unknown) { + await this.postErrorToThread(thread, postError); + this.logger.error('[AgentChatBridge] Buffered post failed', { + error: postError instanceof Error ? postError.message : String(postError), + }); + } + }; + + for await (const chunk of stream) { + switch (chunk.type) { + case 'text-delta': + buffer += chunk.delta; + break; + case 'reasoning-delta': + buffer += `_${chunk.delta}_`; + break; + case 'tool-call': + this.stashRichInteractionInput(chunk); + break; + case 'tool-call-suspended': + this.richInteractionInputs.delete(chunk.toolCallId); + await flushBuffer(); + await this.handleSuspension(chunk, thread); + break; + case 'tool-result': + if (this.isRichInteractionDisplayOnly(chunk)) { + await flushBuffer(); + await this.handleDisplayOnly(chunk, thread); + } else { + this.richInteractionInputs.delete(chunk.toolCallId); + } + break; + case 'message': + await flushBuffer(); + await this.handleMessage(chunk, thread); + break; + case 'error': + await flushBuffer(); + await this.postErrorToThread(thread, chunk.error); + break; + default: + break; + } + } + + await flushBuffer(); + } + + // --------------------------------------------------------------------------- + // Suspension handling (HITL tool cards) + // --------------------------------------------------------------------------- + + private async handleSuspension( + chunk: Extract, + thread: Thread, + ): Promise { + const { runId, toolCallId, suspendPayload } = chunk; + + if (!runId || !toolCallId) { + this.logger.warn('[AgentChatBridge] Suspended chunk missing runId or toolCallId'); + return; + } + + // Rich interaction tool — use the structured payload directly. + // The payload IS the tool input (title, message, components array). + if (chunk.toolName === 'rich_interaction') { + await this.handleRichInteraction(chunk, thread); + return; + } + + const payload = suspendPayload as RichSuspendPayload | Record | undefined; + const hasComponents = + payload && + 'components' in payload && + Array.isArray(payload.components) && + payload.components.length > 0; + + let cardPayload: { + title?: string; + components: Array<{ type: string; [key: string]: unknown }>; + }; + + if (hasComponents) { + cardPayload = payload as RichSuspendPayload; + } else { + // Plain suspend payload — auto-generate approve/deny buttons + const message = + payload && typeof payload === 'object' && 'message' in payload + ? String(payload.message) + : 'Action required — approve or deny?'; + + cardPayload = { + title: message, + components: [ + { type: 'button', label: 'Approve', value: 'true', style: 'primary' }, + { type: 'button', label: 'Deny', value: 'false', style: 'danger' }, + ], + }; + } + + try { + const card = await this.componentMapper.toCard( + cardPayload, + runId, + toolCallId, + chunk.resumeSchema, + this.getShortenCallback(), + this.integrationType, + ); + await thread.post({ card }); + } catch (error) { + this.logger.error('[AgentChatBridge] Failed to post suspension card', { + agentId: this.agentId, + runId, + toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // --------------------------------------------------------------------------- + // Rich interaction handling + // --------------------------------------------------------------------------- + + private async handleRichInteraction( + chunk: Extract, + thread: Thread, + ): Promise { + const { runId, toolCallId, suspendPayload } = chunk; + + const payload = suspendPayload as { + title?: string; + message?: string; + components?: Array<{ type: string; [key: string]: unknown }>; + }; + + if (!payload?.components?.length) { + this.logger.warn('[AgentChatBridge] rich_interaction has no components'); + return; + } + + // Use a resume schema that tells ComponentMapper to encode buttons as + // { type: 'button', value: '...' } for the discriminated union + const riResumeSchema = { + type: 'object', + properties: { + type: { type: 'string' }, + value: { type: 'string' }, + }, + }; + + try { + const card = await this.componentMapper.toCard( + payload as { + title?: string; + message?: string; + components: Array<{ type: string; [key: string]: unknown }>; + }, + runId, + toolCallId, + riResumeSchema, + this.getShortenCallback(), + this.integrationType, + ); + await thread.post(card); + } catch (error) { + this.logger.error('[AgentChatBridge] Failed to post rich interaction card', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // --------------------------------------------------------------------------- + // Display-only card handling (no suspension) + // + // `rich_interaction` returns `{ displayOnly: true }` from its handler when + // the card has no actionable components. The bridge stashes the tool-call + // input on the way down (carrying the components) and posts the card when + // the matching tool-result arrives carrying the marker. The framework + // itself stays free of any chat-rendering semantics. + // --------------------------------------------------------------------------- + + private stashRichInteractionInput(chunk: Extract): void { + if (chunk.toolName !== 'rich_interaction') return; + this.richInteractionInputs.set(chunk.toolCallId, chunk.input); + } + + private isRichInteractionDisplayOnly( + chunk: Extract, + ): boolean { + if (chunk.toolName !== 'rich_interaction') return false; + const out = chunk.output; + return ( + typeof out === 'object' && + out !== null && + 'displayOnly' in out && + (out as { displayOnly: unknown }).displayOnly === true + ); + } + + /** + * Render the stashed `rich_interaction` input as a display-only card. No + * resume callback can fire (no buttons/selects), so we pass a placeholder + * runId — the unique IDs the component mapper builds for interactive + * elements never get used. + */ + private async handleDisplayOnly( + chunk: Extract, + thread: Thread, + ): Promise { + const { toolCallId } = chunk; + const input = this.richInteractionInputs.get(toolCallId); + this.richInteractionInputs.delete(toolCallId); + + const cardPayload = input as { + title?: string; + message?: string; + components?: Array<{ type: string; [key: string]: unknown }>; + }; + + if (!cardPayload?.components?.length) { + this.logger.warn('[AgentChatBridge] display-only rich_interaction has no components', { + toolCallId, + }); + return; + } + + const displayResumeSchema = { + type: 'object', + properties: { type: { type: 'string' }, value: { type: 'string' } }, + }; + + try { + const card = await this.componentMapper.toCard( + cardPayload as { + title?: string; + message?: string; + components: Array<{ type: string; [key: string]: unknown }>; + }, + '', + toolCallId, + displayResumeSchema, + this.getShortenCallback(), + this.integrationType, + ); + await thread.post({ card }); + } catch (error) { + this.logger.error('[AgentChatBridge] Failed to post display card', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // --------------------------------------------------------------------------- + // Custom message handling (tool toMessage output) + // --------------------------------------------------------------------------- + + private async handleMessage( + chunk: Extract, + thread: Thread, + ): Promise { + const agentMessage: AgentMessage = chunk.message; + + // AgentMessage is a union. LLM messages (Message) have a `content` array + // of typed content parts. Extract only text parts for display. + if (!('content' in agentMessage) || !Array.isArray(agentMessage.content)) return; + + const textParts = agentMessage.content + .filter( + (part): part is { type: 'text'; text: string } => part.type === 'text' && 'text' in part, + ) + .map((part) => part.text); + + const textToPost = textParts.join(''); + + // Skip messages with no displayable text (e.g. tool-call-only messages) + if (!textToPost.trim()) return; + + try { + await thread.post(textToPost); + } catch (error) { + this.logger.error('[AgentChatBridge] Failed to post message chunk', { + agentId: this.agentId, + threadId: thread.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // --------------------------------------------------------------------------- + // Button interaction handling (HITL resume) + // --------------------------------------------------------------------------- + + /** Parsed result from an action ID. */ + private parseActionId( + actionId: string, + value: string | undefined, + ): { runId: string; toolCallId: string; resumeData: unknown } | null { + if (actionId.startsWith('ri-btn:')) { + const parts = actionId.split(':'); + if (parts.length < 4) { + this.logger.warn('[AgentChatBridge] Malformed ri-btn action ID', { actionId }); + return null; + } + let resumeData: unknown; + try { + resumeData = JSON.parse(value ?? ''); + } catch { + resumeData = { type: 'button', value }; + } + return { runId: parts[1], toolCallId: parts.slice(2, -1).join(':'), resumeData }; + } + + if (actionId.startsWith('ri-sel:')) { + const parts = actionId.split(':'); + if (parts.length < 4) { + this.logger.warn('[AgentChatBridge] Malformed ri-sel action ID', { actionId }); + return null; + } + return { + runId: parts[2], + toolCallId: parts.slice(3).join(':'), + resumeData: { type: 'select', id: parts[1], value }, + }; + } + + if (actionId.startsWith('resume:')) { + const parts = actionId.split(':'); + if (parts.length < 4) { + this.logger.warn('[AgentChatBridge] Malformed action ID', { actionId }); + return null; + } + let resumeData: unknown; + try { + resumeData = JSON.parse(value ?? ''); + } catch { + resumeData = { value }; + } + return { runId: parts[1], toolCallId: parts.slice(2, -1).join(':'), resumeData }; + } + + return null; + } + + /** + * Resolve short callback keys when the platform uses them (e.g. Telegram). + * Returns the resolved `{ actionId, value }` or `null` if expired/missing. + */ + private async resolveCallbackData( + actionId: string, + value: string | undefined, + thread: Thread, + ): Promise<{ actionId: string; value: string | undefined } | null> { + if (!this.callbackStore) return { actionId, value }; + + const resolved = await this.callbackStore.resolve(actionId); + if (!resolved) { + this.logger.warn('[AgentChatBridge] Callback key not found or expired', { actionId }); + await thread.post( + 'This action is no longer available. The link may have expired or already been used.', + ); + return null; + } + return { actionId: resolved.actionId, value: resolved.value }; + } + + /** + * Delete the card message and apply platform-specific workarounds before + * resuming the agent. + */ + private async cleanUpBeforeResume(event: ActionEvent): Promise { + try { + await event.adapter.deleteMessage(event.threadId, event.messageId); + } catch (deleteError) { + this.logger.warn('[AgentChatBridge] Failed to delete card message', { + error: deleteError instanceof Error ? deleteError.message : String(deleteError), + }); + } + + // TODO(chat-sdk-bug): Remove when Chat SDK normalises Slack interaction + // payloads. Slack sends `team: { id: "T..." }` (object) but Chat SDK's + // streaming path expects `team_id: "T..."` (string) on the raw message. + interface ThreadWithRaw { + _currentMessage?: { raw?: Record }; + } + const threadInternal = event.thread as unknown as ThreadWithRaw; + if (threadInternal?._currentMessage?.raw) { + const raw = threadInternal._currentMessage.raw; + if (raw.team && typeof raw.team === 'object' && !raw.team_id) { + raw.team_id = (raw.team as Record).id; + } + } + } + + /** + * Guard against double resumption, then resume the agent and stream the + * response back into the thread. + */ + private async executeResume( + thread: Thread, + runId: string, + toolCallId: string, + resumeData: unknown, + ): Promise { + if (this.activeResumedRuns.has(runId)) { + this.logger.warn('[AgentChatBridge] Run is already active', { runId, toolCallId }); + await thread.post('This action has already been handled'); + return; + } + + this.activeResumedRuns.add(runId); + try { + const stream = this.agentService.resumeForChat({ + agentId: this.agentId, + projectId: this.n8nProjectId, + runId, + toolCallId, + resumeData, + integrationType: this.integrationType, + }); + await this.consumeStream(stream, thread as Thread); + } finally { + this.activeResumedRuns.delete(runId); + } + } + + /** + * Handle a button/select action. Action IDs use one of three prefixes: + * - `ri-btn:{runId}:{toolCallId}:{index}` — rich interaction button + * - `ri-sel:{selectId}:{runId}:{toolCallId}` — rich interaction select + * - `resume:{runId}:{toolCallId}:{index}` — generic per-tool resume button + */ + private async handleAction(event: ActionEvent): Promise { + const { thread } = event; + + if (!thread) { + this.logger.warn('[AgentChatBridge] Thread is not set for event', { + threadId: event.threadId, + actionId: event.actionId, + }); + return; + } + + const callbackData = await this.resolveCallbackData(event.actionId, event.value, thread); + if (!callbackData) return; + + const parsed = this.parseActionId(callbackData.actionId, callbackData.value); + if (!parsed) return; + + await this.cleanUpBeforeResume(event); + await this.executeResume(thread, parsed.runId, parsed.toolCallId, parsed.resumeData); + } + + // --------------------------------------------------------------------------- + // Error posting + // --------------------------------------------------------------------------- + + private async postErrorToThread( + thread: Thread | null, + error: unknown, + ): Promise { + const message = error instanceof Error ? error.message : 'An unexpected error occurred'; + + this.logger.error('[AgentChatBridge] Error in handler', { + agentId: this.agentId, + threadId: thread?.id, + error: message, + }); + + try { + if (!thread) { + this.logger.warn( + "[AgentChatBridge] Couldn't post error message because thread is not set", + { + agentId: this.agentId, + error: message, + }, + ); + return; + } + await thread.post('⚠️ Something went wrong while processing your request. Please try again.'); + } catch (postError) { + this.logger.error('[AgentChatBridge] Failed to post error message', { + agentId: this.agentId, + error: postError instanceof Error ? postError.message : String(postError), + }); + } + } +} diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts new file mode 100644 index 00000000000..19c9168b6b6 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -0,0 +1,122 @@ +import { Service } from '@n8n/di'; +import type { Thread } from 'chat'; + +import type { SuspendComponent } from './component-mapper'; + +/** Per-connection context handed to AgentChatIntegration hooks. */ +export interface AgentChatIntegrationContext { + agentId: string; + projectId: string; + credentialId: string; + credential: Record; + /** Returns the inbound webhook URL this n8n instance exposes for the given platform. */ + webhookUrlFor: (platform: string) => string; +} + +/** + * A chat platform (Slack, Telegram, …) that an agent can be connected to. + * + * Encapsulates everything platform-specific in one place: adapter construction, + * credential extraction, capability metadata used by the rich_interaction tool, + * component normalization before rendering, and optional lifecycle hooks. + * + * The concrete subclasses live under `./platforms/`. + */ +export abstract class AgentChatIntegration { + /** Platform identifier (`'slack'`, `'telegram'`, …). */ + abstract readonly type: string; + + /** Credential types accepted by the frontend selector. */ + abstract readonly credentialTypes: string[]; + + // --------------------------------------------------------------------------- + // FE display metadata — shown in the trigger-picker and integration cards. + // Localizable copy (help text, connected confirmation) lives in the FE i18n + // catalog keyed by `type`; only stable, brand-level metadata lives here. + // --------------------------------------------------------------------------- + + /** Brand-name label shown in UI (not localized — e.g. "Slack", "Linear"). */ + abstract readonly displayLabel: string; + + /** Lucide icon name (from the shared icon set) for the integration card. */ + abstract readonly displayIcon: string; + + /** + * Component types this platform supports in rich_interaction cards. + * Omit to signal that the platform has no rich_interaction surface — the + * tool won't be injected into agents targeting this platform. + */ + readonly supportedComponents?: string[]; + + /** + * True if this platform has a small callback_data limit (Telegram: 64 bytes). + * When true, buttons encode a short key that the bridge resolves via the + * CallbackStore instead of carrying the full payload. + */ + readonly needsShortCallbackData: boolean = false; + + /** + * True if the bridge should buffer streaming output and post it as a single + * message instead of streaming text deltas via post-and-edit. + */ + readonly disableStreaming: boolean = false; + + /** Build the Chat SDK adapter for this platform. */ + abstract createAdapter(ctx: AgentChatIntegrationContext): Promise; + + /** + * Optional hook run BEFORE the adapter is built. Use it to reject the + * connect early — e.g. a webhook-based platform checking that the + * credential isn't already claimed elsewhere. Throwing aborts the connect. + */ + onBeforeConnect?(ctx: AgentChatIntegrationContext): Promise; + + /** Optional hook run AFTER `chat.initialize()`. Throwing triggers cleanup. */ + onAfterConnect?(ctx: AgentChatIntegrationContext): Promise; + + /** + * Optional per-platform component normalization (applied before toCard). + * Convert unsupported types into close-enough equivalents — e.g. Telegram + * turns select options into individual buttons. + */ + normalizeComponents?(components: SuspendComponent[]): SuspendComponent[]; + + /** + * Optional per-platform thread ID formatting. + * Used to convert between the Chat SDK thread and our format. + */ + formatThreadId?: { + fromSdk: (thread: Thread) => string; + toSdk: (threadId: string) => string; + }; +} + +/** + * Singleton registry of AgentChatIntegration implementations. + * + * Platforms register themselves during module init (`agents.module.ts`). + * Consumers (ChatIntegrationService, ComponentMapper, createRichInteractionTool, + * AgentChatBridge) look up integrations by type. + */ +@Service() +export class ChatIntegrationRegistry { + private readonly integrations = new Map(); + + register(integration: AgentChatIntegration): void { + this.integrations.set(integration.type, integration); + } + + get(type: string): AgentChatIntegration | undefined { + return this.integrations.get(type); + } + + require(type: string): AgentChatIntegration { + const integration = this.integrations.get(type); + if (!integration) throw new Error(`Unknown integration type: ${type}`); + return integration; + } + + list(): AgentChatIntegration[] { + return [...this.integrations.values()]; + } +} diff --git a/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts b/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts new file mode 100644 index 00000000000..f772e97a5a7 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/agent-schedule.service.ts @@ -0,0 +1,379 @@ +import { + AGENT_SCHEDULE_TRIGGER_TYPE, + DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + type AgentScheduleConfig, + type AgentScheduleIntegration, + isAgentScheduleIntegration, +} from '@n8n/api-types'; +import { ProjectRelationRepository } from '@n8n/db'; +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; +import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; +import { Service } from '@n8n/di'; +import { CronJob } from 'cron'; +import { randomUUID } from 'crypto'; +import { DateTime } from 'luxon'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; + +import { AgentsService } from '../agents.service'; +import type { Agent } from '../entities/agent.entity'; +import { AgentRepository } from '../repositories/agent.repository'; +import { isValidCronExpression } from './cron-validation'; + +@Service() +export class AgentScheduleService { + private readonly jobs = new Map(); + + constructor( + private readonly logger: Logger, + private readonly globalConfig: GlobalConfig, + private readonly agentRepository: AgentRepository, + private readonly agentsService: AgentsService, + private readonly projectRelationRepository: ProjectRelationRepository, + ) {} + + getConfig(agent: Agent): AgentScheduleConfig { + const integration = this.getScheduleIntegration(agent); + + return { + active: integration?.active ?? false, + cronExpression: integration?.cronExpression ?? '', + wakeUpPrompt: integration?.wakeUpPrompt ?? DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }; + } + + async saveConfig( + agent: Agent, + cronExpression: string, + wakeUpPrompt?: string, + ): Promise { + const existing = this.getScheduleIntegration(agent); + const normalizedCronExpression = cronExpression.trim(); + const nextWakeUpPrompt = + wakeUpPrompt ?? existing?.wakeUpPrompt ?? DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT; + + if (normalizedCronExpression !== '') { + this.assertCronExpressionIsValid(normalizedCronExpression); + } else if (existing?.active) { + throw new BadRequestError('Cron expression is required while the schedule is active'); + } + + const saved = await this.saveScheduleIntegration(agent, { + type: AGENT_SCHEDULE_TRIGGER_TYPE, + active: existing?.active ?? false, + cronExpression: normalizedCronExpression, + wakeUpPrompt: nextWakeUpPrompt, + }); + + if (this.getScheduleIntegration(saved)?.active) { + await this.registerOrRefresh(saved); + } + + this.logger.debug('[AgentScheduleService] Saved schedule config', { + agentId: agent.id, + projectId: agent.projectId, + active: saved.integrations.find(isAgentScheduleIntegration)?.active ?? false, + cronExpression: normalizedCronExpression || null, + }); + + return this.getConfig(saved); + } + + async activate(agent: Agent): Promise { + if (!agent.publishedVersion) { + throw new ConflictError( + `Agent "${agent.id}" must be published before activating the schedule trigger`, + ); + } + + const existing = this.getScheduleIntegration(agent); + const cronExpression = existing?.cronExpression.trim() ?? ''; + if (cronExpression === '') { + throw new BadRequestError('Cron expression is required before activation'); + } + + this.assertCronExpressionIsValid(cronExpression); + + const saved = await this.saveScheduleIntegration(agent, { + type: AGENT_SCHEDULE_TRIGGER_TYPE, + active: true, + cronExpression, + wakeUpPrompt: existing?.wakeUpPrompt ?? DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT, + }); + + await this.registerOrRefresh(saved); + this.logger.info('[AgentScheduleService] Activated schedule trigger', { + agentId: agent.id, + projectId: agent.projectId, + cronExpression, + }); + + return this.getConfig(saved); + } + + async deactivate(agent: Agent): Promise { + const existing = this.getScheduleIntegration(agent); + if (!existing) { + this.deregister(agent.id); + return this.getConfig(agent); + } + + const saved = await this.saveScheduleIntegration(agent, { + ...existing, + active: false, + }); + + this.deregister(agent.id); + this.logger.info('[AgentScheduleService] Deactivated schedule trigger', { + agentId: agent.id, + projectId: agent.projectId, + }); + + return this.getConfig(saved); + } + + @OnLeaderTakeover() + async reconnectAll(): Promise { + const agents = await this.agentRepository.findPublished(); + this.logger.debug('[AgentScheduleService] Reconnecting active schedules', { + publishedAgentCount: agents.length, + }); + + for (const agent of agents) { + const schedule = this.getScheduleIntegration(agent); + if (!schedule?.active) { + continue; + } + + try { + await this.registerOrRefresh(agent); + } catch (error) { + this.logger.error('[AgentScheduleService] Failed to reconnect agent schedule', { + agentId: agent.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + /** + * Idempotent apply: read the schedule integration off the agent and reconcile + * the cron job. Used when the JSON config writes a new schedule via the + * builder so the live runtime tracks the persisted config. + */ + async applyConfig(agent: Agent): Promise { + const schedule = this.getScheduleIntegration(agent); + if (schedule?.active) { + await this.registerOrRefresh(agent); + } else { + this.deregister(agent.id); + } + } + + async registerOrRefresh(agent: Agent): Promise { + const schedule = this.getScheduleIntegration(agent); + if (!schedule?.active) { + this.logger.debug( + '[AgentScheduleService] Skipping schedule registration for inactive agent', + { + agentId: agent.id, + projectId: agent.projectId, + }, + ); + this.deregister(agent.id); + return; + } + + if (!agent.publishedVersion) { + this.logger.warn( + '[AgentScheduleService] Skipping schedule registration for unpublished agent', + { + agentId: agent.id, + projectId: agent.projectId, + }, + ); + this.deregister(agent.id); + return; + } + + this.assertCronExpressionIsValid(schedule.cronExpression); + + this.deregister(agent.id); + + const timezone = this.globalConfig.generic.timezone; + const job = new CronJob( + schedule.cronExpression, + () => { + void this.runScheduled(agent.id); + }, + null, + false, + timezone, + ); + + job.start(); + this.jobs.set(agent.id, job); + this.logger.info('[AgentScheduleService] Registered schedule trigger', { + agentId: agent.id, + projectId: agent.projectId, + cronExpression: schedule.cronExpression, + timezone, + }); + } + + deregister(agentId: string): void { + const existing = this.jobs.get(agentId); + if (!existing) { + return; + } + + void existing.stop(); + this.jobs.delete(agentId); + this.logger.info('[AgentScheduleService] Deregistered schedule trigger', { agentId }); + } + + @OnLeaderStepdown() + @OnShutdown() + stopAll(): void { + for (const [agentId] of this.jobs) { + this.deregister(agentId); + } + } + + private getScheduleIntegration(agent: Agent): AgentScheduleIntegration | undefined { + return (agent.integrations ?? []).find(isAgentScheduleIntegration); + } + + private async saveScheduleIntegration( + agent: Agent, + schedule: AgentScheduleIntegration, + ): Promise { + agent.integrations = [ + ...(agent.integrations ?? []).filter( + (integration) => !isAgentScheduleIntegration(integration), + ), + schedule, + ]; + + return await this.agentRepository.save(agent); + } + + private assertCronExpressionIsValid(cronExpression: string): void { + if (!isValidCronExpression(cronExpression)) { + throw new BadRequestError('Invalid cron expression'); + } + } + + private async runScheduled(agentId: string): Promise { + let projectId: string | undefined; + let threadId: string | undefined; + let cronExpression: string | undefined; + const startedAt = Date.now(); + + try { + const agent = await this.agentRepository.findOne({ + where: { id: agentId }, + relations: { publishedVersion: true }, + }); + if (!agent) { + this.logger.warn('[AgentScheduleService] Scheduled trigger fired for missing agent', { + agentId, + }); + this.deregister(agentId); + return; + } + + projectId = agent.projectId; + const schedule = this.getScheduleIntegration(agent); + cronExpression = schedule?.cronExpression; + if (!agent.publishedVersion) { + this.logger.warn('[AgentScheduleService] Scheduled trigger fired for unpublished agent', { + agentId, + projectId, + }); + this.deregister(agentId); + return; + } + + if (!schedule?.active) { + this.logger.warn('[AgentScheduleService] Scheduled trigger fired for inactive agent', { + agentId, + projectId, + }); + this.deregister(agentId); + return; + } + + const executionUserId = await this.resolveExecutionUserId(agent); + if (!executionUserId) { + this.logger.warn('[AgentScheduleService] No project member available for scheduled run', { + agentId, + projectId: agent.projectId, + }); + return; + } + + threadId = `schedule-${agentId}-${randomUUID()}`; + const timezone = this.globalConfig.generic.timezone; + const timestamp = DateTime.now().setZone(timezone).toISO() ?? new Date().toISOString(); + const message = `${schedule.wakeUpPrompt}\n\nCurrent date and time: ${timestamp} (timezone: ${timezone})`; + + this.logger.info('[AgentScheduleService] Scheduled trigger fired', { + agentId, + projectId, + threadId, + cronExpression: schedule.cronExpression, + timezone, + }); + this.logger.debug('[AgentScheduleService] Starting scheduled agent run', { + agentId, + projectId, + threadId, + messageLength: message.length, + }); + + let chunkCount = 0; + for await (const _chunk of this.agentsService.executeForSchedulePublished({ + agentId: agent.id, + projectId: agent.projectId, + message, + memory: { threadId, resourceId: executionUserId }, + })) { + chunkCount += 1; + } + + this.logger.info('[AgentScheduleService] Scheduled agent run completed', { + agentId, + projectId, + threadId, + chunkCount, + durationMs: Date.now() - startedAt, + }); + } catch (error) { + this.logger.error('[AgentScheduleService] Scheduled agent run failed', { + agentId, + projectId, + threadId, + cronExpression, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + + private async resolveExecutionUserId(agent: Agent): Promise { + const userIds = await this.projectRelationRepository.findUserIdsByProjectId(agent.projectId); + if (userIds.length === 0) { + return undefined; + } + + const publishedById = agent.publishedVersion?.publishedById; + if (publishedById && userIds.includes(publishedById)) { + return publishedById; + } + + return undefined; + } +} diff --git a/packages/cli/src/modules/agents/integrations/callback-store.ts b/packages/cli/src/modules/agents/integrations/callback-store.ts new file mode 100644 index 00000000000..ef9aae24e95 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/callback-store.ts @@ -0,0 +1,47 @@ +import { randomBytes } from 'node:crypto'; + +import { TtlMap } from '@/utils/ttl-map'; + +export interface CallbackPayload { + actionId: string; + value: string; +} + +/** + * Maps short callback keys to full action payloads. + * + * Telegram limits `callback_data` to 64 bytes, so buttons use an 8-char hex + * key and this store resolves it back to the full `{actionId, value}` on + * click. Entries are deleted on resolve (one-time use); unresolved entries + * auto-expire via the underlying TtlMap (default 1 hour). + */ +export class CallbackStore { + private readonly entries: TtlMap; + + constructor(ttlMs = 60 * 60 * 1000) { + this.entries = new TtlMap(ttlMs); + } + + /** Store a callback payload and return a short key (8 hex chars). */ + async store(actionId: string, value: string): Promise { + let key: string; + do { + key = randomBytes(4).toString('hex'); + } while (this.entries.has(key)); + this.entries.set(key, { actionId, value }); + return key; + } + + /** Resolve a short key and delete it. Returns undefined if missing/expired. */ + async resolve(key: string): Promise { + const entry = this.entries.get(key); + if (!entry) return undefined; + this.entries.delete(key); + return entry; + } + + /** Stop the background sweep timer. Call in service shutdown / test teardown. */ + dispose(): void { + this.entries.dispose(); + } +} diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts new file mode 100644 index 00000000000..d257d1edba9 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -0,0 +1,461 @@ +import { + isAgentCredentialIntegration, + type AgentCredentialIntegration, + type AgentIntegrationStatusResponse, +} from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import type { User } from '@n8n/db'; +import { ProjectRelationRepository, UserRepository } from '@n8n/db'; +import { OnLeaderStepdown, OnLeaderTakeover } from '@n8n/decorators'; +import { Container, Service } from '@n8n/di'; + +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { UrlService } from '@/services/url.service'; + +import { AgentChatBridge } from './agent-chat-bridge'; +import { + ChatIntegrationRegistry, + type AgentChatIntegrationContext, +} from './agent-chat-integration'; +import { ComponentMapper } from './component-mapper'; +import { loadChatSdk, loadMemoryState } from './esm-loader'; +import type { Agent } from '../entities/agent.entity'; +import { AgentRepository } from '../repositories/agent.repository'; + +// --------------------------------------------------------------------------- +// Chat SDK local interfaces +// +// The `chat` package is ESM-only, so we cannot import types at module level. +// These interfaces mirror the subset of the Chat SDK API we consume. +// --------------------------------------------------------------------------- + +type WebhookHandler = ( + request: Request, + options?: { waitUntil?: (task: Promise) => void }, +) => Promise; + +interface ChatInstance { + initialize(): Promise; + shutdown(): Promise; + webhooks: Record; + onNewMention: (handler: unknown) => void; + onSubscribedMessage: (handler: unknown) => void; + onAction: (handler: unknown) => void; +} + +interface ChatAgentConnection { + chat: ChatInstance; + bridge: AgentChatBridge; +} + +/** + * Manages per-agent Chat SDK instances and their lifecycle. + * + * Each integration (e.g. Slack workspace) gets its own `Chat` instance keyed + * by `agentId:type:credentialId`. This supports multiple integrations per agent + * (two Slack workspaces, or Slack + Discord in the future). + */ +@Service() +export class ChatIntegrationService { + private readonly connections = new Map(); + + constructor( + private readonly logger: Logger, + private readonly agentRepository: AgentRepository, + private readonly credentialsService: CredentialsService, + private readonly credentialsFinderService: CredentialsFinderService, + private readonly urlService: UrlService, + private readonly integrationRegistry: ChatIntegrationRegistry, + ) {} + + private connectionKey(agentId: string, type: string, credentialId: string): string { + return `${agentId}:${type}:${credentialId}`; + } + + /** + * Connect an agent to a chat platform via the Chat SDK. + * + * Creates a Chat instance with the appropriate adapter, initializes it, + * and wires up the AgentChatBridge for event handling. + */ + async connect( + agentId: string, + credentialId: string, + integrationType: string, + userId: string, + projectId: string, + ): Promise { + const key = this.connectionKey(agentId, integrationType, credentialId); + + // Tear down existing connection if reconnecting + if (this.connections.has(key)) { + await this.disconnectOne(key); + } + + const integration = this.integrationRegistry.require(integrationType); + + const user = await this.resolveUser(userId); + + // Decrypt the integration credential to get platform tokens + const decryptedData = await this.decryptCredential(credentialId, user); + + const ctx: AgentChatIntegrationContext = { + agentId, + projectId, + credentialId, + credential: decryptedData, + webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform), + }; + + // Pre-connect hook — webhook-based platforms use this to detect + // credential conflicts (e.g. a Telegram bot token already in use) and + // abort the connect before we touch any external API. + if (integration.onBeforeConnect) { + await integration.onBeforeConnect(ctx); + } + + // Delegate adapter construction to the platform implementation. + const adapter = await integration.createAdapter(ctx); + + // Dynamic imports — chat packages are ESM-only, use loader to bypass CJS transform + const { Chat } = await loadChatSdk(); + const { createMemoryState } = await loadMemoryState(); + + // Use the platform type as the adapter key (e.g. 'slack') so that + // bot.webhooks.slack maps correctly to the handler. + const chat = new Chat({ + userName: `n8n-agent-${agentId}`, + adapters: { [integrationType]: adapter } as Record, + state: createMemoryState(), + }); + + // Create supporting infrastructure + const componentMapper = new ComponentMapper(); + + // Lazy-import AgentsService to avoid circular DI dependency + // eslint-disable-next-line import-x/no-cycle + const { AgentsService } = await import('../agents.service'); + const agentService = Container.get(AgentsService); + + const bridge = AgentChatBridge.create( + chat, + agentId, + agentService, + componentMapper, + this.logger, + projectId, + integrationType, + ); + + // Initialize the Chat instance (connects adapters, state adapter, etc.) + await chat.initialize(); + + // Post-initialize hooks (e.g. Telegram setWebhook) run AFTER chat is live. + // If one throws we must shut the chat down, otherwise adapters/timers leak. + if (integration.onAfterConnect) { + try { + await integration.onAfterConnect(ctx); + } catch (error) { + await chat.shutdown().catch((shutdownError: unknown) => { + this.logger.warn( + `[ChatIntegrationService] Shutdown after failed onAfterConnect threw: ${shutdownError instanceof Error ? shutdownError.message : String(shutdownError)}`, + ); + }); + bridge.dispose(); + 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; + + this.connections.set(key, { + chat: chatInstance, + bridge, + }); + this.logger.info(`[ChatIntegrationService] Connected: ${key}`); + } + + /** + * Disconnect one or all integrations for an agent. + * If `type` and `credentialId` are provided, disconnects only that integration. + * Otherwise disconnects all integrations for the agent. + */ + async disconnect(agentId: string, type?: string, credentialId?: string): Promise { + if (type && credentialId) { + await this.disconnectOne(this.connectionKey(agentId, type, credentialId)); + } else { + const keysToRemove = [...this.connections.keys()].filter((k) => k.startsWith(`${agentId}:`)); + for (const k of keysToRemove) { + await this.disconnectOne(k); + } + } + } + + /** + * Disconnect every active integration. Called on `leader-stepdown` so a + * demoted main releases all chat sessions (Telegram setWebhook, polling, etc.) + * before another main takes over. + */ + @OnLeaderStepdown() + async disconnectAll(): Promise { + const keys = [...this.connections.keys()]; + for (const key of keys) { + await this.disconnectOne(key); + } + } + + /** + * Diff the previous and next chat integrations of an agent and reconcile + * runtime connections accordingly. Used by `AgentsService.updateConfig` + * after the builder writes a new integrations array, and by + * `AgentsService.publishAgent` to wake up integrations that were persisted + * while the agent was still a draft. + * + * Disconnects of removed integrations always run (so unpublishing-then- + * editing works). Connects of newly-added integrations are gated on + * `agent.publishedVersion` — matching the controller's connect endpoint, + * which rejects unpublished agents, and `reconnectAll`, which only restores + * published agents. The integration entry stays persisted on the entity so + * it can be picked up later by `publishAgent` calling this method again. + * + * Connection failures are logged at the call site — this method propagates + * errors from disconnect but swallows connect errors per integration so a + * single bad credential doesn't block the others. + */ + async syncToConfig( + agent: Agent, + previous: AgentCredentialIntegration[], + next: AgentCredentialIntegration[], + ): Promise { + const key = (i: AgentCredentialIntegration) => `${i.type}:${i.credentialId}`; + const previousKeys = new Set(previous.map(key)); + const nextKeys = new Set(next.map(key)); + + for (const integration of previous) { + if (!nextKeys.has(key(integration))) { + try { + await this.disconnect(agent.id, integration.type, integration.credentialId); + } catch (error) { + this.logger.warn('[ChatIntegrationService] Disconnect during sync failed', { + agentId: agent.id, + type: integration.type, + error, + }); + } + } + } + + const additions = next.filter((i) => !previousKeys.has(key(i))); + + if (additions.length > 0 && !agent.publishedVersion) { + this.logger.debug( + '[ChatIntegrationService] Skipping connect for unpublished agent — entry persisted, will connect on publish', + { agentId: agent.id, pendingTypes: additions.map((i) => i.type) }, + ); + return; + } + + // TODO: AgentCredentialIntegration has no record of *who* connected the + // integration, so we have no anchor user identity to decrypt credentials + // with on reconnect / sync. We fall back to probing project members until + // one has `credential:read` on the integration credential. + // Replace with a proper solution (fetching credentials should not depend on any specific user) + const userIds = additions.length + ? await Container.get(ProjectRelationRepository).findUserIdsByProjectId(agent.projectId) + : []; + + for (const integration of additions) { + let connected = false; + for (const userId of userIds) { + try { + await this.connect( + agent.id, + integration.credentialId, + integration.type, + userId, + agent.projectId, + ); + connected = true; + break; + } catch (error) { + this.logger.debug('[ChatIntegrationService] Connect attempt failed during sync', { + agentId: agent.id, + userId, + type: integration.type, + error, + }); + } + } + if (!connected) { + this.logger.warn( + '[ChatIntegrationService] Could not connect integration during sync — no project member had credential access', + { agentId: agent.id, type: integration.type, credentialId: integration.credentialId }, + ); + } + } + } + + /** + * Return connection status and count for an agent. + */ + getStatus(agentId: string): AgentIntegrationStatusResponse & { connections: number } { + const integrations: AgentIntegrationStatusResponse['integrations'] = []; + for (const k of this.connections.keys()) { + if (k.startsWith(`${agentId}:`)) { + // Key format: agentId:type:credentialId + const parts = k.split(':'); + if (parts.length >= 3) { + integrations.push({ type: parts[1], credentialId: parts.slice(2).join(':') }); + } + } + } + return { + status: integrations.length > 0 ? 'connected' : 'disconnected', + connections: integrations.length, + integrations, + }; + } + + /** + * Return the first Chat instance for an agent, or undefined if not connected. + */ + getChatInstance(agentId: string): ChatInstance | undefined { + for (const [k, conn] of this.connections) { + if (k.startsWith(`${agentId}:`)) return conn.chat; + } + return undefined; + } + + /** + * Return the webhook handler for a specific platform on an agent. + * This is the pre-built handler from `bot.webhooks[platform]` that + * accepts a Web API Request and returns a Web API Response. + * + * Looks up the connection by platform so that the correct Chat instance + * is used when an agent has multiple integrations (e.g. Slack + Discord). + */ + getWebhookHandler(agentId: string, platform: string): WebhookHandler | undefined { + for (const [key, conn] of this.connections) { + if (key.startsWith(`${agentId}:${platform}:`)) { + return conn.chat.webhooks[platform]; + } + } + return undefined; + } + + /** + * Reconnect all agents that have integrations configured. Called on startup + * (gated by `InstanceSettings.isLeader` in `AgentsModule.init()`) and on + * `leader-takeover` in multi-main mode. + */ + @OnLeaderTakeover() + async reconnectAll(): Promise { + // Only reconnect integrations for published agents — an unpublished agent must not + // receive events, so we don't even load it. + const agents = await this.agentRepository.findPublished(); + for (const agent of agents) { + if (!agent.integrations || agent.integrations.length === 0) continue; + for (const integration of agent.integrations) { + if (!isAgentCredentialIntegration(integration)) { + continue; + } + + const userIds = await Container.get(ProjectRelationRepository).findUserIdsByProjectId( + agent.projectId, + ); + if (userIds.length === 0) { + this.logger.warn( + `[ChatIntegrationService] No users found for project ${agent.projectId} — skipping reconnect for agent ${agent.id}`, + ); + continue; + } + + // Try each project member until one succeeds — the first user may not + // have access to the integration credential. + let connected = false; + for (const userId of userIds) { + try { + await this.connect( + agent.id, + integration.credentialId, + integration.type, + userId, + agent.projectId, + ); + connected = true; + break; + } catch (error) { + this.logger.debug( + `[ChatIntegrationService] User ${userId} could not reconnect ${integration.type} for agent ${agent.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (!connected) { + this.logger.error( + `[ChatIntegrationService] Failed to reconnect ${integration.type} for agent ${agent.id} — no project member could access the credential`, + ); + } + } + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private async disconnectOne(key: string): Promise { + const conn = this.connections.get(key); + if (!conn) return; + + try { + await conn.chat.shutdown(); + } catch (error) { + this.logger.warn( + `[ChatIntegrationService] Error during shutdown for ${key}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + conn.bridge.dispose(); + + this.connections.delete(key); + this.logger.info(`[ChatIntegrationService] Disconnected: ${key}`); + } + + private async resolveUser(userId: string): Promise { + const user = await Container.get(UserRepository).findOne({ + where: { id: userId }, + relations: ['role'], + }); + if (!user) { + throw new Error(`User ${userId} not found`); + } + return user; + } + + private async decryptCredential( + credentialId: string, + user: User, + ): Promise> { + const credential = await this.credentialsFinderService.findCredentialForUser( + credentialId, + user, + ['credential:read'], + ); + if (!credential) { + throw new Error(`Credential ${credentialId} not found or not accessible`); + } + const decrypted = await this.credentialsService.decrypt(credential, true); + return decrypted as Record; + } + + private buildWebhookUrl(agentId: string, projectId: string, platform: string): string { + // getWebhookBaseUrl returns the URL with a trailing slash, honours the + // WEBHOOK_URL env var used by n8n's other webhook triggers. + const base = this.urlService.getWebhookBaseUrl(); + return `${base}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/${platform}`; + } +} diff --git a/packages/cli/src/modules/agents/integrations/component-mapper.ts b/packages/cli/src/modules/agents/integrations/component-mapper.ts new file mode 100644 index 00000000000..5b7769e81c5 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/component-mapper.ts @@ -0,0 +1,369 @@ +import { Container } from '@n8n/di'; + +import { ChatIntegrationRegistry } from './agent-chat-integration'; +import { loadChatSdk } from './esm-loader'; +import type { CardElement } from 'chat'; + +/** + * Component type from agent SDK suspend/toMessage payloads. + */ +export interface SuspendComponent { + type: string; + text?: string; + label?: string; + value?: string; + style?: string; + url?: string; + altText?: string; + placeholder?: string; + /** Accessory button on a section */ + button?: { label: string; value: string; style?: string }; + /** Options for select / radio_select components */ + options?: Array<{ label: string; value: string; description?: string }>; + /** Fields for fields component */ + fields?: Array<{ label: string; value: string }>; + /** Elements array for context blocks */ + elements?: Array<{ type: string; text?: string; url?: string; altText?: string }>; + /** Allow additional properties from the payload */ + id?: string; + [key: string]: unknown; +} + +interface SuspendPayload { + title?: string; + message?: string; + components: SuspendComponent[]; +} + +type ChatSdk = Awaited>; + +/** Shared state threaded through per-component render helpers. */ +interface ComponentRenderContext { + component: SuspendComponent; + sdk: ChatSdk; + runId: string; + toolCallId: string; + children: unknown[]; + buttons: unknown[]; + makeButton: (label: string, rawValue: string, style?: string) => Promise; +} + +/** + * Converts agent SDK suspend payloads into Chat SDK Card elements. + * + * The `chat` package is ESM-only, so every method dynamically imports it + * via the ESM loader to bypass TypeScript's CJS transform. + */ +export class ComponentMapper { + /** + * Convert a suspend payload to a Chat SDK Card. + * + * Button values are JSON-encoded as the full resume payload that + * matches the tool's resume schema. This way the action handler + * can `JSON.parse(value)` and pass it directly — no guessing required. + * + * @param resumeSchema - JSON Schema from the tool's `.resume()` definition. + * Used to wrap raw button values into the expected shape. + * @param platform - Integration type (e.g. 'slack', 'telegram') for component normalization. + */ + async toCard( + payload: SuspendPayload, + runId: string, + toolCallId: string, + resumeSchema?: unknown, + shortenCallback?: (actionId: string, value: string) => Promise<{ id: string; value: string }>, + platform?: string, + ): Promise { + const sdk = await loadChatSdk(); + + // Delegate per-platform normalization to the Integration implementation. + const integration = platform ? Container.get(ChatIntegrationRegistry).get(platform) : undefined; + const components = integration?.normalizeComponents?.(payload.components) ?? payload.components; + + const children: unknown[] = []; + const buttons: unknown[] = []; + const buttonIndex = { value: 0 }; + + const makeButton = async (label: string, rawValue: string, style?: string) => { + let id = `resume:${runId}:${toolCallId}:${buttonIndex.value++}`; + let value = JSON.stringify(this.wrapValueForSchema(rawValue, resumeSchema)); + + // For platforms with short callback limits (e.g. Telegram 64 bytes), + // replace the full id/value with a short lookup key. + if (shortenCallback) { + const shortened = await shortenCallback(id, value); + id = shortened.id; + value = shortened.value; + } + + return sdk.Button({ + id, + label, + style: style === 'danger' ? 'danger' : 'primary', + value, + }); + }; + + for (const component of components) { + await this.appendComponent({ + component, + sdk, + runId, + toolCallId, + children, + buttons, + makeButton, + }); + } + + if (buttons.length > 0) { + children.push(sdk.Actions(buttons as never)); + } + + // Use message as title fallback if title isn't set + const title = payload.title ?? payload.message; + + return sdk.Card({ title, children: children as never }); + } + + /** Dispatch a single component to its dedicated handler. */ + private async appendComponent(ctx: ComponentRenderContext): Promise { + const { component, children, buttons, makeButton } = ctx; + switch (component.type) { + case 'button': + buttons.push( + await makeButton(component.label ?? 'Action', component.value ?? '', component.style), + ); + return; + case 'section': + await this.appendSection(ctx); + return; + case 'divider': + children.push(ctx.sdk.Divider()); + return; + case 'image': + this.appendImage(ctx); + return; + case 'context': + this.appendContext(ctx); + return; + case 'select': + this.appendSelect(ctx); + return; + case 'radio_select': + this.appendRadioSelect(ctx); + return; + case 'fields': + this.appendFields(ctx); + return; + // Unsupported types (text-input, select-input, modal) are silently dropped + default: + return; + } + } + + private async appendSection({ + component, + sdk, + children, + makeButton, + }: ComponentRenderContext): Promise { + if (component.text) { + children.push(sdk.Section([sdk.CardText(component.text)] as never)); + } + // Section accessory buttons must be in a separate Actions block. + // Chat SDK's cardToBlockKit silently drops Button children + // inside Section — only Actions blocks render buttons. + if (component.button) { + children.push( + sdk.Actions([ + await makeButton(component.button.label, component.button.value, component.button.style), + ] as never), + ); + } + } + + private appendImage({ component, sdk, children }: ComponentRenderContext): void { + children.push( + sdk.Image({ + url: component.url as string, + alt: (component.altText as string) ?? 'image', + }), + ); + } + + private appendContext({ component, sdk, children }: ComponentRenderContext): void { + // Context blocks contain an elements array with text/image items + if (component.elements && Array.isArray(component.elements)) { + for (const el of component.elements) { + if (el.type === 'text' && el.text) { + children.push(sdk.CardText(el.text)); + } else if (el.type === 'image' && el.url) { + children.push(sdk.Image({ url: el.url, alt: el.altText ?? '' })); + } + } + } else if (component.text) { + // Fallback: plain text context + children.push(sdk.CardText(component.text)); + } + } + + private appendSelect({ + component, + sdk, + runId, + toolCallId, + children, + }: ComponentRenderContext): void { + children.push( + sdk.Actions([ + sdk.Select({ + id: `ri-sel:${component.id ?? 'select'}:${runId}:${toolCallId}`, + label: component.label ?? 'Select', + placeholder: component.placeholder, + options: this.toSelectOptions(component), + }), + ] as never), + ); + } + + private appendRadioSelect({ + component, + sdk, + runId, + toolCallId, + children, + }: ComponentRenderContext): void { + children.push( + sdk.Actions([ + sdk.RadioSelect({ + id: `ri-sel:${component.id ?? 'radio'}:${runId}:${toolCallId}`, + label: component.label ?? 'Select', + options: this.toSelectOptions(component), + }), + ] as never), + ); + } + + private appendFields({ component, sdk, children }: ComponentRenderContext): void { + const fieldElements = (component.fields ?? []).map((f) => + sdk.Field({ label: f.label, value: f.value }), + ); + children.push(sdk.Fields(fieldElements as never)); + } + + private toSelectOptions( + component: SuspendComponent, + ): Array<{ label: string; value: string; description?: string }> { + return (component.options ?? []).map((o) => ({ + label: o.label, + value: o.value, + description: o.description, + })); + } + + /** + * Wrap a raw button value into a full resume payload that matches the + * tool's resume schema. + * + * Inspects the JSON Schema top-level properties to determine the shape: + * - Schema has `approved` (boolean) → `{ approved: value === 'true' }` + * - Schema has `values` (object) → `{ values: { action: value } }` + * - No schema / unknown → try JSON.parse, fall back to `{ value }` + */ + private wrapValueForSchema(rawValue: string, resumeSchema?: unknown): unknown { + if (!resumeSchema || typeof resumeSchema !== 'object') { + // No schema — try to parse as JSON, otherwise wrap generically + try { + const parsed = JSON.parse(rawValue) as unknown; + if (typeof parsed === 'object' && parsed !== null) return parsed; + } catch { + // not JSON + } + return { value: rawValue }; + } + + const schema = resumeSchema as { + properties?: Record; + }; + const props = schema.properties ?? {}; + + if ('approved' in props) { + return { approved: rawValue === 'true' }; + } + + // Rich interaction resume schema: { type, value } + if ('type' in props && 'value' in props) { + return { type: 'button', value: rawValue }; + } + + if ('values' in props) { + return { values: { action: rawValue } }; + } + + // Unknown schema shape — try JSON parse, fall back to raw object + try { + const parsed = JSON.parse(rawValue) as unknown; + if (typeof parsed === 'object' && parsed !== null) return parsed; + } catch { + // not JSON + } + return { value: rawValue }; + } + + /** + * Convert a tool's `toMessage()` output to a Card or markdown string. + */ + async toCardOrMarkdown(message: unknown): Promise { + if (typeof message === 'string') return message; + + if (message && typeof message === 'object' && 'components' in message) { + const sdk = await loadChatSdk(); + const { components } = message as { components: SuspendComponent[] }; + const children: unknown[] = []; + + for (const c of components) { + this.appendMarkdownChild(c, sdk, children); + } + + return sdk.Card({ children: children as never }); + } + + return String(message); + } + + /** Render a single component into the flat markdown-style child list. */ + private appendMarkdownChild(c: SuspendComponent, sdk: ChatSdk, children: unknown[]): void { + switch (c.type) { + case 'section': + if (c.text) children.push(sdk.Section([sdk.CardText(c.text)] as never)); + return; + case 'divider': + children.push(sdk.Divider()); + return; + case 'image': + if (c.url) children.push(sdk.Image({ url: c.url, alt: c.altText ?? '' })); + return; + case 'context': + this.appendMarkdownContext(c, sdk, children); + return; + default: + if (c.text) children.push(sdk.CardText(c.text)); + return; + } + } + + private appendMarkdownContext(c: SuspendComponent, sdk: ChatSdk, children: unknown[]): void { + if (c.elements) { + for (const el of c.elements) { + if (el.type === 'text' && el.text) { + children.push(sdk.CardText(el.text)); + } else if (el.type === 'image' && el.url) { + children.push(sdk.Image({ url: el.url, alt: el.altText ?? '' })); + } + } + } else if (c.text) { + children.push(sdk.CardText(c.text)); + } + } +} diff --git a/packages/cli/src/modules/agents/integrations/cron-validation.ts b/packages/cli/src/modules/agents/integrations/cron-validation.ts new file mode 100644 index 00000000000..4bc6e82c7a9 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/cron-validation.ts @@ -0,0 +1,11 @@ +import { validateCronExpression } from 'cron'; + +/** + * Whether the given expression parses as a valid 5-field cron. + * + * Shared between the schedule REST endpoint and the JSON-config Zod schema so + * both surfaces reject malformed crons with the same rule. + */ +export function isValidCronExpression(expression: string): boolean { + return validateCronExpression(expression).valid; +} diff --git a/packages/cli/src/modules/agents/integrations/esm-loader.ts b/packages/cli/src/modules/agents/integrations/esm-loader.ts new file mode 100644 index 00000000000..af1f9e77ff7 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/esm-loader.ts @@ -0,0 +1,43 @@ +/** + * Dynamic ESM import helper. + * + * TypeScript compiles `await import('x')` to `require('x')` when the target + * module format is CommonJS. This breaks ESM-only packages like `chat` and + * `@chat-adapter/*` which only export via `"import"` in their package.json. + * + * This helper uses `new Function()` to create an `import()` call that + * TypeScript cannot see or transform, preserving the native ESM import. + * + * The `typeof import(...)` type annotations below are necessary to get type + * safety for the dynamically-loaded modules and are not convertible to + * `import type` syntax. + */ + +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const esmImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string, +) => Promise; + +/* eslint-disable @typescript-eslint/consistent-type-imports */ + +export async function loadChatSdk() { + return await esmImport('chat'); +} + +export async function loadSlackAdapter() { + return await esmImport('@chat-adapter/slack'); +} + +export async function loadTelegramAdapter() { + return await esmImport('@chat-adapter/telegram'); +} + +export async function loadLinearAdapter() { + return await esmImport('@chat-adapter/linear'); +} + +export async function loadMemoryState() { + return await esmImport('@chat-adapter/state-memory'); +} + +/* eslint-enable @typescript-eslint/consistent-type-imports */ diff --git a/packages/cli/src/modules/agents/integrations/integrations-sync.ts b/packages/cli/src/modules/agents/integrations/integrations-sync.ts new file mode 100644 index 00000000000..d715a700b85 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/integrations-sync.ts @@ -0,0 +1,74 @@ +import { + isAgentScheduleIntegration, + type AgentCredentialIntegration, + type AgentIntegration, +} from '@n8n/api-types'; +import type { Logger } from '@n8n/backend-common'; +import { Container } from '@n8n/di'; + +import type { Agent } from '../entities/agent.entity'; + +function scheduleConfigsEqual( + a: AgentIntegration | undefined, + b: AgentIntegration | undefined, +): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + if (!isAgentScheduleIntegration(a) || !isAgentScheduleIntegration(b)) return false; + return ( + a.active === b.active && + a.cronExpression === b.cronExpression && + a.wakeUpPrompt === b.wakeUpPrompt + ); +} + +/** + * Reconcile runtime state (cron job, chat platform connections) after the + * builder writes a new integrations array. Schedule changes call into + * AgentScheduleService.applyConfig; chat changes are diffed and routed to + * connect/disconnect on ChatIntegrationService. + * + * Failures are logged but never thrown — config writes during a builder turn + * must not be rolled back if e.g. the Slack API is briefly unavailable. + * + * Lazy `Container.get(...)` avoids a circular DI dependency: + * AgentScheduleService and ChatIntegrationService both depend on AgentsService, + * which calls into this helper. + */ +export async function syncAgentIntegrations( + agent: Agent, + previous: AgentIntegration[], + next: AgentIntegration[], + logger: Logger, +): Promise { + const prevSchedule = previous.find(isAgentScheduleIntegration); + const nextSchedule = next.find(isAgentScheduleIntegration); + if (!scheduleConfigsEqual(prevSchedule, nextSchedule)) { + try { + const { AgentScheduleService } = await import('./agent-schedule.service'); + await Container.get(AgentScheduleService).applyConfig(agent); + } catch (error) { + logger.warn('Failed to apply schedule integration during sync', { + agentId: agent.id, + error, + }); + } + } + + const prevChat = previous.filter( + (i): i is AgentCredentialIntegration => !isAgentScheduleIntegration(i), + ); + const nextChat = next.filter( + (i): i is AgentCredentialIntegration => !isAgentScheduleIntegration(i), + ); + try { + // eslint-disable-next-line import-x/no-cycle + const { ChatIntegrationService } = await import('./chat-integration.service'); + await Container.get(ChatIntegrationService).syncToConfig(agent, prevChat, nextChat); + } catch (error) { + logger.warn('Failed to sync chat integrations', { + agentId: agent.id, + error, + }); + } +} diff --git a/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts new file mode 100644 index 00000000000..a9a5ffc7f7a --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/n8n-checkpoint-storage.ts @@ -0,0 +1,143 @@ +import type { CheckpointStore, SerializableAgentState } from '@n8n/agents'; +import { Logger, ModuleRegistry } from '@n8n/backend-common'; +import { AgentsConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; +import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; +import { Service } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; +import { jsonParse, UserError } from 'n8n-workflow'; +import { strict } from 'node:assert'; + +import { AgentCheckpointRepository } from '../repositories/agent-checkpoint.repository'; + +type CheckpointStatus = + | { + status: 'expired'; + } + | { status: 'not-found' } + | { + status: 'active'; + checkpoint: SerializableAgentState; + }; + +@Service() +export class N8NCheckpointStorage { + private pruneTimeout: NodeJS.Timeout | undefined; + + private isStopping = false; + + private isInitialized = false; + + constructor( + private readonly instanceSettings: InstanceSettings, + private readonly agentCheckpointRepository: AgentCheckpointRepository, + private readonly logger: Logger, + private readonly agentsConfig: AgentsConfig, + private readonly moduleRegistry: ModuleRegistry, + ) { + this.logger = this.logger.scoped('agents'); + this.isInitialized = this.moduleRegistry.isActive('agents'); + } + + getStorage(agentId: string): CheckpointStore { + return { + save: async (key, state) => await this.save(key, state, agentId), + load: async (key) => await this.load(key), + delete: async (key) => await this.delete(key), + }; + } + + init() { + strict(this.instanceSettings.instanceRole !== 'unset', 'Instance role is not set'); + + if (this.instanceSettings.isLeader) this.startPruning(); + } + + async save( + key: string, + state: SerializableAgentState, + agentId: string | null = null, + ): Promise { + const existing = await this.agentCheckpointRepository.findOneBy({ runId: key }); + + if (existing) { + existing.state = JSON.stringify(state); + existing.expired = false; + await this.agentCheckpointRepository.save(existing); + } else { + const checkpoint = this.agentCheckpointRepository.create({ + runId: key, + agentId, + state: JSON.stringify(state), + expired: false, + }); + await this.agentCheckpointRepository.save(checkpoint); + } + } + + async load(key: string): Promise { + const checkpoint = await this.agentCheckpointRepository.findOneBy({ runId: key }); + + if (!checkpoint) return undefined; + + if (checkpoint.expired || checkpoint.state === null) { + throw new UserError('This action has expired and cannot be resumed'); + } + + return jsonParse(checkpoint.state); + } + + async getStatus(key: string): Promise { + const checkpoint = await this.agentCheckpointRepository.findOneBy({ runId: key }); + if (!checkpoint) return { status: 'not-found' }; + if (checkpoint.expired || checkpoint.state === null) return { status: 'expired' }; + return { status: 'active', checkpoint: jsonParse(checkpoint.state) }; + } + + async delete(key: string): Promise { + await this.agentCheckpointRepository.update({ runId: key }, { expired: true, state: null }); + } + + @OnLeaderTakeover() + startPruning() { + this.isStopping = false; + this.scheduleNextPrune(0); + } + + @OnLeaderStepdown() + stopPruning() { + clearTimeout(this.pruneTimeout); + this.pruneTimeout = undefined; + } + + @OnShutdown() + shutdown() { + this.isStopping = true; + this.stopPruning(); + } + + private scheduleNextPrune(delayMs = Time.hours.toMilliseconds) { + if (this.isStopping || !this.isInitialized) return; + this.pruneTimeout = setTimeout(async () => { + await this.pruneStaleSuspensions(); + }, delayMs); + } + + private async pruneStaleSuspensions() { + const ttlMs = this.agentsConfig.checkpointTtlSeconds * Time.seconds.toMilliseconds; + const cutoffDate = new Date(Date.now() - ttlMs); + + try { + const count = await this.agentCheckpointRepository.markExpired(cutoffDate); + if (count > 0) { + this.logger.info('Marked stale agent checkpoints as expired', { count }); + } else { + this.logger.debug('No stale agent checkpoints to expire'); + } + this.scheduleNextPrune(); + } catch (error: unknown) { + this.logger.warn('Failed to expire stale agent checkpoints', { error }); + this.scheduleNextPrune(Time.seconds.toMilliseconds * 30); + } + } +} diff --git a/packages/cli/src/modules/agents/integrations/n8n-memory.ts b/packages/cli/src/modules/agents/integrations/n8n-memory.ts new file mode 100644 index 00000000000..f30f78316b6 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/n8n-memory.ts @@ -0,0 +1,274 @@ +import type { + AgentDbMessage, + AgentMessage, + BuiltMemory, + MemoryDescriptor, + Thread, +} from '@n8n/agents'; +import { Service } from '@n8n/di'; +import type { FindOptionsWhere } from '@n8n/typeorm'; +import { LessThan, Like } from '@n8n/typeorm'; +import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; + +import type { AgentMessageEntity } from '../entities/agent-message.entity'; +import { AgentThreadEntity } from '../entities/agent-thread.entity'; +import { AgentMessageRepository } from '../repositories/agent-message.repository'; +import { AgentResourceRepository } from '../repositories/agent-resource.repository'; +import { AgentThreadRepository } from '../repositories/agent-thread.repository'; + +/** Key inside the metadata JSON where working memory content is stored. */ +const WORKING_MEMORY_KEY = 'workingMemory'; + +@Service() +export class N8nMemory implements BuiltMemory { + constructor( + private readonly threadRepository: AgentThreadRepository, + private readonly messageRepository: AgentMessageRepository, + private readonly resourceRepository: AgentResourceRepository, + ) {} + + // ── Thread management ──────────────────────────────────────────────── + + async getThread(threadId: string): Promise { + const entity = await this.threadRepository.findOneBy({ id: threadId }); + if (!entity) return null; + return this.toThread(entity); + } + + async saveThread(thread: Omit): Promise { + await this.ensureResource(thread.resourceId); + + const existing = await this.threadRepository.findOneBy({ id: thread.id }); + + if (existing) { + // `resourceId` is treated as immutable on existing threads. Some thread + // IDs can receive messages from more than one resource; overwriting the + // column on each save would make ownership depend on the last writer. + // Per-user scoping is enforced at the message level via resourceId. + if (thread.title !== undefined) existing.title = thread.title; + if (thread.metadata !== undefined) { + existing.metadata = thread.metadata ? JSON.stringify(thread.metadata) : null; + } + const saved = await this.threadRepository.save(existing); + return this.toThread(saved); + } + + const entity = this.threadRepository.create({ + id: thread.id, + resourceId: thread.resourceId, + title: thread.title ?? null, + metadata: thread.metadata ? JSON.stringify(thread.metadata) : null, + }); + const saved = await this.threadRepository.save(entity); + return this.toThread(saved); + } + + 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 }), + ); + } + } + + async deleteThread(threadId: string): Promise { + await this.threadRepository.delete({ id: threadId }); + } + + async deleteThreadsByPrefix(threadIdPrefix: string): Promise { + await this.threadRepository.delete({ id: Like(`${threadIdPrefix}%`) }); + } + + // ── Message persistence ────────────────────────────────────────────── + + async getMessages( + threadId: string, + opts?: { limit?: number; before?: Date; resourceId?: string }, + ): Promise { + // `resourceId` is the per-user scope for any thread that carries messages + // for more than one resource. Use an explicit `!== undefined` check — a + // falsy (empty-string) value would otherwise drop the filter and leak other + // users' messages. + const where: FindOptionsWhere = { + threadId, + ...(opts?.before && { createdAt: LessThan(opts.before) }), + ...(opts?.resourceId !== undefined && { resourceId: opts.resourceId }), + }; + + const entities = await this.messageRepository.find({ + where, + order: { createdAt: opts?.limit !== undefined ? 'DESC' : 'ASC' }, + ...(opts?.limit !== undefined && { take: opts.limit }), + }); + if (opts?.limit !== undefined) { + entities.reverse(); + } + + return entities.map((e) => { + const msg = e.content as AgentMessage & { id?: string }; + msg.id = e.id; + return msg as AgentDbMessage; + }); + } + + async saveMessages(args: { + threadId: string; + resourceId: string; + messages: AgentDbMessage[]; + }): Promise { + if (args.messages.length === 0) return; + + // Upsert by id — bulk INSERT … ON CONFLICT (id) DO UPDATE avoids the + // per-row SELECT that save() performs. createdAt is passed explicitly so + // the column is preserved on conflict; updatedAt is set manually because + // the @BeforeUpdate hook does not fire during upsert. + const now = new Date(); + const entities = args.messages.map((dbMsg) => { + const role = 'role' in dbMsg ? (dbMsg.role as string) : 'custom'; + const type = 'type' in dbMsg ? (dbMsg.type as string) : null; + return { + id: dbMsg.id, + threadId: args.threadId, + resourceId: args.resourceId, + role, + type: type ?? null, + content: dbMsg as unknown as Record, + createdAt: dbMsg.createdAt, + updatedAt: now, + } as QueryDeepPartialEntity; + }); + + await this.messageRepository.upsert(entities, ['id']); + } + + async deleteMessages(messageIds: string[]): Promise { + if (messageIds.length === 0) return; + await this.messageRepository.delete(messageIds); + } + + async deleteMessagesByThread(threadId: string, resourceId?: string): Promise { + // Mirrors `getMessages`: explicit `!== undefined` check so that a falsy + // (empty-string) `resourceId` cannot accidentally delete every user's + // messages on a shared thread. + await this.messageRepository.delete({ + threadId, + ...(resourceId !== undefined && { resourceId }), + }); + } + + // ── Working memory ─────────────────────────────────────────────────── + + async getWorkingMemory(params: { + threadId: string; + resourceId: string; + scope: 'resource' | 'thread'; + }): Promise { + if (params.scope === 'resource') { + const resource = await this.resourceRepository.findOneBy({ id: params.resourceId }); + return this.extractWorkingMemory(resource?.metadata ?? null); + } + + const thread = await this.threadRepository.findOneBy({ id: params.threadId }); + return this.extractWorkingMemory(thread?.metadata ?? null); + } + + async saveWorkingMemory( + params: { threadId: string; resourceId: string; scope: 'resource' | 'thread' }, + content: string, + ): Promise { + if (params.scope === 'resource') { + await this.upsertResourceMetadata(params.resourceId, content); + } else { + await this.upsertThreadMetadata(params.threadId, params.resourceId, content); + } + } + + // ── Descriptor ─────────────────────────────────────────────────────── + + describe(): MemoryDescriptor { + return { name: 'n8n', connectionParams: {}, constructorName: this.constructor.name }; + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private toThread(entity: AgentThreadEntity): Thread { + let metadata: Record | undefined; + if (entity.metadata) { + try { + metadata = JSON.parse(entity.metadata) as Record; + } catch { + metadata = undefined; + } + } + return { + id: entity.id, + resourceId: entity.resourceId, + title: entity.title ?? undefined, + metadata, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + } + + private extractWorkingMemory(metadataJson: string | null): string | null { + if (!metadataJson) return null; + try { + const parsed = JSON.parse(metadataJson) as Record; + const wm = parsed[WORKING_MEMORY_KEY]; + return typeof wm === 'string' ? wm : null; + } catch { + return null; + } + } + + private mergeWorkingMemory(existingJson: string | null, content: string): string { + let parsed: Record = {}; + if (existingJson) { + try { + parsed = JSON.parse(existingJson) as Record; + } catch { + // start fresh on corrupt JSON + } + } + parsed[WORKING_MEMORY_KEY] = content; + return JSON.stringify(parsed); + } + + private async upsertResourceMetadata(resourceId: string, content: string): Promise { + const existing = await this.resourceRepository.findOneBy({ id: resourceId }); + if (existing) { + existing.metadata = this.mergeWorkingMemory(existing.metadata, content); + await this.resourceRepository.save(existing); + } else { + const entity = this.resourceRepository.create({ + id: resourceId, + metadata: this.mergeWorkingMemory(null, content), + }); + await this.resourceRepository.save(entity); + } + } + + private async upsertThreadMetadata( + threadId: string, + resourceId: string, + content: string, + ): Promise { + const existing = await this.threadRepository.findOneBy({ id: threadId }); + if (existing) { + existing.metadata = this.mergeWorkingMemory(existing.metadata, content); + await this.threadRepository.save(existing); + return; + } + + await this.ensureResource(resourceId); + await this.threadRepository.save( + this.threadRepository.create({ + id: threadId, + resourceId, + title: null, + metadata: this.mergeWorkingMemory(null, content), + }), + ); + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts new file mode 100644 index 00000000000..5f0cf353ae4 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram-integration.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'jest-mock-extended'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import type { UrlService } from '@/services/url.service'; + +import type { Agent } from '../../../entities/agent.entity'; +import type { AgentRepository } from '../../../repositories/agent.repository'; +import type { AgentChatIntegrationContext } from '../../agent-chat-integration'; +import { TelegramIntegration } from '../telegram-integration'; + +const makeAgent = ( + id: string, + name: string, + integrations: Array<{ type: string; credentialId: string }>, +) => ({ id, name, integrations }) as Agent; + +const makeContext = ( + overrides: Partial = {}, +): AgentChatIntegrationContext => ({ + agentId: 'agent-1', + projectId: 'proj-1', + credentialId: 'cred-1', + credential: { accessToken: 'bot-token' }, + webhookUrlFor: (platform) => + `https://n8n.example.com/rest/projects/proj-1/agents/v2/agent-1/webhooks/${platform}`, + ...overrides, +}); + +describe('TelegramIntegration.onBeforeConnect', () => { + let integration: TelegramIntegration; + let agentRepository: jest.Mocked; + let urlService: jest.Mocked; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + agentRepository = mock(); + urlService = mock(); + urlService.getWebhookBaseUrl.mockReturnValue('https://n8n.example.com/'); + + integration = new TelegramIntegration(mock(), urlService, agentRepository); + + fetchSpy = jest.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('passes through when no other agent uses the credential and Telegram reports no webhook', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ result: { url: '' } }), { status: 200 }), + ); + + await expect(integration.onBeforeConnect(makeContext())).resolves.toBeUndefined(); + + expect(agentRepository.findByIntegrationCredential).toHaveBeenCalledWith( + 'telegram', + 'cred-1', + 'proj-1', + 'agent-1', + ); + }); + + it('passes through when Telegram reports our own webhook URL (idempotent reconnect)', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + const ourUrl = + 'https://n8n.example.com/rest/projects/proj-1/agents/v2/agent-1/webhooks/telegram'; + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ result: { url: ourUrl } }), { status: 200 }), + ); + + await expect(integration.onBeforeConnect(makeContext())).resolves.toBeUndefined(); + }); + + it('throws ConflictError naming the owning agent when DB has another claimant', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([ + makeAgent('agent-other', 'Agent Other', [{ type: 'telegram', credentialId: 'cred-1' }]), + ]); + + await expect(integration.onBeforeConnect(makeContext())).rejects.toThrow( + new ConflictError('Telegram credential is already connected to agent "Agent Other"'), + ); + // Telegram API must not be called once the DB already indicates a conflict. + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws ConflictError when Telegram reports a foreign webhook URL and no DB owner', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + const foreignUrl = 'https://some-other-host.example/telegram'; + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ result: { url: foreignUrl } }), { status: 200 }), + ); + + await expect(integration.onBeforeConnect(makeContext())).rejects.toBeInstanceOf(ConflictError); + }); + + it('fails open (no throw) when getWebhookInfo itself errors out', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + fetchSpy.mockRejectedValue(new Error('network down')); + + await expect(integration.onBeforeConnect(makeContext())).resolves.toBeUndefined(); + }); + + it('skips the Telegram API probe when instance is in polling mode', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + urlService.getWebhookBaseUrl.mockReturnValue('http://localhost:5678/'); + + await integration.onBeforeConnect(makeContext()); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('names the first conflicting agent when multiple agents share the credential', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([ + makeAgent('agent-a', 'Alpha', [{ type: 'telegram', credentialId: 'cred-1' }]), + makeAgent('agent-b', 'Beta', [{ type: 'telegram', credentialId: 'cred-1' }]), + ]); + + await expect(integration.onBeforeConnect(makeContext())).rejects.toThrow( + new ConflictError('Telegram credential is already connected to agent "Alpha"'), + ); + }); + + it('fails open when getWebhookInfo returns a non-2xx response', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + fetchSpy.mockResolvedValue(new Response('Internal Server Error', { status: 500 })); + + await expect(integration.onBeforeConnect(makeContext())).resolves.toBeUndefined(); + }); + + it('fails open when getWebhookInfo returns malformed JSON', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + fetchSpy.mockResolvedValue(new Response('not json', { status: 200 })); + + await expect(integration.onBeforeConnect(makeContext())).resolves.toBeUndefined(); + }); + + it('does not probe Telegram when the credential payload lacks an accessToken', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + + await integration.onBeforeConnect(makeContext({ credential: { other: 'data' } })); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts new file mode 100644 index 00000000000..ff2641b0fc3 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/linear-integration.ts @@ -0,0 +1,118 @@ +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; + +import { AgentChatIntegration, type AgentChatIntegrationContext } from '../agent-chat-integration'; +import { loadLinearAdapter } from '../esm-loader'; + +type LinearAuth = { kind: 'apiKey'; token: string } | { kind: 'accessToken'; token: string }; + +/** + * Linear platform integration. + * + * Linear comments have no interactive UI surface, so this integration omits + * `supportedComponents` entirely. + * + * Mention detection in the Chat SDK is a literal string match on + * `@` in the comment body. The adapter defaults `userName` to + * `'linear-bot'`, so `createAdapter` eagerly fetches the bot user's + * Linear display name via GraphQL and passes it in — + * that way `@AgentName`-style mentions fire `onNewMention` as expected. + */ +@Service() +export class LinearIntegration extends AgentChatIntegration { + readonly type = 'linear'; + + readonly credentialTypes = ['linearApi', 'linearOAuth2Api']; + + readonly displayLabel = 'Linear'; + + readonly displayIcon = 'linear'; + + constructor(private readonly logger: Logger) { + super(); + } + + async createAdapter(ctx: AgentChatIntegrationContext): Promise { + const auth = this.extractAuth(ctx.credential); + const webhookSecret = this.extractSigningSecret(ctx.credential); + const userName = await this.fetchDisplayName(auth); + + const { createLinearAdapter } = await loadLinearAdapter(); + + return createLinearAdapter({ + ...(auth.kind === 'apiKey' ? { apiKey: auth.token } : { accessToken: auth.token }), + webhookSecret, + ...(userName ? { userName } : {}), + }); + } + + /** + * Determine which auth variant the adapter config expects. + * + * - `linearApi` stores a personal API key in `apiKey` — passed to the adapter + * as `apiKey` (adapter uses it as-is). + * - `linearOAuth2Api` stores an OAuth access token in + * `oauthTokenData.access_token` — passed as `accessToken` (adapter skips + * auto-refresh and treats it as a pre-obtained token, per option B of the + * @chat-adapter/linear README). + */ + private extractAuth(credential: Record): LinearAuth { + if (typeof credential.apiKey === 'string' && credential.apiKey) { + return { kind: 'apiKey', token: credential.apiKey }; + } + + const tokenData = credential.oauthTokenData as Record | undefined; + const oauthToken = tokenData?.access_token ?? tokenData?.accessToken; + if (typeof oauthToken === 'string' && oauthToken) { + return { kind: 'accessToken', token: oauthToken }; + } + + throw new Error( + 'Could not extract an API token from the Linear credential. ' + + 'Please ensure the credential has a valid API key (linearApi) ' + + 'or completed OAuth flow (linearOAuth2Api).', + ); + } + + private extractSigningSecret(credential: Record): string { + const secret = credential.signingSecret; + if (typeof secret === 'string' && secret) { + return secret; + } + + throw new Error( + 'The Linear credential is missing a signing secret, which is required for ' + + 'agent integrations. Edit the credential and add the signing secret from ' + + 'your Linear webhook configuration (Settings → API → Webhooks → Signing secret).', + ); + } + + /** + * Personal API keys go in as a bare `Authorization: ` header; OAuth + * access tokens need `Authorization: Bearer `. Returns undefined on + * any failure — the adapter falls back to its default `linear-bot` name. + */ + private async fetchDisplayName(auth: LinearAuth): Promise { + const authHeader = auth.kind === 'accessToken' ? `Bearer ${auth.token}` : auth.token; + try { + const resp = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authHeader }, + body: JSON.stringify({ query: '{ viewer { displayName } }' }), + }); + if (!resp.ok) return undefined; + + const json = (await resp.json()) as { + data?: { viewer?: { displayName?: string } }; + }; + + return json.data?.viewer?.displayName; + } catch (error) { + this.logger.debug( + `[LinearIntegration] viewer lookup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + + return undefined; + } + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts new file mode 100644 index 00000000000..c256803e039 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts @@ -0,0 +1,90 @@ +import { Service } from '@n8n/di'; + +import { AgentChatIntegration, type AgentChatIntegrationContext } from '../agent-chat-integration'; +import { loadSlackAdapter } from '../esm-loader'; + +/** + * Slack platform integration. + * + * Slack callback_data has no small limit and supports every component type + * the rich_interaction tool emits, so no normalization or callback shortening + * is required. + */ +@Service() +export class SlackIntegration extends AgentChatIntegration { + readonly type = 'slack'; + + readonly credentialTypes = ['slackApi', 'slackOAuth2Api']; + + readonly displayLabel = 'Slack'; + + readonly displayIcon = 'slack'; + + readonly supportedComponents = [ + 'section', + 'button', + 'select', + 'radio_select', + 'divider', + 'image', + 'fields', + ]; + + async createAdapter(ctx: AgentChatIntegrationContext): Promise { + const botToken = this.extractBotToken(ctx.credential); + const signingSecret = this.extractSigningSecret(ctx.credential); + const { createSlackAdapter } = await loadSlackAdapter(); + return createSlackAdapter({ botToken, signingSecret }); + } + + /** + * Extract the bot token from a decrypted Slack credential. + * + * - `slackApi` stores the token as `accessToken`. + * - `slackOAuth2Api` stores the token inside `oauthTokenData.access_token`. + */ + private extractBotToken(credential: Record): string { + let token: string | undefined; + + if (typeof credential.accessToken === 'string' && credential.accessToken) { + token = credential.accessToken; + } + + if (!token) { + const tokenData = credential.oauthTokenData as Record | undefined; + const oauthToken = tokenData?.access_token ?? tokenData?.accessToken; + if (typeof oauthToken === 'string' && oauthToken) { + token = oauthToken; + } + } + + if (!token) { + throw new Error( + 'Could not extract a bot token from the Slack credential. ' + + 'Please ensure the credential has a valid access token.', + ); + } + + if (!token.startsWith('xoxb-')) { + const prefix = token.split('-')[0] ?? 'unknown'; + throw new Error( + `The Slack credential contains a "${prefix}-" token, but agent integrations require a Bot User OAuth Token ("xoxb-"). ` + + 'You can find this in your Slack app under OAuth & Permissions → Bot User OAuth Token.', + ); + } + + return token; + } + + private extractSigningSecret(credential: Record): string { + const secret = credential.signatureSecret; + if (typeof secret === 'string' && secret) { + return secret; + } + + throw new Error( + 'The Slack credential is missing a signing secret, which is required for agent integrations. ' + + 'Edit the credential and add your Slack app\'s "Signing Secret" (found under Basic Information in the Slack API dashboard).', + ); + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts new file mode 100644 index 00000000000..1314d262f6b --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -0,0 +1,186 @@ +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import type { Thread } from 'chat'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { UrlService } from '@/services/url.service'; + +import { AgentRepository } from '../../repositories/agent.repository'; +import { AgentChatIntegration, type AgentChatIntegrationContext } from '../agent-chat-integration'; +import type { SuspendComponent } from '../component-mapper'; +import { loadTelegramAdapter } from '../esm-loader'; + +/** + * Telegram platform integration. + * + * Two capability flags are enabled here because of Telegram constraints: + * - {@link needsShortCallbackData} — `callback_data` is capped at 64 bytes, so the + * bridge stores full payloads in a CallbackStore and emits short 8-char keys as + * button IDs. + * - {@link disableStreaming} — streaming Markdown edits are unstable: intermediate + * frames carry half-formed markup that Telegram rejects or renders inconsistently. + * The bridge buffers agent output and posts it as a single message per flush, + * guaranteeing well-formed Markdown on every post. + * + * The adapter runs in webhook mode when a public `WEBHOOK_URL` is configured, + * otherwise it falls back to polling for local dev. + */ +@Service() +export class TelegramIntegration extends AgentChatIntegration { + readonly type = 'telegram'; + + readonly credentialTypes = ['telegramApi']; + + readonly displayLabel = 'Telegram'; + + readonly displayIcon = 'telegram'; + + readonly supportedComponents = ['section', 'button', 'divider', 'fields']; + + readonly needsShortCallbackData = true; + + readonly disableStreaming = true; + + readonly formatThreadId = { + fromSdk: (thread: Thread) => { + const adapter = thread.adapter; + const botUserId = adapter.botUserId; + if (!botUserId) { + throw new Error('Telegram bot user ID is not set'); + } + // thread.id is simply user id, which means connecting agent to another bot user will result in the same thread id + return `chat:${botUserId}-${thread.id}`; + }, + toSdk: (threadId: string) => { + if (!threadId.includes('-')) { + return threadId; + } + return threadId.split('-').slice(1).join('-'); + }, + }; + + constructor( + private readonly logger: Logger, + private readonly urlService: UrlService, + private readonly agentRepository: AgentRepository, + ) { + super(); + } + + async createAdapter(ctx: AgentChatIntegrationContext): Promise { + const botToken = this.extractBotToken(ctx.credential); + const mode = this.getMode(); + const { createTelegramAdapter } = await loadTelegramAdapter(); + return createTelegramAdapter({ botToken, mode }); + } + + /** + * Block the connect flow if this Telegram credential is already claimed — either + * by another agent in our DB, or by an unrelated webhook registered directly on + * Telegram (stale state, different n8n instance, or a Telegram-trigger workflow). + * + * The DB check is the primary signal (gives us the owning agent's name); the + * Telegram `getWebhookInfo` probe catches leftover webhooks no agent claims. + * The probe fails open — if Telegram's API is flaky, we log a warning and + * proceed rather than blocking a legitimate connect. + */ + async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise { + const others = await this.agentRepository.findByIntegrationCredential( + this.type, + ctx.credentialId, + ctx.projectId, + ctx.agentId, + ); + if (others.length > 0) { + throw new ConflictError( + `Telegram credential is already connected to agent "${others[0].name}"`, + ); + } + + // Only probe Telegram when we'd actually register a webhook (public URL). + if (this.getMode() !== 'webhook') return; + + const botToken = + typeof ctx.credential.accessToken === 'string' ? ctx.credential.accessToken : ''; + if (!botToken) return; + + let info: { url: string }; + try { + const resp = await fetch(`https://api.telegram.org/bot${botToken}/getWebhookInfo`, { + method: 'POST', + }); + if (!resp.ok) throw new Error(await resp.text()); + const body = (await resp.json()) as { result?: { url?: string } }; + info = { url: body.result?.url ?? '' }; + } catch (error) { + this.logger.warn( + `[TelegramIntegration] getWebhookInfo probe failed, proceeding: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + const ourUrl = ctx.webhookUrlFor(this.type); + if (info.url && info.url !== ourUrl) { + throw new ConflictError( + `Telegram bot already has a webhook registered elsewhere: ${info.url}`, + ); + } + } + + async onAfterConnect(ctx: AgentChatIntegrationContext): Promise { + if (this.getMode() !== 'webhook') return; + const botToken = this.extractBotToken(ctx.credential); + const webhookUrl = ctx.webhookUrlFor('telegram'); + const resp = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: webhookUrl }), + }); + if (!resp.ok) { + throw new Error(`Failed to register Telegram webhook: ${await resp.text()}`); + } + this.logger.info(`[TelegramIntegration] Webhook registered: ${webhookUrl}`); + } + + normalizeComponents(components: SuspendComponent[]): SuspendComponent[] { + const normalized: SuspendComponent[] = []; + for (const c of components) { + switch (c.type) { + case 'select': + case 'radio_select': + // Convert select options to individual buttons + for (const opt of c.options ?? []) { + normalized.push({ type: 'button', label: opt.label, value: opt.value }); + } + break; + case 'image': + // Convert image to a section with a markdown link + if (c.url) { + normalized.push({ type: 'section', text: `[${c.altText ?? 'Image'}](${c.url})` }); + } + break; + default: + normalized.push(c); + } + } + return normalized; + } + + /** Webhook when we have a public URL, polling otherwise (local dev). */ + private getMode(): 'webhook' | 'polling' { + const baseUrl = this.urlService.getWebhookBaseUrl(); + const isPublic = baseUrl.startsWith('https://') && !baseUrl.includes('localhost'); + return isPublic ? 'webhook' : 'polling'; + } + + private extractBotToken(credential: Record): string { + const token = credential.accessToken; + if (typeof token === 'string' && token) { + return token; + } + throw new Error( + 'Could not extract a bot token from the Telegram credential. ' + + 'Please ensure the credential has a valid access token from BotFather.', + ); + } +} diff --git a/packages/cli/src/modules/agents/integrations/rich-interaction-tool.ts b/packages/cli/src/modules/agents/integrations/rich-interaction-tool.ts new file mode 100644 index 00000000000..8a09d35afff --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/rich-interaction-tool.ts @@ -0,0 +1,164 @@ +import { Tool } from '@n8n/agents'; +import { Container } from '@n8n/di'; +import { z } from 'zod'; + +import { ChatIntegrationRegistry } from './agent-chat-integration'; + +// Conservative default — works on every platform that supports buttons. +// Used when the tool is constructed without a platform hint. +const DEFAULT_SUPPORTED_COMPONENTS = ['section', 'button', 'divider', 'fields']; + +/** + * System-prompt directive paired with the tool. Tool descriptions answer + * "what does this tool do?" but the LLM weights system instructions much + * more strongly when deciding "should I use this tool over plain text?". + * The runtime collects every tool's `systemInstruction` and wraps them in a + * single `` block above the user's agent instructions. + */ +const RICH_INTERACTION_INSTRUCTION_FRAGMENT = + 'When you have an image URL, gif, or content that benefits from visual ' + + 'structure (key-value summaries, info cards, choice options), call the ' + + 'rich_interaction tool to render it as a card instead of pasting URLs or ' + + 'text directly. With no buttons or selects, the card simply displays and ' + + 'you continue immediately — no need to wait for a user response.'; + +/** + * Compose the tool description from the supported components. The same + * behavioural wording is used everywhere — only the available component + * list is platform-specific. This keeps the LLM-facing contract identical + * regardless of which integration injects the tool. + */ +function buildDescription(supportedComponents: string[]): string { + const componentList = supportedComponents.join(', '); + return [ + 'Render a card to the user in chat. Use this whenever you want the user to ', + 'SEE rich content rather than read plain text — images and gifs, formatted ', + 'info cards, key-value summaries, or sets of choices.', + '\n\n', + `Available components: ${componentList}.`, + '\n\n', + 'Behavior depends on which components you include:', + '\n', + ' • Display-only (no button, select, or radio_select): the card renders in chat ', + 'and you continue immediately. Use this for posting gifs, screenshots, summary cards.', + '\n', + ' • Interactive (any button, select, or radio_select): the card renders and the ', + "run pauses; the user's click/selection is returned as your tool result.", + '\n\n', + 'Prefer this tool over plain text whenever you have an image URL, a gif, or ', + 'content that benefits from visual structure.', + ].join(''); +} + +// --------------------------------------------------------------------------- +// Shared schemas +// --------------------------------------------------------------------------- + +const selectOptionSchema = z.object({ + label: z.string().describe('Display text'), + value: z.string().describe('Value returned on selection'), + description: z.string().optional().describe('Help text'), +}); + +const fieldPairSchema = z.object({ + label: z.string().describe('Field label'), + value: z.string().describe('Field value'), +}); + +const resumeSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('button'), + value: z.string().describe('The clicked button value'), + }), + z.object({ + type: z.literal('select'), + id: z.string().describe('The select component ID'), + value: z.string().describe('The selected option value'), + }), +]); + +// --------------------------------------------------------------------------- +// Tool factory +// --------------------------------------------------------------------------- + +function buildComponentSchema(supportedComponents: string[]) { + const types = supportedComponents as [string, ...string[]]; + const hasSelects = + supportedComponents.includes('select') || supportedComponents.includes('radio_select'); + const hasImage = supportedComponents.includes('image'); + + const shape: Record = { + type: z.enum(types).describe('Component type'), + text: z.string().optional().describe('Text content (supports markdown)'), + label: z.string().optional().describe('Display label'), + value: z.string().optional().describe('Value returned on interaction'), + style: z.enum(['primary', 'danger']).optional().describe('Button style'), + fields: z.array(fieldPairSchema).optional().describe('Key-value pairs for fields component'), + }; + + if (hasSelects) { + shape.id = z.string().optional().describe('Unique ID for select/radio_select'); + shape.placeholder = z.string().optional().describe('Placeholder text'); + shape.options = z + .array(selectOptionSchema) + .optional() + .describe('Options for select/radio_select'); + } + + if (hasImage) { + shape.url = z.string().optional().describe('Image URL'); + shape.alt = z.string().optional().describe('Image alt text'); + } + + return z.object(shape); +} + +export function createRichInteractionTool(platform?: string) { + const integration = platform ? Container.get(ChatIntegrationRegistry).get(platform) : undefined; + const supportedComponents = integration?.supportedComponents ?? DEFAULT_SUPPORTED_COMPONENTS; + const description = buildDescription(supportedComponents); + const componentSchema = buildComponentSchema(supportedComponents); + + const inputSchema = z.object({ + title: z.string().optional().describe('Card title / header text'), + message: z.string().optional().describe('Subtitle or description text'), + components: z.array(componentSchema).describe('Card components to render'), + }); + + // The suspend payload uses the same shape as the input + const suspendSchema = inputSchema; + + return new Tool('rich_interaction') + .description(description) + .systemInstruction(RICH_INTERACTION_INSTRUCTION_FRAGMENT) + .input(inputSchema) + .suspend(suspendSchema) + .resume( + // z.discriminatedUnion is not a ZodObject, but the runtime accepts any + // ZodType for schema validation. Cast to satisfy the builder's generic + // constraint which is limited to ZodObject for type-safety convenience. + resumeSchema as unknown as z.ZodObject, + ) + .handler(async (input, ctx) => { + if (ctx.resumeData) { + return ctx.resumeData; + } + + const hasActionable = input.components.some( + (c: z.infer) => + c.type === 'button' || c.type === 'select' || c.type === 'radio_select', + ); + + if (!hasActionable) { + // Display-only: signal intent to the consumer (the chat bridge) + // via a marker on the tool result. The bridge inspects + // tool-call/tool-result chunks and renders the card from this + // tool's *input*, since we never actually do the chat-SDK work + // here. `displayOnly: true` is a directive ("show this without + // awaiting a response"), not a state assertion. + return { displayOnly: true }; + } + + return await ctx.suspend(input); + }); +} diff --git a/packages/cli/src/modules/agents/integrations/types.ts b/packages/cli/src/modules/agents/integrations/types.ts new file mode 100644 index 00000000000..668c703cbe5 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/types.ts @@ -0,0 +1,21 @@ +/** Callback that pushes a text fragment into the streaming iterable. */ +export type TextYieldFn = (text: string) => void; +/** Callback that signals the end of the streaming iterable. */ +export type TextEndFn = () => void; + +export const INTERNAL_THREAD_ID_SYMBOL = Symbol('internal-thread-id'); + +/** + * The symbol is required to prevent accidental mixing of internal and chat SDK thread IDs as they may be different + */ +export interface InternalThread { + [INTERNAL_THREAD_ID_SYMBOL]: boolean; + id: string; +} + +export const toInternalThreadId = (id: string): InternalThread => { + return { + [INTERNAL_THREAD_ID_SYMBOL]: true, + id, + }; +}; diff --git a/packages/cli/src/modules/agents/json-config/agent-config-composition.ts b/packages/cli/src/modules/agents/json-config/agent-config-composition.ts new file mode 100644 index 00000000000..948c37c5ff2 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/agent-config-composition.ts @@ -0,0 +1,45 @@ +import type { AgentIntegration } from '@n8n/api-types'; + +import type { Agent } from '../entities/agent.entity'; +import type { AgentJsonConfig } from './agent-json-config'; + +/** + * Build the unified `AgentJsonConfig` view from an agent entity. The schema + * column holds everything except triggers, which live on `agent.integrations`. + * The builder LLM consumes the merged shape. + */ +export function composeJsonConfig(agent: Agent): AgentJsonConfig | null { + if (!agent.schema) return null; + return { + ...agent.schema, + integrations: agent.integrations ?? [], + }; +} + +/** + * Split an inbound `AgentJsonConfig` into the part stored on `agent.schema` and + * the part stored on `agent.integrations`. Inverse of `composeJsonConfig`. + */ +export function decomposeJsonConfig(config: AgentJsonConfig): { + schemaConfig: Omit; + integrations: AgentIntegration[]; +} { + const { integrations, ...schemaConfig } = config; + return { schemaConfig, integrations: integrations ?? [] }; +} + +/** + * Coerce a display name into a tool name that satisfies the + * Anthropic/OpenAI constraint `^[a-zA-Z0-9_-]{1,128}$`. Applied at runtime + * only — the persisted `tool.name` keeps the user's original display string + * (e.g. "D&D Invite"), and the LLM-facing name is derived from it on the + * fly. Already-valid names pass through unchanged. + */ +export function sanitizeToolName(name: string): string { + if (/^[a-zA-Z0-9_-]{1,128}$/.test(name)) return name; + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 128); +} diff --git a/packages/cli/src/modules/agents/json-config/agent-json-config.ts b/packages/cli/src/modules/agents/json-config/agent-json-config.ts new file mode 100644 index 00000000000..825abf70d3d --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/agent-json-config.ts @@ -0,0 +1,165 @@ +import { z, type ZodError } from 'zod'; + +import { AgentIntegrationSchema } from './integration-config'; + +const SemanticRecallSchema = z.object({ + topK: z.number().int().min(1).max(100), + scope: z.enum(['thread', 'resource']).optional(), + messageRange: z + .object({ + before: z.number().int().min(0), + after: z.number().int().min(0), + }) + .optional(), + embedder: z.string().optional(), +}); + +// TODO: Create a list of all supported memory storages, define connection params for each storage +const MemoryConfigSchema = z.object({ + enabled: z.boolean(), + storage: z.enum(['n8n', 'sqlite', 'postgres']), + connection: z.record(z.unknown()).optional(), + lastMessages: z.number().int().min(1).max(200).optional(), + semanticRecall: SemanticRecallSchema.optional(), +}); + +const ThinkingConfigSchema = z.object({ + provider: z.enum(['anthropic', 'openai']), + budgetTokens: z.number().int().optional(), + reasoningEffort: z.string().optional(), +}); + +const NodeToolCredentialSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +export const NodeConfigSchema = z.object({ + nodeType: z.string().min(1), + nodeTypeVersion: z.number(), + nodeParameters: z.record(z.unknown()).optional().default({}), + credentials: z.record(NodeToolCredentialSchema).optional(), +}); + +const AgentJsonSkillConfigSchema = z.object({ + type: z.literal('skill'), + id: z + .string() + .min(1) + .regex(/^[A-Za-z0-9_-]+$/), +}); + +const AgentJsonToolConfigSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('custom'), + id: z + .string() + .min(1) + .regex(/^[A-Za-z0-9_-]+$/), + requireApproval: z.boolean().optional(), + }), + z + .object({ + type: z.literal('workflow'), + workflow: z.string().min(1), + name: z.string().optional(), + description: z.string().optional(), + requireApproval: z.boolean().optional(), + allOutputs: z + .boolean() + .optional() + .describe('Whether to return all node outputs instead of just the last node'), + }) + .strict(), + z + .object({ + type: z.literal('node'), + name: z.string().min(1), + description: z.string().optional(), + node: NodeConfigSchema, + requireApproval: z.boolean().optional(), + }) + .strict(), +]); + +export const AgentJsonConfigSchema = z.object({ + name: z.string().min(1).max(128), + description: z.string().max(512).optional(), + model: z + .string() + .min(1) + .regex( + /** + * [a-z0-9-]+: Provider name (e.g. "anthropic") + * (?:[a-z0-9._-]+\/)*: Zero or more sub-providers (e.g. "openrouter/amazon/nova-micro-v1") + * [a-z0-9._-]+: Model name (e.g. "claude-sonnet-4-5") + */ + /^[a-z0-9-]+\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+$/i, + 'Model must be "provider/model-name" format (e.g. "anthropic/claude-sonnet-4-5" or "openrouter/amazon/nova-micro-v1")', + ), + credential: z.string().optional(), + instructions: z.string(), + memory: MemoryConfigSchema.optional(), + tools: z.array(AgentJsonToolConfigSchema).optional(), + skills: z.array(AgentJsonSkillConfigSchema).optional(), + providerTools: z.record(z.record(z.unknown())).optional(), + integrations: z.array(AgentIntegrationSchema).optional(), + config: z + .object({ + thinking: ThinkingConfigSchema.optional(), + toolCallConcurrency: z.number().int().min(1).max(20).optional(), + requireToolApproval: z.boolean().optional(), + nodeTools: z + .object({ + enabled: z.boolean(), + }) + .optional(), + }) + .optional(), +}); + +export const AgentJsonConfigPartialSchema = AgentJsonConfigSchema.partial(); + +export type AgentJsonConfig = z.infer; +export type AgentJsonToolConfig = z.infer; +export type AgentJsonSkillConfig = z.infer; +export type AgentJsonConfigRef = AgentJsonToolConfig | AgentJsonSkillConfig; +export type AgentJsonMemoryConfig = z.infer; + +export interface ConfigValidationError { + path: string; + message: string; + expected?: string; + received?: string; +} + +export function tryParseConfigJson( + raw: string, +): { ok: true; data: unknown } | { ok: false; errors: ConfigValidationError[] } { + try { + return { ok: true, data: JSON.parse(raw) }; + } catch (e) { + const msg = e instanceof SyntaxError ? e.message : String(e); + return { ok: false, errors: [{ path: '(root)', message: `JSON parse error: ${msg}` }] }; + } +} + +export function formatZodErrors(error: ZodError): ConfigValidationError[] { + return error.issues.map((issue) => ({ + path: issue.path.join('.') || '(root)', + message: issue.message, + expected: 'expected' in issue ? String(issue.expected) : undefined, + received: 'received' in issue ? String(issue.received) : undefined, + })); +} + +/** + * Returns whether the built-in node tool chain (search_nodes, get_node_types, + * list_credentials, run_node_tool) should be attached to an agent runtime. + * + * Absent or partial config defaults to disabled — only an explicit + * `nodeTools: { enabled: true }` opts an agent in. + */ +export function isNodeToolsEnabled(config: AgentJsonConfig['config']): boolean { + return config?.nodeTools?.enabled === true; +} diff --git a/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts b/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts new file mode 100644 index 00000000000..31bfd79066e --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/credential-field-mapping.ts @@ -0,0 +1,84 @@ +import type { ResolvedCredential } from '@n8n/agents'; + +type CredMapper = (raw: ResolvedCredential) => Record; + +/** + * Maps a raw n8n credential record onto the shape expected by the AI SDK + * for the given provider prefix (e.g. 'aws-bedrock', 'azure-openai'). + * + * n8n credential field names come from the credential type definitions + * (e.g. Aws.credentials.ts, AzureOpenAiApi.credentials.ts) and differ + * from what the AI SDK expects. Each mapper normalises the names. + * + * Providers not listed here pass through unchanged — they only need `apiKey` + * and optionally `baseURL`, which are already present in most credential types. + */ +const PROVIDER_CREDENTIAL_MAPPERS: Record = { + // OpenAiApi.credentials.ts → apiKey, url (base URL) + openai: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // AnthropicApi.credentials.ts → apiKey, url (base URL) + anthropic: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // GooglePalmApi.credentials.ts → apiKey, host (base URL) + google: (c) => ({ apiKey: c.apiKey, baseURL: c.host }), + // XAiApi.credentials.ts → apiKey, url (hidden, base URL) + xai: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // GroqApi.credentials.ts → apiKey only + groq: (c) => ({ apiKey: c.apiKey }), + // DeepSeekApi.credentials.ts → apiKey, url (hidden, base URL) + deepseek: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // CohereApi.credentials.ts → apiKey, url (hidden, base URL) + cohere: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // MistralCloudApi.credentials.ts → apiKey only + mistral: (c) => ({ apiKey: c.apiKey }), + // VercelAiGatewayApi.credentials.ts → apiKey, url (base URL) + vercel: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + // OpenRouterApi.credentials.ts → apiKey, url (hidden, base URL) + openrouter: (c) => ({ apiKey: c.apiKey, baseURL: c.url }), + + // AzureOpenAiApi.credentials.ts → apiKey, resourceName, apiVersion, endpoint + // AzureEntraCognitiveServicesOAuth2Api.credentials.ts → resourceName, apiVersion, endpoint + // eslint-disable-next-line @typescript-eslint/naming-convention + 'azure-openai': (c) => ({ + apiKey: c.apiKey, + resourceName: c.resourceName, + apiVersion: c.apiVersion, + baseURL: c.endpoint, + }), + + // Aws.credentials.ts → region, accessKeyId, secretAccessKey, sessionToken + // eslint-disable-next-line @typescript-eslint/naming-convention + 'aws-bedrock': (c) => ({ + region: c.region, + accessKeyId: c.accessKeyId, + secretAccessKey: c.secretAccessKey, + sessionToken: c.sessionToken, + }), +}; + +/** + * Given a provider prefix (derived from the `model` string before the first `/`) + * and a resolved credential record, return a new record with field names remapped + * to what the AI SDK provider expects. + * + * Falls back to the raw record when no mapper is registered for that provider. + */ +export function mapCredentialForProvider( + provider: string, + raw: ResolvedCredential, +): Record { + const mapper = PROVIDER_CREDENTIAL_MAPPERS[provider]; + if (!mapper) return raw as Record; + // Strip undefined values so they don't shadow existing keys in the Zod parse. + const mapped = mapper(raw); + return Object.fromEntries(Object.entries(mapped).filter(([, v]) => v !== undefined)); +} + +/** Provider IDs the agents runtime knows how to map a credential for. */ +export const SUPPORTED_AGENT_PROVIDERS = Object.keys( + PROVIDER_CREDENTIAL_MAPPERS, +) as readonly string[]; + +/** Whether a given provider id has a registered credential mapper. */ +export function isSupportedAgentProvider(provider: string): boolean { + return provider in PROVIDER_CREDENTIAL_MAPPERS; +} 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 new file mode 100644 index 00000000000..7474d61fddb --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -0,0 +1,320 @@ +import type { + AgentBuilder, + BuiltMemory, + BuiltTool, + CredentialProvider, + ModelConfig, + ToolDescriptor, + JSONObject, +} from '@n8n/agents'; +import { + Agent, + Memory, + Tool, + UPDATE_WORKING_MEMORY_TOOL_NAME, + wrapToolForApproval, +} from '@n8n/agents'; +import type { AgentSkill } from '@n8n/api-types'; +import { z } from 'zod'; + +import type { + AgentJsonConfig, + AgentJsonConfigRef, + AgentJsonMemoryConfig, + AgentJsonToolConfig, +} from './agent-json-config'; +import { mapCredentialForProvider } from './credential-field-mapping'; +import { resolveProviderToolName } from './provider-tool-aliases'; + +export type ToolResolver = ( + toolSchema: AgentJsonToolConfig, +) => Promise; + +export interface ToolExecutor { + executeTool(toolName: string, input: unknown, ctx: unknown): Promise; + executeToMessageSync?(toolName: string, output: unknown): unknown; +} + +/** Factory function that reconstructs a BuiltMemory backend from serialized params. */ +export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Promise; + +const DEFAULT_WORKING_MEMORY_TEMPLATE = `# Thread memory +- User facts: +- User preferences/instructions: +- Current goal/task: +- Current state: +- Key active items: +- Decisions made: +- Open follow-ups: +- Resolved or superseded:`; + +const DEFAULT_WORKING_MEMORY_INSTRUCTION = [ + 'You have thread-scoped working memory for this conversation.', + `When the user shares durable facts, preferences, decisions, goals, or unresolved follow-ups that will help later turns in this same thread, call ${UPDATE_WORKING_MEMORY_TOOL_NAME} with the complete updated memory.`, + 'Treat working memory as a current-state snapshot, not an append-only log.', + 'Keep it concise, factual, and current.', + 'When facts, preferences, priorities, goals, decisions, or statuses change, replace outdated active items with the latest state.', + 'Preserve distinctions the user makes between primary, secondary, active, resolved, and superseded items.', + 'Move resolved or superseded items to that section only when they will help later; otherwise remove them.', + 'Preserve useful existing notes, remove stale or contradicted notes, and do not store secrets or one-off details.', + `Only call ${UPDATE_WORKING_MEMORY_TOOL_NAME} when the memory should change.`, +].join(' '); + +export interface BuildFromJsonOptions { + /** Executes custom tool handlers inside isolates. */ + toolExecutor: ToolExecutor; + credentialProvider: CredentialProvider; + /** Resolves workflow/node tool refs into BuiltTool instances. */ + resolveTool?: ToolResolver; + /** Stored skill bodies keyed by skill id. Only refs present in config.skills are attached. */ + skills?: Record; + /** Memory backend factories keyed by storage preset name. */ + memoryFactory: MemoryFactory; +} + +/** + * Build a live Agent from an AgentJsonConfig + tool descriptors. + * + * This is the JSON-config execution path — lighter than fromSchema() because + * there are no source strings to evaluate. Custom tool handlers are dispatched + * individually per tool-id via the ToolExecutor. + */ +export async function buildFromJson( + config: AgentJsonConfig, + toolDescriptors: Record, + options: BuildFromJsonOptions, +): Promise { + const agent = new Agent(config.name); + + // Derive the provider prefix for credential field remapping. + const slashIdx = config.model.indexOf('/'); + const providerPrefix = slashIdx !== -1 ? config.model.slice(0, slashIdx) : ''; + + // Resolve credentials upfront and embed them directly in the model config + // object so createModel() receives the full set of fields it needs. + if (config.credential) { + const raw = await options.credentialProvider.resolve(config.credential); + const mapped = mapCredentialForProvider(providerPrefix, raw); + agent.model({ id: config.model, ...mapped } as ModelConfig); + } else { + agent.model(config.model); + } + + const configuredSkills = getConfiguredSkills(config.skills ?? [], options.skills ?? {}); + agent.instructions(withSkillCatalog(config.instructions, configuredSkills)); + + // Tools + if (config.tools) { + for (const ref of config.tools) { + const built = await resolveToolRef(ref, toolDescriptors, options); + if (built) { + agent.tool(built); + } + } + } + if (configuredSkills.length > 0) { + agent.tool(createLoadSkillTool(configuredSkills)); + } + + // Provider tools + if (config.providerTools) { + for (const [name, args] of Object.entries(config.providerTools)) { + const resolved = resolveProviderToolName(name); + agent.providerTool({ name: resolved as `${string}.${string}`, args }); + } + } + + // Memory + if (config.memory?.enabled) { + await applyMemoryFromConfig(agent, config.memory, options.memoryFactory); + } + + // Config options + if (config.config) { + if (config.config.thinking) { + const { provider, ...rest } = config.config.thinking; + agent.thinking(provider, rest); + } + if (config.config.toolCallConcurrency) { + agent.toolCallConcurrency(config.config.toolCallConcurrency); + } + if (config.config.requireToolApproval) { + agent.requireToolApproval(); + } + } + + return agent; +} + +type ConfiguredSkill = { id: string; skill: AgentSkill }; + +function getConfiguredSkills( + refs: Array>, + skills: Record, +): ConfiguredSkill[] { + const seen = new Set(); + const configured: ConfiguredSkill[] = []; + + for (const ref of refs) { + if (seen.has(ref.id)) continue; + seen.add(ref.id); + const skill = skills[ref.id]; + if (!skill) throw new Error(`Skill "${ref.id}" not found in stored skill bodies`); + configured.push({ id: ref.id, skill }); + } + + return configured; +} + +function withSkillCatalog(instructions: string, skills: ConfiguredSkill[]): string { + if (skills.length === 0) return instructions; + + const catalog = formatSkillCatalog(skills); + const baseInstructions = instructions.trimEnd(); + + return `Skill loading protocol: +Skills are optional instruction packs, not execution tools. Use them to get extra guidance only when they are relevant to the user's current request. + +Available skills: +${catalog} + +When deciding whether to load a skill: +- Match the user's request against the skill name and description. +- If one skill clearly matches, call load_skill once with that skill's id, then follow the returned instructions. +- If the relevant skill was already loaded for this request, do not call load_skill again. +- If no skill clearly matches, do not call load_skill. +- Do not load a skill just because it is listed here.${baseInstructions ? `\n\n${baseInstructions}` : ''}`; +} + +function createLoadSkillTool(skills: ConfiguredSkill[]): BuiltTool { + const skillsById = new Map(skills.map(({ id, skill }) => [id, skill])); + + return new Tool('load_skill') + .description( + 'Load the full instructions for an attached skill. Use the skill id listed in the system instructions.', + ) + .input( + z.object({ + skillId: z.string().describe('The skill id from the Available skills list'), + }), + ) + .handler(async ({ skillId }: { skillId: string }) => { + const skill = skillsById.get(skillId); + if (!skill) { + return { + ok: false, + error: `Skill "${skillId}" is not attached to this agent.`, + }; + } + + return { + ok: true, + skillId, + name: skill.name, + description: skill.description, + instructions: skill.instructions, + }; + }) + .build(); +} + +function formatSkillCatalog(skills: ConfiguredSkill[]): string { + return skills + .map( + ({ id, skill }) => `- name: ${skill.name}\n description: ${skill.description}\n id: ${id}`, + ) + .join('\n'); +} + +async function resolveToolRef( + ref: AgentJsonToolConfig, + descriptors: Record, + options: BuildFromJsonOptions, +): Promise { + switch (ref.type) { + case 'custom': { + const descriptor = descriptors[ref.id]; + if (!descriptor) { + throw new Error(`Custom tool "${ref.id}" not found in tool descriptors`); + } + + const builtTool: BuiltTool = { + name: descriptor.name, + description: descriptor.description, + systemInstruction: descriptor.systemInstruction ?? undefined, + inputSchema: descriptor.inputSchema ?? undefined, + handler: async (input, ctx) => { + return await options.toolExecutor.executeTool(descriptor.name, input, { + resumeData: 'resumeData' in ctx ? ctx.resumeData : undefined, + parentTelemetry: ctx.parentTelemetry, + }); + }, + providerOptions: descriptor.providerOptions as Record | undefined, + }; + + if (ref.requireApproval) { + return wrapToolForApproval(builtTool, { requireApproval: true }); + } + return builtTool; + } + + case 'workflow': { + const marker: BuiltTool = { + name: ref.name ?? ref.workflow, + description: ref.description ?? `Execute the "${ref.workflow}" workflow`, + editable: false, + metadata: { + workflowTool: true, + workflowName: ref.workflow, + options: { name: ref.name, description: ref.description }, + }, + }; + const tool = (await options.resolveTool?.(ref)) ?? marker; + if (ref.requireApproval) { + return wrapToolForApproval(tool, { requireApproval: true }); + } + return tool; + } + + case 'node': { + const marker: BuiltTool = { + name: ref.name, + description: ref.description ?? `Execute node ${ref.name}`, + editable: false, + metadata: { nodeTool: true, ...ref.node }, + }; + const tool = (await options.resolveTool?.(ref)) ?? marker; + if (ref.requireApproval) { + return wrapToolForApproval(tool, { requireApproval: true }); + } + return tool; + } + } +} + +async function applyMemoryFromConfig( + agent: AgentBuilder, + memoryConfig: AgentJsonMemoryConfig, + memoryFactory: MemoryFactory, +) { + const memory = new Memory(); + + const builtMemory = memoryFactory(memoryConfig); + memory.storage(await Promise.resolve(builtMemory)); + memory + .freeform(DEFAULT_WORKING_MEMORY_TEMPLATE) + .scope('thread') + .instruction(DEFAULT_WORKING_MEMORY_INSTRUCTION); + + if (memoryConfig.lastMessages) { + memory.lastMessages(memoryConfig.lastMessages); + } + + if (memoryConfig.semanticRecall) { + memory.semanticRecall(memoryConfig.semanticRecall); + } + + memory.titleGeneration({ sync: true }); + + agent.memory(memory); +} diff --git a/packages/cli/src/modules/agents/json-config/integration-config.ts b/packages/cli/src/modules/agents/json-config/integration-config.ts new file mode 100644 index 00000000000..bbe5de31f57 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/integration-config.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +import { isValidCronExpression } from '../integrations/cron-validation'; + +export const AgentScheduleIntegrationSchema = z + .object({ + type: z.literal('schedule'), + active: z.boolean(), + cronExpression: z + .string() + .min(1, 'cronExpression is required') + .refine(isValidCronExpression, { message: 'Invalid cron expression' }), + wakeUpPrompt: z.string().min(1, 'wakeUpPrompt is required'), + }) + .strict(); + +export const AgentCredentialIntegrationSchema = z + .object({ + type: z + .string() + .min(1) + .refine((value) => value !== 'schedule', { + message: 'Type "schedule" is reserved for the schedule trigger', + }), + credentialId: z.string().min(1), + credentialName: z.string().min(1), + }) + .strict(); + +export const AgentIntegrationSchema = z.union([ + AgentScheduleIntegrationSchema, + AgentCredentialIntegrationSchema, +]); + +export type AgentScheduleIntegrationConfig = z.infer; +export type AgentCredentialIntegrationConfig = z.infer; +export type AgentIntegrationConfig = z.infer; diff --git a/packages/cli/src/modules/agents/json-config/provider-tool-aliases.ts b/packages/cli/src/modules/agents/json-config/provider-tool-aliases.ts new file mode 100644 index 00000000000..4be30ce1562 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/provider-tool-aliases.ts @@ -0,0 +1,47 @@ +/** + * Aliases for provider tool IDs. Users (and the builder prompt) write a + * short, stable name like "anthropic.web_search" in their agent config; + * providers expect the versioned form like "anthropic.web_search_20260209". + * + * Bump the versioned targets here when the underlying provider releases a + * new generation of a tool. Agents saved against the short name automatically + * pick up the new version on the next reconstruction — no migration needed. + * + * Keys are organised per-provider so the provider boundary stays clear when + * more tools or vendors are added. + */ + +// Anthropic (https://platform.claude.com/docs/en/agents-and-tools/tool-use) +// +// `web_search` intentionally targets the pre-dynamic-filtering version. The +// newer `web_search_20260209` activates a server-side code-execution pipeline +// that is slower per request, emits code_execution tool results on every +// search, and is only officially supported on 4.6+ models. +// Users who actually want dynamic filtering can opt in by writing the full +// versioned id in their config — unknown names pass through untouched. +const ANTHROPIC_TOOL_ALIASES: Record = { + 'anthropic.web_search': 'anthropic.web_search_20250305', +}; + +// OpenAI (https://platform.openai.com/docs/guides/tools-web-search) +// +// OpenAI tool IDs are unversioned (e.g. `openai.web_search`, +// `openai.image_generation`), so no aliasing is needed today — the config +// names flow straight through `resolveProviderToolName` unchanged. This block +// is left as a marker so future OpenAI additions live alongside Anthropic. +const OPENAI_TOOL_ALIASES: Record = {}; + +const PROVIDER_TOOL_ALIASES: Record = { + ...ANTHROPIC_TOOL_ALIASES, + ...OPENAI_TOOL_ALIASES, +}; + +/** + * Resolve a provider-tool name written in the JSON config to the versioned + * identifier the provider expects. Unknown names pass through unchanged, so + * callers writing a fully-versioned id directly (e.g. `anthropic.web_search_20260209`) + * are not rewritten. + */ +export function resolveProviderToolName(name: string): string { + return PROVIDER_TOOL_ALIASES[name] ?? name; +} diff --git a/packages/cli/src/modules/agents/json-config/schema-text-serializer.ts b/packages/cli/src/modules/agents/json-config/schema-text-serializer.ts new file mode 100644 index 00000000000..92eb7bec3a7 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/schema-text-serializer.ts @@ -0,0 +1,496 @@ +import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; + +/** + * Convert a JSON Schema object to a compact human-readable text representation. + * Used to inject schema information into LLM prompts without depending on Zod internals. + * + * @example + * jsonSchemaToCompactText({ + * type: 'object', + * properties: { name: { type: 'string' }, age: { type: 'integer' } }, + * required: ['name'], + * }); + * // → + * // name: string (required) + * // age?: integer + */ +export function jsonSchemaToCompactText(schema: JSONSchema7, indent = 0): string { + return serializeSchema(schema, '', false, indent).join('\n'); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function pad(level: number): string { + return ' '.repeat(level); +} + +/** Returns `"name?: "` or `"name: "` depending on optionality. */ +function fieldPrefix(name: string, optional: boolean): string { + return `${name}${optional ? '?' : ''}: `; +} + +/** + * Formats a numeric range as a compact label. + * + * @example + * buildRangeLabel(1, 36, 'chars') // → "1..36 chars" + * buildRangeLabel(0, undefined) // → "min 0" + * buildRangeLabel(undefined, 100) // → "max 100" + * buildRangeLabel(undefined, undefined) // → undefined + */ +function buildRangeLabel( + min: number | undefined, + max: number | undefined, + unit = '', +): string | undefined { + const suffix = unit ? ` ${unit}` : ''; + if (min !== undefined && max !== undefined) return `${min}..${max}${suffix}`; + if (min !== undefined) return `min ${min}${suffix}`; + if (max !== undefined) return `max ${max}${suffix}`; + return undefined; +} + +// --------------------------------------------------------------------------- +// Type labels — inline one-line representations +// --------------------------------------------------------------------------- + +/** + * Produces a compact inline type string for a schema node. + * Used both directly for leaf fields and to summarise branch shapes inside union labels. + * + * @example + * typeLabel({ enum: ['red', 'blue'] }) // → '"red" | "blue"' + * typeLabel({ const: 'click' }) // → '"click"' + * typeLabel({ anyOf: [{ type: 'string' }, { type: 'number' }] }) // → 'string | number' + * typeLabel({ type: 'string', minLength: 1 }) // → 'string [min 1 chars]' + * typeLabel({ type: 'array', items: { type: 'boolean' } }) // → 'array of ' + */ +function typeLabel(schema: JSONSchema7): string { + if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(' | '); + if (schema.const !== undefined) return JSON.stringify(schema.const); + + const unionBranches = schema.anyOf ?? schema.oneOf; + if (unionBranches) { + return unionBranches + .filter((b): b is JSONSchema7 => typeof b === 'object' && b !== null) + .map((b) => typeLabel(b)) + .join(' | '); + } + + const { type } = schema; + if (type === 'string') return stringTypeLabel(schema); + if (type === 'integer' || type === 'number') return numericTypeLabel(type, schema); + if (type === 'boolean') return 'boolean'; + if (type === 'array') return arrayTypeLabel(schema); + if (type === 'object') return objectTypeLabel(schema); + return 'unknown'; +} + +/** + * @example + * stringTypeLabel({ type: 'string' }) // → 'string' + * stringTypeLabel({ type: 'string', minLength: 2, maxLength: 20 }) // → 'string [2..20 chars]' + * stringTypeLabel({ type: 'string', pattern: '^\\w+$' }) // → 'string [pattern: ^\w+$]' + */ +function stringTypeLabel(schema: JSONSchema7): string { + const constraints: string[] = []; + const rangeLabel = buildRangeLabel(schema.minLength, schema.maxLength, 'chars'); + if (rangeLabel) constraints.push(rangeLabel); + if (schema.pattern) constraints.push(`pattern: ${schema.pattern}`); + return constraints.length > 0 ? `string [${constraints.join(', ')}]` : 'string'; +} + +/** + * @example + * numericTypeLabel('integer', { minimum: 1, maximum: 5 }) // → 'integer [1..5]' + * numericTypeLabel('number', { maximum: 1 }) // → 'number [max 1]' + * numericTypeLabel('number', {}) // → 'number' + */ +function numericTypeLabel(type: 'integer' | 'number', schema: JSONSchema7): string { + const rangeLabel = buildRangeLabel(schema.minimum, schema.maximum); + return rangeLabel ? `${type} [${rangeLabel}]` : type; +} + +/** + * @example + * arrayTypeLabel({ type: 'array', items: { type: 'string' } }) // → 'array of ' + * arrayTypeLabel({ type: 'array' }) // → 'array' + */ +function arrayTypeLabel(schema: JSONSchema7): string { + if (schema.items && typeof schema.items === 'object' && !Array.isArray(schema.items)) { + return `array of <${typeLabel(schema.items)}>`; + } + return 'array'; +} + +/** + * Used when an object appears inline (e.g. as a union branch summary or nested field type). + * Emits a flat `{ k: type, k?: type }` summary rather than expanding to multiple lines. + * + * @example + * // Named properties → inline shape + * objectTypeLabel({ type: 'object', properties: { x: { type: 'number' } }, required: ['x'] }) + * // → '{ x: number }' + * + * // No properties, only additionalProperties → Record + * objectTypeLabel({ type: 'object', additionalProperties: { type: 'string' } }) + * // → 'Record' + */ +function objectTypeLabel(schema: JSONSchema7): string { + if (schema.properties) { + const required = new Set(schema.required ?? []); + const parts = Object.entries(schema.properties) + .filter( + (entry): entry is [string, JSONSchema7] => + typeof entry[1] === 'object' && entry[1] !== null, + ) + .map(([k, v]) => `${k}${required.has(k) ? '' : '?'}: ${typeLabel(v)}`); + return `{ ${parts.join(', ')} }`; + } + if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { + return `Record`; + } + return 'object'; +} + +// --------------------------------------------------------------------------- +// Multi-line serializers — each returns string[] for its schema node +// --------------------------------------------------------------------------- + +/** + * Central dispatcher: routes a schema node to the right multi-line serializer. + * Returns an empty array when the schema cannot produce output (e.g. a top-level union without a fieldName). + */ +function serializeSchema( + schema: JSONSchema7, + fieldName: string, + optional: boolean, + level: number, +): string[] { + const unionBranches = schema.anyOf ?? schema.oneOf; + if (unionBranches?.length) return serializeUnion(schema, fieldName, optional, level); + if (schema.type === 'object') return serializeObject(schema, fieldName, optional, level); + if (schema.type === 'array' && hasUnionItems(schema)) { + return serializeArrayUnion(schema, fieldName, optional, level); + } + return serializeLeaf(schema, fieldName, optional, level); +} + +/** + * Emits a single line for any scalar field (string, number, boolean, enum, const, plain array). + * + * @example + * serializeLeaf({ type: 'string' }, 'name', false, 1) + * // → [' name: string (required)'] + * + * serializeLeaf({ type: 'integer', default: 0 }, 'count', true, 1) + * // → [' count?: integer (default: 0)'] + */ +function serializeLeaf( + schema: JSONSchema7, + fieldName: string, + optional: boolean, + level: number, +): string[] { + if (!fieldName) return []; + const requiredSuffix = optional ? '' : ' (required)'; + const defaultSuffix = + schema.default !== undefined ? ` (default: ${JSON.stringify(schema.default)})` : ''; + return [ + `${pad(level)}${fieldPrefix(fieldName, optional)}${typeLabel(schema)}${requiredSuffix}${defaultSuffix}`, + ]; +} + +/** + * Emits a header line for a named object field (or nothing for the top-level schema), + * then recurses into each property, and finally appends a catch-all line when + * `additionalProperties` is also present. + * + * @example + * // Named field with properties + * serializeObject({ type: 'object', properties: { street: { type: 'string' } }, required: ['street'] }, 'address', false, 1) + * // → [ + * // ' address: object (required)', + * // ' street: string (required)', + * // ] + * + * // Top-level object (no fieldName) — header suppressed + * serializeObject({ type: 'object', properties: { x: { type: 'number' } } }, '', false, 0) + * // → [' x?: number'] + * + * // additionalProperties only → Record + * serializeObject({ type: 'object', additionalProperties: { type: 'string' } }, 'data', true, 1) + * // → [' data?: Record'] + * + * // Mixed properties + additionalProperties → catch-all appended + * serializeObject({ type: 'object', properties: { baseline: { type: 'number' } }, required: ['baseline'], additionalProperties: { type: 'number' } }, '', false, 0) + * // → [ + * // ' baseline: number (required)', + * // ' [key: string]: number', + * // ] + */ +function serializeObject( + schema: JSONSchema7, + fieldName: string, + optional: boolean, + level: number, +): string[] { + const requiredSuffix = optional ? '' : ' (required)'; + + if (schema.properties) { + const header = fieldName + ? [`${pad(level)}${fieldPrefix(fieldName, optional)}object${requiredSuffix}`] + : []; + const required = new Set(schema.required ?? []); + const propertyLines = Object.entries(schema.properties).flatMap(([propName, propSchema]) => { + if (typeof propSchema !== 'object' || propSchema === null) return []; + return serializeSchema(propSchema, propName, !required.has(propName), level + 1); + }); + const catchAll = + schema.additionalProperties && typeof schema.additionalProperties === 'object' + ? [`${pad(level + 1)}[key: string]: ${typeLabel(schema.additionalProperties)}`] + : []; + return [...header, ...propertyLines, ...catchAll]; + } + + if (schema.additionalProperties) { + const valType = + typeof schema.additionalProperties === 'object' && schema.additionalProperties !== null + ? typeLabel(schema.additionalProperties) + : 'unknown'; + return [ + `${pad(level)}${fieldPrefix(fieldName, optional)}Record${requiredSuffix}`, + ]; + } + + return []; +} + +/** + * Emits a discriminated-union block for `anyOf` / `oneOf` fields. + * Each branch is summarised on a single `| label: { fields }` line. + * Returns empty when there is no `fieldName` (top-level union schemas are not rendered). + * + * @example + * serializeUnion( + * [ + * { type: 'object', properties: { type: { const: 'click' }, x: { type: 'number' } }, required: ['type', 'x'] }, + * { type: 'object', properties: { type: { const: 'key' }, char: { type: 'string' } }, required: ['type', 'char'] }, + * ], + * 'action', false, 1, + * ) + * // → [ + * // ' action?: one of ', + * // ' | type = "click": { type: "click", x: number }', + * // ' | type = "key": { type: "key", char: string }', + * // ] + */ +function serializeUnion( + schema: JSONSchema7, + fieldName: string, + optional: boolean, + level: number, +): string[] { + if (!fieldName) return []; + + const objectBranches = (schema.anyOf ?? schema.oneOf ?? []).filter( + (b): b is JSONSchema7 => typeof b === 'object' && b !== null, + ); + const discriminator = detectDiscriminator(objectBranches); + const requiredSuffix = optional ? '' : ' (required)'; + const defaultSuffix = + schema.default !== undefined ? ` (default: ${JSON.stringify(schema.default)})` : ''; + const header = `${pad(level)}${fieldPrefix(fieldName, optional)}one of ${requiredSuffix}${defaultSuffix}`; + + const branchLines = objectBranches.map((branch) => { + const constProp = discriminator ? getConstValue(branch.properties?.[discriminator]) : undefined; + const branchLabel = + constProp !== undefined ? `${discriminator} = ${JSON.stringify(constProp)}` : '?'; + const fields = serializeBranchFields(branch); + return `${pad(level)} | ${branchLabel}: { ${fields.join(', ')} }`; + }); + + return [header, ...branchLines]; +} + +/** + * Emits a multi-line block for arrays whose `items` is a union (`anyOf` / `oneOf`). + * Each branch is rendered on its own indented line via `serializeArrayUnionBranch`. + * + * @example + * // Scalar union items + * serializeArrayUnion({ type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, 'vals', true, 1) + * // → [ + * // ' vals?: array with items any of:', + * // ' | string', + * // ' | number', + * // ] + * + * // Object union items (see serializeArrayUnionBranch for the expanded shape) + * // → ' events?: array with items any of:' + * // ' | (kind = "click")' + * // ' x: number (required)' + */ +function serializeArrayUnion( + schema: JSONSchema7, + fieldName: string, + optional: boolean, + level: number, +): string[] { + if (!fieldName) return []; + + const items = schema.items as JSONSchema7; + const branches = (items.anyOf ?? items.oneOf ?? []).filter( + (b): b is JSONSchema7 => typeof b === 'object' && b !== null, + ); + const discriminator = detectDiscriminator(branches); + const requiredSuffix = optional ? '' : ' (required)'; + const defaultSuffix = + schema.default !== undefined ? ` (default: ${JSON.stringify(schema.default)})` : ''; + const header = `${pad(level)}${fieldPrefix(fieldName, optional)}array with items any of:${requiredSuffix}${defaultSuffix}`; + const branchLines = branches.flatMap((branch) => + serializeArrayUnionBranch(branch, discriminator, level), + ); + + return [header, ...branchLines]; +} + +/** + * Renders a single branch of an array-item union. + * + * - Scalar branch (no `properties`): single `| type` line. + * - Object branch: a `| (discriminator = value)` header followed by the branch's + * own properties expanded on indented lines (the discriminator field itself is omitted + * from the body since it is already captured in the header label). + * + * @example + * // Scalar branch + * serializeArrayUnionBranch({ type: 'string' }, undefined, 1) + * // → [' | string'] + * + * // Object branch with discriminator + * serializeArrayUnionBranch( + * { type: 'object', properties: { kind: { const: 'click' }, x: { type: 'number' } }, required: ['kind', 'x'] }, + * 'kind', 1, + * ) + * // → [ + * // ' | (kind = "click")', + * // ' x: number (required)', + * // ] + */ +function serializeArrayUnionBranch( + branch: JSONSchema7, + discriminator: string | undefined, + level: number, +): string[] { + if (!branch.properties) { + return [`${pad(level)} | ${typeLabel(branch)}`]; + } + + const constProp = discriminator ? getConstValue(branch.properties[discriminator]) : undefined; + const branchLabel = + constProp !== undefined ? `${discriminator} = ${JSON.stringify(constProp)}` : '?'; + const required = new Set(branch.required ?? []); + + const fieldLines = Object.entries(branch.properties).flatMap(([propName, propSchema]) => { + if (typeof propSchema !== 'object' || propSchema === null) return []; + if (propName === discriminator) return []; + return serializeSchema(propSchema, propName, !required.has(propName), level + 2); + }); + + return [`${pad(level)} | (${branchLabel})`, ...fieldLines]; +} + +// --------------------------------------------------------------------------- +// Discriminator helpers +// --------------------------------------------------------------------------- + +/** + * Returns true when the array schema's `items` is itself a union (`anyOf` / `oneOf`), + * meaning the array needs multi-line branch expansion rather than a simple inline label. + * + * @example + * hasUnionItems({ type: 'array', items: { anyOf: [{ type: 'string' }] } }) // → true + * hasUnionItems({ type: 'array', items: { type: 'string' } }) // → false + * hasUnionItems({ type: 'array' }) // → false + */ +function hasUnionItems(schema: JSONSchema7): boolean { + const { items } = schema; + if (!items || typeof items !== 'object' || Array.isArray(items)) return false; + return !!(items.anyOf?.length ?? items.oneOf?.length); +} + +/** + * Finds the first property whose value is a const (or single-value enum) in every branch — + * this is the discriminator field that uniquely identifies each variant. + * Returns `undefined` when no such property exists across all branches. + * + * @example + * detectDiscriminator([ + * { type: 'object', properties: { type: { const: 'click' }, x: { type: 'number' } } }, + * { type: 'object', properties: { type: { const: 'key' }, y: { type: 'number' } } }, + * ]) + * // → 'type' + * + * detectDiscriminator([ + * { type: 'object', properties: { a: { type: 'string' } } }, + * { type: 'object', properties: { b: { type: 'number' } } }, + * ]) + * // → undefined (no shared const property) + */ +function detectDiscriminator(branches: JSONSchema7[]): string | undefined { + const [first] = branches; + const { properties } = first ?? {}; + if (!properties) return undefined; + + return Object.keys(properties).find((propName) => { + if (getConstValue(properties[propName]) === undefined) return false; + return branches.every( + (b) => b.properties && getConstValue(b.properties[propName]) !== undefined, + ); + }); +} + +/** + * Extracts a fixed constant from a schema property definition. + * A single-value `enum` is treated the same as `const` since both pin the field to one value. + * + * @example + * getConstValue({ const: 'click' }) // → 'click' + * getConstValue({ enum: ['start'] }) // → 'start' + * getConstValue({ enum: ['a', 'b'] }) // → undefined (not a single value) + * getConstValue({ type: 'string' }) // → undefined + */ +function getConstValue(schema: JSONSchema7Definition | undefined): unknown { + if (typeof schema !== 'object' || schema === null) return undefined; + if (schema.const !== undefined) return schema.const; + if (schema.enum && schema.enum.length === 1) return schema.enum[0]; + return undefined; +} + +/** + * Renders all properties of a branch as an inline comma-separated list. + * Used to produce the `{ field: type, field?: type }` part of a union branch label. + * + * @example + * serializeBranchFields({ + * type: 'object', + * properties: { type: { const: 'click' }, x: { type: 'number' }, label: { type: 'string' } }, + * required: ['type', 'x'], + * }) + * // → ['type: "click"', 'x: number', 'label?: string'] + */ +function serializeBranchFields(schema: JSONSchema7): string[] { + if (!schema.properties) return []; + const required = new Set(schema.required ?? []); + return Object.entries(schema.properties) + .filter( + (entry): entry is [string, JSONSchema7] => typeof entry[1] === 'object' && entry[1] !== null, + ) + .map( + ([propName, propSchema]) => + `${propName}${required.has(propName) ? '' : '?'}: ${typeLabel(propSchema)}`, + ); +} diff --git a/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts b/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts new file mode 100644 index 00000000000..24298a72ecd --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-checkpoint.repository.ts @@ -0,0 +1,22 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { AgentCheckpoint } from '../entities/agent-checkpoint.entity'; + +@Service() +export class AgentCheckpointRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentCheckpoint, dataSource.manager); + } + + async markExpired(olderThan: Date): Promise { + const result = await this.createQueryBuilder() + .update() + .set({ expired: true, state: null }) + .where('updatedAt < :olderThan', { olderThan }) + .andWhere('expired = :expired', { expired: false }) + .execute(); + + return result.affected ?? 0; + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts new file mode 100644 index 00000000000..6531860ffe6 --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-execution-thread.repository.ts @@ -0,0 +1,115 @@ +import { Service } from '@n8n/di'; +import { DataSource, LessThan, Repository } from '@n8n/typeorm'; + +import { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; + +export interface AgentExecutionThreadPage { + threads: AgentExecutionThread[]; + nextCursor: string | null; +} + +@Service() +export class AgentExecutionThreadRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentExecutionThread, dataSource.manager); + } + + /** + * Find an existing thread or create a new one. + * On creation, assigns a stable sessionNumber scoped to the project. + */ + async findOrCreate( + threadId: string, + agentId: string, + agentName: string, + projectId: string, + ): Promise<{ thread: AgentExecutionThread; created: boolean }> { + const existing = await this.findOneBy({ id: threadId }); + if (existing) { + return { thread: existing, created: false }; + } + + const maxResult = await this.createQueryBuilder('t') + .select('MAX(t.sessionNumber)', 'max') + .where('t.projectId = :projectId', { projectId }) + .getRawOne<{ max: number | null }>(); + + const sessionNumber = (maxResult?.max ?? 0) + 1; + + const thread = this.create({ id: threadId, agentId, agentName, projectId, sessionNumber }); + const saved = await this.save(thread); + return { thread: saved, created: true }; + } + + /** + * Paginated thread listing sorted by updatedAt DESC. + * Uses cursor-based pagination where the cursor is the updatedAt ISO string + * of the last item on the previous page. + */ + async findByProjectIdPaginated( + projectId: string, + limit: number, + cursor?: string, + agentId?: string, + ): Promise { + const where: Record = { projectId }; + if (agentId) { + where.agentId = agentId; + } + if (cursor) { + where.updatedAt = LessThan(new Date(cursor)); + } + + const threads = await this.find({ + where, + order: { updatedAt: 'DESC' }, + take: limit + 1, + }); + + const hasMore = threads.length > limit; + if (hasMore) threads.pop(); + + return { + threads, + nextCursor: hasMore ? threads[threads.length - 1].updatedAt.toISOString() : null, + }; + } + + /** Bump updatedAt to now so the thread sorts to top of the list. */ + async bumpUpdatedAt(threadId: string): Promise { + await this.update(threadId, { updatedAt: new Date() }); + } + + /** Atomically increment token and cost counters on a thread in a single UPDATE. */ + async incrementUsage( + threadId: string, + promptTokens: number, + completionTokens: number, + cost: number, + duration: number, + ): Promise { + const set: Record string> = { + totalPromptTokens: () => '"totalPromptTokens" + :promptTokens', + totalCompletionTokens: () => '"totalCompletionTokens" + :completionTokens', + }; + if (cost > 0) { + set.totalCost = () => '"totalCost" + :cost'; + } + if (duration > 0) { + set.totalDuration = () => '"totalDuration" + :duration'; + } + + await this.createQueryBuilder() + .update(AgentExecutionThread) + .set(set) + .where('id = :threadId', { threadId }) + .setParameters({ promptTokens, completionTokens, cost, duration }) + .execute(); + } + + /** Delete a thread, validating project ownership. Returns true if deleted. */ + async deleteByIdAndProjectId(threadId: string, projectId: string): Promise { + const result = await this.delete({ id: threadId, projectId }); + return (result.affected ?? 0) > 0; + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts new file mode 100644 index 00000000000..c13138b0d03 --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-execution.repository.ts @@ -0,0 +1,68 @@ +import { Service } from '@n8n/di'; +import { DataSource, IsNull, Repository } from '@n8n/typeorm'; + +import { AgentExecution } from '../entities/agent-execution.entity'; + +@Service() +export class AgentExecutionRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentExecution, dataSource.manager); + } + + /** All executions in a thread, oldest first — used by the timeline view. */ + async findByThreadIdOrdered(threadId: string): Promise { + return await this.find({ where: { threadId }, order: { createdAt: 'ASC' } }); + } + + /** + * The first user-message text in each of the given threads. Used by the + * sessions list to render a preview before the LLM-generated title is + * available. + * + * Excludes resumed runs (empty `userMessage`). Returns one row per thread + * containing the userMessage from that thread's earliest matching run. + */ + async findFirstUserMessageByThreadIds(threadIds: string[]): Promise> { + if (threadIds.length === 0) return new Map(); + + // Correlated subquery: for each thread, pick the row with the smallest + // createdAt that has a non-empty userMessage. + const rows = await this.createQueryBuilder('e') + .select(['e.threadId AS threadId', 'e.userMessage AS userMessage']) + .where('e.threadId IN (:...threadIds)', { threadIds }) + .andWhere("e.userMessage != ''") + .andWhere( + 'e.createdAt = (SELECT MIN(e2.createdAt) FROM agent_execution e2 ' + + "WHERE e2.threadId = e.threadId AND e2.userMessage != '')", + ) + .getRawMany<{ threadId: string; userMessage: string }>(); + + return new Map(rows.map((r) => [r.threadId, r.userMessage])); + } + + /** + * Suspended runs in a thread that don't yet have a `model` recorded. + * Used by the resume-completion path to backfill model info, which only + * arrives once the resumed run finishes. + */ + async findSuspendedWithoutModel(threadId: string): Promise { + return await this.find({ + where: { threadId, hitlStatus: 'suspended', model: IsNull() }, + }); + } + + /** Backfill model on a set of executions in a single statement. */ + async backfillModel(executionIds: string[], model: string): Promise { + if (executionIds.length === 0) return; + await this.createQueryBuilder() + .update(AgentExecution) + .set({ model }) + .whereInIds(executionIds) + .execute(); + } + + /** Delete every run in a thread. Caller must verify ownership first. */ + async deleteByThreadId(threadId: string): Promise { + await this.delete({ threadId }); + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent-message.repository.ts b/packages/cli/src/modules/agents/repositories/agent-message.repository.ts new file mode 100644 index 00000000000..53259396e84 --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-message.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { AgentMessageEntity } from '../entities/agent-message.entity'; + +@Service() +export class AgentMessageRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentMessageEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts b/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts new file mode 100644 index 00000000000..7ec5181d265 --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-published-version.repository.ts @@ -0,0 +1,53 @@ +import { Service } from '@n8n/di'; +import type { EntityManager } from '@n8n/typeorm'; +import { DataSource, Repository } from '@n8n/typeorm'; +import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; + +import { AgentPublishedVersion } from '../entities/agent-published-version.entity'; +import type { Agent } from '../entities/agent.entity'; +import type { AgentJsonConfig } from '../json-config/agent-json-config'; + +@Service() +export class AgentPublishedVersionRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentPublishedVersion, dataSource.manager); + } + + /** + * Atomically creates or updates the single published snapshot for an agent. + * Upserts on `agentId` (the PK) — one row per agent, always overwritten on publish. + * + * Pass `trx` to execute within an existing transaction. + */ + async savePublishedVersion( + data: { + agentId: string; + schema: AgentJsonConfig | null; + tools: Agent['tools'] | null; + skills: Agent['skills'] | null; + publishedFromVersionId: string; + model: string | null; + provider: string | null; + credentialId: string | null; + publishedById: string; + }, + trx?: EntityManager, + ): Promise { + const repo = trx?.getRepository(AgentPublishedVersion) ?? this; + // TypeORM's _QueryDeepPartialEntity cannot represent Zod-inferred types like + // AgentJsonConfig. The cast is safe: @JsonColumn serialises the value at runtime. + // Set `updatedAt` explicitly — upsert's ON CONFLICT UPDATE path does not fire the + // @BeforeUpdate / @UpdateDateColumn hooks, so it would otherwise stay at the first + // publish timestamp on re-publish. + await repo.upsert( + { ...data, updatedAt: new Date() } as QueryDeepPartialEntity, + ['agentId'], + ); + return await repo.findOneByOrFail({ agentId: data.agentId }); + } + + async deleteByAgentId(agentId: string, trx?: EntityManager): Promise { + const repo = trx?.getRepository(AgentPublishedVersion) ?? this; + await repo.delete({ agentId }); + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts b/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts new file mode 100644 index 00000000000..5d41fd25d9a --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-resource.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { AgentResourceEntity } from '../entities/agent-resource.entity'; + +@Service() +export class AgentResourceRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentResourceEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent-thread.repository.ts b/packages/cli/src/modules/agents/repositories/agent-thread.repository.ts new file mode 100644 index 00000000000..7a9fda65619 --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent-thread.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { AgentThreadEntity } from '../entities/agent-thread.entity'; + +@Service() +export class AgentThreadRepository extends Repository { + constructor(dataSource: DataSource) { + super(AgentThreadEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts new file mode 100644 index 00000000000..8ae5159ee4c --- /dev/null +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -0,0 +1,71 @@ +import { isAgentCredentialIntegration } from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { Agent } from '../entities/agent.entity'; + +@Service() +export class AgentRepository extends Repository { + constructor(dataSource: DataSource) { + super(Agent, dataSource.manager); + } + + async findByProjectId(projectId: string): Promise { + return await this.find({ + where: { projectId }, + relations: { publishedVersion: true }, + order: { updatedAt: 'DESC' }, + }); + } + + /** + * Finds an agent by ID and project ID, eagerly loading its `publishedVersion` relation. + * + * TypeORM does not load relations by default — without `relations: { publishedVersion: true }`, + * `agent.publishedVersion` would always be `undefined` even if a row exists in + * `agent_published_version`. The eager load is needed so the frontend receives the full + * published snapshot (or `null`) in a single query, which is what the publish button uses + * to compute its state (published vs. unpublished, has changes vs. up to date). + */ + async findByIdAndProjectId(id: string, projectId: string): Promise { + return await this.findOne({ + where: { id, projectId }, + relations: { publishedVersion: true }, + }); + } + + async findPublished(): Promise { + return await this.createQueryBuilder('agent') + .innerJoinAndSelect('agent.publishedVersion', 'publishedVersion') + .getMany(); + } + + /** + * Finds agents within a project whose `integrations` JSON column contains an + * entry matching the given `type` + `credentialId`, excluding `excludeAgentId`. + * + * Scoped to a single project because credentials are project-scoped in n8n — + * an agent can only use credentials from its own project, so conflicts can + * only occur between agents in the same project. + * + * Filters in memory because `integrations` is a JSON column with no portable + * SQL query across SQLite/Postgres/MySQL. Agent counts per project are small + * enough that this is fine. + */ + async findByIntegrationCredential( + type: string, + credentialId: string, + projectId: string, + excludeAgentId: string, + ): Promise { + const agents = await this.find({ where: { projectId } }); + return agents.filter( + (agent) => + agent.id !== excludeAgentId && + (agent.integrations ?? []).some( + (i) => + isAgentCredentialIntegration(i) && i.type === type && i.credentialId === credentialId, + ), + ); + } +} diff --git a/packages/cli/src/modules/agents/runtime/agent-isolate-pool.ts b/packages/cli/src/modules/agents/runtime/agent-isolate-pool.ts new file mode 100644 index 00000000000..2c0827e0568 --- /dev/null +++ b/packages/cli/src/modules/agents/runtime/agent-isolate-pool.ts @@ -0,0 +1,385 @@ +import type { Logger } from '@n8n/backend-common'; +import type ivm from 'isolated-vm'; + +import { SANDBOX_POLYFILLS } from './sandbox-polyfills'; + +export class PoolDisposedError extends Error { + constructor() { + super('Agent isolate pool is disposed'); + this.name = 'PoolDisposedError'; + } +} + +export class PoolExhaustedError extends Error { + constructor() { + super('Agent isolate pool is exhausted — too many concurrent requests. Try again later.'); + this.name = 'PoolExhaustedError'; + } +} + +/** + * A single V8 isolate + pre-compiled bundle script. + * + * Holds one `ivm.Isolate` and the library bundle compiled into V8 bytecode. + * In Phase 1 each request creates a fresh context via `createContext()`. + * In Phase 2 (reusable context) the context will be created once at init. + */ +export class AgentIsolateSlot { + readonly isolate: ivm.Isolate; + + private bundleScript: ivm.Script; + + constructor(ivmModule: typeof ivm, memoryLimit: number, libraryBundle: string) { + this.isolate = new ivmModule.Isolate({ memoryLimit }); + this.bundleScript = this.isolate.compileScriptSync(libraryBundle); + } + + get isHealthy(): boolean { + return !this.isolate.isDisposed; + } + + /** + * Create a fresh context with polyfills, require shim, and the library bundle + * already evaluated. The caller is responsible for calling `context.release()` + * when done. + */ + createContext(): ivm.Context { + const context = this.isolate.createContextSync(); + try { + const jail = context.global; + + jail.setSync('global', jail.derefInto()); + + context.evalSync(SANDBOX_POLYFILLS, { timeout: 5000 }); + + context.evalSync( + ` + globalThis.__modules = {}; + globalThis.require = function(id) { + if (globalThis.__modules[id]) { + return globalThis.__modules[id]; + } + // Return empty stub for unknown modules (Node built-ins, etc.) + return new Proxy({}, { + get: function(target, prop) { + if (prop === '__esModule') return false; + if (prop === 'default') return {}; + return function() { return {}; }; + } + }); + }; + `, + { timeout: 5000 }, + ); + + this.bundleScript.runSync(context, { timeout: 10000 }); + + return context; + } catch (e) { + context.release(); + throw e; + } + } + + dispose(): void { + if (this.isHealthy) { + this.bundleScript.release(); + this.isolate.dispose(); + } + } +} + +export interface AgentIsolatePoolOptions { + /** Number of warm isolate slots to keep ready. Default: 2. */ + size?: number; + /** Per-isolate memory limit in MB. Default: 32. */ + memoryLimit?: number; + /** + * Fraction of `memoryLimit` above which a slot is proactively recycled + * on release instead of being returned to the pool. Default: 0.8. + */ + highWaterMarkRatio?: number; + /** + * Maximum number of `acquire()` calls that can wait for a free slot. + * Beyond this, `acquire()` rejects immediately with `PoolExhaustedError`. + * Default: 10. + */ + maxQueueDepth?: number; + /** Logger instance for pool lifecycle events. */ + logger?: Logger; +} + +type WaitEntry = { + resolve: (slot: AgentIsolateSlot) => void; + reject: (error: Error) => void; +}; + +// TODO: add slot TTL eviction to reduce idle memory usage +/** + * Pool of `AgentIsolateSlot` instances with queuing semantics. + * + * - `acquire()` pops a healthy slot from the pool. If the pool is empty the + * call returns a Promise that resolves when a slot is released back. + * - `release(slot)` returns a slot to the pool (or discards it if unhealthy / + * over the high-water mark) and triggers background replenishment. + * - `tryAcquireSync()` is a non-blocking variant for the sync execution path. + * - Replenishment retries up to `MAX_REPLENISH_RETRIES` times with + * exponential backoff on failure. + */ +export class AgentIsolatePool { + private slots: AgentIsolateSlot[] = []; + + private waitQueue: WaitEntry[] = []; + + private disposed = false; + + /** Number of slots currently being created asynchronously. */ + private warming = 0; + + /** Tracked so `dispose()` can await in-flight replenishments. */ + private replenishPromises = new Set>(); + + private readonly size: number; + + private readonly memoryLimit: number; + + private readonly highWaterMarkRatio: number; + + private readonly maxQueueDepth: number; + + private readonly logger: Logger | undefined; + + private static readonly MAX_REPLENISH_RETRIES = 3; + + private static readonly REPLENISH_RETRY_BASE_MS = 500; + + constructor( + private readonly ivmModule: typeof ivm, + private readonly libraryBundle: string, + options: AgentIsolatePoolOptions = {}, + ) { + this.size = options.size ?? 2; + this.memoryLimit = options.memoryLimit ?? 32; + this.highWaterMarkRatio = options.highWaterMarkRatio ?? 0.8; + this.maxQueueDepth = options.maxQueueDepth ?? 10; + this.logger = options.logger; + } + + async initialize(): Promise { + const results = await Promise.allSettled( + Array.from({ length: this.size }, async () => await Promise.resolve(this.createSlot())), + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + this.slots.push(result.value); + } else { + this.logger?.warn('[AgentIsolatePool] Failed to create slot during init', { + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + + if (this.slots.length === 0) { + const firstRejection = results.find((r) => r.status === 'rejected'); + const cause = + firstRejection?.reason instanceof Error + ? firstRejection.reason + : new Error(String(firstRejection?.reason)); + throw new Error( + 'AgentIsolatePool: failed to create any isolate slots during initialization', + { cause }, + ); + } + + // Kick off background replenishment for any slots that failed during init. + const missing = this.size - this.slots.length; + for (let i = 0; i < missing; i++) { + void this.replenish(); + } + } + + /** + * Acquire a slot from the pool. + * + * If the pool is empty the returned Promise is queued and resolves when + * another caller releases a slot (or replenishment completes). + * Rejects immediately with `PoolExhaustedError` when the wait queue is full. + */ + async acquire(): Promise { + if (this.disposed) throw new PoolDisposedError(); + + const slot = this.slots.shift(); + if (slot) { + // Kick off background replenishment to refill the pool proactively. + void this.replenish(); + return slot; + } + + if (this.waitQueue.length >= this.maxQueueDepth) { + this.logger?.warn('[AgentIsolatePool] Pool exhausted — request rejected', { + queueDepth: this.waitQueue.length, + maxQueueDepth: this.maxQueueDepth, + }); + throw new PoolExhaustedError(); + } + + return await new Promise((resolve, reject) => { + this.waitQueue.push({ resolve, reject }); + }); + } + + /** + * Return a slot to the pool. + * + * Unhealthy slots (disposed isolate or heap over high-water mark) are + * discarded and background replenishment is triggered. Healthy slots + * are passed directly to the next waiter or pushed back into the pool. + */ + release(slot: AgentIsolateSlot): void { + if (this.disposed) { + slot.dispose(); + return; + } + + if (!slot.isHealthy) { + this.logger?.warn('[AgentIsolatePool] Slot OOM — discarding and replenishing'); + slot.dispose(); + void this.replenish(); + return; + } + + if (this.isOverHighWaterMark(slot)) { + this.logger?.debug('[AgentIsolatePool] Slot over high-water mark — proactively recycling'); + slot.dispose(); + void this.replenish(); + return; + } + + const waiter = this.waitQueue.shift(); + if (waiter) { + waiter.resolve(slot); + return; + } + + this.slots.push(slot); + } + + /** + * Non-blocking acquire for the synchronous execution path. + * Returns `null` instead of queuing when the pool is empty. + */ + tryAcquireSync(): AgentIsolateSlot | null { + if (this.disposed) return null; + + const slot = this.slots.shift(); + if (slot) { + void this.replenish(); + return slot; + } + + return null; + } + + async dispose(): Promise { + this.disposed = true; + + const error = new PoolDisposedError(); + for (const { reject } of this.waitQueue) { + reject(error); + } + this.waitQueue = []; + + await Promise.all([...this.replenishPromises]); + + for (const slot of this.slots) { + slot.dispose(); + } + this.slots = []; + } + + private isOverHighWaterMark(slot: AgentIsolateSlot): boolean { + try { + const stats = slot.isolate.getHeapStatisticsSync(); + const limitBytes = this.memoryLimit * 1024 * 1024; + return stats.used_heap_size > limitBytes * this.highWaterMarkRatio; + } catch (error) { + this.logger?.warn( + '[AgentIsolatePool] Failed to get heap statistics — assuming over high-water mark', + { + error: error instanceof Error ? error.message : String(error), + }, + ); + return true; + } + } + + private createSlot(): AgentIsolateSlot { + return new AgentIsolateSlot(this.ivmModule, this.memoryLimit, this.libraryBundle); + } + + private replenish(attempt = 0): void { + if (this.disposed) return; + // Don't over-warm: keep total of (available + warming) at pool size. + if (this.slots.length + this.warming >= this.size) return; + + this.warming++; + let promise: Promise; + // eslint-disable-next-line prefer-const + promise = Promise.resolve() + .then(() => { + const slot = this.createSlot(); + this.warming--; + this.replenishPromises.delete(promise); + + if (this.disposed) { + slot.dispose(); + return; + } + + const waiter = this.waitQueue.shift(); + if (waiter) { + waiter.resolve(slot); + // Slot went to a waiter, not into the pool — try to replenish again. + void this.replenish(); + } else { + this.slots.push(slot); + } + }) + .catch((error: unknown) => { + this.warming--; + this.replenishPromises.delete(promise); + + if (attempt < AgentIsolatePool.MAX_REPLENISH_RETRIES) { + const delay = AgentIsolatePool.REPLENISH_RETRY_BASE_MS * 2 ** attempt; + this.logger?.debug('[AgentIsolatePool] Replenishment failed, retrying', { + attempt, + delayMs: delay, + error: error instanceof Error ? error.message : String(error), + }); + // Track the retry timer in replenishPromises so dispose() awaits it. + let retryPromise: Promise; + // eslint-disable-next-line prefer-const + retryPromise = new Promise((resolve) => { + setTimeout(resolve, delay).unref(); + }).then(() => { + this.replenishPromises.delete(retryPromise); + this.replenish(attempt + 1); + }); + this.replenishPromises.add(retryPromise); + } else { + this.logger?.warn('[AgentIsolatePool] Replenishment failed after max retries', { + error: error instanceof Error ? error.message : String(error), + }); + // Unblock any callers waiting for a slot — they cannot be served. + const waitError = new Error( + 'Isolate slot creation permanently failed — no slots available', + ); + for (const { reject } of this.waitQueue.splice(0)) { + reject(waitError); + } + } + }); + this.replenishPromises.add(promise); + } +} diff --git a/packages/cli/src/modules/agents/runtime/agent-secure-runtime.ts b/packages/cli/src/modules/agents/runtime/agent-secure-runtime.ts new file mode 100644 index 00000000000..896459e1b80 --- /dev/null +++ b/packages/cli/src/modules/agents/runtime/agent-secure-runtime.ts @@ -0,0 +1,373 @@ +import type { ToolDescriptor } from '@n8n/agents'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { readFileSync } from 'fs'; +import type ivm from 'isolated-vm'; +import path from 'path'; +import { transform as sucraseTransform } from 'sucrase'; + +import { AgentIsolatePool, type AgentIsolateSlot } from './agent-isolate-pool'; +import type { ToolExecutor } from '../json-config/from-json-config'; + +/** + * Location of the pre-built library bundle (see scripts/bundle-agent-library.mjs). + * + * This path resolves the same way whether the current file is running from + * `src/modules/agents/runtime/` (ts-jest) or `dist/modules/agents/runtime/` + * (production), because both trees have the same depth under the cli package. + */ +const LIBRARY_BUNDLE_PATH = path.resolve(__dirname, '../../../../dist/agent-library-bundle.js'); + +/** + * Sandboxed execution runtime for agent user code. + * + * Uses `isolated-vm` (V8 isolates) to run untrusted TypeScript in a confined + * environment. Libraries (`@n8n/agents`, `zod`) are pre-bundled at build time + * (scripts/bundle-agent-library.mjs) into `dist/agent-library-bundle.js` and + * loaded into each isolate context as source code. This avoids module + * resolution issues with pnpm workspace symlinks and keeps esbuild off the + * runtime path. + * + * The runtime maintains a pool of V8 isolates (`AgentIsolatePool`) to handle + * concurrent requests without sharing heap memory. Each public method acquires + * a slot, runs the operation in a fresh context, and releases the slot back. + * OOM'd isolates are discarded and replenished in the background. + */ +@Service() +export class AgentSecureRuntime { + constructor(private readonly logger: Logger) {} + + /** Active pool — null until first use. */ + private pool: AgentIsolatePool | null = null; + + /** Serializes pool initialization across concurrent first-callers. */ + private poolInitPromise: Promise | null = null; + + /** Set by dispose() to prevent a concurrent getPool() from re-installing a disposed pool. */ + private disposed = false; + + /** + * Pre-bundled JS string containing @n8n/agents + zod for injection into + * isolates. Read from disk on first use and cached here at the runtime + * level so it is never cleared on OOM (the bundle is a plain string, + * not bound to any isolate instance). + */ + private libraryBundle: string | null = null; + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private async getPool(): Promise { + if (this.pool) return this.pool; + this.poolInitPromise ??= (async () => { + try { + const ivmModule = (await import('isolated-vm')).default; + const libraryBundle = this.getLibraryBundle(); + const pool = new AgentIsolatePool(ivmModule, libraryBundle, { + logger: this.logger, + }); + await pool.initialize(); + // Guard: dispose() may have run while we were awaiting initialization. + // If so, discard the freshly-created pool and surface a clear error. + if (this.disposed) { + void pool.dispose(); + throw new Error('AgentSecureRuntime was disposed during pool initialization'); + } + this.pool = pool; + return pool; + } catch (e) { + this.poolInitPromise = null; + throw e; + } + })(); + return await this.poolInitPromise; + } + + /** + * Acquire an isolate slot, run `fn`, and release the slot. + * + * If the slot's isolate was disposed during execution (OOM), the operation + * is retried once with a fresh slot before re-throwing the error. + */ + private async withIsolate(fn: (slot: AgentIsolateSlot) => T | Promise): Promise { + const pool = await this.getPool(); + const slot = await pool.acquire(); + try { + return await fn(slot); + } catch (error) { + if (!slot.isHealthy) { + // Slot OOM'd during execution — retry once with a fresh slot. + this.logger.warn( + '[AgentSecureRuntime] Isolate OOM during execution — retrying with fresh slot', + ); + const freshSlot = await pool.acquire(); + try { + return await fn(freshSlot); + } catch (retryError) { + if (!freshSlot.isHealthy) { + // Both slots OOM'd — cap at one retry; both are recycled via release() below. + this.logger.error( + '[AgentSecureRuntime] Retry slot also OOM — both isolates will be recycled; not retrying further', + ); + } + throw retryError; + } finally { + pool.release(freshSlot); + } + } + throw error; + } finally { + pool.release(slot); + } + } + + /** + * Load the pre-built `@n8n/agents` + `zod` CJS bundle from disk. + * + * The bundle is produced at build time by `scripts/bundle-agent-library.mjs` + * and loaded into each isolate context as source code, making + * `require('@n8n/agents')` and `require('zod')` work inside the sandbox + * without needing filesystem access or module resolution. + * + * Cached after the first read so repeated pool initialisations (e.g. after + * dispose + re-init) don't re-hit the disk. + */ + private getLibraryBundle(): string { + if (this.libraryBundle) return this.libraryBundle; + + try { + this.libraryBundle = readFileSync(LIBRARY_BUNDLE_PATH, 'utf8'); + } catch (e) { + throw new Error( + `Agent library bundle not found at ${LIBRARY_BUNDLE_PATH}. ` + + "Run 'pnpm build' in packages/cli to generate it.", + { cause: e instanceof Error ? e : undefined }, + ); + } + return this.libraryBundle; + } + + /** + * Strip TypeScript types and convert ESM imports to CommonJS using sucrase. + * + * sucrase is a pure-JS transpiler (no native binary), so it works in the + * Docker image where esbuild's platform-specific binary is absent. + */ + private compileTs(tsCode: string): string { + const { code } = sucraseTransform(tsCode, { + transforms: ['typescript', 'imports'], + }); + return code; + } + + /** + * Safely parse a JSON string that originated from the sandbox. + * + * Two protections over a bare `JSON.parse`: + * 1. Validates the value is a string before parsing (ivm's copySync can in + * theory return non-strings if the sandbox assigns a non-string to + * module.exports — this makes the failure explicit rather than a + * confusing "unexpected token" downstream). + * 2. Uses a reviver that drops `__proto__` keys. Modern V8 does not allow + * `JSON.parse` to directly write `Object.prototype`, but a sandbox-crafted + * payload containing `{"__proto__": {...}}` could still poison objects + * downstream when the result is spread or passed to `Object.assign` / a + * deep-merge utility. Dropping the key at parse time removes the vector + * before the data ever reaches host code. + * Note: `ivm.copySync` (structured clone) already strips `__proto__` across + * the isolate boundary, so this is belt-and-suspenders protection. + */ + private parseSandboxJson(raw: unknown, context: string): T { + if (typeof raw !== 'string') { + throw new Error( + `Sandbox (${context}) produced a non-string result: ${typeof raw}. Expected JSON string.`, + ); + } + try { + return JSON.parse(raw, (key, value) => (key === '__proto__' ? undefined : (value as T))) as T; + } catch (e) { + throw new Error( + `Sandbox (${context}) produced invalid JSON: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + /** + * Run code in the isolate and return the result. + */ + private runInContext(context: ivm.Context, slot: AgentIsolateSlot, code: string): T { + // Set up module.exports + context.evalSync('var module = { exports: {} }; var exports = module.exports;', { + timeout: 5000, + }); + + const script = slot.isolate.compileScriptSync(code); + script.runSync(context, { timeout: 5000 }); + script.release(); + + // Extract module.exports via reference + copy + const ref = context.global.getSync('module', { reference: true }); + const moduleObj = ref.copySync() as { exports: T }; + ref.release(); + + return moduleObj.exports; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Run a standalone tool TypeScript file in a sandbox, call describe() on the + * exported Tool instance, and return the ToolDescriptor JSON. + * + * Expects `export default new Tool(...)` pattern (no .build() call needed). + */ + async describeToolSecurely(tsCode: string): Promise { + const jsCode = this.compileTs(tsCode); + + return await this.withIsolate( + // eslint-disable-next-line @typescript-eslint/require-await -- `withIsolate` expects a Promise-returning callback + async (slot) => { + const context = slot.createContext(); + try { + const wrapper = ` + var __me = {}; + var __mod = { exports: __me }; + (function(exports, require, module) { + ${jsCode} + })(__me, require, __mod); + + var __exported = __mod.exports.default || __mod.exports; + + if (!__exported || typeof __exported !== 'object' || typeof __exported.describe !== 'function') { + throw new Error('No Tool found. Export a Tool as default: export default new Tool(...);'); + } + + module.exports = JSON.stringify(__exported.describe()); + `; + + const resultJson = this.runInContext(context, slot, wrapper); + return this.parseSandboxJson(resultJson, 'describeToolSecurely'); + } finally { + context.release(); + } + }, + ); + } + + /** + * Execute a standalone tool's handler in the sandbox. + * The tool code must follow `export default new Tool(...)` pattern. + */ + async executeToolInIsolate(toolCode: string, input: unknown, ctx: unknown): Promise { + const jsCode = this.compileTs(toolCode); + + return await this.withIsolate(async (slot) => { + const context = slot.createContext(); + try { + const setupCode = ` + var __me = {}; + var __mod = { exports: __me }; + (function(exports, require, module) { + ${jsCode} + })(__me, require, __mod); + + globalThis.__exportedTool = __mod.exports.default || __mod.exports; + if (!globalThis.__exportedTool || typeof globalThis.__exportedTool !== 'object') { + throw new Error('No Tool found'); + } + `; + const setupScript = slot.isolate.compileScriptSync(setupCode); + setupScript.runSync(context, { timeout: 5000 }); + setupScript.release(); + + const serializedArgs = JSON.stringify({ input, ctx }); + + const invokeCode = ` + (async function() { + var tool = globalThis.__exportedTool; + var handlerFn = tool.handlerFn || (tool.build ? tool.build().handler : null); + if (!handlerFn) { + var built = tool.build ? tool.build() : tool; + handlerFn = built.handler; + } + if (!handlerFn) throw new Error('Tool has no handler'); + + var args = ${serializedArgs}; + var suspendPayload = { called: false, data: null }; + var ctx = Object.assign({}, args.ctx || {}, { + suspend: function(payload) { + suspendPayload.called = true; + suspendPayload.data = payload; + return Promise.resolve({ suspended: true }); + }, + }); + + var result = await handlerFn(args.input, ctx); + + if (suspendPayload.called) { + return JSON.stringify({ __suspend: true, payload: suspendPayload.data }); + } + return JSON.stringify(result); + })() + `; + + const resultJson = (await context.eval(invokeCode, { + timeout: 5000, + promise: true, + copy: true, + })) as string; + + const parsed = this.parseSandboxJson>( + resultJson, + 'executeToolInIsolate', + ); + + if (parsed?.__suspend) { + return { + [Symbol.for('n8n.agent.suspend')]: true, + payload: parsed.payload, + }; + } + + return parsed; + } finally { + context.release(); + } + }); + } + + /** + * Create a ToolExecutor for use with buildFromJson(). + * @param toolsByName Map of tool name -> TypeScript source code + */ + createToolExecutor(toolsByName: Record): ToolExecutor { + return { + executeTool: async (toolName: string, input: unknown, ctx: unknown) => { + const toolCode = toolsByName[toolName]; + if (!toolCode) { + throw new Error(`Tool "${toolName}" not found in tools map`); + } + return await this.executeToolInIsolate(toolCode, input, ctx); + }, + }; + } + + /** + * Dispose the pool and all underlying V8 isolates. Safe to call multiple times. + */ + dispose(): void { + this.disposed = true; + const poolPromise = this.poolInitPromise; + this.pool = null; + this.poolInitPromise = null; + + if (poolPromise) { + void poolPromise.then(async (pool) => await pool.dispose()).catch(() => {}); + } + // Note: libraryBundle is intentionally NOT cleared — it is a plain JS string + // independent of isolate state and can be reused if the pool is re-initialized. + } +} diff --git a/packages/cli/src/modules/agents/runtime/sandbox-polyfills.ts b/packages/cli/src/modules/agents/runtime/sandbox-polyfills.ts new file mode 100644 index 00000000000..4a6669ce7ea --- /dev/null +++ b/packages/cli/src/modules/agents/runtime/sandbox-polyfills.ts @@ -0,0 +1,80 @@ +/** + * Web API stubs for the isolated-vm sandbox. + * + * Bare V8 isolates don't provide Web APIs (streams, fetch, encoding, etc.) + * but bundled code may reference them at module evaluation time. + * These stubs are injected so that `require()` calls don't throw — they + * are never actually called during `describe()` or handler execution. + * + * Extracted from AgentSecureRuntime for testability and maintainability. + */ +export const SANDBOX_POLYFILLS = ` +if (typeof TransformStream === 'undefined') { + globalThis.TransformStream = function() {}; + globalThis.ReadableStream = function() {}; + globalThis.WritableStream = function() {}; + globalThis.TextEncoder = function() { this.encode = function(s) { return s; }; }; + globalThis.TextDecoder = function() { this.decode = function(s) { return s; }; }; +} +if (typeof URL === 'undefined') { + globalThis.URL = function(u) { this.href = u; this.toString = function() { return u; }; }; +} +if (typeof AbortController === 'undefined') { + globalThis.AbortController = function() { this.signal = {}; this.abort = function() {}; }; +} +if (typeof Event === 'undefined') { + globalThis.Event = function(type) { this.type = type; }; +} +if (typeof EventTarget === 'undefined') { + globalThis.EventTarget = function() { + this._listeners = {}; + }; + EventTarget.prototype.addEventListener = function(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + }; + EventTarget.prototype.removeEventListener = function(type, fn) { + if (!this._listeners[type]) return; + this._listeners[type] = this._listeners[type].filter(function(f) { return f !== fn; }); + }; + EventTarget.prototype.dispatchEvent = function() { return true; }; +} +if (typeof CustomEvent === 'undefined') { + globalThis.CustomEvent = function(type, opts) { + this.type = type; + this.detail = opts && opts.detail; + }; +} +if (typeof MessageEvent === 'undefined') { + globalThis.MessageEvent = function(type, opts) { + this.type = type; + this.data = opts && opts.data; + }; +} +if (typeof Headers === 'undefined') { + globalThis.Headers = function(init) { + this._headers = {}; + if (init) for (var k in init) this._headers[k.toLowerCase()] = init[k]; + }; + Headers.prototype.get = function(k) { return this._headers[k.toLowerCase()] || null; }; + Headers.prototype.set = function(k, v) { this._headers[k.toLowerCase()] = v; }; + Headers.prototype.has = function(k) { return k.toLowerCase() in this._headers; }; +} +if (typeof Request === 'undefined') { + globalThis.Request = function(url) { this.url = url; }; +} +if (typeof Response === 'undefined') { + globalThis.Response = function(body) { this.body = body; }; + Response.json = function(data) { return new Response(JSON.stringify(data)); }; +} +if (typeof console === 'undefined') { + globalThis.console = { log: function() {}, warn: function() {}, error: function() {} }; +} +if (typeof setTimeout === 'undefined') { + globalThis.setTimeout = function(fn) { fn(); return 0; }; + globalThis.clearTimeout = function() {}; +} +if (typeof fetch === 'undefined') { + globalThis.fetch = function() { throw new Error('fetch is not available in the sandbox'); }; +} +`; diff --git a/packages/cli/src/modules/agents/tool-registry.ts b/packages/cli/src/modules/agents/tool-registry.ts new file mode 100644 index 00000000000..d5adbe6515f --- /dev/null +++ b/packages/cli/src/modules/agents/tool-registry.ts @@ -0,0 +1,61 @@ +import type { BuiltTool } from '@n8n/agents'; + +export interface ToolRegistryEntry { + kind: 'tool' | 'workflow' | 'node'; + workflowId?: string; + workflowName?: string; + triggerType?: string; + nodeType?: string; + nodeTypeVersion?: number; + nodeDisplayName?: string; + /** + * Configured node parameters from the agent's JSON config (only set for + * `kind: 'node'`). Surfaced to the session timeline so the IO viewer can + * render the node's actual config — channel, operation, `$fromAI(...)` + * templates, etc. — instead of an empty parameters block. + */ + nodeParameters?: Record; +} + +export type ToolRegistry = Map; + +/** + * Build a registry mapping tool name -> { kind, workflowId?, workflowName?, triggerType? }. + * Reads metadata attached to each BuiltTool (populated by workflow-tool-factory for workflow tools). + */ +export function buildToolRegistry(tools: BuiltTool[]): ToolRegistry { + const registry: ToolRegistry = new Map(); + for (const tool of tools) { + const m = tool.metadata; + if ( + m !== undefined && + m.kind === 'workflow' && + typeof m.workflowId === 'string' && + typeof m.workflowName === 'string' + ) { + const entry: ToolRegistryEntry = { + kind: 'workflow', + workflowId: m.workflowId, + workflowName: m.workflowName, + }; + if (typeof m.triggerType === 'string') { + entry.triggerType = m.triggerType; + } + registry.set(tool.name, entry); + } else if (m !== undefined && m.kind === 'node' && typeof m.nodeType === 'string') { + const entry: ToolRegistryEntry = { + kind: 'node', + nodeType: m.nodeType, + }; + if (typeof m.nodeTypeVersion === 'number') entry.nodeTypeVersion = m.nodeTypeVersion; + if (typeof m.displayName === 'string') entry.nodeDisplayName = m.displayName; + if (m.nodeParameters && typeof m.nodeParameters === 'object') { + entry.nodeParameters = m.nodeParameters as Record; + } + registry.set(tool.name, entry); + } else { + registry.set(tool.name, { kind: 'tool' }); + } + } + return registry; +} diff --git a/packages/cli/src/modules/agents/tools/__tests__/node-tool-factory.test.ts b/packages/cli/src/modules/agents/tools/__tests__/node-tool-factory.test.ts new file mode 100644 index 00000000000..0342c93c622 --- /dev/null +++ b/packages/cli/src/modules/agents/tools/__tests__/node-tool-factory.test.ts @@ -0,0 +1,160 @@ +import { Container } from '@n8n/di'; +import { z } from 'zod'; + +import type { EphemeralNodeExecutor } from '@/node-execution'; +import { NodeTypes } from '@/node-types'; + +import { resolveNodeTool } from '../node-tool-factory'; + +// The node-tool-factory imports the DI `Container` to look up NodeTypes inside +// `resolveInputSchema` (for auto-seeding a `{ input: string }` schema on +// native tools). In the unit-test scope the container isn't registered and +// `Container.get(NodeTypes)` throws — the function's own `try/catch` swallows +// that and falls back to an empty schema. The test doesn't need a real executor +// either; the handler isn't invoked. +const mockCtx = { + executor: {} as unknown as EphemeralNodeExecutor, + projectId: 'p1', +}; + +const baseToolSchema = { + type: 'node' as const, + name: 'Google Drive', + node: { + nodeType: 'n8n-nodes-base.googleDriveTool', + nodeTypeVersion: 1, + nodeParameters: {}, + }, +}; + +afterEach(() => { + Container.reset(); +}); + +describe('resolveNodeTool → tool name sanitization', () => { + it('replaces whitespace with underscores so Anthropic accepts the identifier', async () => { + // Anthropic rejects names that don't match ^[a-zA-Z0-9_-]{1,128}$. + // "Google Drive" must become "Google_Drive" before the Tool builder sees it. + const tool = await resolveNodeTool(baseToolSchema, mockCtx); + expect(tool.name).toBe('Google_Drive'); + }); + + it('leaves already-valid names untouched', async () => { + const tool = await resolveNodeTool({ ...baseToolSchema, name: 'slack_search' }, mockCtx); + expect(tool.name).toBe('slack_search'); + }); + + it('collapses non-alphanumerics and handles edge characters', async () => { + const tool = await resolveNodeTool({ ...baseToolSchema, name: 'Foo / Bar: (v2)' }, mockCtx); + // `nodeNameToToolName` collapses any run of disallowed characters into a single `_`. + expect(tool.name).toBe('Foo_Bar_v2_'); + }); + + it('executes the mirrored tool node when config stores the base node type', async () => { + const executeInline = jest.fn().mockResolvedValue({ status: 'success', data: [] }); + const getByNameAndVersion = jest.fn().mockReturnValue({ + description: { description: 'HTTP Request Tool' }, + }); + Container.set(NodeTypes, { getByNameAndVersion } as unknown as NodeTypes); + + const tool = await resolveNodeTool( + { + ...baseToolSchema, + node: { + nodeType: 'n8n-nodes-base.httpRequest', + nodeTypeVersion: 4, + nodeParameters: {}, + }, + }, + { + executor: { executeInline } as unknown as EphemeralNodeExecutor, + projectId: 'p1', + }, + ); + + await tool.handler!({ url: 'https://example.com' }, {} as never); + + expect(getByNameAndVersion).toHaveBeenCalledWith('n8n-nodes-base.httpRequestTool', 4); + expect(executeInline).toHaveBeenCalledWith( + expect.objectContaining({ nodeType: 'n8n-nodes-base.httpRequestTool' }), + ); + }); + + it('derives inputSchema from $fromAI node parameters', async () => { + const tool = await resolveNodeTool( + { + ...baseToolSchema, + node: { + ...baseToolSchema.node, + nodeParameters: { + url: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('url', 'The URL to request', 'string') }}", + }, + }, + }, + mockCtx, + ); + + const schema = tool.inputSchema as z.ZodObject; + expect(typeof schema.safeParse).toBe('function'); + expect(schema.safeParse({ url: 'https://example.com' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + }); + + it('passes node parameters through unchanged at execution time', async () => { + const executeInline = jest.fn().mockResolvedValue({ status: 'success', data: [] }); + const tool = await resolveNodeTool( + { + ...baseToolSchema, + description: 'Make an HTTP request to any URL', + node: { + ...baseToolSchema.node, + nodeParameters: { method: 'GET', toolDescription: 'Stale generated description' }, + }, + }, + { + executor: { executeInline } as unknown as EphemeralNodeExecutor, + projectId: 'p1', + }, + ); + + await tool.handler!({ url: 'https://example.com' }, {} as never); + + expect(executeInline).toHaveBeenCalledWith( + expect.objectContaining({ + nodeParameters: { + method: 'GET', + toolDescription: 'Stale generated description', + }, + }), + ); + }); + + it('uses the introspected supplyData schema directly', async () => { + const inputSchema = z.object({ query: z.string() }); + const introspectSupplyDataToolSchema = jest.fn().mockResolvedValue(inputSchema); + Container.set(NodeTypes, { + getByNameAndVersion: jest.fn().mockReturnValue({ + description: { description: 'Search Wikipedia' }, + supplyData: jest.fn(), + }), + } as unknown as NodeTypes); + + const tool = await resolveNodeTool( + { + ...baseToolSchema, + node: { + nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia', + nodeTypeVersion: 1, + nodeParameters: {}, + }, + }, + { + executor: { introspectSupplyDataToolSchema } as unknown as EphemeralNodeExecutor, + projectId: 'p1', + }, + ); + + expect(tool.inputSchema).toBe(inputSchema); + expect(introspectSupplyDataToolSchema).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/modules/agents/tools/node-tool-factory.ts b/packages/cli/src/modules/agents/tools/node-tool-factory.ts new file mode 100644 index 00000000000..bf76269aafe --- /dev/null +++ b/packages/cli/src/modules/agents/tools/node-tool-factory.ts @@ -0,0 +1,174 @@ +import type { BuiltTool } from '@n8n/agents'; +import { Tool } from '@n8n/agents'; +import { createZodSchemaFromArgs, extractFromAIParameters } from '@n8n/ai-utilities'; +import type { JSONSchema7 } from 'json-schema'; +import type { IDataObject, INodeParameters } from 'n8n-workflow'; +import { isToolType, nodeNameToToolName } from 'n8n-workflow'; +import type { z } from 'zod'; + +import type { EphemeralNodeExecutor } from '@/node-execution'; +import { NodeTypes } from '@/node-types'; +import { Container } from '@n8n/di'; + +import type { AgentJsonToolConfig } from '../json-config/agent-json-config'; + +type NodeToolInputSchema = JSONSchema7 | z.ZodType; + +export interface NodeToolFactoryContext { + executor: EphemeralNodeExecutor; + projectId: string; +} + +/** + * Shape the config's credential map for the executor. Both introspection (at + * tool-registration time) and invocation (at LLM-call time) need to hand the + * executor a `Record`, and both want to drop entries whose + * credential hasn't been persisted yet (no id). + */ +function toExecutorCredentials( + credentials: Extract['node']['credentials'], +): Record | undefined { + if (!credentials) return undefined; + const out: Record = {}; + for (const [slot, ref] of Object.entries(credentials)) { + if (ref.id) out[slot] = { id: ref.id, name: ref.name }; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function resolveToolNodeType(nodeType: string, nodeTypeVersion: number): string { + if (isToolType(nodeType)) return nodeType; + + const toolNodeType = `${nodeType}Tool`; + try { + Container.get(NodeTypes).getByNameAndVersion(toolNodeType, nodeTypeVersion); + return toolNodeType; + } catch { + return nodeType; + } +} + +/** + * Native tool nodes expose a LangChain tool via `supplyData`. The shape of its + * schema depends on the class: + * - Base `Tool` / `DynamicTool` (toolWikipedia, toolCalculator, etc.) has no + * `.schema` — the input contract is the implicit `{ input: string }`. + * - `StructuredTool` / `DynamicStructuredTool` / `N8nTool` (ToolCode with a + * configured schema, ToolWorkflow v1/v2, McpClientTool) carries a Zod + * `.schema` with multi-field requirements. + * + * Strategy: + * 1. If nodeParameters contain `$fromAI(...)`, derive the schema from those + * placeholders so the tool schema cannot drift from runtime params. + * 2. Otherwise, ask the executor to instantiate the LangChain tool and + * report its `.schema`. Zod and JSON schemas can be handed to the SDK as-is. + * 3. Fall back to the `{ input: string }` shape for plain `Tool` nodes. + */ +async function resolveInputSchema( + toolSchema: Extract, + ctx: NodeToolFactoryContext, +): Promise { + const collectedArguments = extractFromAIParameters( + (toolSchema.node.nodeParameters ?? {}) as INodeParameters, + ); + if (collectedArguments.length > 0) return createZodSchemaFromArgs(collectedArguments); + + let nodeType; + const nodeTypeName = resolveToolNodeType( + toolSchema.node.nodeType, + toolSchema.node.nodeTypeVersion, + ); + try { + nodeType = Container.get(NodeTypes).getByNameAndVersion( + nodeTypeName, + toolSchema.node.nodeTypeVersion, + ); + } catch { + // If the node type can't be resolved here, fall through — the executor + // will surface a clearer error at invocation time. + return { type: 'object', properties: {} }; + } + + if (typeof nodeType.supplyData === 'function') { + const introspected = await ctx.executor.introspectSupplyDataToolSchema({ + projectId: ctx.projectId, + nodeType: nodeTypeName, + nodeTypeVersion: toolSchema.node.nodeTypeVersion, + nodeParameters: toolSchema.node.nodeParameters as INodeParameters, + credentials: toExecutorCredentials(toolSchema.node.credentials) ?? null, + }); + + if (introspected) return introspected as NodeToolInputSchema; + + return { + type: 'object', + properties: { + input: { + type: 'string', + description: + toolSchema.description ?? + nodeType.description.description ?? + `The query or input text to pass to ${toolSchema.node.nodeType}.`, + }, + }, + required: ['input'], + }; + } + + return { type: 'object', properties: {} }; +} + +/** + * Convert a single {@link NodeToolDescriptor} marker (from `ToolFromNode.build()`) into + * a real `BuiltTool` backed by {@link EphemeralNodeExecutor}. + */ +export async function resolveNodeTool( + toolSchema: Extract, + ctx: NodeToolFactoryContext, +): Promise { + // Anthropic + OpenAI accept only `[a-zA-Z0-9_-]` (len ≤ 64/128) for tool + // identifiers. The persisted `name` is a human-readable label (e.g. "Google + // Drive") so we normalize it here — same helper the LangChain canvas path + // uses (see `create-node-as-tool.ts:101`). + const sanitizedName = nodeNameToToolName(toolSchema.name); + const nodeType = resolveToolNodeType(toolSchema.node.nodeType, toolSchema.node.nodeTypeVersion); + + const built = new Tool(sanitizedName) + .description(toolSchema.description ?? `Execute the ${nodeType} node`) + .input(await resolveInputSchema(toolSchema, ctx)) + .handler(async (input: Record) => { + const result = await ctx.executor.executeInline({ + nodeType, + nodeTypeVersion: toolSchema.node.nodeTypeVersion, + nodeParameters: toolSchema.node.nodeParameters as INodeParameters, + credentialDetails: toExecutorCredentials(toolSchema.node.credentials), + inputData: [{ json: input as IDataObject }], + projectId: ctx.projectId, + }); + // Throw on the executor's structured error so the agent runtime + // flags the tool-result with `isError: true` and the recorder + // marks the timeline entry as a failed call. Returning the error + // object normally would otherwise read as a successful tool call. + if (result.status === 'error') { + throw new Error(result.error ?? `Node "${toolSchema.node.nodeType}" failed to execute`); + } + return result; + }) + .build(); + + return { + ...built, + metadata: { + kind: 'node', + nodeType: toolSchema.node.nodeType, + nodeTypeVersion: toolSchema.node.nodeTypeVersion, + displayName: toolSchema.name, + // Preserve the configured node parameters (channel, operation, + // `$fromAI(...)` templates, etc.) so the session-detail timeline + // can render the real node config alongside the LLM's runtime + // input. Without this the synthetic execution viewer shows empty + // parameters and input/output read as the same thing. + nodeParameters: toolSchema.node.nodeParameters as INodeParameters, + }, + }; +} diff --git a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts new file mode 100644 index 00000000000..bb226df1366 --- /dev/null +++ b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts @@ -0,0 +1,645 @@ +import type { BuiltTool } from '@n8n/agents'; +import { Tool } from '@n8n/agents'; +import { INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES } from '@n8n/api-types'; +import type { SUPPORTED_WORKFLOW_TOOL_TRIGGERS } from '@n8n/api-types'; +import type { + ExecutionRepository, + UserRepository, + WorkflowRepository, + WorkflowEntity, +} from '@n8n/db'; +import type { + IDataObject, + INode, + IPinData, + IWorkflowExecutionDataProcess, + WorkflowExecuteMode, +} from 'n8n-workflow'; +import { + createRunExecutionData, + CHAT_TRIGGER_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + SCHEDULE_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + TimeoutExecutionCancelledError, +} from 'n8n-workflow'; +import { z } from 'zod'; + +import type { ActiveExecutions } from '@/active-executions'; +import type { WorkflowRunner } from '@/workflow-runner'; +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import { sanitizeToolName } from '../json-config/agent-config-composition'; +import type { AgentJsonToolConfig } from '../json-config/agent-json-config'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Map a supported trigger node type to the input-schema key the workflow tool + * builds against. Keys are sourced from `SUPPORTED_WORKFLOW_TOOL_TRIGGERS` in + * `@n8n/api-types` so the backend compatibility check and the frontend + * Available list can't drift. + */ +const SUPPORTED_TRIGGERS: Record = { + [MANUAL_TRIGGER_NODE_TYPE]: 'manual', + [EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE]: 'executeWorkflow', + [CHAT_TRIGGER_NODE_TYPE]: 'chat', + [SCHEDULE_TRIGGER_NODE_TYPE]: 'schedule', + [FORM_TRIGGER_NODE_TYPE]: 'form', +}; + +// Compile-time check: `SUPPORTED_TRIGGERS` must cover every trigger the shared +// list declares. Adding a trigger to `SUPPORTED_WORKFLOW_TOOL_TRIGGERS` without +// adding its input-schema mapping here will fail this assertion. +const _assertSupportedTriggersInSync: Record< + (typeof SUPPORTED_WORKFLOW_TOOL_TRIGGERS)[number], + string +> = SUPPORTED_TRIGGERS; +void _assertSupportedTriggersInSync; + +const INCOMPATIBLE_NODE_TYPES = new Set(INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES); + +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_RESULT_CHARS = 20_000; +const MAX_NODE_OUTPUT_BYTES = 5_000; + +// --------------------------------------------------------------------------- +// Context passed from the compile step +// --------------------------------------------------------------------------- + +export interface WorkflowToolContext { + workflowRepository: WorkflowRepository; + workflowRunner: WorkflowRunner; + activeExecutions: ActiveExecutions; + executionRepository: ExecutionRepository; + workflowFinderService: WorkflowFinderService; + userRepository: UserRepository; + userId: string; + projectId?: string; + /** Base URL for webhooks/forms (e.g. http://localhost:5678/) */ + webhookBaseUrl?: string; +} + +// --------------------------------------------------------------------------- +// 1. detectTriggerNode +// --------------------------------------------------------------------------- + +interface DetectedTrigger { + node: INode; + triggerType: string; +} + +export function detectTriggerNode(workflow: WorkflowEntity): DetectedTrigger { + const nodes = workflow.nodes ?? []; + + for (const node of nodes) { + const triggerType = SUPPORTED_TRIGGERS[node.type]; + if (triggerType) { + return { node, triggerType }; + } + } + + throw new Error( + `Workflow "${workflow.name}" has no supported trigger node. ` + + `Supported triggers: ${Object.keys(SUPPORTED_TRIGGERS).join(', ')}`, + ); +} + +// --------------------------------------------------------------------------- +// 2. validateCompatibility +// --------------------------------------------------------------------------- + +export function validateCompatibility(workflow: WorkflowEntity): void { + const nodes = workflow.nodes ?? []; + const incompatible = nodes.filter((n) => INCOMPATIBLE_NODE_TYPES.has(n.type)); + + if (incompatible.length > 0) { + const names = incompatible.map((n) => `${n.name} (${n.type})`).join(', '); + throw new Error( + `Workflow "${workflow.name}" contains incompatible nodes for agent execution: ${names}`, + ); + } +} + +// --------------------------------------------------------------------------- +// 3. normalizeTriggerInput +// --------------------------------------------------------------------------- + +export function normalizeTriggerInput( + triggerNode: INode, + triggerType: string, + inputData: Record, +): IPinData { + switch (triggerType) { + case 'chat': + return { + [triggerNode.name]: [ + { + json: { + sessionId: `agent-${Date.now()}`, + action: 'sendMessage', + chatInput: + typeof inputData.message === 'string' + ? inputData.message + : JSON.stringify(inputData), + }, + }, + ], + }; + + case 'schedule': { + const now = new Date(); + // Keys below match the schedule trigger's $json output shape, which uses + // human-readable labels — the naming-convention rule doesn't apply. + /* eslint-disable @typescript-eslint/naming-convention */ + return { + [triggerNode.name]: [ + { + json: { + timestamp: now.toISOString(), + 'Readable date': now.toLocaleString(), + 'Day of week': now.toLocaleDateString('en-US', { weekday: 'long' }), + Year: String(now.getFullYear()), + Month: now.toLocaleDateString('en-US', { month: 'long' }), + 'Day of month': String(now.getDate()).padStart(2, '0'), + Hour: String(now.getHours()).padStart(2, '0'), + Minute: String(now.getMinutes()).padStart(2, '0'), + Second: String(now.getSeconds()).padStart(2, '0'), + }, + }, + ], + }; + /* eslint-enable @typescript-eslint/naming-convention */ + } + + default: + // manual, executeWorkflow, and any other trigger type + return { + [triggerNode.name]: [{ json: inputData as IDataObject }], + }; + } +} + +// --------------------------------------------------------------------------- +// 4. inferInputSchema +// --------------------------------------------------------------------------- + +/** Map an n8n-field primitive type to the matching Zod type. */ +function fieldTypeToZod(type: string | undefined, label: string): z.ZodTypeAny { + switch (type) { + case 'number': + return z.number().describe(label); + case 'boolean': + return z.boolean().describe(label); + default: + return z.string().describe(label); + } +} + +/** Derive a Zod schema from a trigger's declared `workflowInputs.values`. */ +function schemaFromWorkflowInputs(triggerNode: INode): z.ZodObject | null { + const params = triggerNode.parameters ?? {}; + const workflowInputs = params.workflowInputs as + | { values?: Array<{ name: string; type?: string }> } + | undefined; + + if (!workflowInputs?.values?.length) return null; + + const shape: z.ZodRawShape = {}; + for (const field of workflowInputs.values) { + if (!field.name) continue; + shape[field.name] = fieldTypeToZod(field.type, field.name); + } + return Object.keys(shape).length > 0 ? z.object(shape) : null; +} + +/** Derive a Zod schema from a trigger's `jsonExample` passthrough config. */ +function schemaFromJsonExample(triggerNode: INode): z.ZodObject | null { + const jsonExample = triggerNode.parameters?.jsonExample as string | undefined; + if (!jsonExample) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(jsonExample); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + + const shape: z.ZodRawShape = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + shape[key] = fieldTypeToZod(typeof value, key); + } + return Object.keys(shape).length > 0 ? z.object(shape) : null; +} + +export function inferInputSchema( + triggerNode: INode, + triggerType: string, +): z.ZodObject { + switch (triggerType) { + case 'chat': + return z.object({ message: z.string() }); + + case 'manual': + return z.object({ input: z.string().optional() }); + + case 'schedule': + return z.object({}); + + case 'form': + return z.object({ + reason: z.string().optional().describe('Why the user should fill out this form'), + }); + + case 'executeWorkflow': + return ( + schemaFromWorkflowInputs(triggerNode) ?? + schemaFromJsonExample(triggerNode) ?? + z.object({}).catchall(z.unknown()) + ); + + default: + return z.object({}).catchall(z.unknown()); + } +} + +// --------------------------------------------------------------------------- +// 5. executeWorkflow +// --------------------------------------------------------------------------- + +export async function executeWorkflow( + workflow: WorkflowEntity, + triggerNode: INode, + triggerType: string, + inputData: Record, + context: WorkflowToolContext, + allOutputs = false, +): Promise<{ + executionId: string; + status: string; + data?: Record; + error?: string; +}> { + const { workflowRunner, activeExecutions, executionRepository } = context; + + // Build pin data for the trigger + const triggerPinData = normalizeTriggerInput(triggerNode, triggerType, inputData); + + // Merge with workflow's existing pinData + const workflowPinData = workflow.pinData ?? {}; + const mergedPinData: IPinData = { ...workflowPinData, ...triggerPinData }; + + // Determine execution mode from trigger type + const executionMode: WorkflowExecuteMode = + triggerType === 'chat' ? 'chat' : triggerType === 'schedule' ? 'trigger' : 'manual'; + + // Build execution data following Instance AI adapter's pattern + const runData: IWorkflowExecutionDataProcess = { + executionMode, + workflowData: workflow, + userId: context.userId, + startNodes: [{ name: triggerNode.name, sourceData: null }], + pinData: mergedPinData, + executionData: createRunExecutionData({ + startData: {}, + resultData: { pinData: mergedPinData, runData: {} }, + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack: [ + { + node: triggerNode, + data: { main: [triggerPinData[triggerNode.name]] }, + source: null, + }, + ], + waitingExecution: {}, + waitingExecutionSource: {}, + }, + }), + }; + + const executionId = await workflowRunner.run(runData); + + // Wait for completion with timeout protection + const timeoutMs = DEFAULT_TIMEOUT_MS; + + if (activeExecutions.has(executionId)) { + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Execution timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + await Promise.race([activeExecutions.getPostExecutePromise(executionId), timeoutPromise]); + clearTimeout(timeoutId); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.message.includes('timed out')) { + try { + activeExecutions.stopExecution( + executionId, + new TimeoutExecutionCancelledError(executionId), + ); + } catch { + // Execution may have completed between timeout and cancel + } + return { + executionId, + status: 'error', + error: `Execution timed out after ${timeoutMs}ms and was cancelled`, + }; + } + throw error; + } + } + + return await extractResult(executionRepository, executionId, allOutputs); +} + +// --------------------------------------------------------------------------- +// 6. extractResult +// --------------------------------------------------------------------------- + +/** Map an execution's raw status into the tool's simplified status value. */ +function normaliseExecutionStatus(status: string | undefined): string { + if (status === 'error' || status === 'crashed') return 'error'; + if (status === 'running' || status === 'new') return 'running'; + if (status === 'waiting') return 'waiting'; + return 'success'; +} + +/** Extract the JSON items produced by the last run of a node. */ +function outputItemsFromNodeRuns( + nodeRuns: Array<{ data?: { main?: Array> } }>, +): unknown[] { + const lastRun = nodeRuns[nodeRuns.length - 1]; + if (!lastRun?.data?.main) return []; + return lastRun.data.main + .flat() + .filter((item): item is NonNullable => item !== null && item !== undefined) + .map((item) => item.json); +} + +/** Build the resultData map from an execution's runData, scoped by `allOutputs`. */ +function collectResultData( + runData: Record[0]>, + allOutputs: boolean, +): Record { + const resultData: Record = {}; + + if (allOutputs) { + for (const [nodeName, nodeRuns] of Object.entries(runData)) { + const outputItems = outputItemsFromNodeRuns(nodeRuns); + if (outputItems.length > 0) { + resultData[nodeName] = truncateNodeOutput(outputItems); + } + } + return resultData; + } + + const nodeNames = Object.keys(runData); + const lastNodeName = nodeNames[nodeNames.length - 1]; + if (lastNodeName) { + const outputItems = outputItemsFromNodeRuns(runData[lastNodeName]); + if (outputItems.length > 0) { + resultData[lastNodeName] = truncateNodeOutput(outputItems); + } + } + return resultData; +} + +export async function extractResult( + executionRepository: ExecutionRepository, + executionId: string, + allOutputs: boolean, +): Promise<{ + executionId: string; + status: string; + data?: Record; + error?: string; +}> { + const execution = await executionRepository.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + + if (!execution) { + return { executionId, status: 'unknown' }; + } + + const runData = execution.data?.resultData?.runData; + const resultData = runData + ? collectResultData( + runData as Record[0]>, + allOutputs, + ) + : {}; + + return { + executionId, + status: normaliseExecutionStatus(execution.status), + data: Object.keys(resultData).length > 0 ? truncateResultData(resultData) : undefined, + error: execution.data?.resultData?.error?.message, + }; +} + +// --------------------------------------------------------------------------- +// Truncation helpers (following Instance AI patterns) +// --------------------------------------------------------------------------- + +function truncateNodeOutput(items: unknown[]): unknown { + const serialized = JSON.stringify(items); + if (serialized.length <= MAX_NODE_OUTPUT_BYTES) return items; + + const truncated: unknown[] = []; + let size = 2; // account for "[]" + for (const item of items) { + const itemStr = JSON.stringify(item); + if (size + itemStr.length + 2 > MAX_NODE_OUTPUT_BYTES) break; + truncated.push(item); + size += itemStr.length + 1; + } + + return { + items: truncated, + truncated: true, + totalItems: items.length, + shownItems: truncated.length, + message: `Output truncated: showing ${truncated.length} of ${items.length} items.`, + }; +} + +function truncateResultData(data: Record): Record { + const serialized = JSON.stringify(data); + if (serialized.length <= MAX_RESULT_CHARS) return data; + + const truncated: Record = {}; + for (const [nodeName, rawItems] of Object.entries(data)) { + if (!Array.isArray(rawItems) || rawItems.length === 0) { + truncated[nodeName] = rawItems; + continue; + } + + const items = rawItems as unknown[]; + const firstItem = items[0]; + const itemStr = JSON.stringify(firstItem); + const preview = itemStr.length > 1_000 ? `${itemStr.slice(0, 1_000)}…` : firstItem; + + truncated[nodeName] = { + _itemCount: items.length, + _truncated: true, + _firstItemPreview: preview, + }; + } + return truncated; +} + +// --------------------------------------------------------------------------- +// 7. resolveWorkflowTool — resolve a single workflow tool descriptor +// --------------------------------------------------------------------------- + +export async function resolveWorkflowTool( + descriptor: Extract, + context: WorkflowToolContext, +): Promise { + return await buildWorkflowTool(descriptor, context); +} + +async function buildWorkflowTool( + descriptor: Extract, + context: WorkflowToolContext, +): Promise { + const { workflowRepository, workflowFinderService, userRepository } = context; + const workflowName = descriptor.workflow; + + // Step 1: Find the workflow by name, scoped to the project if available. + const whereClause: Record = { name: workflowName }; + if (context.projectId) { + whereClause.shared = { projectId: context.projectId }; + } + const candidateWorkflow = await workflowRepository.findOne({ + where: whereClause, + relations: ['shared'], + }); + + if (!candidateWorkflow) { + throw new Error(`Workflow "${workflowName}" not found`); + } + + // Step 2: Verify the user has execute access via RBAC. + const user = await userRepository.findOne({ where: { id: context.userId }, relations: ['role'] }); + if (!user) { + throw new Error(`User "${context.userId}" not found`); + } + + const workflow = await workflowFinderService.findWorkflowForUser(candidateWorkflow.id, user, [ + 'workflow:execute', + ]); + + if (!workflow) { + throw new Error(`Workflow "${workflowName}" not found or user does not have execute access`); + } + + validateCompatibility(workflow); + const { node: triggerNode, triggerType } = detectTriggerNode(workflow); + + // Always run through `toToolName` even when the user supplied `descriptor.name`. + // Anthropic and OpenAI both require tool names to match `^[a-zA-Z0-9_-]{1,128}$`, + // so a workflow display name like "D&D Invite" must be sanitized before reaching + // the model. Schema validation rejects invalid names on save (see + // `agent-json-config.ts`); this is the runtime safety net for legacy configs. + const toolName = toToolName(descriptor.name ?? workflowName); + const toolDescription = descriptor.description ?? `Execute the "${workflowName}" workflow`; + const inputSchema = inferInputSchema(triggerNode, triggerType); + const allOutputs = descriptor.allOutputs ?? false; + + // Form triggers return a link — the user fills out the form in their browser, + // and the workflow executes independently when they submit. + if (triggerType === 'form') { + const formPath = + (triggerNode.parameters?.path as string) ?? + ((triggerNode.parameters?.options as Record)?.path as string) ?? + triggerNode.webhookId ?? + workflow.id; + const baseUrl = (context.webhookBaseUrl ?? 'http://localhost:5678/').replace(/\/$/, ''); + const formUrl = `${baseUrl}/form/${formPath}`; + + const builder = new Tool(toolName) + .description( + toolDescription === `Execute the "${workflowName}" workflow` + ? `Send the user a link to the "${workflowName}" form. The workflow runs automatically when they submit.` + : toolDescription, + ) + .input(inputSchema) + .toMessage( + () => + ({ + type: 'custom', + components: [ + { type: 'section', text: `📋 *<${formUrl}|Click here to open the form>*` }, + ], + }) as never, + ) + // eslint-disable-next-line @typescript-eslint/require-await -- Tool.handler() expects an async callback + .handler(async (input: Record) => { + const reason = (input.reason as string) ?? `Please fill out the ${workflowName} form`; + return { status: 'form_link_sent', formUrl, message: reason }; + }); + + const built = builder.build(); + return { + ...built, + metadata: { + kind: 'workflow', + workflowId: workflow.id, + workflowName: workflow.name, + triggerType, + }, + }; + } + + // Standard execution-based tool for all other triggers + const builder = new Tool(toolName) + .description(toolDescription) + .input(inputSchema) + .output( + z.object({ + executionId: z.string(), + status: z.string(), + data: z.record(z.unknown()).optional(), + error: z.string().optional(), + }), + ) + .handler(async (input: Record) => { + return await executeWorkflow(workflow, triggerNode, triggerType, input, context, allOutputs); + }); + + const built = builder.build(); + return { + ...built, + metadata: { + kind: 'workflow', + workflowId: workflow.id, + workflowName: workflow.name, + triggerType, + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Re-export the shared sanitiser under the local name used in this file. + * Lives in `agent-config-composition` so save-time healing and runtime + * fallback share a single source of truth. + */ +const toToolName = sanitizeToolName; diff --git a/packages/cli/src/modules/agents/types/components.ts b/packages/cli/src/modules/agents/types/components.ts new file mode 100644 index 00000000000..56b96eb5abf --- /dev/null +++ b/packages/cli/src/modules/agents/types/components.ts @@ -0,0 +1,66 @@ +/** 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 new file mode 100644 index 00000000000..b0efc188be5 --- /dev/null +++ b/packages/cli/src/modules/agents/types/index.ts @@ -0,0 +1,7 @@ +export type { + InlineComponent, + FormComponent, + SuspendComponent, + RichSuspendPayload, + ComponentResumeData, +} from './components'; diff --git a/packages/cli/src/modules/agents/utils/agent-draft.utils.ts b/packages/cli/src/modules/agents/utils/agent-draft.utils.ts new file mode 100644 index 00000000000..532d25c5e06 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-draft.utils.ts @@ -0,0 +1,17 @@ +import { v4 as uuid } from 'uuid'; + +import type { Agent } from '../entities/agent.entity'; + +/** + * Start a new draft if the agent is currently in sync with the published snapshot. + * Any mutation that changes how the agent would run must call this so that + * `hasUnpublishedChanges` stays accurate. + */ +export function markAgentDraftDirty(agent: Agent): void { + if ( + agent.versionId !== null && + agent.versionId === agent.publishedVersion?.publishedFromVersionId + ) { + agent.versionId = uuid(); + } +} diff --git a/packages/cli/src/modules/agents/utils/agent-resource-id.ts b/packages/cli/src/modules/agents/utils/agent-resource-id.ts new file mode 100644 index 00000000000..3f987b14104 --- /dev/null +++ b/packages/cli/src/modules/agents/utils/agent-resource-id.ts @@ -0,0 +1,17 @@ +import { generateNanoId } from '@n8n/utils'; + +type AgentResourceIdPrefix = 'skill' | 'tool'; + +export function generateAgentResourceId( + prefix: AgentResourceIdPrefix, + existingIds: Iterable = [], +): string { + const existing = new Set(existingIds); + + for (let attempt = 0; attempt < 10; attempt++) { + const id = `${prefix}_${generateNanoId()}`; + if (!existing.has(id)) return id; + } + + throw new Error(`Could not generate unique ${prefix} id`); +} diff --git a/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts index 37dd014c92d..df55e74a265 100644 --- a/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts @@ -168,6 +168,7 @@ describe('workflowExecuteAfterHandler', () => { { mode: 'internal' }, { mode: 'manual' }, { mode: 'integrated' }, + { mode: 'chat' }, ])('does not store events for executions with the mode `$mode`', async ({ mode }) => { // ARRANGE const ctx = mock({ workflow }); diff --git a/packages/cli/src/modules/insights/insights-collection.service.ts b/packages/cli/src/modules/insights/insights-collection.service.ts index 71a585e254e..74916b7d077 100644 --- a/packages/cli/src/modules/insights/insights-collection.service.ts +++ b/packages/cli/src/modules/insights/insights-collection.service.ts @@ -48,6 +48,9 @@ const shouldSkipMode: Record = { // n8n Chat hub messages chat: true, + + // Agent executions + agent: true, }; const MIN_RUNTIME = 0; diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index eb19e2a70ee..a3274315ec9 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -105,7 +105,7 @@ import type { TypeORMWorkflowsStorage } from './storage/typeorm-workflows-storag import { DbSnapshotStorage } from './storage/db-snapshot-storage'; import { DbIterationLogStorage } from './storage/db-iteration-log-storage'; import { InstanceAiCompactionService } from './compaction.service'; -import { ProxyTokenManager } from './proxy-token-manager'; +import { ProxyTokenManager } from '@/services/proxy-token-manager'; import { InstanceAiThreadRepository } from './repositories/instance-ai-thread.repository'; import { TraceReplayState } from './trace-replay-state'; diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts index 2809f1b7788..9f102ff5701 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.service.test.ts @@ -13,7 +13,7 @@ import type { IRun } from 'n8n-workflow'; import { createEmptyRunExecutionData, ManualExecutionCancelledError } from 'n8n-workflow'; import { McpService } from '../mcp.service'; -import { WorkflowBuilderToolsService } from '../tools/workflow-builder/workflow-builder-tools.service'; +import { NodeCatalogService } from '@/node-catalog'; import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; @@ -63,7 +63,7 @@ describe('McpService', () => { mockInstance(WorkflowRunner), mockInstance(RoleService), mockInstance(ProjectService), - mockInstance(WorkflowBuilderToolsService), + mockInstance(NodeCatalogService), mockInstance(WorkflowCreationService), mockInstance(NodeTypes), mockInstance(ProjectRepository), @@ -103,7 +103,7 @@ describe('McpService', () => { mockInstance(WorkflowRunner), mockInstance(RoleService), mockInstance(ProjectService), - mockInstance(WorkflowBuilderToolsService), + mockInstance(NodeCatalogService), mockInstance(WorkflowCreationService), mockInstance(NodeTypes), mockInstance(ProjectRepository), @@ -286,7 +286,7 @@ describe('McpService', () => { it('should not register builder tools when mcpBuilderEnabled is false', async () => { const user = Object.assign(new User(), { id: 'user-1' }); - const workflowBuilderToolsService = mockInstance(WorkflowBuilderToolsService); + const nodeCatalogService = mockInstance(NodeCatalogService); const service = new McpService( mockLogger(), @@ -308,7 +308,7 @@ describe('McpService', () => { mockInstance(WorkflowRunner), mockInstance(RoleService), mockInstance(ProjectService), - workflowBuilderToolsService, + nodeCatalogService, mockInstance(WorkflowCreationService), mockInstance(NodeTypes), mockInstance(ProjectRepository), @@ -323,12 +323,12 @@ describe('McpService', () => { const server = await service.getServer(user); expect(server).toBeDefined(); // Builder tools service should NOT have been initialized - expect(workflowBuilderToolsService.initialize).not.toHaveBeenCalled(); + expect(nodeCatalogService.initialize).not.toHaveBeenCalled(); }); it('should register builder tools when mcpBuilderEnabled is true', async () => { const user = Object.assign(new User(), { id: 'user-1' }); - const workflowBuilderToolsService = mockInstance(WorkflowBuilderToolsService); + const nodeCatalogService = mockInstance(NodeCatalogService); const service = new McpService( mockLogger(), @@ -350,7 +350,7 @@ describe('McpService', () => { mockInstance(WorkflowRunner), mockInstance(RoleService), mockInstance(ProjectService), - workflowBuilderToolsService, + nodeCatalogService, mockInstance(WorkflowCreationService), mockInstance(NodeTypes), mockInstance(ProjectRepository), @@ -365,7 +365,7 @@ describe('McpService', () => { const server = await service.getServer(user); expect(server).toBeDefined(); // Builder tools service should have been initialized - expect(workflowBuilderToolsService.initialize).toHaveBeenCalled(); + expect(nodeCatalogService.initialize).toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index 1468125f9e5..30032b3ac3e 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -46,7 +46,7 @@ import { getMcpInstructions } from './tools/workflow-builder/mcp-instructions'; import { createSearchWorkflowNodesTool } from './tools/workflow-builder/search-workflow-nodes.tool'; import { getSdkReferenceContent } from './tools/workflow-builder/sdk-reference-content'; import { createValidateWorkflowCodeTool } from './tools/workflow-builder/validate-workflow-code.tool'; -import { WorkflowBuilderToolsService } from './tools/workflow-builder/workflow-builder-tools.service'; +import { NodeCatalogService } from '@/node-catalog'; import { ActiveExecutions } from '@/active-executions'; import { CollaborationService } from '@/collaboration/collaboration.service'; @@ -96,7 +96,7 @@ export class McpService { private readonly workflowRunner: WorkflowRunner, private readonly roleService: RoleService, private readonly projectService: ProjectService, - private readonly workflowBuilderToolsService: WorkflowBuilderToolsService, + private readonly nodeCatalogService: NodeCatalogService, private readonly workflowCreationService: WorkflowCreationService, private readonly nodeTypes: NodeTypes, private readonly projectRepository: ProjectRepository, @@ -316,25 +316,25 @@ export class McpService { } private async registerBuilderTools(server: InstanceType, user: User) { - await this.workflowBuilderToolsService.initialize(); + await this.nodeCatalogService.initialize(); const searchNodesTool = createSearchWorkflowNodesTool( user, - this.workflowBuilderToolsService, + this.nodeCatalogService, this.telemetry, ); server.registerTool(searchNodesTool.name, searchNodesTool.config, searchNodesTool.handler); const getNodeTypesTool = createGetWorkflowNodeTypesTool( user, - this.workflowBuilderToolsService, + this.nodeCatalogService, this.telemetry, ); server.registerTool(getNodeTypesTool.name, getNodeTypesTool.config, getNodeTypesTool.handler); const suggestedNodesTool = createGetSuggestedWorkflowNodesTool( user, - this.workflowBuilderToolsService, + this.nodeCatalogService, this.telemetry, ); server.registerTool( diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/get-suggested-workflow-nodes.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/get-suggested-workflow-nodes.tool.ts index 93ae3680caa..6834bf5dda5 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/get-suggested-workflow-nodes.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/get-suggested-workflow-nodes.tool.ts @@ -4,10 +4,10 @@ import z from 'zod'; import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; +import type { NodeCatalogService } from '@/node-catalog'; import type { Telemetry } from '@/telemetry'; import { CODE_BUILDER_GET_SUGGESTED_NODES_TOOL } from './constants'; -import type { WorkflowBuilderToolsService } from './workflow-builder-tools.service'; const inputSchema = { categories: z @@ -29,7 +29,7 @@ const outputSchema = { */ export const createGetSuggestedWorkflowNodesTool = ( user: User, - workflowBuilderToolsService: WorkflowBuilderToolsService, + nodeCatalogService: NodeCatalogService, telemetry: Telemetry, ): ToolDefinition => ({ name: CODE_BUILDER_GET_SUGGESTED_NODES_TOOL.toolName, @@ -54,7 +54,7 @@ export const createGetSuggestedWorkflowNodesTool = ( }; try { - const result = await workflowBuilderToolsService.getSuggestedNodes(categories); + const result = await nodeCatalogService.getSuggestedNodes(categories); telemetryPayload.results = { success: true, diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-node-types.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-node-types.tool.ts index 149a8696d88..e129aa109fa 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-node-types.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/get-workflow-node-types.tool.ts @@ -4,10 +4,10 @@ import z from 'zod'; import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; +import type { NodeCatalogService } from '@/node-catalog'; import type { Telemetry } from '@/telemetry'; import { CODE_BUILDER_GET_NODE_TYPES_TOOL } from './constants'; -import type { WorkflowBuilderToolsService } from './workflow-builder-tools.service'; const nodeIdWithDiscriminators = z.object({ nodeId: z.string().describe('The node type ID (e.g. "n8n-nodes-base.gmail")'), @@ -46,7 +46,7 @@ type NodeRequest = */ export const createGetWorkflowNodeTypesTool = ( user: User, - workflowBuilderToolsService: WorkflowBuilderToolsService, + nodeCatalogService: NodeCatalogService, telemetry: Telemetry, ): ToolDefinition => ({ name: CODE_BUILDER_GET_NODE_TYPES_TOOL.toolName, @@ -71,7 +71,7 @@ export const createGetWorkflowNodeTypesTool = ( }; try { - const result = await workflowBuilderToolsService.getNodeTypes(nodeIds); + const result = await nodeCatalogService.getNodeTypes(nodeIds); telemetryPayload.results = { success: true, data: { nodeIdCount: nodeIds.length } }; telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/search-workflow-nodes.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/search-workflow-nodes.tool.ts index 6778637e447..94a7fbcdf6c 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/search-workflow-nodes.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/search-workflow-nodes.tool.ts @@ -4,10 +4,10 @@ import z from 'zod'; import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; +import type { NodeCatalogService } from '@/node-catalog'; import type { Telemetry } from '@/telemetry'; import { CODE_BUILDER_SEARCH_NODES_TOOL } from './constants'; -import type { WorkflowBuilderToolsService } from './workflow-builder-tools.service'; const inputSchema = { queries: z @@ -30,7 +30,7 @@ const outputSchema = { */ export const createSearchWorkflowNodesTool = ( user: User, - workflowBuilderToolsService: WorkflowBuilderToolsService, + nodeCatalogService: NodeCatalogService, telemetry: Telemetry, ): ToolDefinition => ({ name: CODE_BUILDER_SEARCH_NODES_TOOL.toolName, @@ -55,7 +55,7 @@ export const createSearchWorkflowNodesTool = ( }; try { - const result = await workflowBuilderToolsService.searchNodes(queries); + const result = await nodeCatalogService.searchNodes(queries); telemetryPayload.results = { success: true, data: { queryCount: queries.length } }; telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); diff --git a/packages/cli/src/modules/mcp/__tests__/workflow-builder-tools.service.test.ts b/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts similarity index 77% rename from packages/cli/src/modules/mcp/__tests__/workflow-builder-tools.service.test.ts rename to packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts index 790b59851cd..67a36a984a6 100644 --- a/packages/cli/src/modules/mcp/__tests__/workflow-builder-tools.service.test.ts +++ b/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts @@ -2,25 +2,26 @@ import type { Logger } from '@n8n/backend-common'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; -import { WorkflowBuilderToolsService } from '../tools/workflow-builder/workflow-builder-tools.service'; - import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { NodeCatalogService } from '../node-catalog.service'; + const MockNodeTypeParser = jest.fn(); const mockSetSchemaBaseDirs = jest.fn(); const mockSearchInvoke = jest.fn().mockResolvedValue('search-result'); const mockGetInvoke = jest.fn().mockResolvedValue('get-result'); const mockSuggestInvoke = jest.fn().mockResolvedValue('suggest-result'); +const mockCreateSearchTool = jest.fn((..._args: unknown[]) => ({ invoke: mockSearchInvoke })); jest.mock('@n8n/ai-workflow-builder', () => ({ NodeTypeParser: MockNodeTypeParser, - createCodeBuilderSearchTool: jest.fn(() => ({ invoke: mockSearchInvoke })), + createCodeBuilderSearchTool: (...args: unknown[]) => mockCreateSearchTool(...args), createCodeBuilderGetTool: jest.fn(() => ({ invoke: mockGetInvoke })), createGetSuggestedNodesTool: jest.fn(() => ({ invoke: mockSuggestInvoke })), })); jest.mock('@n8n/workflow-sdk', () => ({ - setSchemaBaseDirs: (...args: unknown[]) => mockSetSchemaBaseDirs(...args), + setSchemaBaseDirs: (...args: unknown[]) => mockSetSchemaBaseDirs(...(args as [string[]])), })); jest.mock('fs', () => ({ @@ -30,8 +31,8 @@ jest.mock('fs', () => ({ const mockLogger = (): Logger => mock({ scoped: jest.fn().mockReturnValue(mock()) }); -describe('WorkflowBuilderToolsService', () => { - let service: WorkflowBuilderToolsService; +describe('NodeCatalogService', () => { + let service: NodeCatalogService; let loadNodesAndCredentials: jest.Mocked; let postProcessorCallback: (() => Promise) | undefined; @@ -52,14 +53,12 @@ describe('WorkflowBuilderToolsService', () => { MockNodeTypeParser.mockClear(); - service = new WorkflowBuilderToolsService(loadNodesAndCredentials, mockLogger()); + service = new NodeCatalogService(loadNodesAndCredentials, mockLogger()); }); describe('getNodeTypeParser', () => { test('throws when called before initialize', () => { - expect(() => service.getNodeTypeParser()).toThrow( - 'WorkflowBuilderToolsService not initialized', - ); + expect(() => service.getNodeTypeParser()).toThrow('NodeCatalogService not initialized'); }); test('returns parser after initialization', async () => { @@ -102,7 +101,6 @@ describe('WorkflowBuilderToolsService', () => { await service.initialize(); const dirs = service.getNodeDefinitionDirs(); - // Should have found dirs for n8n-nodes-base and @n8n/n8n-nodes-langchain expect(dirs.length).toBeGreaterThan(0); for (const dir of dirs) { expect(dir).toContain('node-definitions'); @@ -115,7 +113,6 @@ describe('WorkflowBuilderToolsService', () => { await service.initialize(); expect(MockNodeTypeParser).toHaveBeenCalledTimes(1); - // Simulate post-processor callback (refreshNodeTypes) loadNodesAndCredentials.collectTypes.mockResolvedValue({ nodes: [ { name: 'n8n-nodes-base.webhook' }, @@ -136,11 +133,9 @@ describe('WorkflowBuilderToolsService', () => { }); test('no-ops when nodeTypeParser is undefined (not initialized)', async () => { - // Call postProcessor before init expect(postProcessorCallback).toBeDefined(); await postProcessorCallback!(); - // Should not create a parser since service was never initialized expect(MockNodeTypeParser).not.toHaveBeenCalled(); }); }); @@ -180,6 +175,41 @@ describe('WorkflowBuilderToolsService', () => { expect(mockSearchInvoke).toHaveBeenCalledTimes(2); }); + + test('creates a separate tool instance per nodeFilter reference', async () => { + await service.initialize(); + + const filterA = () => true; + const filterB = () => false; + + await service.searchNodes(['gmail'], { nodeFilter: filterA }); + await service.searchNodes(['gmail'], { nodeFilter: filterB }); + + expect(mockCreateSearchTool).toHaveBeenCalledTimes(2); + expect(mockCreateSearchTool).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ nodeFilter: filterA }), + ); + expect(mockCreateSearchTool).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ nodeFilter: filterB }), + ); + }); + + test('caches filtered results independently of unfiltered results', async () => { + await service.initialize(); + + const filter = () => true; + await service.searchNodes(['gmail']); + await service.searchNodes(['gmail'], { nodeFilter: filter }); + await service.searchNodes(['gmail']); + await service.searchNodes(['gmail'], { nodeFilter: filter }); + + // Two distinct tool instances, each invoked once. + expect(mockSearchInvoke).toHaveBeenCalledTimes(2); + }); }); describe('getNodeTypes', () => { @@ -203,6 +233,15 @@ describe('WorkflowBuilderToolsService', () => { expect(mockGetInvoke).toHaveBeenCalledTimes(1); }); + + test('is order-independent across nodeIds', async () => { + await service.initialize(); + + await service.getNodeTypes(['n8n-nodes-base.gmail', 'n8n-nodes-base.slack']); + await service.getNodeTypes(['n8n-nodes-base.slack', 'n8n-nodes-base.gmail']); + + expect(mockGetInvoke).toHaveBeenCalledTimes(1); + }); }); describe('getSuggestedNodes', () => { @@ -222,25 +261,24 @@ describe('WorkflowBuilderToolsService', () => { test('clears all caches when node types are refreshed', async () => { await service.initialize(); - // Populate caches - await service.searchNodes(['gmail']); - await service.getNodeTypes(['n8n-nodes-base.set']); - await service.getSuggestedNodes(['chatbot']); - - expect(mockSearchInvoke).toHaveBeenCalledTimes(1); - expect(mockGetInvoke).toHaveBeenCalledTimes(1); - expect(mockSuggestInvoke).toHaveBeenCalledTimes(1); - - // Trigger refresh - expect(postProcessorCallback).toBeDefined(); - await postProcessorCallback!(); - - // Same calls should invoke tools again (cache was cleared) await service.searchNodes(['gmail']); + await service.searchNodes(['gmail'], { nodeFilter: () => true }); await service.getNodeTypes(['n8n-nodes-base.set']); await service.getSuggestedNodes(['chatbot']); expect(mockSearchInvoke).toHaveBeenCalledTimes(2); + expect(mockGetInvoke).toHaveBeenCalledTimes(1); + expect(mockSuggestInvoke).toHaveBeenCalledTimes(1); + + expect(postProcessorCallback).toBeDefined(); + await postProcessorCallback!(); + + await service.searchNodes(['gmail']); + await service.searchNodes(['gmail'], { nodeFilter: () => true }); + await service.getNodeTypes(['n8n-nodes-base.set']); + await service.getSuggestedNodes(['chatbot']); + + expect(mockSearchInvoke).toHaveBeenCalledTimes(4); expect(mockGetInvoke).toHaveBeenCalledTimes(2); expect(mockSuggestInvoke).toHaveBeenCalledTimes(2); }); diff --git a/packages/cli/src/node-catalog/index.ts b/packages/cli/src/node-catalog/index.ts new file mode 100644 index 00000000000..22e01c7b8e9 --- /dev/null +++ b/packages/cli/src/node-catalog/index.ts @@ -0,0 +1,5 @@ +export { + NodeCatalogService, + type NodeFilter, + type SearchNodesOptions, +} from './node-catalog.service'; diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-builder-tools.service.ts b/packages/cli/src/node-catalog/node-catalog.service.ts similarity index 65% rename from packages/cli/src/modules/mcp/tools/workflow-builder/workflow-builder-tools.service.ts rename to packages/cli/src/node-catalog/node-catalog.service.ts index e05d6a4068c..b6502dccbf0 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/workflow-builder-tools.service.ts +++ b/packages/cli/src/node-catalog/node-catalog.service.ts @@ -16,36 +16,51 @@ type NodeRequest = mode?: string; }; +export type NodeFilter = (nodeId: string) => boolean; + +export interface SearchNodesOptions { + /** + * Optional predicate restricting which node IDs are included in search results. + * Each unique filter reference gets its own tool instance and result cache; + * callers should use module-level function references to avoid unbounded growth. + */ + nodeFilter?: NodeFilter; +} + interface InvokableTool { invoke(input: TInput): Promise; } +interface SearchState { + tool?: InvokableTool<{ queries: string[] }>; + cache: Map; +} + +const UNFILTERED: unique symbol = Symbol('unfiltered'); + /** - * Shared service for MCP workflow builder tools. - * Lazily initializes NodeTypeParser and resolves nodeDefinitionDirs - * for the code-builder search/get/suggest tools. + * Shared node catalog for features that need to search, describe or suggest n8n nodes + * (MCP workflow-builder tools, the agents runtime, future callers). * - * Caches tool instances and their results across successive builds. - * All caches are invalidated when node types are refreshed. + * Lazily initializes a {@link NodeTypeParser} on first use and resolves the built-in + * node-definition directories used to load schemas. All caches invalidate automatically + * when LoadNodesAndCredentials signals that node types were reloaded. */ @Service() -export class WorkflowBuilderToolsService { +export class NodeCatalogService { private nodeTypeParser: NodeTypeParser | undefined; private nodeDefinitionDirs: string[] = []; private initPromise: Promise | undefined; - // Cached tool instances — created once, reused across invocations - private searchTool: InvokableTool<{ queries: string[] }> | undefined; + /** Tool + cache per unique `nodeFilter` reference (plus one unfiltered slot). */ + private readonly searchStates = new Map(); private getTool: InvokableTool<{ nodeIds: NodeRequest[] }> | undefined; private suggestTool: InvokableTool<{ categories: string[] }> | undefined; - // Result caches — keyed by normalized input, cleared on node type refresh - private readonly searchCache = new Map(); - private readonly getCache = new Map(); private readonly suggestCache = new Map(); @@ -64,7 +79,7 @@ export class WorkflowBuilderToolsService { getNodeTypeParser(): NodeTypeParser { if (!this.nodeTypeParser) { - throw new Error('WorkflowBuilderToolsService not initialized. Call initialize() first.'); + throw new Error('NodeCatalogService not initialized. Call initialize() first.'); } return this.nodeTypeParser; } @@ -73,25 +88,40 @@ export class WorkflowBuilderToolsService { return this.nodeDefinitionDirs; } - /** Search for nodes by keyword, with result caching. */ - async searchNodes(queries: string[]): Promise { + /** + * Search the node catalog for node IDs matching `queries`. + * Results are cached per `(filter, queries)` pair and invalidated on node-type refresh. + */ + async searchNodes(queries: string[], options: SearchNodesOptions = {}): Promise { + const { nodeFilter } = options; + const stateKey: NodeFilter | typeof UNFILTERED = nodeFilter ?? UNFILTERED; + + let state = this.searchStates.get(stateKey); + if (!state) { + state = { cache: new Map() }; + this.searchStates.set(stateKey, state); + } + const cacheKey = JSON.stringify([...queries].sort()); - const cached = this.searchCache.get(cacheKey); + const cached = state.cache.get(cacheKey); if (cached) return cached; - if (!this.searchTool) { + if (!state.tool) { const { createCodeBuilderSearchTool } = await import('@n8n/ai-workflow-builder'); - this.searchTool = createCodeBuilderSearchTool(this.getNodeTypeParser()); + state.tool = nodeFilter + ? createCodeBuilderSearchTool(this.getNodeTypeParser(), { nodeFilter }) + : createCodeBuilderSearchTool(this.getNodeTypeParser()); } - const result = await this.searchTool.invoke({ queries }); - this.searchCache.set(cacheKey, result); + + const result = await state.tool.invoke({ queries }); + state.cache.set(cacheKey, result); return result; } /** Get TypeScript type definitions for nodes, with result caching. */ async getNodeTypes(nodeIds: NodeRequest[]): Promise { const cacheKey = JSON.stringify( - nodeIds.map((id) => (typeof id === 'string' ? id : JSON.stringify(id))), + nodeIds.map((id) => (typeof id === 'string' ? id : JSON.stringify(id))).sort(), ); const cached = this.getCache.get(cacheKey); if (cached) return cached; @@ -132,7 +162,7 @@ export class WorkflowBuilderToolsService { setSchemaBaseDirs(this.nodeDefinitionDirs); - this.logger.debug('WorkflowBuilderToolsService initialized', { + this.logger.debug('NodeCatalogService initialized', { nodeTypeCount: nodeTypeDescriptions.length, nodeDefinitionDirs: this.nodeDefinitionDirs.length, }); @@ -145,17 +175,14 @@ export class WorkflowBuilderToolsService { const { nodes: nodeTypeDescriptions } = await this.loadNodesAndCredentials.collectTypes(); this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions); - // Invalidate cached tool instances (they hold references to the old parser) - this.searchTool = undefined; + this.searchStates.clear(); this.getTool = undefined; this.suggestTool = undefined; - // Clear result caches - this.searchCache.clear(); this.getCache.clear(); this.suggestCache.clear(); - this.logger.debug('WorkflowBuilderToolsService refreshed node types', { + this.logger.debug('NodeCatalogService refreshed node types', { nodeTypeCount: nodeTypeDescriptions.length, }); } @@ -166,7 +193,11 @@ export class WorkflowBuilderToolsService { try { const packageJsonPath = require.resolve(`${packageId}/package.json`); const distDir = path.dirname(packageJsonPath); - const nodeDefsDir = path.join(distDir, 'dist', 'node-definitions'); + let nodeDefsDir = path.join(distDir, 'dist', 'node-definitions'); + const separator = process.platform === 'win32' ? '\\' : '/'; + if (!nodeDefsDir.endsWith(separator)) { + nodeDefsDir += separator; + } await fs.access(nodeDefsDir); dirs.push(nodeDefsDir); } catch (error) { diff --git a/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts b/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts new file mode 100644 index 00000000000..f158fa18a77 --- /dev/null +++ b/packages/cli/src/node-execution/__tests__/ephemeral-node-executor.test.ts @@ -0,0 +1,620 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import { + CredentialsRepository, + SharedCredentialsRepository, + type CredentialsEntity, + type SharedCredentials, +} from '@n8n/db'; +import { mock } from 'jest-mock-extended'; +import { StructuredToolkit } from 'n8n-core'; +import { + NodeConnectionTypes, + type INodeCredentialsDetails, + type INodeType, + type INodeTypeDescription, +} from 'n8n-workflow'; + +import { NodeTypes } from '@/node-types'; + +import { + AGENT_PROVIDER_NODE_WHITELIST, + EphemeralNodeExecutor, + isAgentProviderNode, + isUsableAsAgentTool, +} from '../ephemeral-node-executor'; + +const mockGetBase = jest.fn(); + +jest.mock('@/workflow-execute-additional-data', () => ({ + getBase: (...args: unknown[]) => mockGetBase(...args), +})); + +describe('isUsableAsAgentTool', () => { + it('accepts a standard node with usableAsTool: true', () => { + expect(isUsableAsAgentTool({ usableAsTool: true, outputs: ['main'] })).toBe(true); + }); + + it('accepts a native tool node that outputs AiTool (string form)', () => { + expect(isUsableAsAgentTool({ outputs: [NodeConnectionTypes.AiTool] })).toBe(true); + }); + + it('accepts a native tool node that outputs AiTool (object form)', () => { + expect(isUsableAsAgentTool({ outputs: [{ type: NodeConnectionTypes.AiTool }] })).toBe(true); + }); + + it('rejects a non-tool node with only main outputs and no flag', () => { + expect(isUsableAsAgentTool({ outputs: ['main'] })).toBe(false); + }); + + it('rejects a description without outputs or flag', () => { + expect(isUsableAsAgentTool({})).toBe(false); + }); + + it('ignores outputs that are not an array (e.g. expression form)', () => { + expect(isUsableAsAgentTool({ outputs: '={{$json.out}}' })).toBe(false); + }); +}); + +describe('isAgentProviderNode', () => { + it('accepts whitelisted provider nodes (OpenAI, Anthropic, etc.)', () => { + expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.openAi')).toBe(true); + expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.anthropic')).toBe(true); + expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.googleGemini')).toBe(true); + }); + + it('rejects non-provider langchain nodes (lm chat models, agents, summarization)', () => { + expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.lmChatOpenAi')).toBe(false); + expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.agent')).toBe(false); + expect(isAgentProviderNode('@n8n/n8n-nodes-langchain.chainSummarization')).toBe(false); + }); + + it('rejects unrelated nodes', () => { + expect(isAgentProviderNode('n8n-nodes-base.httpRequest')).toBe(false); + expect(isAgentProviderNode('')).toBe(false); + }); + + it('exposes the whitelist as a stable Set', () => { + expect(AGENT_PROVIDER_NODE_WHITELIST).toBeInstanceOf(Set); + expect(AGENT_PROVIDER_NODE_WHITELIST.has('@n8n/n8n-nodes-langchain.openAi')).toBe(true); + }); +}); + +describe('EphemeralNodeExecutor', () => { + const nodeTypes = mockInstance(NodeTypes); + const credentialsRepository = mockInstance(CredentialsRepository); + const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository); + const logger = mockInstance(Logger); + + const executor = new EphemeralNodeExecutor( + nodeTypes, + credentialsRepository, + sharedCredentialsRepository, + logger, + ); + + const toolDescription: INodeTypeDescription = { + displayName: 'Tool', + name: 'testTool', + group: ['output'], + version: 1, + description: 'Test tool', + defaults: { name: 'Test' }, + inputs: [], + outputs: [NodeConnectionTypes.AiTool], + properties: [], + usableAsTool: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetBase.mockResolvedValue({}); + }); + + describe('validateNodeForExecution (via executeInline)', () => { + it('returns a structured error when the node is not usable as a tool', async () => { + const nonToolDescription: INodeTypeDescription = { + ...toolDescription, + usableAsTool: undefined, + outputs: ['main'], + }; + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ description: nonToolDescription }), + ); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.notATool', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Node is not usable as a tool'); + expect(result.error).toContain('n8n-nodes-base.notATool'); + expect(result.data).toEqual([]); + }); + + it('returns a structured error when the node is a trigger', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ description: { ...toolDescription, group: ['trigger'] } }), + ); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.triggerNode', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Trigger nodes cannot be executed standalone'); + }); + + it('admits a whitelisted provider node even without `usableAsTool`', async () => { + const providerDescription: INodeTypeDescription = { + ...toolDescription, + usableAsTool: undefined, + outputs: ['main'], + }; + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ description: providerDescription }), + ); + + const result = await executor.executeInline({ + nodeType: '@n8n/n8n-nodes-langchain.openAi', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.error).not.toMatch(/not usable as a tool/); + }); + + it('returns a structured error when the operation is on the blacklist (sendAndWait)', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ description: toolDescription }), + ); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: { operation: 'sendAndWait' }, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/not supported for agent tool execution/); + }); + }); + + describe('resolveInlineCredentials (via executeInline)', () => { + function mockToolNodeWithSupplyData() { + // Short-circuit past the execute path — we only want to assert credential resolution. + const supplyData = jest.fn().mockResolvedValue({ + response: { invoke: jest.fn().mockResolvedValue('ok') }, + }); + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData, + }), + ); + return supplyData; + } + + it('throws when no matching credential is found for the project', async () => { + mockToolNodeWithSupplyData(); + credentialsRepository.findAllCredentialsForProject.mockResolvedValue([]); + + await expect( + executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + credentials: { slackApi: 'Prod Slack' }, + inputData: [], + projectId: 'p-1', + }), + ).rejects.toThrow(/No accessible credential found/); + }); + + it('throws when multiple credentials match the same type + name (case-insensitive)', async () => { + mockToolNodeWithSupplyData(); + credentialsRepository.findAllCredentialsForProject.mockResolvedValue([ + mock({ id: 'c1', name: 'Prod Slack', type: 'slackApi' }), + mock({ id: 'c2', name: 'prod slack', type: 'slackApi' }), + ]); + + await expect( + executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + credentials: { slackApi: 'Prod Slack' }, + inputData: [], + projectId: 'p-1', + }), + ).rejects.toThrow(/Multiple credentials match/); + }); + + it('skips resolution when no credentials are requested', async () => { + mockToolNodeWithSupplyData(); + + await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(credentialsRepository.findAllCredentialsForProject).not.toHaveBeenCalled(); + }); + }); + + describe('verifyCredentialDetailsForProject (via executeInline)', () => { + function mockToolNodeWithSupplyData() { + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ + response: { invoke: jest.fn().mockResolvedValue('ok') }, + }), + }), + ); + } + + it('throws when credentialDetails are missing an id', async () => { + mockToolNodeWithSupplyData(); + + await expect( + executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + // Intentionally missing `id` to exercise the validation path. + credentialDetails: { slackApi: { name: 'Prod Slack' } as INodeCredentialsDetails }, + inputData: [], + projectId: 'p-1', + }), + ).rejects.toThrow(/missing an id/); + }); + + it('throws when the credential is not shared with the project', async () => { + mockToolNodeWithSupplyData(); + sharedCredentialsRepository.findOne.mockResolvedValue(null); + + await expect( + executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + credentialDetails: { slackApi: { id: 'c1', name: 'Prod Slack' } }, + inputData: [], + projectId: 'p-1', + }), + ).rejects.toThrow(/not accessible or does not exist/); + }); + + it('throws when the resolved credential has a different type than the slot', async () => { + mockToolNodeWithSupplyData(); + sharedCredentialsRepository.findOne.mockResolvedValue( + mock({ + credentials: mock({ + id: 'c1', + name: 'Prod Slack', + type: 'gmailOAuth2', // wrong type for slackApi slot + }), + }), + ); + + await expect( + executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + credentialDetails: { slackApi: { id: 'c1', name: 'Prod Slack' } }, + inputData: [], + projectId: 'p-1', + }), + ).rejects.toThrow(/has type .* but the node expects credential slot/); + }); + }); + + describe('executeInline routing', () => { + it('invokes the LangChain tool when the node implements supplyData', async () => { + const invoke = jest.fn().mockResolvedValue('wiki-result'); + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ response: { invoke } }), + }), + ); + + const result = await executor.executeInline({ + nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [{ json: { query: 'n8n' } }], + projectId: 'p-1', + }); + + expect(invoke).toHaveBeenCalledWith({ query: 'n8n' }); + expect(result).toEqual({ + status: 'success', + data: [{ json: { response: 'wiki-result' } }], + }); + }); + + it('returns an error result when the supplyData tool invocation throws', async () => { + const invoke = jest.fn().mockRejectedValue(new Error('upstream 500')); + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ response: { invoke } }), + }), + ); + + const result = await executor.executeInline({ + nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [{ json: {} }], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toBe('upstream 500'); + }); + + it('returns an error result when the node does not expose a valid LangChain tool', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ response: undefined }), + }), + ); + + const result = await executor.executeInline({ + nodeType: '@n8n/n8n-nodes-langchain.brokenTool', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/did not return a valid LangChain tool/); + }); + + it('returns an error result when a direct-execute node has no execute method', async () => { + // Plain object (not mock<>) — jest-mock-extended auto-proxies *every* + // property access as a callable, which would make the supplyData + // routing check match and send us down the wrong path. + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: toolDescription, + } as unknown as INodeType); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/does not have an execute method/); + }); + + it('surfaces a clear error when supplyData returns a StructuredToolkit (multi-tool dispatch unsupported)', async () => { + // MCP client nodes legitimately return a toolkit wrapping N tools; + // the ephemeral runtime treats one ref as one invocable target, so + // this path must fail with an explicit message rather than silently + // invoke a non-existent `.invoke` on the toolkit. + const toolkit = new StructuredToolkit([ + mock({ name: 'list-docs' }), + mock({ name: 'read-doc' }), + ]); + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ response: toolkit }), + }), + ); + + const result = await executor.executeInline({ + nodeType: '@n8n/n8n-nodes-langchain.mcpClientTool', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [{ json: {} }], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/StructuredToolkit.*multi-tool dispatch/); + }); + }); + + describe('executeInline → executeNodeDirectly (nodes without supplyData)', () => { + // Use plain objects so `typeof nodeType.supplyData === 'function'` + // reliably returns false — jest-mock-extended auto-proxies every + // property as callable, which would route us to the supplyData path. + + it('runs nodeType.execute and returns its first output batch on success', async () => { + const execute = jest.fn().mockResolvedValue([[{ json: { ok: true, count: 3 } }]]); + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: toolDescription, + execute, + } as unknown as INodeType); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: { channel: '#general', text: 'hi' }, + inputData: [{ json: { userId: 'u-1' } }], + projectId: 'p-1', + }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(result).toEqual({ status: 'success', data: [{ json: { ok: true, count: 3 } }] }); + }); + + it('returns an error result when nodeType.execute throws', async () => { + const execute = jest.fn().mockRejectedValue(new Error('upstream 500')); + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: toolDescription, + execute, + } as unknown as INodeType); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toBe('upstream 500'); + }); + + it('returns an error when execute resolves without an output array', async () => { + // Downstream consumers expect NodeExecutionData[] — resolving with + // `null` or a truthy-but-not-array value must not leak through as + // `status: 'success'`. + const execute = jest.fn().mockResolvedValue(null); + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: toolDescription, + execute, + } as unknown as INodeType); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/No output data/); + }); + + it('returns an error when execute resolves with an empty output array', async () => { + const execute = jest.fn().mockResolvedValue([]); + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: toolDescription, + execute, + } as unknown as INodeType); + + const result = await executor.executeInline({ + nodeType: 'n8n-nodes-base.slack', + nodeTypeVersion: 1, + nodeParameters: {}, + inputData: [], + projectId: 'p-1', + }); + + expect(result.status).toBe('error'); + expect(result.error).toMatch(/No output data/); + }); + }); + + describe('introspectSupplyDataToolSchema', () => { + it('returns the schema a structured tool exposes', async () => { + const schema = { type: 'object', properties: { query: { type: 'string' } } }; + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ + response: { invoke: jest.fn(), schema }, + }), + }), + ); + + const result = await executor.introspectSupplyDataToolSchema({ + projectId: 'p-1', + nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia', + nodeTypeVersion: 1, + nodeParameters: {}, + }); + + expect(result).toBe(schema); + }); + + it('returns null when the tool has no structured schema (base Tool/DynamicTool)', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ + response: { invoke: jest.fn() }, + }), + }), + ); + + const result = await executor.introspectSupplyDataToolSchema({ + projectId: 'p-1', + nodeType: '@n8n/n8n-nodes-langchain.toolBasic', + nodeTypeVersion: 1, + nodeParameters: {}, + }); + + expect(result).toBeNull(); + }); + + it('returns null for a StructuredToolkit response (per-method introspection not yet wired)', async () => { + // Toolkit carries N tools, each with its own schema — there's no + // single schema to advertise, so we fall back to the factory's + // `{ input: string }` default rather than crash on `.schema` lookup. + const toolkit = new StructuredToolkit([ + mock({ name: 'list-docs' }), + ]); + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockResolvedValue({ response: toolkit }), + }), + ); + + const result = await executor.introspectSupplyDataToolSchema({ + projectId: 'p-1', + nodeType: '@n8n/n8n-nodes-langchain.mcpClientTool', + nodeTypeVersion: 1, + nodeParameters: {}, + }); + + expect(result).toBeNull(); + }); + + it('swallows introspection errors and returns null (keeps tool registration robust)', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue( + mock({ + description: toolDescription, + supplyData: jest.fn().mockRejectedValue(new Error('MCP server unreachable')), + }), + ); + + const result = await executor.introspectSupplyDataToolSchema({ + projectId: 'p-1', + nodeType: '@n8n/n8n-nodes-langchain.mcpClientTool', + nodeTypeVersion: 1, + nodeParameters: {}, + }); + + expect(result).toBeNull(); + // Warn-level so MCP/credential introspection bugs surface in dev — a + // silent failure here would let the LLM be told a different schema + // than the one it's invoked against. + expect(logger.warn).toHaveBeenCalledWith( + 'supplyData tool introspection failed', + expect.objectContaining({ error: 'MCP server unreachable' }), + ); + }); + }); +}); diff --git a/packages/cli/src/node-execution/ephemeral-node-executor.ts b/packages/cli/src/node-execution/ephemeral-node-executor.ts new file mode 100644 index 00000000000..0e9a67b9dd6 --- /dev/null +++ b/packages/cli/src/node-execution/ephemeral-node-executor.ts @@ -0,0 +1,534 @@ +import { Logger } from '@n8n/backend-common'; +import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +import type { Tool } from '@langchain/core/tools'; +import { ExecuteContext, StructuredToolkit, SupplyDataContext } from 'n8n-core'; +import type { + CloseFunction, + IExecuteData, + INode, + INodeCredentialsDetails, + INodeExecutionData, + INodeParameters, + ITaskDataConnections, + NodeOutput, +} from 'n8n-workflow'; +import { + Workflow, + Node, + UserError, + AI_VENDOR_NODE_TYPES, + createEmptyRunExecutionData, + NodeConnectionTypes, + SEND_AND_WAIT_OPERATION, +} from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; + +import { NodeTypes } from '@/node-types'; +import { getBase } from '@/workflow-execute-additional-data'; + +/** Minimal tool shape for constructing an in-memory single-node execution. */ +export type EphemeralWorkflowToolLike = { + projectId: string; + nodeType: string; + nodeTypeVersion: number; + nodeParameters: INodeParameters; + credentials?: Record | null; +}; + +export interface InlineNodeExecutionRequest { + nodeType: string; + nodeTypeVersion: number; + nodeParameters: INodeParameters; + /** Credential type name → credential instance display name (resolved at execution). */ + credentials?: Record; + /** + * Pre-resolved credential ids (e.g. from ToolFromNode which has { id, name } from list_credentials). + */ + credentialDetails?: Record; + inputData: INodeExecutionData[]; + projectId: string; +} + +export interface NodeExecutionResult { + status: 'success' | 'error'; + data: INodeExecutionData[]; + error?: string; +} + +// send and wait requires persistent workflows to handle the wait logic +const OPERATION_BLACKLIST = [SEND_AND_WAIT_OPERATION, 'dispatchAndWait']; + +/** + * Vendor-API nodes the agent runtime can execute even though they aren't + * marked `usableAsTool`. They expose the full provider API (image generation, + * audio, files, embeddings, etc.) — not just chat completion — and are + * designed to back LangChain agents rather than be called as agent tools, so + * they ship without the flag. Surfacing them here lets the agent builder use + * e.g. OpenAI image generation as a tool. + * + * Scope this list to *provider* nodes only. Don't add the agent node itself, + * the LM Chat sub-models (`lmChatOpenAi`, etc.), or generic LangChain helpers + * like summarization — those have different semantics. + */ +export const AGENT_PROVIDER_NODE_WHITELIST = new Set([...AI_VENDOR_NODE_TYPES]); + +export function isAgentProviderNode(nodeType: string): boolean { + return AGENT_PROVIDER_NODE_WHITELIST.has(nodeType); +} + +/** + * Two classes of node are legitimate agent tools: + * + * 1. Standard nodes whose description carries `usableAsTool: true`. The + * node-types resolver can wrap these into a Tool variant on demand + * (`convertNodeToAiTool`), and agent-created node tools should use that + * `*Tool` node type. + * 2. Native tool nodes (e.g. `toolWikipedia`, `toolCalculator`) that declare + * `outputs: [AiTool]` up front. These never carry `usableAsTool` — they + * *are* tools — so a plain `usableAsTool` check rejects them. + * + * Accept either signal so the agent runtime works for both families. + * Provider nodes (see {@link AGENT_PROVIDER_NODE_WHITELIST}) are admitted + * separately by id at the call site. + */ +export function isUsableAsAgentTool(description: { + usableAsTool?: unknown; + outputs?: unknown; +}): boolean { + if (description.usableAsTool) return true; + const outputs = description.outputs; + if (!Array.isArray(outputs)) return false; + return outputs.some((o: unknown) => { + if (typeof o === 'string') return o === NodeConnectionTypes.AiTool; + if (o && typeof o === 'object' && 'type' in o) { + return (o as { type: unknown }).type === NodeConnectionTypes.AiTool; + } + return false; + }); +} + +@Service() +export class EphemeralNodeExecutor { + constructor( + private readonly nodeTypes: NodeTypes, + private readonly credentialsRepository: CredentialsRepository, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly logger: Logger, + ) {} + + /** + * Resolve credential type + display name to IDs for workflow execution, + * scoped to the project's accessible credentials. + */ + private async resolveInlineCredentials( + projectId: string, + credentials?: Record, + ): Promise | null> { + if (!credentials || Object.keys(credentials).length === 0) { + return null; + } + + const accessible = await this.credentialsRepository.findAllCredentialsForProject(projectId); + + const resolved: Record = {}; + + for (const [credType, credName] of Object.entries(credentials)) { + const matches = accessible.filter( + (c) => c.type === credType && c.name.toLowerCase() === credName.toLowerCase(), + ); + + if (matches.length === 0) { + throw new UserError( + `No accessible credential found for type "${credType}" with name "${credName}"`, + { extra: { credType, credName } }, + ); + } + + if (matches.length > 1) { + throw new UserError( + `Multiple credentials match type "${credType}" with name "${credName}". Use a unique credential name.`, + { extra: { credType, credName } }, + ); + } + + const entity = matches[0]; + resolved[credType] = { id: entity.id, name: entity.name }; + } + + return resolved; + } + + private async verifyCredentialDetailsForProject( + projectId: string, + details: Record, + ): Promise> { + const verified: Record = {}; + + for (const [credType, d] of Object.entries(details)) { + if (!d.id) { + throw new UserError( + `Credential reference for "${credType}" is missing an id (required for execution).`, + { extra: { credType, name: d.name } }, + ); + } + + const sharedCredential = await this.sharedCredentialsRepository.findOne({ + where: { credentialsId: d.id, projectId }, + relations: { credentials: true }, + }); + + if (!sharedCredential) { + throw new UserError(`Credential "${d.name}" is not accessible or does not exist.`, { + extra: { credType, credentialId: d.id }, + }); + } + + if (sharedCredential.credentials.type !== credType) { + throw new UserError( + `Credential "${sharedCredential.credentials.name}" has type "${sharedCredential.credentials.type}" but the node expects credential slot "${credType}".`, + { + extra: { + credType, + actualType: sharedCredential.credentials.type, + credentialId: d.id, + }, + }, + ); + } + + verified[credType] = { + id: sharedCredential.credentials.id, + name: sharedCredential.credentials.name, + }; + } + + return verified; + } + + private validateNodeForExecution( + nodeType: string, + typeVersion: number, + nodeParameters: INodeParameters, + ): void { + const resolved = this.nodeTypes.getByNameAndVersion(nodeType, typeVersion); + + if (!isUsableAsAgentTool(resolved.description) && !isAgentProviderNode(nodeType)) { + throw new UserError('Node is not usable as a tool', { extra: { nodeType } }); + } + + if (resolved.description.group.includes('trigger')) { + throw new UserError('Trigger nodes cannot be executed standalone', { extra: { nodeType } }); + } + + const operation = nodeParameters.operation; + + if (operation && typeof operation === 'string' && OPERATION_BLACKLIST.includes(operation)) { + throw new UserError(`The "${operation}" is not supported for agent tool execution.`, { + extra: { nodeType, operation }, + }); + } + } + + /** + * Assemble the shared pieces (node, ephemeral workflow, additionalData, + * execute data) both context classes need. Keeps `executeNodeDirectly` and + * `withSupplyDataTool` from drifting — the setup is identical up to the + * choice of context class. + */ + private async buildEphemeralContextParts( + tool: EphemeralWorkflowToolLike, + inputItems: INodeExecutionData[], + ) { + const node: INode = { + id: uuid(), + name: 'Target Node', + type: tool.nodeType, + typeVersion: tool.nodeTypeVersion, + position: [0, 0], + parameters: tool.nodeParameters, + credentials: tool.credentials ?? undefined, + }; + const workflow = new Workflow({ + nodes: [node], + connections: {}, + active: false, + nodeTypes: this.nodeTypes, + }); + const additionalData = await getBase({ projectId: tool.projectId }); + const runExecutionData = createEmptyRunExecutionData(); + const inputData: ITaskDataConnections = { main: [inputItems] }; + const executeData: IExecuteData = { node, data: inputData, source: null }; + const mode = 'internal' as const; + return { + node, + workflow, + additionalData, + runExecutionData, + inputData, + executeData, + mode, + }; + } + + /** + * Execute a node directly without persisting a workflow or execution to the DB. + * Mirrors the pattern from WorkflowExecute.executeNode and DynamicNodeParametersService. + */ + private async executeNodeDirectly( + tool: EphemeralWorkflowToolLike, + inputItems: INodeExecutionData[], + ): Promise { + const parts = await this.buildEphemeralContextParts(tool, inputItems); + + const context = new ExecuteContext( + parts.workflow, + parts.node, + parts.additionalData, + parts.mode, + parts.runExecutionData, + 0, + inputItems, + parts.inputData, + parts.executeData, + [], + ); + + const nodeType = this.nodeTypes.getByNameAndVersion(tool.nodeType, tool.nodeTypeVersion); + + if (!nodeType.execute) { + return { status: 'error', data: [], error: 'Node type does not have an execute method' }; + } + + let output: NodeOutput; + try { + output = + nodeType instanceof Node + ? await nodeType.execute(context) + : await nodeType.execute.call(context); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.debug('Node execution failed', { nodeType: tool.nodeType, error: message }); + return { status: 'error', data: [], error: message }; + } + + if (!Array.isArray(output) || !output[0]) { + return { status: 'error', data: [], error: 'No output data' }; + } + return { status: 'success', data: output[0] }; + } + + async executeInline(request: InlineNodeExecutionRequest): Promise { + // Validation failures (unknown node type, trigger nodes, blacklisted + // operations like send-and-wait) need to surface to the agent as a + // tool error rather than crashing silently. Returning the standard + // `{ status: 'error', error }` shape lets `run_node_tool` translate + // it into a tool-result the LLM sees AND lets the ExecutionRecorder + // record it as a failed tool call in the session timeline. + try { + this.validateNodeForExecution( + request.nodeType, + request.nodeTypeVersion, + request.nodeParameters, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.debug('Node execution validation failed', { + nodeType: request.nodeType, + error: message, + }); + return { + status: 'error', + data: [], + error: `Cannot execute node "${request.nodeType}": ${message}`, + }; + } + + const fromNames = + (await this.resolveInlineCredentials(request.projectId, request.credentials)) ?? {}; + + let fromDetails: Record = {}; + if (request.credentialDetails && Object.keys(request.credentialDetails).length > 0) { + fromDetails = await this.verifyCredentialDetailsForProject( + request.projectId, + request.credentialDetails, + ); + } + + const mergedCredentials = + Object.keys(fromNames).length === 0 && Object.keys(fromDetails).length === 0 + ? null + : { ...fromNames, ...fromDetails }; + + const tool: EphemeralWorkflowToolLike = { + projectId: request.projectId, + nodeType: request.nodeType, + nodeTypeVersion: request.nodeTypeVersion, + nodeParameters: request.nodeParameters, + credentials: mergedCredentials, + }; + + // Native tool nodes (toolWikipedia, toolCalculator, etc.) expose their real + // behavior via `supplyData`, which returns a LangChain `Tool`. Calling their + // `execute` method bypasses that and loses the LLM's arguments. Pick the + // right path based on which method the node implements. + const nodeType = this.nodeTypes.getByNameAndVersion(tool.nodeType, tool.nodeTypeVersion); + if (typeof nodeType.supplyData === 'function') { + return await this.invokeSupplyDataTool(tool, request.inputData); + } + + // TODO: for nodes with send and wait operations implement persistent workflows to handle the wait logic + return await this.executeNodeDirectly(tool, request.inputData); + } + + /** + * Instantiate the LangChain tool (or toolkit) that a `supplyData` node + * exposes, run a caller-supplied action against it, and clean up. Both + * invocation (with LLM args) and schema introspection (at tool-registration + * time) need the same setup/teardown, so they share this helper. + * + * `supplyData` legitimately returns either a single LangChain `Tool` or a + * `StructuredToolkit` (the shape MCP client nodes produce — see + * `SupplyDataToolResponse` in `@n8n/core`). Callers branch on which shape + * arrived; a `null`/malformed response is treated as an error. + */ + private async withSupplyDataTool( + tool: EphemeralWorkflowToolLike, + inputItems: INodeExecutionData[], + onTool: (response: Tool | StructuredToolkit) => Promise | T, + ): Promise<{ ok: true; value: T } | { ok: false; error: string }> { + const parts = await this.buildEphemeralContextParts(tool, inputItems); + const closeFunctions: CloseFunction[] = []; + + const context = new SupplyDataContext( + parts.workflow, + parts.node, + parts.additionalData, + parts.mode, + parts.runExecutionData, + 0, + inputItems, + parts.inputData, + NodeConnectionTypes.AiTool, + parts.executeData, + closeFunctions, + ); + + const nodeType = this.nodeTypes.getByNameAndVersion(tool.nodeType, tool.nodeTypeVersion); + if (typeof nodeType.supplyData !== 'function') { + return { ok: false, error: 'Node does not implement supplyData' }; + } + + try { + const supplyDataResult = await nodeType.supplyData.call(context, 0); + const response = supplyDataResult.response as Tool | StructuredToolkit | undefined; + + if (response instanceof StructuredToolkit) { + return { ok: true, value: await onTool(response) }; + } + if (response && typeof response.invoke === 'function') { + return { ok: true, value: await onTool(response) }; + } + return { + ok: false, + error: `Node "${tool.nodeType}" did not return a valid LangChain tool or toolkit`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: message }; + } finally { + for (const closeFunction of closeFunctions) { + try { + await closeFunction(); + } catch (error) { + this.logger.warn(`Error closing tool resource: ${String(error)}`); + } + } + } + } + + /** + * Run a native tool node (one with `supplyData`) by instantiating its + * LangChain tool and invoking that tool with the LLM's arguments. Mirrors + * the pattern in `scaling/job-processor.ts:invokeTool` but scoped to the + * ephemeral, single-node execution the agent runtime wants. + * + * MCP client nodes return a `StructuredToolkit` (multiple LangChain tools + * keyed by MCP method) rather than a single tool — the ephemeral runtime + * currently treats one `AgentJsonToolRef` as one invocable target, so + * toolkit dispatch is surfaced as an explicit error. Proper per-method + * expansion is tracked separately. + */ + private async invokeSupplyDataTool( + tool: EphemeralWorkflowToolLike, + inputItems: INodeExecutionData[], + ): Promise { + // The LLM's structured input is the first inputItem's `json` (see + // node-tool-factory.ts). Pass it straight through to the LangChain tool. + const toolArgs = (inputItems[0]?.json ?? {}) as Record; + + const result = await this.withSupplyDataTool(tool, inputItems, async (response) => { + if (response instanceof StructuredToolkit) { + // TODO(AGENT-26 follow-up): expand toolkit members into per-method + // BuiltTools at registration so the LLM can name the specific MCP + // tool to dispatch. For now, fail with a clear message rather than + // silently invoke a non-existent `.invoke` on the toolkit. + throw new Error( + `Node "${tool.nodeType}" returned a StructuredToolkit (${response.tools.length} tools); multi-tool dispatch via the ephemeral runtime isn't supported yet.`, + ); + } + return (await response.invoke(toolArgs)) as unknown; + }); + + if (!result.ok) { + this.logger.debug('supplyData tool invocation failed', { + nodeType: tool.nodeType, + error: result.error, + }); + return { status: 'error', data: [], error: result.error }; + } + + return { + status: 'success', + data: [{ json: { response: result.value as INodeExecutionData['json'] } }], + }; + } + + /** + * Instantiate the LangChain tool a `supplyData` node exposes and return its + * Zod schema (if it's a StructuredTool / DynamicStructuredTool / N8nTool). + * Used by the tool factory at registration time so the schema the LLM sees + * matches what `tool.invoke(args)` will zod-parse against at call time. + * + * Returns `null` when the tool has no structured schema (base `Tool` / + * `DynamicTool` — caller falls back to `{ input: string }`) or when + * introspection fails for any reason (credentials missing, MCP server + * unreachable). Swallowing failures here keeps tool registration robust: + * a bad MCP connection shouldn't prevent the agent from loading. + */ + async introspectSupplyDataToolSchema(tool: EphemeralWorkflowToolLike): Promise { + const result = await this.withSupplyDataTool(tool, [], (response) => { + // Toolkits hold multiple tools, each with its own schema — there's no + // single Zod schema to hand back. Return null so the factory falls + // through to its `{ input: string }` default; proper per-method + // introspection ships with multi-tool expansion. + if (response instanceof StructuredToolkit) return null; + const maybeSchema = (response as unknown as { schema?: unknown }).schema; + return maybeSchema ?? null; + }); + + if (!result.ok) { + // Warn (not debug) so MCP / credential introspection bugs surface in the + // normal dev loop — registration continues either way via `null`, but an + // invisible failure here has historically been a source of "why is the + // LLM being told a different schema than the one it's called against?". + this.logger.warn('supplyData tool introspection failed', { + nodeType: tool.nodeType, + error: result.error, + }); + return null; + } + + return result.value; + } +} diff --git a/packages/cli/src/node-execution/index.ts b/packages/cli/src/node-execution/index.ts new file mode 100644 index 00000000000..310e13e80b6 --- /dev/null +++ b/packages/cli/src/node-execution/index.ts @@ -0,0 +1,10 @@ +export type { + EphemeralWorkflowToolLike, + InlineNodeExecutionRequest, + NodeExecutionResult, +} from './ephemeral-node-executor'; +export { + EphemeralNodeExecutor, + isAgentProviderNode, + AGENT_PROVIDER_NODE_WHITELIST, +} from './ephemeral-node-executor'; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 5c3cceb8fe1..d5704ea1f98 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -57,6 +57,7 @@ import '@/controllers/security-settings.controller'; import '@/credentials/credentials.controller'; import '@/events/events.controller'; import '@/executions/executions.controller'; +import '@/node-execution/ephemeral-node-executor'; import '@/license/license.controller'; import '@/evaluation.ee/test-runs.controller.ee'; import '@/workflows/workflow-history/workflow-history.controller'; diff --git a/packages/cli/src/services/__tests__/auth.roles.service.test.ts b/packages/cli/src/services/__tests__/auth.roles.service.test.ts index d5fd85a0424..6551783c53f 100644 --- a/packages/cli/src/services/__tests__/auth.roles.service.test.ts +++ b/packages/cli/src/services/__tests__/auth.roles.service.test.ts @@ -429,6 +429,7 @@ describe('AuthRolesService', () => { const scopes = roleDef.scopes.map((scopeSlug) => createMinimalScope(scopeSlug)); if (roleDef.slug === PROJECT_OWNER_ROLE_SLUG) { scopes.push(createMinimalScope('workflow:publish')); + scopes.push(createMinimalScope('agent:publish')); scopes.push(createMinimalScope('workflow:share')); scopes.push(createMinimalScope('credential:share')); scopes.push(createMinimalScope('credential:move')); diff --git a/packages/cli/src/modules/instance-ai/__tests__/proxy-token-manager.test.ts b/packages/cli/src/services/__tests__/proxy-token-manager.test.ts similarity index 100% rename from packages/cli/src/modules/instance-ai/__tests__/proxy-token-manager.test.ts rename to packages/cli/src/services/__tests__/proxy-token-manager.test.ts diff --git a/packages/cli/src/modules/instance-ai/proxy-token-manager.ts b/packages/cli/src/services/proxy-token-manager.ts similarity index 100% rename from packages/cli/src/modules/instance-ai/proxy-token-manager.ts rename to packages/cli/src/services/proxy-token-manager.ts diff --git a/packages/cli/src/services/workflow-statistics.service.ts b/packages/cli/src/services/workflow-statistics.service.ts index fb70e77fe65..3d7de193be2 100644 --- a/packages/cli/src/services/workflow-statistics.service.ts +++ b/packages/cli/src/services/workflow-statistics.service.ts @@ -50,6 +50,9 @@ const isModeRootExecution = { // n8n Chat hub messages chat: false, + + // Agent executions + agent: false, } satisfies Record; type WorkflowStatisticsEvents = { @@ -102,8 +105,7 @@ export class WorkflowStatisticsService extends TypedEmitter { + private readonly store = new Map(); + + private sweepTimer: NodeJS.Timeout | null = null; + + /** + * @param ttlMs Time-to-live for each entry in milliseconds. + * @param sweepIntervalMs How often to run the background sweep (defaults to `ttlMs`). + * Set to `0` to disable the background sweep entirely. + */ + constructor( + private readonly ttlMs: number, + sweepIntervalMs: number = ttlMs, + ) { + if (sweepIntervalMs > 0) { + this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs).unref(); + } + } + + set(key: K, value: V): this { + this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + return this; + } + + get(key: K): V | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.value; + } + + has(key: K): boolean { + if (!this.store.has(key)) return false; + const expiresAt = this.store.get(key)?.expiresAt; + if (!expiresAt) return false; + if (Date.now() > expiresAt) { + this.store.delete(key); + return false; + } + return true; + } + + delete(key: K): boolean { + return this.store.delete(key); + } + + /** Iterate over non-expired keys. Triggers a sweep first to evict stale entries. */ + keys(): IterableIterator { + this.sweep(); + return this.store.keys(); + } + + /** Iterate over non-expired `[key, value]` pairs. */ + *entries(): IterableIterator<[K, V]> { + const now = Date.now(); + for (const [key, entry] of this.store) { + if (now <= entry.expiresAt) { + yield [key, entry.value]; + } + } + } + + /** Makes `TtlMap` directly iterable: `for (const [k, v] of map)` works as expected. */ + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.entries(); + } + + /** Number of non-expired entries (triggers a sweep). */ + get size(): number { + this.sweep(); + return this.store.size; + } + + clear(): void { + this.store.clear(); + } + + /** Evict all entries whose TTL has elapsed. */ + sweep(): void { + const now = Date.now(); + for (const [key, entry] of this.store) { + if (now > entry.expiresAt) { + this.store.delete(key); + } + } + } + + /** Stop the background sweep timer. */ + dispose(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + } +} diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index c4d723597e2..9cd3bc2a566 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -30,6 +30,7 @@ import type { IWorkflowExecutionDataProcess, EnvProviderState, ExecuteWorkflowData, + ExecuteAgentData, RelatedExecution, IRunExecutionData, } from 'n8n-workflow'; @@ -250,6 +251,54 @@ export async function executeWorkflow( return await executionPromise; } +/** + * Executes an agent with the given ID and message. + */ +export async function executeAgent( + agentId: string, + message: string, + executionId: string, + threadId: string, + additionalData: IWorkflowExecuteAdditionalData, +): Promise { + if (!additionalData.userId) { + throw new UnexpectedError('Cannot execute agent without a userId in additional data'); + } + + let projectId = additionalData.projectId; + if (!projectId) { + if (!additionalData.workflowId) { + throw new UnexpectedError( + 'Cannot execute agent without a projectId or workflowId in additional data', + ); + } + const { OwnershipService } = await import('@/services/ownership.service'); + const project = await Container.get(OwnershipService).getWorkflowProjectCached( + additionalData.workflowId, + ); + projectId = project.id; + } + + const { AgentsService } = await import('@/modules/agents/agents.service'); + const agentsService = Container.get(AgentsService); + + return await agentsService.executeForWorkflow( + agentId, + message, + executionId, + threadId, + additionalData.userId, + projectId, + ); +} + +async function listAgents(userId: string): Promise> { + const { AgentsService } = await import('@/modules/agents/agents.service'); + const agentsService = Container.get(AgentsService); + const agents = await agentsService.findByUser(userId); + return agents.map((agent) => ({ id: agent.id, name: agent.name })); +} + async function startExecution( additionalData: IWorkflowExecuteAdditionalData, options: ExecuteWorkflowOptions, @@ -313,6 +362,8 @@ async function startExecution( // This one already contains changes to talk to parent process // and get executionID from `activeExecutions` running on main process additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow; + additionalDataIntegrated.executeAgent = additionalData.executeAgent; + additionalDataIntegrated.listAgents = additionalData.listAgents; // Propagate the root execution mode so nested subworkflows retain the original // mode (e.g. 'manual') even though their own WorkflowExecute runs as 'integrated' additionalDataIntegrated.rootExecutionMode = @@ -485,6 +536,8 @@ export async function getBase({ currentNodeExecutionIndex: 0, credentialsHelper: Container.get(CredentialsHelper), executeWorkflow, + executeAgent, + listAgents, restApiUrl: urlBaseWebhook + globalConfig.endpoints.rest, instanceBaseUrl: `${instanceBaseUrl}/`, formWaitingBaseUrl: urlBaseWebhook + globalConfig.endpoints.formWaiting, diff --git a/packages/core/package.json b/packages/core/package.json index d2ec6a973c0..1178188271d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,7 +58,7 @@ "axios": "catalog:", "callsites": "catalog:", "chardet": "2.0.0", - "cron": "4.4.0", + "cron": "catalog:", "fast-glob": "catalog:", "file-type": "16.5.4", "form-data": "catalog:", diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 5e0716cbe19..7030ac3eefd 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -13,6 +13,8 @@ import type { IExecuteWorkflowInfo, RelatedExecution, ExecuteWorkflowData, + ExecuteAgentInfo, + ExecuteAgentData, ITaskMetadata, ContextType, IContextObject, @@ -25,6 +27,7 @@ import type { } from 'n8n-workflow'; import { ApplicationError, + OperationalError, NodeHelpers, NodeConnectionTypes, WAIT_INDEFINITELY, @@ -153,6 +156,27 @@ export class BaseExecuteContext extends NodeExecutionContext { return result; } + async executeAgent( + agentInfo: ExecuteAgentInfo, + message: string, + executionId: string, + itemIndex: number, + ): Promise { + if (!this.additionalData.executeAgent) { + throw new OperationalError('Agent execution is not available in this context'); + } + + const threadId = `${executionId}-${itemIndex}`; + + return await this.additionalData.executeAgent( + agentInfo.agentId, + message, + executionId, + threadId, + this.additionalData, + ); + } + async getExecutionDataById(executionId: string): Promise { return await this.additionalData.getRunExecutionData(executionId); } diff --git a/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts index 5b5120f9a9b..bf4196482bd 100644 --- a/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts @@ -259,6 +259,13 @@ export abstract class NodeExecutionContext implements Omit> { + if (!this.additionalData.listAgents || !this.additionalData.userId) { + return []; + } + return await this.additionalData.listAgents(this.additionalData.userId); + } + getInstanceId() { return this.instanceSettings.instanceId; } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue index 134785e462f..9eca82a22a8 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue @@ -246,7 +246,8 @@ const handleTooltipClose = () => {

  • { .item { border: var(--border-width) var(--border-style) transparent; + color: var(--color--text--tint-1); } .item.dragging:hover { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/linear.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/linear.svg new file mode 100644 index 00000000000..0ce2eee0af6 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/linear.svg @@ -0,0 +1 @@ + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/slack.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/slack.svg new file mode 100644 index 00000000000..12747e2eec5 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/telegram.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/telegram.svg new file mode 100644 index 00000000000..b5b32b1822b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/telegram.svg @@ -0,0 +1 @@ + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 3c9c0777a1a..01ac59e62a8 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -6,6 +6,7 @@ import EmptyOutput from './custom/empty-output.svg'; import FilledSquare from './custom/filled-square.svg'; import Form from './custom/form.svg'; import GripLinesVertical from './custom/grip-lines-vertical.svg'; +import Linear from './custom/linear.svg'; import Lovable from './custom/lovable.svg'; import Mcp from './custom/mcp.svg'; import NodeDirty from './custom/node-dirty.svg'; @@ -22,6 +23,7 @@ import Resolver from './custom/resolver.svg'; import Retry from './custom/retry.svg'; import RunOnce from './custom/run-once.svg'; import Schema from './custom/schema.svg'; +import Slack from './custom/slack.svg'; import Spinner from './custom/spinner.svg'; import StarFilled from './custom/star-filled.svg'; import StatusCanceled from './custom/status-canceled.svg'; @@ -31,6 +33,7 @@ import StatusNew from './custom/status-new.svg'; import StatusUnknown from './custom/status-unknown.svg'; import StatusWaiting from './custom/status-waiting.svg'; import StatusWarning from './custom/status-warning.svg'; +import Telegram from './custom/telegram.svg'; import Text from './custom/text.svg'; import Toolbox from './custom/toolbox.svg'; import Triangle from './custom/triangle.svg'; @@ -163,6 +166,7 @@ import IconLucideMaximize from '~icons/lucide/maximize'; import IconLucideMaximize2 from '~icons/lucide/maximize-2'; import IconLucideMenu from '~icons/lucide/menu'; import IconLucideMessageCircle from '~icons/lucide/message-circle'; +import IconLucideMessageCirclePlus from '~icons/lucide/message-circle-plus'; import IconLucideMessageSquare from '~icons/lucide/message-square'; import IconLucideMessageSquarePlus from '~icons/lucide/message-square-plus'; import IconLucideMessagesSquare from '~icons/lucide/messages-square'; @@ -278,6 +282,9 @@ export const deprecatedIconSet = { binary: Binary, text: Text, toolbox: Toolbox, + slack: Slack, + linear: Linear, + telegram: Telegram, spinner: Spinner, xmark: IconLucideX, mcp: Mcp, @@ -494,6 +501,9 @@ export const updatedIconSet = { binary: Binary, text: Text, toolbox: Toolbox, + slack: Slack, + linear: Linear, + telegram: Telegram, spinner: Spinner, 'node-dirty': NodeDirty, 'node-ellipsis': NodeEllipsis, @@ -634,6 +644,7 @@ export const updatedIconSet = { 'message-circle': IconLucideMessageCircle, 'message-square': IconLucideMessageSquare, 'message-square-plus': IconLucideMessageSquarePlus, + 'message-circle-plus': IconLucideMessageCirclePlus, 'messages-square': IconLucideMessagesSquare, mic: IconLucideMic, milestone: IconLucideMilestone, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue index 082b903b916..723d440fd32 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue @@ -40,8 +40,12 @@ const emojiRanges = [ type Props = { buttonTooltip: string; - buttonSize?: 'small' | 'large'; + buttonSize?: 'small' | 'large' | 'xlarge'; isReadOnly?: boolean; + /** Additional CSS class(es) for the outer container element */ + containerClass?: string | Record | Array>; + /** Additional CSS class(es) for the trigger button */ + buttonClass?: string | Record | Array>; }; const { t } = useI18n(); @@ -166,11 +170,14 @@ async function loadEmojiMetadataMap() {