From 94bdf85a8afbf76dec99dff2bca0d166be380509 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Wed, 14 Oct 2026 00:12:36 +0000 Subject: [PATCH] feat(core): Switch agent skills to the skills hub (no-changelog) (#40860) Co-authored-by: Ricardo Espinoza Co-authored-by: Roman Makowiak Co-authored-by: Claude Opus 5.5 --- .../agent-config-validation.schema.test.ts | 15 + .../agent-json-config.schema.test.ts | 4 +- .../agents/__tests__/skills.schema.test.ts | 9 + .../agents/agent-config-validation.schema.ts | 16 + .../src/agents/agent-json-config.schema.ts | 16 +- .../src/agents/agent-skill.schema.ts | 8 + packages/@n8n/api-types/src/agents/dto.ts | 42 +- .../api-types/src/agents/skills.schema.ts | 9 +- .../@n8n/api-types/src/frontend-settings.ts | 2 + packages/cli/src/modules/agents/AGENTS.md | 12 +- .../__tests__/agent-config.service.test.ts | 138 ++- .../agent-custom-tools.service.test.ts | 2 +- .../agents/__tests__/agent-document.test.ts | 16 +- ...ent-execution-orchestrator.service.test.ts | 2 +- ...nt-integration-persistence.service.test.ts | 2 +- .../__tests__/agent-publish.service.test.ts | 222 ++-- .../agent-runnable-state.service.test.ts | 41 + .../agent-runtime-cache.service.test.ts | 6 +- ...e-reconstruction-integration-tools.test.ts | 12 +- ...time-reconstruction-tool-filtering.test.ts | 6 +- .../agent-skill-refs.service.test.ts | 670 ++++++++++- .../__tests__/agent-skills.service.test.ts | 1069 +++++++++-------- .../__tests__/agent-task.service.test.ts | 2 +- .../agent-validation.service.test.ts | 43 +- .../agent-workflow-execution.service.test.ts | 55 +- .../agents-builder-tools.service.test.ts | 13 +- .../__tests__/agents.controller.test.ts | 23 +- .../agents/__tests__/agents.service.test.ts | 46 +- .../agents/__tests__/from-json-config.test.ts | 36 +- .../instance-ai-agent-context.adapter.test.ts | 37 +- .../agents/__tests__/skill.service.test.ts | 102 +- .../__tests__/skills-api.service.test.ts | 4 +- .../__tests__/skills-hub-rollout.test.ts | 45 + .../__tests__/test-utils/skills-hub-flag.ts | 14 + .../test-utils/stored-agent-config.ts | 144 ++- .../modules/agents/agent-config.service.ts | 20 +- .../agents/agent-definition.service.ts | 20 +- .../modules/agents/agent-publish.service.ts | 80 +- .../agents/agent-runnable-state.service.ts | 16 +- .../agent-runtime-reconstruction.service.ts | 6 +- .../agents/agent-skill-refs.service.ts | 520 +++++++- .../modules/agents/agent-skills.service.ts | 480 +++++--- .../agents/agent-validation.service.ts | 27 +- .../agent-workflow-execution.service.ts | 2 +- .../agents/agents-skills.controller.ts | 25 +- .../cli/src/modules/agents/agents.module.ts | 6 + .../cli/src/modules/agents/agents.service.ts | 21 +- .../builder/agents-builder-tools.service.ts | 17 +- .../builder/prompts/config-mutation.prompt.ts | 3 + .../builder/skills/target-skills.skill.ts | 8 +- .../instance-ai-agent-context.adapter.ts | 10 +- .../agents/json-config/agent-document.ts | 14 +- .../agents/json-config/from-json-config.ts | 2 - .../prune-missing-config-references.ts | 15 +- .../repositories/agent-history.repository.ts | 4 +- .../agents/repositories/agent.repository.ts | 30 +- .../agents/repositories/skill.repository.ts | 55 +- .../src/modules/agents/skills.controller.ts | 30 +- .../modules/agents/skills/skill.service.ts | 109 +- .../agents/skills/skills-api.service.ts | 11 +- .../agents/skills/skills-hub-rollout.ts | 30 + .../sub-agent-source-resolver.test.ts | 69 +- .../sub-agents/sub-agent-source-resolver.ts | 24 +- .../modules/agents/utils/agent-definition.ts | 13 +- .../agents/utils/agent-missing-skill-ids.ts | 1 - .../agents/utils/agent-published-snapshot.ts | 8 +- .../agents/utils/agent-runtime-assets.ts | 9 +- .../__tests__/agent-execution.service.test.ts | 10 +- .../__tests__/thread-restore.service.test.ts | 1 + .../eval/agent-execution.service.ts | 9 +- .../eval/thread-restore.service.ts | 7 +- .../mcp/__tests__/agent-tools.service.test.ts | 78 +- .../mcp/tools/agents/agent-reference.ts | 13 +- .../mcp/tools/agents/agent-tools.service.ts | 14 + .../export-agent-package.integration.test.ts | 54 +- ...export-agent-selection.integration.test.ts | 1 - .../fixtures/agent-package-fixtures.ts | 2 +- .../n8n-package-parser.agent.test.ts | 2 +- .../agents/agent-conversation-state.test.ts | 1 - .../agents/agent-skills-hub.test.ts | 488 ++++++++ .../agent-thread-grant.repository.test.ts | 1 - .../agents/n8n-chat-page.api.test.ts | 2 - .../agents/n8n-chat-production.api.test.ts | 2 - .../agents/n8n-chat-production.model.test.ts | 2 - .../agents/skill.repository.test.ts | 50 +- .../agents/skills.controller.test.ts | 16 +- ...ecution-thread-n8n-chat.repository.test.ts | 6 - .../agent-execution.repository.test.ts | 3 - .../repositories/agent.repository.test.ts | 46 +- .../n8n-packages-export-agents.test.ts | 27 +- .../scheduling/agent-task-multi-main.test.ts | 2 - .../workflow-dependency.controller.test.ts | 2 - .../frontend/@n8n/i18n/src/locales/en.json | 8 +- .../agents/__tests__/AgentBuilderView.test.ts | 7 +- .../AgentCapabilitiesSection.test.ts | 15 +- .../agents/__tests__/AgentSkillModal.test.ts | 3 - .../agents/__tests__/AgentSkillViewer.test.ts | 32 +- .../__tests__/AgentSkillsSection.test.ts | 60 + .../useAgentCapabilitiesActions.test.ts | 63 +- .../src/features/agents/agent.types.ts | 1 + .../components/AgentBuilderEditorColumn.vue | 4 +- .../components/AgentCapabilitiesSection.vue | 5 +- .../agents/components/AgentSkillModal.vue | 11 +- .../agents/components/AgentSkillViewer.vue | 188 +-- .../agents/components/AgentSkillsSection.vue | 42 +- .../useAgentCapabilitiesActions.test.ts | 19 +- .../useAgentCapabilitiesActions.ts | 109 +- .../src/features/agents/utils/agentSkill.ts | 14 +- .../agents/views/AgentBuilderView.vue | 1 - .../roles/project/ProjectRoleView.test.ts | 17 + .../roles/project/ProjectRoleView.vue | 11 +- 111 files changed, 4239 insertions(+), 1778 deletions(-) create mode 100644 packages/@n8n/api-types/src/agents/__tests__/agent-config-validation.schema.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/agent-runnable-state.service.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/skills-hub-rollout.test.ts create mode 100644 packages/cli/src/modules/agents/__tests__/test-utils/skills-hub-flag.ts create mode 100644 packages/cli/src/modules/agents/skills/skills-hub-rollout.ts create mode 100644 packages/cli/test/integration/agents/agent-skills-hub.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-config-validation.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-config-validation.schema.test.ts new file mode 100644 index 00000000000..e59cee0bbd9 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-config-validation.schema.test.ts @@ -0,0 +1,15 @@ +import { agentConfigValidationWarningSchema } from '../agent-config-validation.schema'; + +describe('agentConfigValidationWarningSchema', () => { + const warning = { code: 'similar_skill_names', capability: { kind: 'skill' } }; + + it('accepts a similar_skill_names warning with two names', () => { + expect( + agentConfigValidationWarningSchema.safeParse({ ...warning, names: ['Tone', 'tone'] }).success, + ).toBe(true); + }); + + it.each([[[]], [['Tone']], [['Tone', '']]])('rejects the names %j', (names) => { + expect(agentConfigValidationWarningSchema.safeParse({ ...warning, names }).success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts index 719d3500da7..af3550eb53e 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts @@ -16,7 +16,7 @@ describe('AgentJsonConfigSchema — capability activation', () => { const activation = enabled === undefined ? {} : { enabled }; const config = { ...minimalConfig, - skills: [{ type: 'skill', id: 'notes', ...activation }], + skills: [{ type: 'skill', id: 'notes' }], tools: [ { type: 'custom', id: 'search', requireApproval: true, ...activation }, { type: 'workflow', workflow: 'My Workflow', workflowId: 'wf-1', ...activation }, @@ -41,7 +41,7 @@ describe('AgentJsonConfigSchema — capability activation', () => { expect( AgentJsonConfigSchema.safeParse({ ...minimalConfig, - skills: [{ type: 'skill', id: 'notes', enabled: 'false' }], + tools: [{ type: 'custom', id: 'search', enabled: 'false' }], }).success, ).toBe(false); }); diff --git a/packages/@n8n/api-types/src/agents/__tests__/skills.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/skills.schema.test.ts index a4b59565991..6251e47b247 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/skills.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/skills.schema.test.ts @@ -50,6 +50,15 @@ describe('CreateSkillDto', () => { expect(CreateSkillDto.safeParse({ ...target, skill }).success).toBe(true); }); + it('rejects allowed tools, which hub skills ignore', () => { + expect( + CreateSkillDto.safeParse({ + scope: 'instance', + skill: { ...skill, allowedTools: ['search'] }, + }).success, + ).toBe(false); + }); + it('rejects a skill without instructions', () => { expect( CreateSkillDto.safeParse({ scope: 'instance', skill: { ...skill, instructions: '' } }) diff --git a/packages/@n8n/api-types/src/agents/agent-config-validation.schema.ts b/packages/@n8n/api-types/src/agents/agent-config-validation.schema.ts index 5c5aa3e94b1..ec59bd06f25 100644 --- a/packages/@n8n/api-types/src/agents/agent-config-validation.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-config-validation.schema.ts @@ -58,8 +58,24 @@ export const agentConfigValidationIssueSchema = z.object({ }); export type AgentConfigValidationIssue = z.infer; +/** Stable, i18n-free code for a validation warning. A warning does not make a config invalid. */ +export const agentConfigValidationWarningCodeSchema = z.enum(['similar_skill_names']); +export type AgentConfigValidationWarningCode = z.infer< + typeof agentConfigValidationWarningCodeSchema +>; + +export const agentConfigValidationWarningSchema = z.object({ + code: agentConfigValidationWarningCodeSchema, + capability: agentConfigValidationCapabilityRefSchema, + /** For `similar_skill_names`: the skill names that are hard to tell apart, at least two. */ + names: z.array(z.string().min(1)).min(2), +}); +export type AgentConfigValidationWarning = z.infer; + export const agentConfigValidationResponseSchema = z.object({ status: z.enum(['valid', 'invalid']), issues: z.array(agentConfigValidationIssueSchema), + /** Absent when there are no warnings. */ + warnings: z.array(agentConfigValidationWarningSchema).optional(), }); export type AgentConfigValidationResponse = z.infer; diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 36c38cafa9a..a0be490a076 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -188,9 +188,14 @@ export const NodeConfigSchema = z.object({ credentials: z.record(NodeToolCredentialSchema).optional(), }); +/** + * A ref to a skill. A skill has no on/off state: a ref is attached or it is + * not. `versionId` pins the ref of a saved agent to one saved version of the + * skill. Without it, the agent draft runs the latest saved version. + */ const AgentJsonSkillConfigSchema = z.object({ type: z.literal('skill'), - enabled: z.boolean().optional(), + versionId: z.string().uuid().optional(), id: z .string() .min(1) @@ -576,8 +581,9 @@ export type AgentJsonConfig = z.infer; declare const storedSkillRefsBrand: unique symbol; /** - * Opaque type of the legacy `skills` key in a stored config. Only the skill - * refs seam reads or writes this key. + * Opaque type of the legacy `skills` key in a stored config. Nothing reads + * or writes this key: the skill refs live in the skills hub tables. The data + * migration removes the key from stored configs. */ export interface OpaqueStoredSkillRefs { readonly [storedSkillRefsBrand]: true; @@ -585,8 +591,8 @@ export interface OpaqueStoredSkillRefs { /** * Agent config as the backend stores it on the agent draft and on each - * published version. Its `skills` key is opaque: read and write skill refs - * through `AgentSkillRefsService`. `AgentJsonConfig` (with `skills`) stays the + * published version. It holds no skill refs: read and write them through + * `AgentSkillRefsService`. `AgentJsonConfig` (with `skills`) stays the * document format of REST, MCP and the agent builder. * * Because the `skills` key is opaque, a stored config is not assignable to diff --git a/packages/@n8n/api-types/src/agents/agent-skill.schema.ts b/packages/@n8n/api-types/src/agents/agent-skill.schema.ts index bbc862ca826..5ed1ff1cad8 100644 --- a/packages/@n8n/api-types/src/agents/agent-skill.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-skill.schema.ts @@ -85,3 +85,11 @@ export const agentSkillShape = { }; export const agentSkillSchema = z.object(agentSkillShape).strict(); + +export const agentSkillAvailabilitySchema = z.enum(['project', 'agent', 'instance']); + +export type AgentSkillAvailability = z.infer; + +export const createAgentSkillSchema = agentSkillSchema.extend({ + availableTo: agentSkillAvailabilitySchema.optional(), +}); diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 15770b656da..30e2e8c1eb7 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -10,7 +10,12 @@ import { } from './agent-chat-attachments.constants'; import { AgentApprovalSchema, AgentTeamsSettingsSchema } from './agent-integration.schema'; import { AgentVectorStoreConfigSchema, AgentJsonConfigSchema } from './agent-json-config.schema'; -import { agentSkillSchema, agentSkillShape } from './agent-skill.schema'; +import { + agentSkillAvailabilitySchema, + agentSkillSchema, + agentSkillShape, + createAgentSkillSchema, +} from './agent-skill.schema'; import { agentTaskSchema } from './agent-task.schema'; import { N8N_CHAT_INTEGRATION_TYPE } from './types'; import { paginationSchema } from '../dto/pagination/pagination.dto'; @@ -200,19 +205,26 @@ const updateAgentSkillShape = { const updateAgentSkillSchema = z.object(updateAgentSkillShape).strict(); -export class CreateAgentSkillDto extends Z.class(agentSkillShape) { - static override schema = agentSkillSchema; +const updateAttachedAgentSkillShape = { + ...updateAgentSkillShape, + availableTo: agentSkillAvailabilitySchema.optional(), +}; - constructor(data: z.infer) { - super(agentSkillSchema.parse(data)); +const updateAttachedAgentSkillSchema = z.object(updateAttachedAgentSkillShape).strict(); + +export class CreateAgentSkillDto extends Z.class(createAgentSkillSchema.shape) { + static override schema = createAgentSkillSchema; + + constructor(data: z.infer) { + super(createAgentSkillSchema.parse(data)); } static override safeParse(data: unknown) { - return agentSkillSchema.safeParse(data); + return createAgentSkillSchema.safeParse(data); } static override parse(data: unknown) { - return agentSkillSchema.parse(data); + return createAgentSkillSchema.parse(data); } } @@ -232,6 +244,22 @@ export class UpdateAgentSkillDto extends Z.class(updateAgentSkillShape) { } } +export class UpdateAttachedAgentSkillDto extends Z.class(updateAttachedAgentSkillShape) { + static override schema = updateAttachedAgentSkillSchema; + + constructor(data: z.infer) { + super(updateAttachedAgentSkillSchema.parse(data)); + } + + static override safeParse(data: unknown) { + return updateAttachedAgentSkillSchema.safeParse(data); + } + + static override parse(data: unknown) { + return updateAttachedAgentSkillSchema.parse(data); + } +} + export const agentChatAttachmentSchema = z.object({ fileName: z.string().min(1).max(MAX_AGENT_CHAT_ATTACHMENT_FILENAME_LENGTH), mimeType: z.string().min(1).max(MAX_AGENT_CHAT_ATTACHMENT_MIMETYPE_LENGTH), diff --git a/packages/@n8n/api-types/src/agents/skills.schema.ts b/packages/@n8n/api-types/src/agents/skills.schema.ts index f3b4b4a4408..020b16d4a6b 100644 --- a/packages/@n8n/api-types/src/agents/skills.schema.ts +++ b/packages/@n8n/api-types/src/agents/skills.schema.ts @@ -5,6 +5,12 @@ import type { AgentSkill } from './types'; import { paginationSchema } from '../dto/pagination/pagination.dto'; import { Z } from '../zod-class'; +/** + * PostHog flag for the rollout of the skills hub, read for the instance once at startup. + * Off: agents keep their inline skills and the skills routes answer 404. + */ +export const SKILLS_FLAG = '125_context_skills'; + export const SKILLS_MAX_PAGE_SIZE = 100; /** Optional on purpose: without `take` the caller gets every visible skill. */ @@ -106,7 +112,8 @@ export class CreateSkillDto extends Z.class({ projectId: z.string().max(36).optional(), /** Required for an agent skill, absent otherwise. */ agentId: z.string().max(36).optional(), - skill: agentSkillSchema, + /** Hub skills ignore `allowedTools`, so a create that sends it is refused. */ + skill: agentSkillSchema.omit({ allowedTools: true }), }) {} /** diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 2eba428c936..600de0d4ced 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -465,6 +465,8 @@ export type FrontendModuleSettings = { knowledgeBaseEnabled: boolean; /** Whether the AI Assistant proxy is available to the agents module. */ proxyEnabled: boolean; + /** Whether agents use the skills hub (PostHog flag `125_context_skills`, read at startup). */ + skillsHubEnabled?: boolean; }; }; diff --git a/packages/cli/src/modules/agents/AGENTS.md b/packages/cli/src/modules/agents/AGENTS.md index 101b8f28202..30e5935d050 100644 --- a/packages/cli/src/modules/agents/AGENTS.md +++ b/packages/cli/src/modules/agents/AGENTS.md @@ -9,7 +9,13 @@ Guidance for the agents module (`packages/cli/src/modules/agents`). See the [cli ## Skill refs -- `agents.schema` and `agent_history.schema` hold a `StoredAgentConfig`. It has no readable `skills` key. -- Read and write the skill refs of a draft or a published version only through `AgentSkillRefsService`. +- The PostHog flag `125_context_skills` (`SKILLS_FLAG`) selects where skills live. `SkillsHubRollout` reads it for the instance once, when the agents module starts, and keeps the value for the life of the process; a change needs a restart. `AgentSkillRefsService.usesSkillsHub` and the `/skills` routes read `SkillsHubRollout`. Turn it on locally with `N8N_FEATURE_FLAG_OVERRIDES`. There are no double writes: data written with one setting is not visible with the other. +- Hub on: skills live in the skills hub (`skill`, `skill_version`, `skill_file`). A draft keeps its skill refs in `agent_skill_dependency`, in attach order (`createdAt`). A published version keeps its pins in `agent_version_skill_dependency`, in the same order. +- Hub on: a skill that an agent page creates is available to the project, to that agent only (`skill.agentId`), or to the instance (`availableTo` on the create request, default `project`). A duplicate of an agent gets its own copy of each agent-only skill. +- Hub off: the refs are the `skills` key of `agents.schema` and `agent_history.schema`, and the bodies are the `agents.skills` and `agent_history.skills` columns, as before the skills hub. A ref with `enabled: false` is hidden, and its body stays in the column. +- Only `AgentSkillRefsService` reads the `skills` key of a `StoredAgentConfig`. +- Read and write the skill refs, and read the skill bodies, only through `AgentSkillRefsService`. A draft runs the latest saved version of each skill, or its pin (`versionId`). A published version runs its pins. - Compose the JSON document (`AgentJsonConfig`) with `toAgentDocument` or `composeJsonConfig` only where code needs the document: REST, MCP, the agent builder tools, publish, runtime reconstruction, sub-agent resolution, telemetry, config hashes and create with a config. Compute `configHash` on the composed document. Split an inbound document with `fromAgentDocument`. -- A write that changes the skill refs of an existing draft calls `saveAgentDraftFenced` and then `replaceDraftRefs` with the same `ctx`, in one `TransactionRunner.run`. Agent creation calls `insertNew` and then `replaceDraftRefs` in one transaction. +- A write that changes the skill refs of an existing draft calls `saveAgentDraftFenced` and then `replaceDraftRefs` with the same `ctx`, in one `TransactionRunner.run`. Agent creation calls `insertNew` and then `createDraftRefs` in one transaction. Revert calls `restoreDraftRefs`. Publish calls `versionSnapshot` before it writes the `agent_history` row and `pinVersion` after. +- Publish writes the legacy columns in full with either setting: the refs in `agent_history.schema.skills` and the bodies in `agent_history.skills`. With the hub on, the bodies are the versions the draft runs, so a published agent keeps running after the flag is turned off. +- Inline agents (`InlineAgentPayload`) embed their skills and never call the skill service. diff --git a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts index 115b2b54ca8..929a472aabd 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts @@ -21,7 +21,6 @@ import { AgentModificationTelemetryService } from '../agent-modification-telemet import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentSetupCompletionService } from '../agent-setup-completion.service'; -import type { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { AgentValidationService } from '../agent-validation.service'; import type { Agent } from '../entities/agent.entity'; @@ -34,7 +33,6 @@ import type { AgentPolicyService } from '../agent-policy.service'; import { composeStoredJsonConfig, createAgentSkillRefsService, - storedSkillRefs, type AgentFixtureOverrides, } from './test-utils/stored-agent-config'; @@ -63,7 +61,6 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { activeVersionId: null, schema: baseConfig, tools: {}, - skills: {}, integrations: [], setupCompletedAt: null, updatedAt: new Date('2025-01-01T00:00:00Z'), @@ -83,7 +80,7 @@ const byUser = fencedOn(makeAgent()); function makeService() { const agentRepository = mock(); const agentTaskRepository = mock(); - const agentSkillsService = mock(); + const skillRefs = createAgentSkillRefsService(); const runtimeCacheService = mock(); const credentialsService = mock(); const workflowRepository = mock(); @@ -105,12 +102,6 @@ function makeService() { credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); agentTaskRepository.findByAgentId.mockResolvedValue([]); workflowRepository.findManyByAgentToolReferences.mockResolvedValue([]); - agentSkillsService.removeUnreferencedSkills.mockImplementation((agent, config) => { - const ids = new Set((config.skills ?? []).map((skill) => skill.id)); - agent.skills = Object.fromEntries( - Object.entries(agent.skills ?? {}).filter(([id]) => ids.has(id)), - ); - }); const transactionRunner = mock(); transactionRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx)); @@ -119,7 +110,6 @@ function makeService() { mockLogger(), agentRepository, agentTaskRepository, - agentSkillsService, new AgentConfigPreparationService( credentialsService, workflowRepository, @@ -133,7 +123,7 @@ function makeService() { new AgentModificationTelemetryService(telemetry), ), agentPolicyService, - createAgentSkillRefsService(agentRepository), + skillRefs, ); return { @@ -141,7 +131,7 @@ function makeService() { agentPolicyService, agentRepository, agentTaskRepository, - agentSkillsService, + skillRefs, runtimeCacheService, credentialsService, workflowRepository, @@ -335,7 +325,7 @@ describe('AgentConfigService', () => { projectId, agentId, expect.objectContaining({ tools: [expect.objectContaining(dateTimeTool)] }), - baseConfig, + { ...baseConfig, skills: [] }, { kind: 'user', user }, ); }); @@ -676,8 +666,8 @@ describe('AgentConfigService', () => { expect(saved.schema).toHaveProperty('description', ''); }); - it('deletes the resources of omitted tools, skills, and tasks when clearOmittedOptionalFields is set', async () => { - const { service, agentRepository, agentTaskRepository } = makeService(); + it('deletes the resources of omitted tools and tasks and detaches omitted skills when clearOmittedOptionalFields is set', async () => { + const { service, agentRepository, agentTaskRepository, skillRefs } = makeService(); const agent = makeAgent({ schema: { ...baseConfig, @@ -686,7 +676,6 @@ describe('AgentConfigService', () => { tasks: [{ type: 'task', id: 'task-1', enabled: true }], } as unknown as AgentJsonConfig, tools: storedCustomTool, - skills: { 'skill-1': { name: 'Skill' } } as unknown as Agent['skills'], }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentTaskRepository.findByAgentId.mockResolvedValue([{ id: 'task-1' }] as never); @@ -698,12 +687,12 @@ describe('AgentConfigService', () => { const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; expect(saved.tools).toEqual({}); - expect(saved.skills).toEqual({}); + expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith(saved, undefined, {}); expect(agentTaskRepository.deleteForAgent).toHaveBeenCalledWith(agentId, ['task-1'], {}); }); it('keeps the resources of omitted tools, skills, and tasks by default', async () => { - const { service, agentRepository, agentTaskRepository } = makeService(); + const { service, agentRepository, agentTaskRepository, skillRefs } = makeService(); const agent = makeAgent({ schema: { ...baseConfig, @@ -711,7 +700,6 @@ describe('AgentConfigService', () => { skills: [{ type: 'skill', id: 'skill-1' }], } as unknown as AgentJsonConfig, tools: storedCustomTool, - skills: { 'skill-1': { name: 'Skill' } } as unknown as Agent['skills'], }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -719,7 +707,11 @@ describe('AgentConfigService', () => { const saved = agentRepository.saveDraftFenced.mock.calls.at(-1)?.[0] as Agent; expect(saved.tools).toEqual(storedCustomTool); - expect(Object.keys(saved.skills ?? {})).toEqual(['skill-1']); + expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith( + saved, + [{ type: 'skill', id: 'skill-1' }], + {}, + ); expect(agentTaskRepository.deleteForAgent).not.toHaveBeenCalled(); }); @@ -820,14 +812,29 @@ describe('AgentConfigService', () => { ]); }); + it('drops the inline body of a removed skill ref while the skills hub is off', async () => { + const { service, agentRepository, skillRefs } = makeService(); + Object.assign(skillRefs, { usesSkillsHub: false }); + const kept = { name: 'Kept', description: 'Kept', instructions: 'Keep.' }; + const agent = makeAgent({ + skills: { kept, removed: { name: 'Removed', description: 'Removed', instructions: 'Go.' } }, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + await service.updateConfig( + agentId, + projectId, + { ...baseConfig, skills: [{ type: 'skill', id: 'kept' }] }, + user, + byUser, + ); + + expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({ kept }); + }); + it('removes config refs and stored bodies that no longer have matching definitions', async () => { - const { - service, - agentRepository, - agentTaskRepository, - agentSkillsService, - runtimeCacheService, - } = makeService(); + const { service, agentRepository, agentTaskRepository, skillRefs, runtimeCacheService } = + makeService(); const agent = makeAgent({ tools: { tool_1: { @@ -839,9 +846,6 @@ describe('AgentConfigService', () => { descriptor: { name: 'tool_2', description: 'b', inputSchema: {} }, }, } as unknown as Agent['tools'], - skills: { - 'skill-1': { name: 'Skill', description: 'desc', instructions: 'Use it' }, - }, }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentTaskRepository.findByAgentId.mockResolvedValue([ @@ -861,10 +865,9 @@ describe('AgentConfigService', () => { { type: 'custom', id: 'disabled_missing_tool', enabled: false }, ], skills: [ - { type: 'skill', id: 'skill-1', enabled: false }, + { type: 'skill', id: 'skill-1' }, { type: 'skill', id: 'missing-skill' }, { type: 'skill', id: 'toString' }, - { type: 'skill', id: 'disabled-missing-skill', enabled: false }, ], tasks: [ { type: 'task', id: 'task-1', enabled: true }, @@ -880,14 +883,20 @@ describe('AgentConfigService', () => { { type: 'custom', id: 'tool_1', enabled: false, requireApproval: true }, { type: 'custom', id: 'disabled_missing_tool', enabled: false }, ]); - expect(saved.schema?.skills).toEqual([ - { type: 'skill', id: 'skill-1', enabled: false }, - { type: 'skill', id: 'disabled-missing-skill', enabled: false }, - ]); + // The skill refs seam checks skill refs against the skills hub. + expect(saved.schema).not.toHaveProperty('skills'); + expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith( + saved, + [ + { type: 'skill', id: 'skill-1' }, + { type: 'skill', id: 'missing-skill' }, + { type: 'skill', id: 'toString' }, + ], + {}, + ); expect(saved.schema?.tasks).toEqual([{ type: 'task', id: 'task-1', enabled: true }]); expect(Object.keys(saved.tools)).toEqual(['tool_1']); expect(agentTaskRepository.deleteForAgent).toHaveBeenCalledWith(agentId, ['task-2'], {}); - expect(agentSkillsService.removeUnreferencedSkills).toHaveBeenCalled(); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); }); @@ -896,7 +905,6 @@ describe('AgentConfigService', () => { const config: AgentJsonConfig = { ...baseConfig, tools: [{ type: 'custom', id: 'missing_tool', enabled: false, requireApproval: true }], - skills: [{ type: 'skill', id: 'missing_skill', enabled: false }], subAgents: { agents: [{ agentId: 'missing-agent', enabled: false, useWhen: 'Review' }] }, }; const agent = makeAgent({ schema: config }); @@ -906,7 +914,6 @@ describe('AgentConfigService', () => { const activated: AgentJsonConfig = { ...config, tools: [{ type: 'custom', id: 'missing_tool', enabled: true, requireApproval: true }], - skills: [{ type: 'skill', id: 'missing_skill', enabled: true }], subAgents: { agents: [{ agentId: 'missing-agent', enabled: true, useWhen: 'Review' }] }, }; @@ -1270,21 +1277,18 @@ describe('AgentConfigService', () => { }); describe('skill refs round trip', () => { + const pinId = '6f1c1c5e-1b7a-4d55-9d4e-3c2e8f6a9b10'; const skillRefs = [ { type: 'skill' as const, id: 'zeta' }, - { type: 'skill' as const, id: 'alpha', enabled: false }, - { type: 'skill' as const, id: 'mid', enabled: true }, + { type: 'skill' as const, id: 'alpha', versionId: pinId }, + { type: 'skill' as const, id: 'mid' }, ]; - const skillBody = { name: 'Skill', description: 'A skill', instructions: 'Do it.' }; function makeAgentWithSkillRefs() { - return makeAgent({ - schema: { ...baseConfig, skills: skillRefs }, - skills: { zeta: skillBody, mid: { ...skillBody, name: 'Mid' } }, - }); + return makeAgent({ schema: { ...baseConfig, skills: skillRefs } }); } - it('returns the stored skill refs from getConfig in order, with the enabled flags', async () => { + it('returns the skill refs of the draft from getConfig in order, with the pins', async () => { const { service, agentRepository } = makeService(); agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgentWithSkillRefs()); @@ -1294,7 +1298,7 @@ describe('AgentConfigService', () => { }); it('saves the refs of a config that getConfig returned without a change', async () => { - const { service, agentRepository } = makeService(); + const { service, agentRepository, skillRefs: refsService } = makeService(); const agent = makeAgentWithSkillRefs(); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); const config = await service.getConfig(agentId, projectId); @@ -1310,27 +1314,45 @@ describe('AgentConfigService', () => { expect(await service.getConfig(agentId, projectId)).toEqual(config); // The fenced save and the skill refs write share one transaction. const [[, saveCtx]] = agentRepository.saveDraftFenced.mock.calls; - const [[written, refsCtx]] = agentRepository.updateDraftSchema.mock.calls; + const [[written, refs, refsCtx]] = refsService.replaceDraftRefs.mock.calls; expect(refsCtx).toBe(saveCtx); - expect(storedSkillRefs(written)).toEqual(skillRefs); + expect(written).toBe(agent); + expect(refs).toEqual(skillRefs); + // The stored config holds no skill refs. + expect(agent.schema).not.toHaveProperty('skills'); }); - it('saves new refs in the order of the submitted config', async () => { - const { service, agentRepository } = makeService(); + it('passes the submitted refs to the skill refs seam', async () => { + const { service, agentRepository, skillRefs: refsService } = makeService(); agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgentWithSkillRefs()); const config = await service.getConfig(agentId, projectId); - const reordered = [skillRefs[2], { ...skillRefs[0], enabled: false }, skillRefs[1]]; + const next = [skillRefs[2], { type: 'skill' as const, id: 'new' }]; + + await service.updateConfig(agentId, projectId, { ...config, skills: next }, user, { + baseConfigHash: getAgentConfigHash(config), + modifiedBy: 'user', + }); + + expect(refsService.replaceDraftRefs).toHaveBeenCalledWith(expect.anything(), next, {}); + }); + + it('validates, saves and reports the effective refs, without the stale ids', async () => { + const { service, agentRepository, skillRefs: refsService } = makeService(); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgentWithSkillRefs()); + const config = await service.getConfig(agentId, projectId); + const kept = [skillRefs[2]]; + refsService.effectiveDraftRefs.mockResolvedValue(kept); const result = await service.updateConfig( agentId, projectId, - { ...config, skills: reordered }, + { ...config, skills: [skillRefs[2], { type: 'skill' as const, id: 'stale' }] }, user, { baseConfigHash: getAgentConfigHash(config), modifiedBy: 'user' }, ); - expect(result.config.skills).toEqual(reordered); - expect((await service.getConfig(agentId, projectId)).skills).toEqual(reordered); + expect(refsService.replaceDraftRefs).toHaveBeenCalledWith(expect.anything(), kept, {}); + expect(result.config.skills).toEqual(kept); }); }); @@ -1368,7 +1390,6 @@ describe('AgentConfigService', () => { it('counts the stored skill refs in the modification event', async () => { const { service, agentRepository, telemetry } = makeService(); - const skillBody = { name: 'Skill', description: 'A skill', instructions: 'Do it.' }; const agent = makeAgent({ schema: { ...baseConfig, @@ -1377,7 +1398,6 @@ describe('AgentConfigService', () => { { type: 'skill', id: 'billing' }, ], }, - skills: { refunds: skillBody, billing: skillBody }, }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); const config = composeStoredJsonConfig(agent); diff --git a/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts index 90b87a3bfdd..971bd680c06 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-custom-tools.service.test.ts @@ -71,7 +71,7 @@ function makeService() { agentUpdateBroadcaster, modificationTelemetry, ), - createAgentSkillRefsService(agentRepository), + createAgentSkillRefsService(), ); return { diff --git a/packages/cli/src/modules/agents/__tests__/agent-document.test.ts b/packages/cli/src/modules/agents/__tests__/agent-document.test.ts index 4d7e0e9c967..432f9b569b7 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-document.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-document.test.ts @@ -14,7 +14,7 @@ const document: AgentJsonConfig = { tools: [{ type: 'custom', id: 'lookup' }], skills: [ { type: 'skill', id: 'refunds' }, - { type: 'skill', id: 'billing', enabled: false }, + { type: 'skill', id: 'billing', versionId: '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f' }, { type: 'skill', id: 'apology' }, ], tasks: [{ type: 'task', id: 'daily', enabled: true }], @@ -30,12 +30,12 @@ describe('toAgentDocument / fromAgentDocument', () => { expect(toAgentDocument(config, skillRefs)).toEqual(document); }); - it('keeps the ref order and the enabled flags', () => { + it('keeps the ref order and the pins', () => { const { config, skillRefs } = fromAgentDocument(document); expect(toAgentDocument(config, skillRefs).skills).toEqual([ { type: 'skill', id: 'refunds' }, - { type: 'skill', id: 'billing', enabled: false }, + { type: 'skill', id: 'billing', versionId: '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f' }, { type: 'skill', id: 'apology' }, ]); }); @@ -69,11 +69,11 @@ describe('toAgentDocument / fromAgentDocument', () => { }); describe('config hash of a stored agent', () => { - // The value that master computes for this agent, before the skill refs seam. - // Clients send this hash back as the base hash of a save, so it must not change. - const MASTER_CONFIG_HASH = '04622862951435434cad418aeb1f64a5aa9152535af3f93e68d7730c94cb4dfa'; + // Clients send this hash back as the base hash of a save, so an accidental change to + // how skill refs compose must fail here. + const CONFIG_HASH = '1c29b5f1b9a64d762f735e1c218b23da83416ee305dca842f807019a6b4ba752'; - it('composes the same document and hash as before the seam', async () => { + it('composes the same document and hash from the stored config and its refs', async () => { const { integrations, ...schema } = document; const agent = { id: 'agent-1', schema: storedAgentConfig(schema), integrations } as Agent; @@ -81,6 +81,6 @@ describe('config hash of a stored agent', () => { const composed = composeJsonConfig(agent, skillRefs); expect(composed).toEqual(document); - expect(getAgentConfigHash(composed)).toBe(MASTER_CONFIG_HASH); + expect(getAgentConfigHash(composed)).toBe(CONFIG_HASH); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index b9946bdb221..d986ae8b7f8 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -295,7 +295,7 @@ function makeService(sandboxEnabled = false) { backgroundJobRepository, backgroundJobs, settingsService, - createAgentSkillRefsService(agentRepository), + createAgentSkillRefsService(), ); return { diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts index 007bc1e62bd..24b3a8c9c3c 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-persistence.service.test.ts @@ -102,7 +102,7 @@ function setup(options: SetupOptions = {}) { new AgentModificationTelemetryService(telemetry), credentialsService, setupCompletionService, - createAgentSkillRefsService(agentRepository), + createAgentSkillRefsService(), ), agent, row, diff --git a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts index 6cf1fa4d14b..9d7e8b860d8 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-publish.service.test.ts @@ -6,6 +6,7 @@ import type { User, TransactionRunner, OperationContext, Transaction } from '@n8 import { Container } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { QueryFailedError } from '@n8n/typeorm'; +import { UserError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; @@ -16,6 +17,7 @@ import type { AgentCustomToolsService } from '../agent-custom-tools.service'; import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentDefinitionService } from '../agent-definition.service'; import { AgentPublishService } from '../agent-publish.service'; +import { withoutLegacySkillRefs } from '../json-config/agent-document'; import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; import { AgentSetupCompletionService } from '../agent-setup-completion.service'; @@ -33,7 +35,7 @@ import type { AgentRepository } from '../repositories/agent.repository'; import type { AgentPolicyService } from '../agent-policy.service'; import { type AgentFixtureOverrides, - AgentHistoryFixtureOverrides, + type AgentHistoryFixtureOverrides, createAgentSkillRefsService, storedSkillRefs, } from './test-utils/stored-agent-config'; @@ -139,6 +141,7 @@ function makeService() { Container.set(ChatIntegrationService, chatIntegrationService); Container.set(AgentTaskService, taskService); + const skillRefs = createAgentSkillRefsService(); const service = new AgentPublishService( mockLogger(), agentRepository, @@ -164,14 +167,15 @@ function makeService() { taskSnapshotRepository, agentRepository, transactionRunner, - createAgentSkillRefsService(agentRepository), + skillRefs, ), agentPolicyService, - createAgentSkillRefsService(agentRepository), + skillRefs, ); return { service, + skillRefs, agentPolicyService, agentUpdateBroadcaster, agentRepository, @@ -501,12 +505,9 @@ describe('AgentPublishService', () => { eventService, agentUpdateBroadcaster, ctx, + skillRefs, } = makeService(); const configuredTools = { tool: { descriptor: { name: 'tool' } } }; - const configuredSkills = { - skill: { name: 'Skill', description: 'desc', instructions: 'Use it' }, - disabled_skill: { name: 'Disabled skill', description: 'desc', instructions: 'Keep it' }, - }; const integrations = [ { type: 'slack', credentialId: 'slack-1' }, { @@ -519,14 +520,9 @@ describe('AgentPublishService', () => { schema: { ...schema, tools: [{ type: 'custom', id: 'tool' }], - skills: [ - { type: 'skill', id: 'skill' }, - { type: 'skill', id: 'disabled_skill', enabled: false }, - { type: 'skill', id: 'missing_skill', enabled: false }, - ], + skills: [{ type: 'skill', id: 'skill' }], tasks: [{ type: 'task', id: 'task-1', enabled: true }], }, - skills: configuredSkills, integrations: [...integrations, { type: 'n8n_chat', credentialId: '' }], }); const draftValidation = { status: 'valid' as const, issues: [] }; @@ -559,13 +555,23 @@ describe('AgentPublishService', () => { { versionId, agentId, - schema: { ...agent.schema, integrations: [{ type: 'n8n_chat', credentialId: '' }] }, + // The legacy columns keep the refs and bodies, so a rollback can run this version. + schema: { + ...withoutLegacySkillRefs(agent.schema!), + skills: storedSkillRefs(agent), + integrations: [{ type: 'n8n_chat', credentialId: '' }], + }, tools: configuredTools, - skills: configuredSkills, + skills: {}, publishedBy: user, }, ctx, ); + expect(skillRefs.pinVersion).toHaveBeenCalledWith( + versionId, + await skillRefs.versionSnapshot.mock.results[0].value, + ctx, + ); expect(taskSnapshotRepository.saveForVersion).toHaveBeenCalledWith( [expect.objectContaining({ versionId, taskId: 'task-1', objective: 'Summarize messages' })], ctx, @@ -644,8 +650,8 @@ describe('AgentPublishService', () => { expect(telemetry.track).not.toHaveBeenCalled(); }); - it('rejects publishing when a configured skill body is missing', async () => { - const { service, agentRepository, runtimeCacheService } = makeService(); + it('rejects publishing when a skill cannot be pinned', async () => { + const { service, agentRepository, runtimeCacheService, skillRefs } = makeService(); const agent = makeAgent({ schema: { ...schema, @@ -653,9 +659,12 @@ describe('AgentPublishService', () => { }, }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + skillRefs.versionSnapshot.mockRejectedValue( + new UserError('Cannot publish agent with missing skills: missing_skill'), + ); await expect(service.publishAgent(agentId, projectId, user, byUser)).rejects.toThrow( - 'Cannot publish agent with missing skill bodies: missing_skill', + 'Cannot publish agent with missing skills: missing_skill', ); expect(agent.activeVersionId).toBeNull(); @@ -793,7 +802,8 @@ describe('AgentPublishService', () => { }); it('reverts draft fields and task bodies from the active published snapshot', async () => { - const { service, agentRepository, taskSnapshotRepository, agentTaskRepository } = makeService(); + const { service, agentRepository, taskSnapshotRepository, agentTaskRepository, skillRefs } = + makeService(); const activeVersion = makeHistory({ versionId: 'published-v1', schema: { @@ -809,12 +819,11 @@ describe('AgentPublishService', () => { }, }, ], - skills: [{ type: 'skill', id: 'skill', enabled: false }], + skills: [{ type: 'skill', id: 'skill' }], subAgents: { agents: [{ agentId: 'agent-2', enabled: false, useWhen: 'Review notes' }] }, tasks: [{ type: 'task', id: 'task-1', enabled: false }], }, tools: { tool: { descriptor: { name: 'published' } } } as unknown as AgentHistory['tools'], - skills: { skill: { name: 'Skill', description: 'desc', instructions: 'Use it' } }, }); const agent = makeAgent({ name: 'Draft Agent', @@ -823,7 +832,6 @@ describe('AgentPublishService', () => { activeVersion, schema: { ...schema, name: 'Draft Agent' }, tools: {}, - skills: {}, }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -834,11 +842,20 @@ describe('AgentPublishService', () => { agent, ); - expect(agent.schema).toEqual(activeVersion.schema); + const { skills: publishedSkillRefs, ...publishedConfig } = + activeVersion.schema as AgentJsonConfig; + expect(agent.schema).toEqual(publishedConfig); expect(agent.name).toBe(schema.name); expect(agent.versionId).toBe('published-v1'); expect(agent.tools).toEqual(activeVersion.tools); - expect(agent.skills).toEqual(activeVersion.skills); + // The skill refs are pinned to the versions that the published version ran. + expect(skillRefs.restoreDraftRefs).toHaveBeenCalledWith( + agent, + activeVersion, + publishedSkillRefs, + expect.anything(), + ); + expect(skillRefs.replaceDraftRefs).not.toHaveBeenCalled(); expect(agentTaskRepository.replaceForAgent).toHaveBeenCalledWith( agentId, new Map([['task-1', expect.objectContaining({ objective: 'Summarize messages' })]]), @@ -918,8 +935,14 @@ describe('AgentPublishService', () => { }); it('reports sidecar body-only reverts when schema references are unchanged', async () => { - const { service, agentRepository, taskSnapshotRepository, agentTaskRepository, telemetry } = - makeService(); + const { + service, + agentRepository, + taskSnapshotRepository, + agentTaskRepository, + telemetry, + skillRefs, + } = makeService(); const schemaWithRefs: AgentJsonConfig = { ...schema, tools: [{ type: 'custom', id: 'tool-1' }], @@ -929,23 +952,21 @@ describe('AgentPublishService', () => { const publishedTools = { 'tool-1': { code: 'published', descriptor: { name: 'tool-1' } }, } as unknown as AgentHistory['tools']; - const publishedSkills = { - 'skill-1': { name: 'Skill', description: 'published', instructions: 'Use published' }, - }; const activeVersion = makeHistory({ versionId: 'published-v1', schema: schemaWithRefs, tools: publishedTools, - skills: publishedSkills, }); const agent = makeAgent({ activeVersionId: 'published-v1', activeVersion, schema: schemaWithRefs, tools: { 'tool-1': { code: 'draft', descriptor: { name: 'tool-1' } } } as never, - skills: { - 'skill-1': { name: 'Skill', description: 'draft', instructions: 'Use draft' }, - }, + }); + // The draft follows the latest version; the revert pins the published one. + const pin = '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f'; + skillRefs.restoreDraftRefs.mockImplementation(async (draft) => { + skillRefs.draftRefs.set(draft.id, [{ type: 'skill', id: 'skill-1', versionId: pin }]); }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); @@ -969,6 +990,44 @@ describe('AgentPublishService', () => { expect(telemetry.track).toHaveBeenCalledTimes(1); }); + it('reports a skills change when a revert with the hub off keeps the ids and restores other bodies', async () => { + const { + service, + agentRepository, + taskSnapshotRepository, + agentTaskRepository, + telemetry, + skillRefs, + } = makeService(); + Object.assign(skillRefs, { usesSkillsHub: false }); + const refs: AgentJsonConfig['skills'] = [{ type: 'skill', id: 'skill-1' }]; + const activeVersion = makeHistory({ + versionId: 'published-v1', + schema: { ...schema, skills: refs }, + skills: { 'skill-1': { name: 'Notes', description: 'Notes', instructions: 'Published.' } }, + }); + const agent = makeAgent({ + activeVersionId: 'published-v1', + activeVersion, + schema: { ...schema, skills: refs }, + skills: { 'skill-1': { name: 'Notes', description: 'Notes', instructions: 'Draft.' } }, + }); + // As the legacy writer: the refs stay, and the bodies of the version come back. + skillRefs.restoreDraftRefs.mockImplementation(async (draft, version) => { + draft.skills = structuredClone(version.skills ?? {}); + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + taskSnapshotRepository.findByVersionId.mockResolvedValue([]); + agentTaskRepository.replaceForAgent.mockResolvedValue(false); + + await service.revertToPublishedAgent(agentId, projectId, user, 'user'); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.AGENTS.USER_MODIFIED_AGENT, + expect.objectContaining({ changed_parts: expect.arrayContaining(['skills']) }), + ); + }); + it('reads the selected version without using draft or active-version bodies', async () => { const { service, @@ -976,6 +1035,7 @@ describe('AgentPublishService', () => { agentHistoryRepository, taskSnapshotRepository, agentTaskRepository, + skillRefs, } = makeService(); const version = makeHistory({ versionId: 'old-version', @@ -985,26 +1045,18 @@ describe('AgentPublishService', () => { tasks: [{ type: 'task', id: 'task-1', enabled: false }], integrations: [{ type: 'n8n_chat', credentialId: '' }], }, - skills: { - skill: { - name: 'Older skill', - description: 'Older description', - instructions: 'Older body', - }, - }, }); + const olderSkill = { + name: 'Older skill', + description: 'Older description', + instructions: 'Older body', + }; + skillRefs.readVersion.mockResolvedValue({ skillRefs: [], skills: { skill: olderSkill } }); const agent = makeAgent({ schema: { ...schema, name: 'Draft agent' }, tools: { draft_tool: { code: 'return 1;', descriptor: mock({ name: 'draft_tool' }) }, }, - skills: { - skill: { - name: 'Draft skill', - description: 'Draft description', - instructions: 'Draft body', - }, - }, activeVersion: makeHistory({ versionId: 'active-version', schema: { ...schema, name: 'Active agent' }, @@ -1025,9 +1077,9 @@ describe('AgentPublishService', () => { ); expect(result.version).toBe(version); expect(result.definition).toEqual({ - schema: version.schema, + schema: { ...version.schema, skills: [] }, tools: {}, - skills: version.skills, + skills: { skill: olderSkill }, tasks: new Map([ [ 'task-1', @@ -1049,21 +1101,19 @@ describe('AgentPublishService', () => { describe('skill refs round trip', () => { const skillRefs = [ { type: 'skill' as const, id: 'zeta' }, - { type: 'skill' as const, id: 'alpha', enabled: false }, - { type: 'skill' as const, id: 'mid', enabled: true }, + { type: 'skill' as const, id: 'mid' }, ]; - const skillBodies = { - zeta: { name: 'Zeta', description: 'desc', instructions: 'Use zeta' }, - mid: { name: 'Mid', description: 'desc', instructions: 'Use mid' }, - }; - it('publishes the draft skill refs into the version', async () => { - const { service, agentRepository, agentHistoryRepository, agentValidationService } = - makeService(); - const agent = makeAgent({ - schema: { ...schema, skills: skillRefs }, - skills: skillBodies, - }); + it('pins the draft skill refs in the publish transaction', async () => { + const { + service, + agentRepository, + agentHistoryRepository, + agentValidationService, + skillRefs: refsService, + ctx, + } = makeService(); + const agent = makeAgent({ schema: { ...schema, skills: skillRefs } }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); agentValidationService.validateAgentEntityConfiguration.mockResolvedValue({ status: 'valid', @@ -1072,22 +1122,33 @@ describe('AgentPublishService', () => { await service.publishAgent(agentId, projectId, user, byBuilder); - const [[saved]] = agentHistoryRepository.saveVersion.mock.calls; - const version = makeHistory({ versionId: saved.versionId }); - version.schema = saved.schema; - await expect(createAgentSkillRefsService().refsForVersion(version, {})).resolves.toEqual( - skillRefs, + const [[saved, saveCtx]] = agentHistoryRepository.saveVersion.mock.calls; + // The legacy columns keep the refs, so a rollback still finds the published skills. + expect(saved.schema?.skills).toEqual(skillRefs); + expect(refsService.versionSnapshot).toHaveBeenCalledWith(agent, ctx); + expect(refsService.pinVersion).toHaveBeenCalledWith( + saved.versionId, + expect.objectContaining({ pins: expect.any(Map) }), + ctx, + ); + expect(saveCtx).toBe(ctx); + // The pins need the history row. + expect(agentHistoryRepository.saveVersion.mock.invocationCallOrder[0]).toBeLessThan( + refsService.pinVersion.mock.invocationCallOrder[0], ); - expect(saved.skills).toEqual(skillBodies); }); - it('reverts the draft skill refs to the refs of the version', async () => { - const { service, agentRepository, taskSnapshotRepository, agentTaskRepository } = - makeService(); + it('reverts the draft skill refs to the pinned refs of the version', async () => { + const { + service, + agentRepository, + taskSnapshotRepository, + agentTaskRepository, + skillRefs: refsService, + } = makeService(); const activeVersion = makeHistory({ versionId: 'published-v1', schema: { ...schema, skills: skillRefs }, - skills: skillBodies, }); const agent = makeAgent({ versionId: 'draft-v2', @@ -1098,16 +1159,23 @@ describe('AgentPublishService', () => { agentRepository.findByIdAndProjectId.mockResolvedValue(agent); taskSnapshotRepository.findByVersionId.mockResolvedValue([]); agentTaskRepository.replaceForAgent.mockResolvedValue(false); + // The published version ran these skill versions. + refsService.versionPins.set('published-v1', { zeta: 'zeta-v1', mid: 'mid-v3' }); await service.revertToPublishedAgent(agentId, projectId, user, 'user'); - await expect(createAgentSkillRefsService().refsForDraft(agent, {})).resolves.toEqual( - skillRefs, - ); + // The draft gets the refs back, each pinned to the version that was published. + const pinned = [ + { type: 'skill', id: 'zeta', versionId: 'zeta-v1' }, + { type: 'skill', id: 'mid', versionId: 'mid-v3' }, + ]; + await expect(refsService.refsForDraft(agent, {})).resolves.toEqual(pinned); const [[, saveCtx]] = agentRepository.saveDraftFenced.mock.calls; - const [[written, refsCtx]] = agentRepository.updateDraftSchema.mock.calls; + const [[written, version, refs, refsCtx]] = refsService.restoreDraftRefs.mock.calls; expect(refsCtx).toBe(saveCtx); - expect(storedSkillRefs(written)).toEqual(skillRefs); + expect(written).toBe(agent); + expect(version).toBe(activeVersion); + expect(refs).toEqual(skillRefs); }); it('shows the skill refs of a version in its document', async () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-runnable-state.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runnable-state.service.test.ts new file mode 100644 index 00000000000..c0fc0408fb3 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-runnable-state.service.test.ts @@ -0,0 +1,41 @@ +import type { User } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialsService } from '@/credentials/credentials.service'; + +import type { AgentPublishService } from '../agent-publish.service'; +import { AgentRunnableStateService } from '../agent-runnable-state.service'; +import type { AgentSkillRefsService } from '../agent-skill-refs.service'; +import type { AgentValidationService } from '../agent-validation.service'; +import type { Agent } from '../entities/agent.entity'; + +const pinId = '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f'; + +describe('AgentRunnableStateService', () => { + it('returns the pinned skills that have a newer version', async () => { + const skillRefs = mock(); + const draft = { + skillRefs: [{ type: 'skill' as const, id: 'pinned_older', versionId: pinId }], + skills: { pinned_older: { name: 'Skill', description: 'A skill', instructions: 'Old.' } }, + }; + skillRefs.readDraft.mockResolvedValue(draft); + skillRefs.findOutdatedSkillIds.mockResolvedValue(['pinned_older']); + const publishService = mock(); + publishService.hasPublishHistory.mockResolvedValue(false); + const service = new AgentRunnableStateService( + mock(), + mock(), + publishService, + skillRefs, + ); + const agent = { id: 'agent-1', schema: null, integrations: [] } as unknown as Agent; + + const result = await service.addRunnableState(agent, 'project-1', mock(), { + status: 'valid', + issues: [], + }); + + expect(skillRefs.findOutdatedSkillIds).toHaveBeenCalledWith(draft.skillRefs, draft.skills, {}); + expect(result.outdatedSkillIds).toEqual(['pinned_older']); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts index 7644d6ca21d..3c723087991 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-cache.service.test.ts @@ -68,7 +68,7 @@ function makeService({ reconstructionService, credentialsService, sandboxRuntimeService, - createAgentSkillRefsService(agentRepository), + createAgentSkillRefsService(), ); return { service, agentRepository, publisher, reconstructionService }; @@ -592,7 +592,6 @@ describe('AgentRuntimeCacheService', () => { skills: [], }, tools: { tool: { name: 'Tool' } }, - skills: { skill: { name: 'Skill' } }, publishedById: 'publisher-1', }; const runtime = makeRuntime(); @@ -614,9 +613,9 @@ describe('AgentRuntimeCacheService', () => { agent: expect.objectContaining({ schema: activeVersion.schema, tools: activeVersion.tools, - skills: activeVersion.skills, }), skillRefs: activeVersion.schema.skills, + skills: {}, }, expect.anything(), 'production', @@ -644,7 +643,6 @@ describe('AgentRuntimeCacheService', () => { instructions: 'Published instructions', }, tools: {}, - skills: {}, } as Agent['activeVersion'], }); agentRepository.findByIdAndProjectId.mockResolvedValue(agent); diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index a0bc995c8f6..3b32b5e9248 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -59,7 +59,6 @@ import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstructi import type { AgentSandboxRuntimeService } from '../agent-sandbox-runtime.service'; import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentDefinitionService } from '../agent-definition.service'; -import { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; import type { AgentTaskService } from '../agent-task.service'; @@ -211,7 +210,6 @@ describe('AgentRuntimeReconstructionService integration tools', () => { let globalConfig: Mocked; let telemetry: Mocked; let runtimeCacheService: AgentRuntimeCacheService; - let agentSkillsService: AgentSkillsService; let agentConfigService: AgentConfigService; let agentCustomToolsService: AgentCustomToolsService; let agentExecutionOrchestratorService: AgentExecutionOrchestratorService; @@ -253,7 +251,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { isEnabled: () => false, }); - const skillRefs = createAgentSkillRefsService(agentRepository); + const skillRefs = createAgentSkillRefsService(); runtimeCacheService = new AgentRuntimeCacheService( logger, agentRepository, @@ -281,18 +279,10 @@ describe('AgentRuntimeReconstructionService integration tools', () => { transactionRunner, skillRefs, ); - agentSkillsService = new AgentSkillsService( - logger, - agentRepository, - saveCompletion, - skillRefs, - transactionRunner, - ); agentConfigService = new AgentConfigService( logger, agentRepository, agentTaskRepository, - agentSkillsService, new AgentConfigPreparationService( credentialsService, mock(), diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts index b49fdb45735..76f795f072f 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-tool-filtering.test.ts @@ -131,7 +131,7 @@ function makeAgentEntity(tools: AgentJsonToolConfig[]): AgentRunSource { schema, tools: { custom_tool: { descriptor: { name: 'custom_tool' }, code: '' } }, } as unknown as Agent; - return { agent, skillRefs: undefined }; + return { agent, skillRefs: undefined, skills: {} }; } function makeService(overrides: { @@ -235,10 +235,10 @@ describe('AgentRuntimeReconstructionService — per-user tool filtering', () => const { service } = makeService({}); const draftRefs: AgentJsonSkillConfig[] = [ { type: 'skill', id: 'zeta' }, - { type: 'skill', id: 'alpha', enabled: false }, + { type: 'skill', id: 'alpha', versionId: '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f' }, ]; const publishedRefs: AgentJsonSkillConfig[] = [ - { type: 'skill', id: 'mid', enabled: true }, + { type: 'skill', id: 'mid' }, { type: 'skill', id: 'zeta' }, ]; const baseConfig = { diff --git a/packages/cli/src/modules/agents/__tests__/agent-skill-refs.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-skill-refs.service.test.ts index 1615ec21b19..9b613b5e6e0 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-skill-refs.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-skill-refs.service.test.ts @@ -1,106 +1,658 @@ -import type { AgentJsonSkillConfig } from '@n8n/api-types'; -import type { OperationContext } from '@n8n/db'; +/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests reference unbound methods */ +import type { AgentJsonSkillConfig, StoredAgentConfig } from '@n8n/api-types'; +import type { OperationContext, User } from '@n8n/db'; +import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { userHasScopes } from '@/permissions.ee/check-access'; + import { AgentSkillRefsService } from '../agent-skill-refs.service'; import type { AgentHistory } from '../entities/agent-history.entity'; import type { Agent } from '../entities/agent.entity'; -import { toAgentDocument } from '../json-config/agent-document'; import type { AgentRepository } from '../repositories/agent.repository'; -import { storedAgentConfig } from './test-utils/stored-agent-config'; +import type { SkillFile } from '../entities/skill-file.entity'; +import type { SkillVersion } from '../entities/skill-version.entity'; +import type { Skill } from '../entities/skill.entity'; +import type { ResolvedSkillRow, SkillRepository } from '../repositories/skill.repository'; +import type { SkillService } from '../skills/skill.service'; +import type { SkillsHubRollout } from '../skills/skills-hub-rollout'; -const refs: AgentJsonSkillConfig[] = [ - { type: 'skill', id: 'zeta' }, - { type: 'skill', id: 'alpha', enabled: false }, - { type: 'skill', id: 'mid', enabled: true }, -]; +vi.mock('@/permissions.ee/check-access', () => ({ userHasScopes: vi.fn() })); +const ctx: OperationContext = {}; +const pinId = '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f'; const baseConfig = { name: 'Agent', model: 'anthropic/claude-sonnet-4-5', instructions: 'Help.' }; +const agent = { id: 'agent-1', projectId: 'project-1', schema: baseConfig } as unknown as Agent; -function makeAgent(schema: Agent['schema']): Agent { - return { id: 'agent-1', schema, updatedAt: new Date('2026-01-01T00:00:00.000Z') } as Agent; +function row(skillId: string, versionId: string, name: string): ResolvedSkillRow { + return { + skill: mock({ id: skillId, agentId: null }), + version: mock({ + id: versionId, + skillId, + name, + description: `${name} description`, + instructions: `${name} instructions`, + }), + files: [] as SkillFile[], + }; } describe('AgentSkillRefsService', () => { - let agentRepository: ReturnType>; + let skillRepository: Mocked; + let skillService: Mocked; + let agentRepository: Mocked; + let rollout: { isEnabled: boolean }; let service: AgentSkillRefsService; - const ctx: OperationContext = {}; beforeEach(() => { + // The module mock outlives each test, so clear the calls of earlier ones. + vi.mocked(userHasScopes).mockClear(); + skillRepository = mock(); + skillService = mock(); agentRepository = mock(); - service = new AgentSkillRefsService(agentRepository); + rollout = { isEnabled: true }; + service = new AgentSkillRefsService( + skillRepository, + skillService, + agentRepository, + rollout as SkillsHubRollout, + ); + skillRepository.findDependencies.mockResolvedValue(new Map()); + skillRepository.findPinned.mockResolvedValue(new Map()); }); describe('refsForDraft', () => { - it('returns the refs of the draft in stored order, with the enabled flags', async () => { - const agent = makeAgent(storedAgentConfig({ ...baseConfig, skills: refs })); + it('returns the dependency rows in attach order, with the pins', async () => { + skillRepository.findDependencies.mockResolvedValue( + new Map([ + [ + 'agent-1', + [ + { skillId: 'zeta', versionId: null }, + { skillId: 'alpha', versionId: pinId }, + ], + ], + ]), + ); - await expect(service.refsForDraft(agent, ctx)).resolves.toEqual(refs); + await expect(service.refsForDraft(agent, ctx)).resolves.toEqual([ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'alpha', versionId: pinId }, + ]); + expect(skillRepository.findDependencies).toHaveBeenCalledWith(['agent-1'], ctx); }); - it('returns undefined when the draft has no skill list', async () => { - await expect( - service.refsForDraft(makeAgent(storedAgentConfig(baseConfig)), ctx), - ).resolves.toBeUndefined(); - await expect(service.refsForDraft(makeAgent(null), ctx)).resolves.toBeUndefined(); - }); - - it('returns an empty list when the draft has an empty skill list', async () => { - const agent = makeAgent(storedAgentConfig({ ...baseConfig, skills: [] })); - + it('returns an empty list when the draft has no skills', async () => { await expect(service.refsForDraft(agent, ctx)).resolves.toEqual([]); }); }); describe('refsForVersion', () => { - it('returns the refs of the published version', async () => { - const version = { - versionId: 'version-1', - schema: storedAgentConfig({ ...baseConfig, skills: refs }), - } as AgentHistory; + it('reads the pinned skill ids in order, without the skill bodies', async () => { + skillRepository.findPinnedSkillIds.mockResolvedValue(['zeta', 'alpha']); - await expect(service.refsForVersion(version, ctx)).resolves.toEqual(refs); + const refs = await service.refsForVersion( + { versionId: 'version-1', schema: null, skills: null }, + ctx, + ); + + expect(refs).toEqual([ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'alpha' }, + ]); + expect(skillRepository.findPinned).not.toHaveBeenCalled(); + }); + }); + + describe('readVersion', () => { + it('returns the pins of the version in order and the pinned bodies', async () => { + skillRepository.findPinned.mockResolvedValue( + new Map([ + ['zeta', row('zeta', 'zeta-v1', 'Zeta')], + ['alpha', row('alpha', 'alpha-v3', 'Alpha')], + ]), + ); + + const result = await service.readVersion( + { versionId: 'version-1', schema: null, skills: null }, + ctx, + ); + + expect(result.skillRefs).toEqual([ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'alpha' }, + ]); + expect(Object.keys(result.skills)).toEqual(['zeta', 'alpha']); + expect(result.skills.alpha).toEqual({ + name: 'Alpha', + description: 'Alpha description', + instructions: 'Alpha instructions', + }); + expect(skillRepository.findPinned).toHaveBeenCalledWith('version-1', ctx); + }); + }); + + describe('skillsForRefs', () => { + it('resolves the latest or the pinned version of each ref', async () => { + const refs: AgentJsonSkillConfig[] = [ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'alpha', versionId: pinId }, + ]; + skillService.resolveForAgentDraft.mockResolvedValue({}); + + await service.skillsForRefs(agent, refs, ctx); + + expect(skillService.resolveForAgentDraft).toHaveBeenCalledWith( + [{ id: 'zeta' }, { id: 'alpha', versionId: pinId }], + ctx, + ); + }); + + it('reads nothing for a draft without skills', async () => { + await expect(service.skillsForRefs(agent, undefined, ctx)).resolves.toEqual({}); + expect(skillService.resolveForAgentDraft).not.toHaveBeenCalled(); + }); + }); + + describe('effectiveDraftRefs', () => { + it('returns the refs the attach check keeps and writes nothing', async () => { + skillService.checkRefs.mockResolvedValue([{ id: 'zeta' }]); + + const refs = await service.effectiveDraftRefs( + agent, + [ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'stale' }, + ], + ctx, + ); + + expect(refs).toEqual([{ type: 'skill', id: 'zeta' }]); + expect(skillRepository.replaceDependencies).not.toHaveBeenCalled(); }); }); describe('replaceDraftRefs', () => { - it('writes the refs and keeps the rest of the config', async () => { - const agent = makeAgent(storedAgentConfig({ ...baseConfig, tasks: [] })); - const next: AgentJsonSkillConfig[] = [{ type: 'skill', id: 'new' }]; + it('checks the refs and writes the dependency rows', async () => { + skillService.checkRefs.mockResolvedValue([{ id: 'zeta' }, { id: 'alpha', versionId: pinId }]); - await service.replaceDraftRefs(agent, next, ctx); + await service.replaceDraftRefs( + agent, + [ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'alpha', versionId: pinId }, + ], + ctx, + ); - expect(agentRepository.updateDraftSchema).toHaveBeenCalledWith(agent, ctx); - await expect(service.refsForDraft(agent, ctx)).resolves.toEqual(next); - expect(agent.schema && toAgentDocument(agent.schema, next)).toEqual({ - ...baseConfig, - tasks: [], - skills: next, - }); + expect(skillService.checkRefs).toHaveBeenCalledWith( + { id: 'agent-1', projectId: 'project-1' }, + [{ id: 'zeta' }, { id: 'alpha', versionId: pinId }], + ctx, + ); + expect(skillRepository.replaceDependencies).toHaveBeenCalledWith( + 'agent-1', + [ + { skillId: 'zeta', versionId: null }, + { skillId: 'alpha', versionId: pinId }, + ], + ctx, + ); }); - it('removes the skill list when the refs are undefined', async () => { - const agent = makeAgent(storedAgentConfig({ ...baseConfig, skills: refs })); + it('removes every row when the refs are undefined', async () => { + skillService.checkRefs.mockResolvedValue([]); await service.replaceDraftRefs(agent, undefined, ctx); - expect(agent.schema).not.toHaveProperty('skills'); - expect(agentRepository.updateDraftSchema).toHaveBeenCalledWith(agent, ctx); - }); - - it('does nothing for an agent without a config and without refs', async () => { - const agent = makeAgent(null); - - await service.replaceDraftRefs(agent, undefined, ctx); - - expect(agentRepository.updateDraftSchema).not.toHaveBeenCalled(); + expect(skillRepository.replaceDependencies).toHaveBeenCalledWith('agent-1', [], ctx); }); it('rejects refs for an agent without a config', async () => { - await expect(service.replaceDraftRefs(makeAgent(null), refs, ctx)).rejects.toThrow( - 'Cannot set skill refs on an agent without a config', + await expect( + service.replaceDraftRefs( + { ...agent, schema: null } as Agent, + [{ type: 'skill', id: 'zeta' }], + ctx, + ), + ).rejects.toThrow('Cannot set skill refs on an agent without a config'); + expect(skillRepository.replaceDependencies).not.toHaveBeenCalled(); + }); + }); + + it('restores the pins of a published version without the attach check', async () => { + skillService.restoreFromVersion.mockResolvedValue([{ id: 'zeta', versionId: pinId }]); + + await service.restoreDraftRefs( + agent, + { versionId: 'version-1', skills: null }, + [{ type: 'skill', id: 'zeta' }], + ctx, + ); + + expect(skillService.restoreFromVersion).toHaveBeenCalledWith( + 'version-1', + [{ id: 'zeta' }], + ctx, + ); + expect(skillService.checkRefs).not.toHaveBeenCalled(); + expect(skillRepository.replaceDependencies).toHaveBeenCalledWith( + 'agent-1', + [{ skillId: 'zeta', versionId: pinId }], + ctx, + ); + }); + + it('removes the pin of one ref and keeps the others', async () => { + skillRepository.findDependencies.mockResolvedValue( + new Map([ + [ + 'agent-1', + [ + { skillId: 'zeta', versionId: pinId }, + { skillId: 'alpha', versionId: 'alpha-pin' }, + ], + ], + ]), + ); + + await service.unpinDraftRef(agent, 'zeta', ctx); + + expect(skillRepository.replaceDependencies).toHaveBeenCalledWith( + 'agent-1', + [ + { skillId: 'zeta', versionId: null }, + { skillId: 'alpha', versionId: 'alpha-pin' }, + ], + ctx, + ); + }); + + describe('findOutdatedSkillIds', () => { + const body = (instructions: string) => ({ + name: 'Skill', + description: 'A skill', + instructions, + }); + + it('lists the pinned skills whose latest text differs from the pinned text', async () => { + skillService.resolveForAgentDraft.mockResolvedValue({ + pinned_older: body('New text.'), + pinned_latest: body('Latest text.'), + }); + + const outdated = await service.findOutdatedSkillIds( + [ + { type: 'skill', id: 'pinned_older', versionId: pinId }, + { type: 'skill', id: 'pinned_latest', versionId: pinId }, + { type: 'skill', id: 'unpinned' }, + ], + { + pinned_older: body('Old text.'), + pinned_latest: body('Latest text.'), + unpinned: body('Latest text.'), + }, + ctx, ); - expect(agentRepository.updateDraftSchema).not.toHaveBeenCalled(); + + expect(outdated).toEqual(['pinned_older']); + expect(skillService.resolveForAgentDraft).toHaveBeenCalledWith( + [{ id: 'pinned_older' }, { id: 'pinned_latest' }], + ctx, + ); + }); + + it('reads nothing when no ref is pinned', async () => { + await expect( + service.findOutdatedSkillIds([{ type: 'skill', id: 'unpinned' }], {}, ctx), + ).resolves.toEqual([]); + expect(skillService.resolveForAgentDraft).not.toHaveBeenCalled(); + }); + }); + + describe('createDraftRefs', () => { + const body = { name: 'New', description: 'New description', instructions: 'Do it.' }; + const user = mock({ id: 'user-1' }); + + beforeEach(() => { + skillService.checkRefs.mockImplementation(async (_agent, refs) => refs); + skillService.create.mockResolvedValue('skill_created'); + vi.mocked(userHasScopes).mockResolvedValue(true); + }); + + it('keeps refs to hub skills and creates agent skills for refs with only a body', async () => { + skillRepository.findByIds.mockResolvedValue([mock({ id: 'shared', agentId: null })]); + + const refs = await service.createDraftRefs( + agent, + [ + { type: 'skill', id: 'shared', versionId: pinId }, + { type: 'skill', id: 'seed_skill' }, + { type: 'skill', id: 'unknown' }, + ], + { shared: body, seed_skill: body }, + user, + ctx, + ); + + expect(skillService.create).toHaveBeenCalledTimes(1); + expect(skillService.create).toHaveBeenCalledWith( + { + target: { projectId: null, agentId: 'agent-1' }, + skill: body, + source: 'agent', + createdById: 'user-1', + }, + ctx, + ); + expect(refs).toEqual([ + { type: 'skill', id: 'shared' }, + { type: 'skill', id: 'skill_created' }, + ]); + // The attach check runs on every ref, so a ref to a skill of another project fails. + expect(skillService.checkRefs).toHaveBeenCalledWith( + { id: 'agent-1', projectId: 'project-1' }, + [{ id: 'shared' }, { id: 'skill_created' }], + ctx, + ); + }); + + it('creates an agent skill without a project skill scope', async () => { + skillRepository.findByIds.mockResolvedValue([]); + vi.mocked(userHasScopes).mockResolvedValue(false); + + await expect( + service.createDraftRefs( + agent, + [{ type: 'skill', id: 'seed_skill' }], + { seed_skill: body }, + user, + ctx, + ), + ).resolves.toEqual([{ type: 'skill', id: 'skill_created' }]); + // The skill is part of the agent, so the agent permission of the caller is enough. + expect(userHasScopes).not.toHaveBeenCalled(); + }); + + it('needs no skill permission for refs to hub skills', async () => { + skillRepository.findByIds.mockResolvedValue([mock({ id: 'shared', agentId: null })]); + vi.mocked(userHasScopes).mockResolvedValue(false); + + await expect( + service.createDraftRefs(agent, [{ type: 'skill', id: 'shared' }], {}, user, ctx), + ).resolves.toEqual([{ type: 'skill', id: 'shared' }]); + expect(userHasScopes).not.toHaveBeenCalled(); + }); + + it('copies a skill of another agent only for the new agent', async () => { + skillRepository.findByIds.mockResolvedValue([ + mock({ id: 'private', agentId: 'agent-source' }), + ]); + skillRepository.findAgents.mockResolvedValue( + new Map([['agent-source', { name: 'Source', projectId: 'project-1' }]]), + ); + skillService.resolveForAgentDraft.mockResolvedValue({ private: body }); + + const refs = await service.createDraftRefs( + agent, + [{ type: 'skill', id: 'private' }], + {}, + user, + ctx, + ); + + expect(skillService.create).toHaveBeenCalledWith( + { + target: { projectId: null, agentId: 'agent-1' }, + skill: body, + source: 'agent', + createdById: 'user-1', + }, + ctx, + ); + expect(refs).toEqual([{ type: 'skill', id: 'skill_created' }]); + }); + + it('does not read or copy an agent skill of another project', async () => { + skillRepository.findByIds.mockResolvedValue([ + mock({ id: 'private', agentId: 'agent-elsewhere' }), + ]); + skillRepository.findAgents.mockResolvedValue( + new Map([['agent-elsewhere', { name: 'Elsewhere', projectId: 'project-2' }]]), + ); + + await service.createDraftRefs(agent, [{ type: 'skill', id: 'private' }], {}, user, ctx); + + expect(skillService.resolveForAgentDraft).not.toHaveBeenCalled(); + expect(skillService.create).not.toHaveBeenCalled(); + // The ref stays a plain ref, so the attach check refuses it. + expect(skillService.checkRefs).toHaveBeenCalledWith( + { id: 'agent-1', projectId: 'project-1' }, + [{ id: 'private' }], + ctx, + ); + }); + + it('creates no skill without a user', async () => { + skillRepository.findByIds.mockResolvedValue([]); + + await expect( + service.createDraftRefs( + agent, + [{ type: 'skill', id: 'seed_skill' }], + { seed_skill: body }, + null, + ctx, + ), + ).resolves.toEqual([]); + expect(skillService.create).not.toHaveBeenCalled(); + }); + }); + + describe('createSkills', () => { + it.each([ + ['project', { projectId: 'project-1', agentId: null }], + ['agent', { projectId: null, agentId: 'agent-1' }], + ['instance', { projectId: null, agentId: null }], + ] as const)('creates a hub skill available to %s', async (availableTo, target) => { + const body = { name: 'New', description: 'New description', instructions: 'Do it.' }; + skillService.create.mockResolvedValue('skill_created'); + + await expect( + service.createSkills(agent, [body], 'user-1', availableTo, ctx), + ).resolves.toEqual(['skill_created']); + expect(skillService.create).toHaveBeenCalledWith( + { target, skill: body, source: 'agent', createdById: 'user-1' }, + ctx, + ); + }); + }); + + describe('setAvailability', () => { + it('moves the skill to the target of the choice, keeping this agent', async () => { + await service.setAvailability(agent, 'skill_a', 'agent', ctx); + + expect(skillService.changeTarget).toHaveBeenCalledWith( + 'skill_a', + { projectId: null, agentId: 'agent-1' }, + 'agent-1', + ctx, + ); + }); + }); + + describe('versionSnapshot and pinVersion', () => { + const notes = { name: 'Notes', description: 'Take notes', instructions: 'Write it down.' }; + + it('writes the refs and the bodies the draft runs to the legacy columns, and pins them', async () => { + skillRepository.findDependencies.mockResolvedValue( + new Map([ + [ + 'agent-1', + [ + { skillId: 'zeta', versionId: null }, + { skillId: 'alpha', versionId: pinId }, + ], + ], + ]), + ); + const versionByRef = new Map([ + ['zeta', 'zeta-v2'], + ['alpha', pinId], + ]); + skillService.snapshotForPublish.mockResolvedValue({ + skills: { zeta: notes, alpha: notes }, + versionByRef, + }); + + const snapshot = await service.versionSnapshot(agent, ctx); + await service.pinVersion('version-1', snapshot, ctx); + + expect(skillService.snapshotForPublish).toHaveBeenCalledWith( + [{ id: 'zeta' }, { id: 'alpha', versionId: pinId }], + ctx, + ); + // A rollback reads these columns, so a published agent keeps its skills. + expect(snapshot.schema?.skills).toEqual([ + { type: 'skill', id: 'zeta' }, + { type: 'skill', id: 'alpha' }, + ]); + expect(snapshot.skills).toEqual({ zeta: notes, alpha: notes }); + expect(skillService.pinForPublish).toHaveBeenCalledWith('version-1', versionByRef, ctx); + }); + + it('rejects a ref that resolves to no version', async () => { + skillRepository.findDependencies.mockResolvedValue( + new Map([['agent-1', [{ skillId: 'zeta', versionId: null }]]]), + ); + skillService.snapshotForPublish.mockResolvedValue({ skills: {}, versionByRef: new Map() }); + + await expect(service.versionSnapshot(agent, ctx)).rejects.toThrow( + 'Cannot publish agent with missing skills: zeta', + ); + }); + + it('writes an empty skill list and pins nothing for a draft without skills', async () => { + const snapshot = await service.versionSnapshot(agent, ctx); + await service.pinVersion('version-1', snapshot, ctx); + + expect(snapshot.schema?.skills).toEqual([]); + expect(snapshot.skills).toEqual({}); + expect(skillService.snapshotForPublish).not.toHaveBeenCalled(); + expect(skillService.pinForPublish).not.toHaveBeenCalled(); + }); + }); + + describe('with the skills hub off', () => { + const notes = { name: 'Notes', description: 'Take notes', instructions: 'Write.' }; + const billing = { name: 'Billing', description: 'Bills', instructions: 'Bill.' }; + const legacyAgent = (skills: unknown[], bodies = { notes, billing }) => + ({ + id: 'agent-1', + projectId: 'project-1', + schema: { ...baseConfig, skills } as unknown as StoredAgentConfig, + skills: { ...bodies }, + }) as unknown as Agent; + + beforeEach(() => { + rollout.isEnabled = false; + }); + + it('reads the refs and bodies from the agent row and hides disabled refs', async () => { + const draft = legacyAgent([ + { type: 'skill', id: 'notes' }, + { type: 'skill', id: 'billing', enabled: false }, + ]); + + await expect(service.readDraft(draft, ctx)).resolves.toEqual({ + skillRefs: [{ type: 'skill', id: 'notes' }], + skills: { notes }, + }); + expect(skillRepository.findDependencies).not.toHaveBeenCalled(); + }); + + it('reads a published version from the agent_history row', async () => { + const version = { + versionId: 'version-1', + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'notes' }] }, + skills: { notes }, + } as unknown as AgentHistory; + + await expect(service.readVersion(version, ctx)).resolves.toEqual({ + skillRefs: [{ type: 'skill', id: 'notes' }], + skills: { notes }, + }); + expect(skillRepository.findPinned).not.toHaveBeenCalled(); + }); + + it('writes the refs to the stored config and drops refs without a body', async () => { + const draft = legacyAgent([{ type: 'skill', id: 'notes' }]); + + await service.replaceDraftRefs( + draft, + [ + { type: 'skill', id: 'billing', versionId: pinId }, + { type: 'skill', id: 'unknown' }, + ], + ctx, + ); + + expect(draft.schema?.skills).toEqual([{ type: 'skill', id: 'billing' }]); + expect(agentRepository.updateDraftSkills).toHaveBeenCalledWith(draft, ctx); + expect(skillService.checkRefs).not.toHaveBeenCalled(); + }); + + it('creates skills as bodies on the agent row', async () => { + const draft = legacyAgent([], {} as never); + + const ids = await service.createSkills(draft, [notes], 'user-1', 'agent', ctx); + + expect(draft.skills).toEqual({ [ids[0]]: notes }); + expect(agentRepository.updateDraftSkills).toHaveBeenCalledWith(draft, ctx); + expect(skillService.create).not.toHaveBeenCalled(); + }); + + it('restores the refs and bodies of a published version', async () => { + const draft = legacyAgent([{ type: 'skill', id: 'billing' }]); + + await service.restoreDraftRefs( + draft, + { versionId: 'version-1', skills: { notes } }, + [{ type: 'skill', id: 'notes' }], + ctx, + ); + + expect(draft.schema?.skills).toEqual([{ type: 'skill', id: 'notes' }]); + expect(draft.skills).toEqual({ notes }); + expect(skillService.restoreFromVersion).not.toHaveBeenCalled(); + }); + + it('copies the refs and bodies into a new published version', async () => { + const draft = legacyAgent([ + { type: 'skill', id: 'notes' }, + { type: 'skill', id: 'billing', enabled: false }, + ]); + + await expect(service.versionSnapshot(draft, ctx)).resolves.toEqual({ + schema: draft.schema, + skills: { notes }, + pins: new Map(), + }); + }); + + it('refuses to publish a ref without a body', async () => { + const draft = legacyAgent([{ type: 'skill', id: 'gone' }]); + + await expect(service.versionSnapshot(draft, ctx)).rejects.toThrow( + 'Cannot publish agent with missing skill bodies: gone', + ); + }); + + it('reports no outdated skills', async () => { + await expect( + service.findOutdatedSkillIds([{ type: 'skill', id: 'notes', versionId: pinId }], {}, ctx), + ).resolves.toEqual([]); }); }); }); 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 index b9524d7aab7..2d8f213fc9f 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-skills.service.test.ts @@ -1,70 +1,107 @@ /* eslint-disable @typescript-eslint/require-await, @typescript-eslint/unbound-method -- async mock stubs and unbound-method references are acceptable test idioms */ -import type { Mocked } from 'vitest'; +import type { AgentJsonSkillConfig, AgentSkill } from '@n8n/api-types'; import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; -import type { TransactionRunner } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, type TransactionRunner, type User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; +import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { Agent } from '../entities/agent.entity'; -import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; +import { userHasScopes } from '@/permissions.ee/check-access'; + import type { AgentModificationTelemetryService } from '../agent-modification-telemetry.service'; +import { AgentRuntimeCacheService } from '../agent-runtime-cache.service'; import { AgentSaveCompletionService } from '../agent-save-completion.service'; import { AgentSkillsService } from '../agent-skills.service'; import type { AgentUpdateBroadcaster } from '../agent-update-broadcaster'; +import type { Agent } from '../entities/agent.entity'; +import { composeJsonConfig } from '../json-config/agent-config-composition'; import type { AgentRepository } from '../repositories/agent.repository'; +import type { SkillSaveResult, SkillService } from '../skills/skill.service'; import { getAgentConfigHash, getAgentSkillHash } from '../utils/agent-config-hash'; import { type AgentFixtureOverrides, - composeStoredJsonConfig, + type FakeAgentSkillRefsService, createAgentSkillRefsService, - storedSkillRefs, } from './test-utils/stored-agent-config'; +vi.mock('@/permissions.ee/check-access', () => ({ userHasScopes: vi.fn() })); + const agentId = 'agent-1'; const projectId = 'project-1'; -const telemetryContext = { user: { id: 'user-1' } as never, modifiedBy: 'user' as const }; +const user = { id: 'user-1' } as never; +const context = { user, modifiedBy: 'user' as const, pushRef: 'push-ref-1' }; const versionId = 'v1'; +const pinId = '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f'; +const baseConfig = { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Be helpful', +}; + +const skill: AgentSkill = { + name: 'Summarize Notes', + description: 'Summarizes a meeting transcript', + instructions: 'Extract decisions and action items.', +}; function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { return { id: agentId, + projectId, versionId, schema: null, activeVersionId: null, activeVersion: null, tools: {}, - skills: {}, updatedAt: new Date(), ...overrides, } as unknown as Agent; } +function saveResult(overrides: Partial = {}): SkillSaveResult { + return { + versionId: 'skill-version-2', + version: 2, + created: true, + skill: { ...skill, instructions: 'New instructions.' }, + followingAgentIds: [], + ...overrides, + }; +} + describe('AgentSkillsService', () => { let service: AgentSkillsService; let agentRepository: Mocked; + let skillService: Mocked; + let skillRefs: FakeAgentSkillRefsService; + let transactionRunner: Mocked; let runtimeCacheService: Mocked; let modificationTelemetry: Mocked; let agentUpdateBroadcaster: Mocked; - const skill = { - name: 'Summarize Notes', - description: 'Summarizes a meeting transcript', - instructions: 'Extract decisions and action items.', - }; + function givenAgent(overrides: AgentFixtureOverrides = {}) { + const agent = makeAgent(overrides); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + return agent; + } beforeEach(() => { vi.clearAllMocks(); Container.reset(); agentRepository = mock(); + skillService = mock(); + skillRefs = createAgentSkillRefsService(); runtimeCacheService = mock(); Container.set(AgentRuntimeCacheService, runtimeCacheService); agentRepository.saveDraftFenced.mockResolvedValue(true); modificationTelemetry = mock(); agentUpdateBroadcaster = mock(); - const transactionRunner = mock(); + transactionRunner = mock(); transactionRunner.run.mockImplementation(async (ctx, fn) => await fn(ctx)); + vi.mocked(userHasScopes).mockResolvedValue(true); service = new AgentSkillsService( mockLogger(), agentRepository, @@ -73,579 +110,565 @@ describe('AgentSkillsService', () => { agentUpdateBroadcaster, modificationTelemetry, ), - createAgentSkillRefsService(agentRepository), + skillRefs, + skillService, transactionRunner, ); }); - 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, telemetryContext); - - expect(result).toEqual({ - id: expect.stringMatching(/^skill_[A-Za-z0-9]{16}$/), - skill, - skillHash: expect.stringMatching(/^[a-f0-9]{64}$/), - versionId: agent.versionId, - }); - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({ - [result.id]: skill, - }); - expect(storedSkillRefs(agent)).toEqual([]); - expect(agentRepository.updateDraftSchema).not.toHaveBeenCalled(); - expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); - }); - - it('stores references without derived metadata when creating a skill', async () => { - const agent = makeAgent({ - schema: { - name: 'Test Agent', - model: 'anthropic/claude-sonnet-4-5', - instructions: 'Be helpful', - }, - }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - const result = await service.createSkill( - agentId, - projectId, - { - ...skill, - references: [ - { - path: 'references/guide.md', - content: '# Guide', - }, - ], - }, - telemetryContext, - ); - - expect(result.skill.references).toEqual([ - { - path: 'references/guide.md', - content: '# Guide', - }, - ]); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith( - expect.objectContaining({ - skills: { - [result.id]: expect.objectContaining({ references: result.skill.references }), - }, - }), - {}, - ); - }); - describe('createAndAttachSkills', () => { - const skillTwo = { - name: 'Draft Follow-up', - description: 'Drafts a follow-up email', - instructions: 'Summarize next steps and send a draft.', - }; + beforeEach(() => { + let next = 0; + skillRefs.createSkills.mockImplementation(async (_agent, skills) => + skills.map(() => `skill_new${++next}`), + ); + }); - const configuredAgent = (overrides: AgentFixtureOverrides = {}) => - makeAgent({ - schema: { - name: 'Test Agent', - model: 'anthropic/claude-sonnet-4-5', - instructions: 'Be helpful', - skills: [], - }, - ...overrides, + it('creates hub skills in the agent project and attaches them in one transaction, in input order', async () => { + skillRefs.bodies.existing = { ...skill, name: 'Existing' }; + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'existing' }] }, }); + const second = { ...skill, name: 'Second Skill' }; - it('creates and attaches multiple skills with one load, save, and cache clear, preserving input order', async () => { - const agent = makeAgent({ - schema: { - name: 'Test Agent', - model: 'anthropic/claude-sonnet-4-5', - instructions: 'Be helpful', - skills: [], - }, - }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - const { skills: results, configHash } = await service.createAndAttachSkills( + const result = await service.createAndAttachSkills( agentId, projectId, - [skill, skillTwo], - telemetryContext, + [skill, second], + context, ); - expect(results).toHaveLength(2); - expect(results[0].skill).toEqual(skill); - expect(results[1].skill).toEqual(skillTwo); - expect(results[0].id).not.toEqual(results[1].id); - expect(results.map((r) => r.versionId)).toEqual([agent.versionId, agent.versionId]); - - expect(agentRepository.findByIdAndProjectId).toHaveBeenCalledTimes(1); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledTimes(1); - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({ - [results[0].id]: skill, - [results[1].id]: skillTwo, - }); - expect(storedSkillRefs(agent)).toEqual([ - { type: 'skill', id: results[0].id }, - { type: 'skill', id: results[1].id }, + expect(result.skills.map(({ id, skill: body }) => [id, body.name])).toEqual([ + ['skill_new1', 'Summarize Notes'], + ['skill_new2', 'Second Skill'], ]); - expect(agentRepository.updateDraftSchema).toHaveBeenCalledTimes(1); - expect(agentRepository.updateDraftSchema).toHaveBeenCalledWith(agent, {}); - expect(configHash).toBe(getAgentConfigHash(composeStoredJsonConfig(agent))); + expect(skillRefs.createSkills).toHaveBeenCalledWith( + agent, + [skill, second], + 'user-1', + 'project', + {}, + ); + expect(userHasScopes).toHaveBeenCalledWith(user, ['projectSkill:create'], false, { + projectId, + }); + const refs: AgentJsonSkillConfig[] = [ + { type: 'skill', id: 'existing' }, + { type: 'skill', id: 'skill_new1' }, + { type: 'skill', id: 'skill_new2' }, + ]; + expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith(agent, refs, {}); + expect(transactionRunner.run).toHaveBeenCalledTimes(1); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledTimes(1); expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledTimes(1); - expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); + expect(result.configHash).toBe(getAgentConfigHash(composeJsonConfig(agent, refs))); }); - it('rejects the batch without saving when the agent has no config yet', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + it('keeps allowed tools on the agent row when the skills hub is off', async () => { + Object.assign(skillRefs, { usesSkillsHub: false }); + givenAgent({ schema: baseConfig }); - await expect( - service.createAndAttachSkills(agentId, projectId, [skill], telemetryContext), - ).rejects.toThrow('Agent has no JSON config yet.'); - - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - }); - - it('rejects an empty batch before loading or writing anything', async () => { - await expect( - service.createAndAttachSkills(agentId, projectId, [], telemetryContext), - ).rejects.toThrow('At least one skill is required.'); - - expect(agentRepository.findByIdAndProjectId).not.toHaveBeenCalled(); - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); - }); - - it('rejects the whole batch without saving when a name collides with an existing skill', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - configuredAgent({ skills: { summarize_notes: skill } }), + await service.createAndAttachSkill( + agentId, + projectId, + { ...skill, allowedTools: ['search'] }, + context, ); - await expect( - service.createAndAttachSkills( - agentId, - projectId, - [skillTwo, { ...skill }], - telemetryContext, - ), - ).rejects.toThrow('Agent already has a skill named "Summarize Notes".'); - - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); + expect(skillRefs.createSkills).toHaveBeenCalledWith( + expect.anything(), + [{ ...skill, allowedTools: ['search'] }], + 'user-1', + 'project', + {}, + ); }); - it('rejects the whole batch without saving when two items in the batch share a name', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue(configuredAgent()); + it('drops allowed tools from the stored skill', async () => { + givenAgent({ schema: baseConfig }); - await expect( - service.createAndAttachSkills( - agentId, - projectId, - [skill, { ...skill, name: ' summarize notes ' }], - telemetryContext, - ), - ).rejects.toThrow('Duplicate skill name in batch: "summarize notes".'); + const result = await service.createAndAttachSkill( + agentId, + projectId, + { ...skill, allowedTools: ['search'] }, + context, + ); - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); + expect(result.skill).toEqual(skill); + expect(skillRefs.createSkills).toHaveBeenCalledWith( + expect.anything(), + [skill], + 'user-1', + 'project', + {}, + ); }); - it('rejects the whole batch without saving when one item is invalid', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue(configuredAgent()); + it('refuses an instance skill without the global skill create permission', async () => { + givenAgent({ schema: baseConfig }); + + const member = mock({ id: 'user-1', role: GLOBAL_MEMBER_ROLE }); + + await expect( + service.createAndAttachSkill( + agentId, + projectId, + skill, + { ...context, user: member }, + 'instance', + ), + ).rejects.toThrow('You are not allowed to create skills for the whole instance.'); + expect(skillRefs.createSkills).not.toHaveBeenCalled(); + }); + + it('rejects the batch without writing when the agent has no config yet', async () => { + givenAgent(); + + await expect( + service.createAndAttachSkills(agentId, projectId, [skill], context), + ).rejects.toThrow('Agent has no JSON config yet.'); + expect(skillRefs.createSkills).not.toHaveBeenCalled(); + }); + + it('rejects an empty batch before loading anything', async () => { + await expect(service.createAndAttachSkills(agentId, projectId, [], context)).rejects.toThrow( + 'At least one skill is required.', + ); + expect(agentRepository.findByIdAndProjectId).not.toHaveBeenCalled(); + }); + + it('rejects a user who may not create skills in the project', async () => { + givenAgent({ schema: baseConfig }); + vi.mocked(userHasScopes).mockResolvedValue(false); + + await expect( + service.createAndAttachSkills(agentId, projectId, [skill], context), + ).rejects.toThrow(ForbiddenError); + expect(skillRefs.createSkills).not.toHaveBeenCalled(); + }); + + it('creates a skill for this agent only without a project skill scope', async () => { + givenAgent({ schema: baseConfig }); + vi.mocked(userHasScopes).mockResolvedValue(false); + skillRefs.createSkills.mockResolvedValue(['skill_new']); + + await service.createAndAttachSkill(agentId, projectId, skill, context, 'agent'); + + expect(userHasScopes).not.toHaveBeenCalled(); + expect(skillRefs.createSkills).toHaveBeenCalledWith( + expect.anything(), + [skill], + 'user-1', + 'agent', + {}, + ); + }); + + it('rejects a name that is hard to tell apart from a skill of the agent', async () => { + skillRefs.bodies.existing = skill; + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'existing' }] } }); await expect( service.createAndAttachSkills( agentId, projectId, - [skill, { ...skillTwo, name: '' }], - telemetryContext, + [{ ...skill, name: ' summarize-notes ' }], + context, + ), + ).rejects.toThrow('Agent already has a skill with a name like "summarize-notes".'); + expect(skillRefs.createSkills).not.toHaveBeenCalled(); + expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); + }); + + it('rejects two names in one batch that are hard to tell apart', async () => { + givenAgent({ schema: baseConfig }); + + await expect( + service.createAndAttachSkills( + agentId, + projectId, + [skill, { ...skill, name: 'summarize_notes' }], + context, + ), + ).rejects.toThrow( + 'The new skills "Summarize Notes", "summarize_notes" are hard to tell apart.', + ); + expect(skillRefs.createSkills).not.toHaveBeenCalled(); + }); + + it('rejects the whole batch when one item is invalid', async () => { + givenAgent({ schema: baseConfig }); + + await expect( + service.createAndAttachSkills( + agentId, + projectId, + [skill, { ...skill, name: 'Other', instructions: '' }], + context, ), ).rejects.toThrow('Invalid agent skill'); - - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); + expect(skillRefs.createSkills).not.toHaveBeenCalled(); }); }); - 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: [], - }, + it('lists and updates every body on the agent row while the skills hub is off', async () => { + Object.assign(skillRefs, { usesSkillsHub: false }); + // `old` has no ref, as after a ref was switched off or removed on master. + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, + skills: { skill_a: skill, old: { ...skill, name: 'Old' } }, }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - const result = await service.createAndAttachSkill(agentId, projectId, skill, telemetryContext); - - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({ - [result.id]: skill, + await expect(service.listSkills(agentId, projectId)).resolves.toEqual(agent.skills); + await expect(service.getSkill(agentId, projectId, 'old')).resolves.toMatchObject({ + name: 'Old', }); - expect(storedSkillRefs(agent)).toEqual([{ type: 'skill', id: result.id }]); - expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); + await service.updateSkill(agentId, projectId, 'old', { instructions: 'Updated.' }, context); + expect(agent.skills.old).toMatchObject({ name: 'Old', instructions: 'Updated.' }); }); - 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); - expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); - }); - - it('updates an existing skill on the agent and preserves omitted references', async () => { - const skillWithReferences = { - ...skill, - references: [ - { - path: 'references/guide.md', - content: '# Guide', - }, - ], - }; - const agent = makeAgent({ + it('refuses a rename onto the name of an unreferenced body while the skills hub is off', async () => { + Object.assign(skillRefs, { usesSkillsHub: false }); + givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, skills: { - summarize_notes: skillWithReferences, + skill_a: skill, + old: { ...skill, name: 'Old' }, + older: { ...skill, name: 'Older' }, }, }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - const result = await service.updateSkill( - agentId, - projectId, - 'summarize_notes', - { - description: 'Summarizes support notes', - }, - { ...telemetryContext, pushRef: 'writer-push-ref' }, - getAgentSkillHash(skillWithReferences), - ); - - expect(result).toEqual({ - id: 'summarize_notes', - skill: { - ...skill, - description: 'Summarizes support notes', - references: [ - { - path: 'references/guide.md', - content: '# Guide', - }, - ], - }, - skillHash: expect.stringMatching(/^[a-f0-9]{64}$/), - versionId: agent.versionId, - }); - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({ - summarize_notes: result.skill, - }); - expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); - expect(agentUpdateBroadcaster.notify).toHaveBeenCalledWith( - { projectId, agentId, source: 'user' }, - 'writer-push-ref', - ); - }); - - it('rejects an update based on a stale skill without mutating the agent', async () => { - const agent = makeAgent({ skills: { summarize_notes: skill } }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); await expect( - service.updateSkill( - agentId, - projectId, - 'summarize_notes', - { instructions: 'Replace newer work' }, - telemetryContext, - 'stale-hash', - ), - ).rejects.toThrow('Skill was changed elsewhere; reload to get the latest version'); - - expect(agent.skills?.summarize_notes).toBe(skill); - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - expect(runtimeCacheService.clearRuntimes).not.toHaveBeenCalled(); - expect(modificationTelemetry.record).not.toHaveBeenCalled(); - expect(agentUpdateBroadcaster.notify).not.toHaveBeenCalled(); + service.updateSkill(agentId, projectId, 'old', { name: 'older' }, context), + ).rejects.toThrow('Agent already has a skill with a name like "older".'); }); - describe('instructionEdits', () => { - const editableSkill = { - ...skill, - instructions: '## Steps\n1. Extract decisions.\n2. Extract action items.\n', - }; + it('lists the skills the draft runs', async () => { + skillRefs.bodies.skill_a = skill; + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); - it('applies the edits to the stored instructions', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - makeAgent({ skills: { summarize_notes: editableSkill } }), + await expect(service.listSkills(agentId, projectId)).resolves.toEqual({ skill_a: skill }); + await expect(service.getSkill(agentId, projectId, 'skill_a')).resolves.toEqual(skill); + await expect(service.getSkill(agentId, projectId, 'toString')).rejects.toThrow(NotFoundError); + }); + + describe('updateSkill', () => { + beforeEach(() => { + skillRefs.bodies.skill_a = skill; + skillService.save.mockResolvedValue(saveResult()); + }); + + it('changes where the skill can be added without saving a new version', async () => { + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, + skills: { skill_a: skill }, + }); + skillRefs.availabilityOf.mockResolvedValue('project'); + + await service.updateSkill( + agentId, + projectId, + 'skill_a', + {}, + context, + undefined, + undefined, + 'agent', ); + expect(skillService.assertCanEdit).toHaveBeenCalledWith(user, ['skill_a']); + expect(skillRefs.setAvailability).toHaveBeenCalledWith(agent, 'skill_a', 'agent', {}); + expect(skillService.save).not.toHaveBeenCalled(); + }); + + it('writes nothing when the skill keeps where it can be added', async () => { + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); + skillRefs.availabilityOf.mockResolvedValue('agent'); + + await service.updateSkill( + agentId, + projectId, + 'skill_a', + {}, + context, + undefined, + undefined, + 'agent', + ); + + expect(skillRefs.setAvailability).not.toHaveBeenCalled(); + expect(skillService.save).not.toHaveBeenCalled(); + }); + + it('sets and clears allowed tools on the agent row when the skills hub is off', async () => { + Object.assign(skillRefs, { usesSkillsHub: false }); + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, + skills: { skill_a: skill }, + }); + + await service.updateSkill( + agentId, + projectId, + 'skill_a', + { allowedTools: ['search'] }, + context, + getAgentSkillHash(skill), + ); + expect(agent.skills.skill_a).toEqual({ ...skill, allowedTools: ['search'] }); + + await service.updateSkill(agentId, projectId, 'skill_a', { allowedTools: [] }, context); + expect(agent.skills.skill_a).toEqual(skill); + }); + + it('updates the body on the agent row when the skills hub is off', async () => { + Object.assign(skillRefs, { usesSkillsHub: false }); + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, + skills: { skill_a: skill }, + }); + const result = await service.updateSkill( agentId, projectId, - 'summarize_notes', - { description: 'Summarizes support notes' }, - telemetryContext, - getAgentSkillHash(editableSkill), - [{ oldText: '2. Extract action items.', newText: '2. Extract action items with owners.' }], + 'skill_a', + { instructions: 'New instructions.' }, + context, + getAgentSkillHash(skill), ); - expect(result.skill).toEqual({ - ...editableSkill, - description: 'Summarizes support notes', - instructions: '## Steps\n1. Extract decisions.\n2. Extract action items with owners.\n', + expect(agent.skills.skill_a).toEqual({ ...skill, instructions: 'New instructions.' }); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, {}); + expect(result.skill.instructions).toBe('New instructions.'); + expect(skillService.assertCanEdit).not.toHaveBeenCalled(); + expect(skillService.save).not.toHaveBeenCalled(); + }); + + it('saves the next version with the skill edit permission, then refreshes the agents that follow it', async () => { + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] }, + skills: { skill_a: skill }, }); - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({ - summarize_notes: result.skill, - }); - }); + const fresh = { ...agent, versionId: 'v2' } as Agent; + agentRepository.findByIdAndProjectId + .mockResolvedValueOnce(agent) + .mockResolvedValueOnce(fresh); + skillService.save.mockResolvedValue(saveResult({ followingAgentIds: [agentId, 'agent-2'] })); - it('rejects an edit that does not match and saves nothing', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - makeAgent({ skills: { summarize_notes: editableSkill } }), - ); - - await expect( - service.updateSkill( - agentId, - projectId, - 'summarize_notes', - {}, - telemetryContext, - getAgentSkillHash(editableSkill), - [{ oldText: 'Extract risks.', newText: 'x' }], - ), - ).rejects.toThrow('instructionEdits[0]: oldText matches 0 places'); - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - }); - - it('checks the base hash before it applies the edits', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - makeAgent({ skills: { summarize_notes: editableSkill } }), - ); - - await expect( - service.updateSkill( - agentId, - projectId, - 'summarize_notes', - {}, - telemetryContext, - 'stale-hash', - [{ oldText: 'Extract risks.', newText: 'x' }], - ), - ).rejects.toThrow('Skill was changed elsewhere'); - }); - - it('rejects instructions and instructionEdits together', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - makeAgent({ skills: { summarize_notes: editableSkill } }), - ); - - await expect( - service.updateSkill( - agentId, - projectId, - 'summarize_notes', - { instructions: 'New body' }, - telemetryContext, - undefined, - [{ oldText: 'Extract decisions.', newText: 'x' }], - ), - ).rejects.toThrow('Pass either instructions or instructionEdits, not both.'); - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - }); - }); - - it('removes optional list fields when an update clears them', async () => { - const agent = makeAgent({ - skills: { - summarize_notes: { - ...skill, - allowedTools: ['load_workflow'], - references: [ - { - path: 'references/guide.md', - content: '# Guide', - }, - ], - }, - }, - }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - const result = await service.updateSkill( - agentId, - projectId, - 'summarize_notes', - { - allowedTools: undefined, - references: [], - }, - telemetryContext, - ); - - expect(result.skill).not.toHaveProperty('allowedTools'); - expect(result.skill).not.toHaveProperty('references'); - expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith( - expect.objectContaining({ - skills: { - summarize_notes: skill, - }, - }), - {}, - ); - }); - - it('rejects creating a skill with a duplicate name', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - makeAgent({ - skills: { summarize_notes: skill }, - schema: { - name: 'Test Agent', - model: 'anthropic/claude-sonnet-4-5', - instructions: 'Be helpful', - }, - }), - ); - - await expect( - service.createAndAttachSkill(agentId, projectId, skill, telemetryContext), - ).rejects.toThrow('Agent already has a skill named "Summarize Notes".'); - }); - - it('rejects renaming a skill to another existing skill name', async () => { - agentRepository.findByIdAndProjectId.mockResolvedValue( - makeAgent({ - skills: { - summarize_notes: skill, - other_skill: { name: 'Other Skill', description: 'desc', instructions: 'Use it' }, - }, - }), - ); - - await expect( - service.updateSkill( + const result = await service.updateSkill( agentId, projectId, - 'summarize_notes', - { name: 'Other Skill' }, - telemetryContext, - ), - ).rejects.toThrow('Agent already has a skill named "Other Skill".'); + 'skill_a', + { instructions: 'New instructions.', allowedTools: ['search'] }, + context, + getAgentSkillHash(skill), + ); + + expect(skillService.assertCanEdit).toHaveBeenCalledWith(user, ['skill_a']); + expect(skillService.save).toHaveBeenCalledWith( + 'skill_a', + { + name: skill.name, + description: skill.description, + instructions: 'New instructions.', + references: [], + baseSkillHash: getAgentSkillHash(skill), + }, + 'user-1', + {}, + ); + // The other agents get a refresh after the commit. This agent gets the save completion. + expect(skillService.refreshFollowingAgents).toHaveBeenCalledWith(['agent-2'], 'push-ref-1'); + expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); + expect(agentUpdateBroadcaster.notify).toHaveBeenCalledWith( + { projectId, agentId, source: 'user' }, + 'push-ref-1', + ); + expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); + expect(result).toEqual({ + id: 'skill_a', + skill: { ...skill, instructions: 'New instructions.' }, + skillHash: getAgentSkillHash({ ...skill, instructions: 'New instructions.' }), + versionId: 'v2', + }); + }); + + it('clears the pin of this agent when the pin is the latest version', async () => { + const agent = givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a', versionId: pinId }] }, + }); + + await service.updateSkill( + agentId, + projectId, + 'skill_a', + { instructions: 'New instructions.' }, + context, + getAgentSkillHash(skill), + ); + + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, {}); + expect(skillRefs.unpinDraftRef).toHaveBeenCalledWith(agent, 'skill_a', {}); + await expect(skillRefs.refsForDraft(agent, {})).resolves.toEqual([ + { type: 'skill', id: 'skill_a' }, + ]); + expect(skillService.save).toHaveBeenCalledWith( + 'skill_a', + expect.objectContaining({ baseSkillHash: getAgentSkillHash(skill) }), + 'user-1', + {}, + ); + }); + + it('refuses to edit a pin to an older version', async () => { + givenAgent({ + schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a', versionId: pinId }] }, + }); + const olderSkill = { ...skill, instructions: 'Older instructions.' }; + skillRefs.skillsForRefs.mockResolvedValueOnce({ skill_a: olderSkill }); + + await expect( + service.updateSkill( + agentId, + projectId, + 'skill_a', + { instructions: 'New instructions.' }, + context, + getAgentSkillHash(olderSkill), + ), + ).rejects.toThrow( + 'This agent uses an older version of this skill. Use the latest version first, then edit the skill. To use the latest version, remove versionId from the skill ref in the agent config.', + ); + expect(skillRefs.unpinDraftRef).not.toHaveBeenCalled(); + expect(skillService.save).not.toHaveBeenCalled(); + }); + + it('rejects a stale base hash without saving', async () => { + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); + + await expect( + service.updateSkill( + agentId, + projectId, + 'skill_a', + { instructions: 'New instructions.' }, + context, + 'stale', + ), + ).rejects.toThrow(ConflictError); + expect(skillService.save).not.toHaveBeenCalled(); + }); + + it('rejects a user who may use but not edit the skill', async () => { + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); + skillService.assertCanEdit.mockRejectedValue(new ForbiddenError('You can use but not edit')); + + await expect( + service.updateSkill(agentId, projectId, 'skill_a', { instructions: 'New.' }, context), + ).rejects.toThrow(ForbiddenError); + expect(skillService.save).not.toHaveBeenCalled(); + }); + + it('rejects a skill that is not attached to the agent', async () => { + givenAgent({ schema: baseConfig }); + + await expect( + service.updateSkill(agentId, projectId, 'skill_a', { instructions: 'New.' }, context), + ).rejects.toThrow(NotFoundError); + }); + + it('saves nothing when the update does not change the skill', async () => { + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); + + const result = await service.updateSkill( + agentId, + projectId, + 'skill_a', + { name: skill.name }, + context, + ); + + expect(result).toEqual({ + id: 'skill_a', + skill, + skillHash: getAgentSkillHash(skill), + versionId, + }); + expect(skillService.assertCanEdit).not.toHaveBeenCalled(); + expect(skillService.save).not.toHaveBeenCalled(); + expect(modificationTelemetry.record).not.toHaveBeenCalled(); + }); + + it('applies instruction edits to the version this agent shows', async () => { + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); + + await service.updateSkill( + agentId, + projectId, + 'skill_a', + {}, + context, + getAgentSkillHash(skill), + [{ oldText: 'action items', newText: 'owners' }], + ); + + expect(skillService.save).toHaveBeenCalledWith( + 'skill_a', + expect.objectContaining({ instructions: 'Extract decisions and owners.' }), + 'user-1', + {}, + ); + }); + + it('removes the references when an update clears them', async () => { + skillRefs.bodies.skill_a = { + ...skill, + references: [{ path: 'references/a.md', content: 'A' }], + }; + givenAgent({ schema: { ...baseConfig, skills: [{ type: 'skill', id: 'skill_a' }] } }); + + await service.updateSkill(agentId, projectId, 'skill_a', { references: [] }, context); + + expect(skillService.save).toHaveBeenCalledWith( + 'skill_a', + expect.objectContaining({ references: [] }), + 'user-1', + {}, + ); + }); }); - 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' }], - }, + describe('deleteSkill', () => { + it('detaches the skill and keeps the hub skill', async () => { + const agent = givenAgent({ + schema: { + ...baseConfig, + skills: [ + { type: 'skill', id: 'skill_a' }, + { type: 'skill', id: 'skill_b' }, + ], + }, + }); + + await service.deleteSkill(agentId, projectId, 'skill_a', context); + + expect(skillRefs.replaceDraftRefs).toHaveBeenCalledWith( + agent, + [{ type: 'skill', id: 'skill_b' }], + {}, + ); + expect(agentRepository.saveDraftFenced).toHaveBeenCalledWith(agent, {}); + expect(skillService.deleteSkill).not.toHaveBeenCalled(); + expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - await service.deleteSkill(agentId, projectId, 'summarize_notes', telemetryContext); + it('rejects a skill that is not attached to the agent', async () => { + givenAgent({ schema: baseConfig }); - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({}); - expect(agent.schema?.tools).toEqual([{ type: 'custom', id: 'custom_tool' }]); - expect(storedSkillRefs(agent)).toEqual([]); - expect(agentRepository.updateDraftSchema).toHaveBeenCalledTimes(1); - expect(runtimeCacheService.clearRuntimes).toHaveBeenCalledWith(agentId); - }); - - it('does not add a skills key when it deletes a skill from a config without one', async () => { - const agent = makeAgent({ - skills: { summarize_notes: skill }, - schema: { - name: 'Test Agent', - model: 'anthropic/claude-sonnet-4-5', - instructions: 'Be helpful', - }, + await expect(service.deleteSkill(agentId, projectId, 'skill_a', context)).rejects.toThrow( + NotFoundError, + ); + expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - await service.deleteSkill(agentId, projectId, 'summarize_notes', telemetryContext); - - expect(agentRepository.saveDraftFenced.mock.calls[0][0].skills).toEqual({}); - expect(agentRepository.updateDraftSchema).not.toHaveBeenCalled(); - expect(agent.schema).not.toHaveProperty('skills'); - }); - - it('reports skill body changes through lifecycle telemetry and stays silent on a no-op update', async () => { - 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 }, - integrations: [], - }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - await service.updateSkill( - agentId, - projectId, - 'summarize_notes', - { description: 'Updated description' }, - telemetryContext, - ); - - expect(agentRepository.saveDraftFenced).toHaveBeenCalledTimes(1); - expect(agentRepository.updateDraftSchema).not.toHaveBeenCalled(); - expect(storedSkillRefs(agent)).toEqual([{ type: 'skill', id: 'summarize_notes' }]); - expect(modificationTelemetry.record).toHaveBeenCalledWith( - expect.objectContaining({ - by: 'user', - changedParts: ['skills'], - wasUnconfigured: false, - }), - ); - - vi.clearAllMocks(); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - await service.updateSkill( - agentId, - projectId, - 'summarize_notes', - { description: 'Updated description' }, - telemetryContext, - ); - expect(agentRepository.saveDraftFenced).not.toHaveBeenCalled(); - expect(modificationTelemetry.record).not.toHaveBeenCalled(); - }); - - it('stays silent when a detached skill is written on an unconfigured agent', async () => { - const agent = makeAgent({ - schema: { name: 'Test Agent', model: '', instructions: '', skills: [] }, - integrations: [], - }); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent); - - await service.createSkill(agentId, projectId, skill, telemetryContext); - - expect(modificationTelemetry.record).toHaveBeenCalledWith( - expect.objectContaining({ - changedParts: ['skills'], - wasUnconfigured: true, - }), - ); - // record itself suppresses blank-to-blank; the service still reports the write. }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts index f804ee9dbab..2fb88fda9e8 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts @@ -170,7 +170,7 @@ describe('AgentTaskService', () => { ), settingsService, transactionRunner, - createAgentSkillRefsService(agentRepository), + createAgentSkillRefsService(), ); } diff --git a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts index 3ddf90329c2..6d3a38b1ad7 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-validation.service.test.ts @@ -65,6 +65,7 @@ function makeCredentialProvider( } function makeService() { + const skillRefs = createAgentSkillRefsService(); const agentRepository = mock(); agentRepository.hasRevision.mockResolvedValue(true); const agentSkillsService = mock(); @@ -87,14 +88,15 @@ function makeService() { agentTaskSnapshotRepository, agentRepository, mock(), - createAgentSkillRefsService(agentRepository), + skillRefs, ), nodeTypes, workflowRepository, chatIntegrationRegistry, aiGatewayService, - createAgentSkillRefsService(agentRepository), + skillRefs, ), + skillRefs, agentRepository, agentSkillsService, agentTaskRepository, @@ -167,7 +169,6 @@ describe('AgentValidationService — structured issues', () => { }, { type: 'custom', id: 'active_missing' }, ], - skills: [{ type: 'skill', id: 'missing_skill', enabled: false }], subAgents: { agents: [{ agentId: 'missing_subagent', enabled: false }] }, }; agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent(config)); @@ -202,7 +203,8 @@ describe('AgentValidationService — structured issues', () => { }); for (const ref of config.tools!) ref.enabled = true; - config.skills![0].enabled = true; + // A skill has no on/off state: an attached ref to a missing skill is an issue. + config.skills = [{ type: 'skill', id: 'missing_skill' }]; config.subAgents!.agents![0].enabled = true; const reactivated = await service.validateAgentConfiguration( agentId, @@ -223,6 +225,39 @@ describe('AgentValidationService — structured issues', () => { }, ); + it('warns about skill names that are hard to tell apart without failing validation', async () => { + const { service, agentRepository, skillRefs } = makeService(); + const body = { description: 'A skill', instructions: 'Do it.' }; + skillRefs.bodies.brand = { ...body, name: 'Brand voice' }; + skillRefs.bodies.brand_copy = { ...body, name: 'brand-voice' }; + skillRefs.bodies.pricing = { ...body, name: 'Pricing' }; + agentRepository.findByIdAndProjectId.mockResolvedValue( + makeAgent({ + ...runnableConfig, + skills: [ + { type: 'skill', id: 'brand' }, + { type: 'skill', id: 'pricing' }, + { type: 'skill', id: 'brand_copy' }, + ], + }), + ); + + const result = await service.validateAgentConfiguration( + agentId, + projectId, + makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]), + ); + + expect(result.status).toBe('valid'); + expect(result.warnings).toEqual([ + { + code: 'similar_skill_names', + capability: { kind: 'skill' }, + names: ['Brand voice', 'brand-voice'], + }, + ]); + }); + it('flags a main model credential whose provider does not match the configured model, but not when the model itself is invalid', async () => { const { service, agentRepository } = makeService(); agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent(runnableConfig)); diff --git a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts index d31a29f2ef1..d0cb79d3943 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-workflow-execution.service.test.ts @@ -1,7 +1,7 @@ import type { AgentMessageSteeringService } from '../agent-message-steering.service'; import type { Agent as RuntimeAgent, ExecutionOptions, RunOptions, StreamChunk } from '@n8n/agents'; import type { AgentJsonConfig } from '@n8n/api-types'; -import { mockLogger } from '@n8n/backend-test-utils'; +import { mockInstance, mockLogger } from '@n8n/backend-test-utils'; import type { AiConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; @@ -11,6 +11,7 @@ import type { ExecuteAgentWorkflowContext, IRunExecutionData } from 'n8n-workflo import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { AgentsSettingsService } from '../agents-settings.service'; +import { SkillService } from '../skills/skill.service'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { ExecutionLevelTracer } from '@/modules/otel/execution-level-tracer'; @@ -131,6 +132,7 @@ function makeRuntime(chunks: StreamChunk[] = [{ type: 'finish', finishReason: 's } function makeService() { + const skillRefs = createAgentSkillRefsService(); const settingsService = mock(); const agentRepository = mock(); const executionService = mock(); @@ -177,11 +179,12 @@ function makeService() { aiConfigMock, integrationMessageContextService, settingsService, - createAgentSkillRefsService(agentRepository), + skillRefs, ); return { service, + skillRefs, settingsService, agentRepository, executionService, @@ -886,6 +889,49 @@ describe('AgentWorkflowExecutionService', () => { }, }; + it('runs the embedded skills and never calls the skill service', async () => { + const skillService = mockInstance(SkillService); + const { service, reconstructionService, skillRefs } = makeService(); + reconstructionService.reconstructFromResolvedSource.mockResolvedValue( + makeRuntime([{ type: 'finish', finishReason: 'stop' }]), + ); + const embedded = { + name: 'Embedded skill', + description: 'An inline skill', + instructions: 'Use the embedded text.', + }; + + await service.executeInlineForWorkflow( + { + config: { ...inlinePayload.config, skills: [{ type: 'skill', id: 'skill_inline' }] }, + skills: { skill_inline: embedded }, + }, + 'hello', + 'execution-1', + 'thread-1', + projectId, + userId, + 'production', + ); + + expect(reconstructionService.reconstructFromResolvedSource).toHaveBeenCalledWith( + expect.objectContaining({ skills: { skill_inline: embedded } }), + ); + for (const read of [ + skillRefs.refsForDraft, + skillRefs.refsForVersion, + skillRefs.readDraft, + skillRefs.readVersion, + skillRefs.skillsForRefs, + skillService.resolveForAgentDraft, + skillService.resolvePinned, + skillService.checkRefs, + skillService.create, + ]) { + expect(read).not.toHaveBeenCalled(); + } + }); + it('compiles from the embedded config, records no session, and tracks inline telemetry', async () => { const { service, @@ -1504,7 +1550,10 @@ describe('AgentWorkflowExecutionService', () => { { prepareForEval }, ); - expect(prepareForEval).toHaveBeenCalledWith(schema, { hasChatIntegrations: true }); + expect(prepareForEval).toHaveBeenCalledWith( + { ...schema, skills: [] }, + { hasChatIntegrations: true }, + ); const [entity, , , , , passedInstrumentation] = reconstructionService.reconstructFromAgentEntity.mock.calls[0]; expect(entity.agent.schema).toEqual(evalConfig); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 2033e095905..d9cdb305f27 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -1742,7 +1742,6 @@ describe('AgentsBuilderToolsService', () => { expect(tool.systemInstruction).toContain('vague or placeholder skill'); expect(tool.systemInstruction).toContain('ask the user clarifying'); expect(tool.systemInstruction).toContain('Gotchas'); - expect(tool.systemInstruction).toContain('exact target-agent tool names'); expect(tool.systemInstruction).toContain('references are not automatically loaded'); expect(tool.systemInstruction).toContain( 'instructions must say exactly when to load each one', @@ -2032,7 +2031,7 @@ describe('AgentsBuilderToolsService', () => { }); }); - it('uses null to remove optional list fields and rejects ambiguous empty arrays', async () => { + it('uses null to remove the references and rejects an ambiguous empty array', async () => { const { service, agentsService } = makeService(); agentsService.updateSkill.mockResolvedValue({ id: 'skill_create_tickets', @@ -2055,7 +2054,7 @@ describe('AgentsBuilderToolsService', () => { { skillId: 'skill_create_tickets', baseSkillHash: 'skill-hash-0', - updates: { allowedTools: null, references: null }, + updates: { references: null }, }, ctx, ); @@ -2064,7 +2063,7 @@ describe('AgentsBuilderToolsService', () => { agentId, projectId, 'skill_create_tickets', - { allowedTools: undefined, references: undefined }, + { references: undefined }, { user, modifiedBy: 'builder' }, 'skill-hash-0', undefined, @@ -2074,9 +2073,13 @@ describe('AgentsBuilderToolsService', () => { safeParse: (input: unknown) => { success: boolean }; }; const baseInput = { skillId: 'skill_create_tickets', baseSkillHash: 'skill-hash-0' }; - expect(inputSchema.safeParse({ ...baseInput, updates: { allowedTools: [] } }).success).toBe( + expect(inputSchema.safeParse({ ...baseInput, updates: { references: [] } }).success).toBe( false, ); + // Skills do not take allowed tools. + expect( + inputSchema.safeParse({ ...baseInput, updates: { allowedTools: ['search'] } }).success, + ).toBe(false); expect(inputSchema.safeParse({ ...baseInput, updates: {} }).success).toBe(false); expect( inputSchema.safeParse({ diff --git a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts index d731df42e82..13add393d2b 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.controller.test.ts @@ -30,6 +30,7 @@ function makeController({ credentialsService = mock(), agentDefaultModelResolverService = mock(), collaborationService = mock(), + skillRefs = createAgentSkillRefsService(), }: { agentsService?: Mocked< Pick< @@ -42,12 +43,13 @@ function makeController({ credentialsService?: Mocked; agentDefaultModelResolverService?: Mocked; collaborationService?: Mocked; + skillRefs?: ReturnType; } = {}) { const agentRunnableStateService = new AgentRunnableStateService( credentialsService, agentValidationService, agentPublishService, - createAgentSkillRefsService(), + skillRefs, ); return { @@ -264,15 +266,19 @@ describe('AgentsController agent resource', () => { mock>(); const agentPublishService = mock(); const agentValidationService = mock(); + const triage = { + name: 'Triage', + description: 'Triage requests', + instructions: 'Route each request.', + }; agentsService.findById.mockResolvedValue({ id: 'agent-1', projectId: 'project-1', - skills: { - triage: { - name: 'Triage', - description: 'Triage requests', - instructions: 'Route each request.', - }, + schema: { + name: 'Agent', + model: '', + instructions: '', + skills: [{ type: 'skill', id: 'triage' }], }, } as never); agentValidationService.validateLoadedAgentConfiguration.mockResolvedValue({ @@ -285,6 +291,7 @@ describe('AgentsController agent resource', () => { agentsService: agentsService as never, agentPublishService, agentValidationService, + skillRefs: createAgentSkillRefsService({ triage }), }); const result = await controller.get( @@ -302,6 +309,8 @@ describe('AgentsController agent resource', () => { isRunnable: true, }), ); + // The skill bodies come from the skills hub, not from the agent row. + expect(result.skills).toEqual({ triage }); expect(result.skillHashes.triage).toMatch(/^[a-f0-9]{64}$/); expect(agentValidationService.validateLoadedAgentConfiguration).toHaveBeenCalledWith( expect.objectContaining({ id: 'agent-1' }), diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 16b678bd810..fb4c10fa964 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -26,7 +26,7 @@ import type { AgentRepository } from '../repositories/agent.repository'; import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { AgentPolicyService } from '../agent-policy.service'; -import { createAgentSkillRefsService, storedSkillRefs } from './test-utils/stored-agent-config'; +import { createAgentSkillRefsService } from './test-utils/stored-agent-config'; const agentId = 'agent-1'; const actor = { kind: 'user', user: { id: 'user-1' } } as const; @@ -56,6 +56,7 @@ function makeAgent(overrides: Partial = {}): Agent { } function makeService() { + const skillRefs = createAgentSkillRefsService(); const agentRepository = mock(); const projectRelationRepository = mock(); const agentKnowledgeService = mock(); @@ -104,12 +105,13 @@ function makeService() { projectScopeService, agentsSettingsService, agentPolicyService, - createAgentSkillRefsService(agentRepository), + skillRefs, transactionRunner, ); return { service, + skillRefs, agentPolicyService, agentRepository, projectRelationRepository, @@ -139,7 +141,7 @@ describe('AgentsService', () => { }); it('creates a draft agent without a default model or credential', async () => { - const { service, agentRepository } = makeService(); + const { service, agentRepository, skillRefs } = makeService(); const saved = makeAgent(); mockCreateReturning(agentRepository, saved); @@ -164,8 +166,7 @@ describe('AgentsService', () => { availableInMCP: false, }); // The skill refs of the new draft go through the skill refs seam. - expect(storedSkillRefs(saved)).toEqual([]); - expect(agentRepository.updateDraftSchema).toHaveBeenCalledWith(saved, expect.anything()); + expect(skillRefs.createDraftRefs).toHaveBeenCalledWith(saved, [], {}, null, expect.anything()); }); it('creates an agent with a resolved default model and credential', async () => { @@ -257,16 +258,19 @@ describe('AgentsService', () => { }); }); - it('seeds skill bodies onto the entity alongside their schema refs', async () => { - const { service, agentRepository } = makeService(); + it('passes seeded skill bodies to the skill refs seam, not to the entity', async () => { + const { service, agentRepository, skillRefs } = makeService(); const saved = makeAgent(); mockCreateReturning(agentRepository, saved); const skills = { skill_abc: { name: 'Triage', description: '', instructions: 'Sort tickets.' }, }; + const skillCreator = mock({ id: 'user-1' }); + await service.create(projectId, 'Support Agent', { actor, + skillCreator, schema: { name: 'Support Agent', model: 'anthropic/claude-sonnet-4-5', @@ -277,18 +281,28 @@ describe('AgentsService', () => { }); const [entity] = agentRepository.create.mock.calls[0]; - expect(entity.skills).toEqual(skills); + expect(entity).not.toHaveProperty('skills'); + expect(skillRefs.createDraftRefs).toHaveBeenCalledWith( + saved, + [{ type: 'skill', id: 'skill_abc' }], + skills, + skillCreator, + expect.anything(), + ); }); - it('stores the skill refs of a seeded config in order, with the enabled flags', async () => { - const { service, agentRepository } = makeService(); + it('stores the skill refs of a seeded config in order', async () => { + const { service, agentRepository, skillRefs: refsService } = makeService(); const saved = makeAgent(); mockCreateReturning(agentRepository, saved); const skillRefs = [ { type: 'skill' as const, id: 'zeta' }, - { type: 'skill' as const, id: 'alpha', enabled: false }, - { type: 'skill' as const, id: 'mid', enabled: true }, + { type: 'skill' as const, id: 'alpha' }, + { type: 'skill' as const, id: 'mid' }, ]; + // Refs to skills that are in the hub stay, also without a user. + const skill = { name: 'Skill', description: 'A skill', instructions: 'Do it.' }; + refsService.bodies = { zeta: skill, alpha: skill, mid: skill }; await service.create(projectId, 'Support Agent', { actor, @@ -302,10 +316,10 @@ describe('AgentsService', () => { const [entity] = agentRepository.create.mock.calls[0]; expect(entity.schema).not.toHaveProperty('skills'); - expect(storedSkillRefs(saved)).toEqual(skillRefs); + await expect(refsService.refsForDraft(saved, {})).resolves.toEqual(skillRefs); // The insert and the skill refs write share one transaction. const [[, insertCtx]] = agentRepository.insertNew.mock.calls; - const [[, refsCtx]] = agentRepository.updateDraftSchema.mock.calls; + const [[, , , , refsCtx]] = refsService.createDraftRefs.mock.calls; expect(refsCtx).toBe(insertCtx); }); @@ -1160,7 +1174,8 @@ describe('AgentsService', () => { describe('getCapabilitySummary', () => { it('projects model, channels, tools, skills and tasks into per-item labels', async () => { - const { service, agentRepository, agentTaskRepository } = makeService(); + const { service, agentRepository, agentTaskRepository, skillRefs } = makeService(); + skillRefs.bodies.s1 = { name: 'Triage', description: '', instructions: '' }; agentRepository.findByIdAndProjectId.mockResolvedValue( makeAgent({ name: 'Support Agent', @@ -1193,7 +1208,6 @@ describe('AgentsService', () => { { type: 'telegram', credentialId: 'cred-2' }, ], tools: { c1: { code: '', descriptor: { name: 'Refund tool' } } }, - skills: { s1: { name: 'Triage', description: '', instructions: '' } }, } as unknown as Partial), ); agentTaskRepository.findByAgentId.mockResolvedValue([ diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 13f1e67593e..e21ffdbce51 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -330,13 +330,39 @@ describe('buildFromJson()', () => { expect(agent.snapshot.tools.some((tool) => tool.name === 'load_skill')).toBe(true); }); + // Agents can no longer disable a skill. The schema strips a stale `enabled: false`, so the + // runtime loads every ref it gets. Disabled refs are detached before they reach it. + it('loads a skill whose ref still says enabled: false', async () => { + const config = AgentJsonConfigSchema.parse( + makeConfig({ + skills: [{ type: 'skill', id: 'summarize_notes', enabled: false } as never], + }), + ); + expect(config.skills).toEqual([{ 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.', + }, + }, + }, + ); + + expect(agent.snapshot.tools.some((tool) => tool.name === 'load_skill')).toBe(true); + }); + 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' }, - { type: 'skill', id: 'unused_skill', enabled: false }, - { type: 'skill', id: 'missing_skill', enabled: false }, - ], + skills: [{ type: 'skill', id: 'summarize_notes' }], }); const agent = await buildFromJson( diff --git a/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts b/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts index e504acba9bd..7935974ad39 100644 --- a/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts +++ b/packages/cli/src/modules/agents/__tests__/instance-ai-agent-context.adapter.test.ts @@ -17,7 +17,7 @@ import type { AgentsService } from '../agents.service'; import type { AttachableWorkflowsService } from '../attachable-workflows.service'; import type { Agent } from '../entities/agent.entity'; import { InstanceAiAgentContextAdapterService } from '../instance-ai-agent-context.adapter'; -import { createAgentSkillRefsService } from './test-utils/stored-agent-config'; +import { createAgentSkillRefsService, storedAgentConfig } from './test-utils/stored-agent-config'; vi.mock('@/permissions.ee/check-access', () => ({ userHasScopes: vi.fn() })); @@ -30,6 +30,7 @@ function makeService() { const attachableWorkflowsService = mock(); const mcpRegistryService = mock(); const agentsToolsService = mock(); + const agentSkillRefs = createAgentSkillRefsService(); const service = new InstanceAiAgentContextAdapterService( agentsService, agentSkillsService, @@ -39,7 +40,7 @@ function makeService() { attachableWorkflowsService, mcpRegistryService, agentsToolsService, - createAgentSkillRefsService(), + agentSkillRefs, ); return { service, @@ -51,6 +52,7 @@ function makeService() { attachableWorkflowsService, mcpRegistryService, agentsToolsService, + agentSkillRefs, }; } @@ -75,7 +77,7 @@ describe('InstanceAiAgentContextAdapterService', () => { }); it('labels config as the current draft and includes publication state', async () => { - const { service, agentsService } = makeService(); + const { service, agentsService, agentSkillRefs } = makeService(); agentsService.findById.mockResolvedValue(agent); const result = await service.createReader(user, 'project-1').lookup({ @@ -84,6 +86,8 @@ describe('InstanceAiAgentContextAdapterService', () => { }); expect(agentsService.findById).toHaveBeenCalledWith('agent-1', 'project-1'); + // The config needs the skill refs, not the skill bodies. + expect(agentSkillRefs.skillsForRefs).not.toHaveBeenCalled(); expect(result).toMatchObject({ configState: 'current-draft', config: { name: 'Support Agent', model: '', instructions: 'Help users.' }, @@ -233,6 +237,33 @@ describe('InstanceAiAgentContextAdapterService', () => { await expect(lookup).rejects.toThrow(error.message); }); + it('lists the hub skills attached to the agent', async () => { + const { service, agentsService, agentSkillRefs } = makeService(); + agentSkillRefs.bodies.notes = { + name: 'Notes', + description: 'Take notes', + instructions: 'Write notes.', + }; + agentsService.findById.mockResolvedValue({ + ...agent, + schema: storedAgentConfig({ + name: 'Support Agent', + model: '', + instructions: 'Help users.', + skills: [{ type: 'skill', id: 'notes' }], + }), + } as Agent); + + const result = await service.createReader(user, 'project-1').lookup({ + type: 'skills', + agentId: 'agent-1', + }); + + expect(result).toEqual({ + skills: [{ id: 'notes', name: 'Notes', description: 'Take notes', attached: true }], + }); + }); + it('reads task enabled state from the current Agent config', async () => { const { service, agentsService, agentTaskService } = makeService(); agentsService.findById.mockResolvedValue({ diff --git a/packages/cli/src/modules/agents/__tests__/skill.service.test.ts b/packages/cli/src/modules/agents/__tests__/skill.service.test.ts index d84b9639770..309c8e59dc3 100644 --- a/packages/cli/src/modules/agents/__tests__/skill.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/skill.service.test.ts @@ -392,11 +392,45 @@ describe('SkillService', () => { expect(clearRuntimes.mock.calls).toEqual([['agent-1'], ['agent-2']]); expect(broadcaster.notify).toHaveBeenCalledTimes(2); - expect(broadcaster.notify).toHaveBeenCalledWith({ - projectId: PROJECT, - agentId: 'agent-2', - source: 'user', - }); + expect(broadcaster.notify).toHaveBeenCalledWith( + { projectId: PROJECT, agentId: 'agent-2', source: 'user' }, + undefined, + ); + }); + + it('sends no update push to the tab that saved', async () => { + const baseSkillHash = givenLatest(versionRow(skill, 2)); + skills.findFollowingAgentIds.mockResolvedValue(['agent-1']); + agents.findProjectIdsByIds.mockResolvedValue([{ id: 'agent-1', projectId: PROJECT }]); + + await service.save( + 'skill_a', + { baseSkillHash, instructions: 'new' }, + user.id, + {}, + 'push-ref-1', + ); + + expect(broadcaster.notify).toHaveBeenCalledWith( + { projectId: PROJECT, agentId: 'agent-1', source: 'user' }, + 'push-ref-1', + ); + }); + + it("leaves the refresh to the caller when it runs in the caller's transaction", async () => { + const baseSkillHash = givenLatest(versionRow(skill, 2)); + skills.findFollowingAgentIds.mockResolvedValue(['agent-1']); + + const result = await service.save( + 'skill_a', + { baseSkillHash, instructions: 'new' }, + user.id, + TX_CTX, + ); + + expect(result.followingAgentIds).toEqual(['agent-1']); + expect(clearRuntimes).not.toHaveBeenCalled(); + expect(broadcaster.notify).not.toHaveBeenCalled(); }); it('rejects a rename that clashes on a following agent', async () => { @@ -452,6 +486,33 @@ describe('SkillService', () => { }); }); + describe('changeTarget', () => { + it('moves a skill that only the given agent uses, under the edit lock', async () => { + skills.findUsingAgentIds.mockResolvedValue(['agent-1']); + + await service.changeTarget('skill_a', { projectId: null, agentId: 'agent-1' }, 'agent-1'); + + expect(skills.lockForEdit).toHaveBeenCalledWith(['skill_a'], TX_CTX); + expect(skills.lockForEdit.mock.invocationCallOrder[0]).toBeLessThan( + skills.findUsingAgentIds.mock.invocationCallOrder[0], + ); + expect(skills.updateTarget).toHaveBeenCalledWith( + 'skill_a', + { projectId: null, agentId: 'agent-1' }, + TX_CTX, + ); + }); + + it('refuses to move a skill that another agent uses', async () => { + skills.findUsingAgentIds.mockResolvedValue(['agent-1', 'agent-2']); + + await expect( + service.changeTarget('skill_a', { projectId: null, agentId: null }, 'agent-1'), + ).rejects.toThrow('Other agents use this skill.'); + expect(skills.updateTarget).not.toHaveBeenCalled(); + }); + }); + describe('publish', () => { it('locks the skills and pins the version each ref resolves to', async () => { const a = skillRow('skill_a'); @@ -561,6 +622,37 @@ describe('SkillService', () => { expect(refs).toEqual([{ id: 'skill_old' }, { id: 'skill_new' }]); }); + it('holds a shared lock on newly attached skills in the caller transaction', async () => { + givenSkills([ + { skill: skillRow('skill_old', { projectId: PROJECT }), name: 'Pricing' }, + { skill: skillRow('skill_new'), name: 'Tone' }, + ]); + + await service.checkRefs(agent, [{ id: 'skill_old' }, { id: 'skill_new' }], TX_CTX); + + expect(skills.lockForPublish).toHaveBeenCalledWith(['skill_new'], TX_CTX); + }); + + it('compares the name of the version a pinned ref runs, not the latest name', async () => { + const old = skillRow('skill_old', { projectId: PROJECT }); + givenAgentRefs([{ skillId: 'skill_old', versionId: 'skill_old-v1' }]); + givenSkills([ + { skill: old, name: 'Tone' }, + { skill: skillRow('skill_new'), name: 'tone' }, + ]); + // The agent runs v1, which is still called "Pricing". + skills.findVersionsByIds.mockResolvedValue( + new Map([['skill_old-v1', versionRow(old, 1, { name: 'Pricing' })]]), + ); + + await expect( + service.checkRefs(agent, [ + { id: 'skill_old', versionId: 'skill_old-v1' }, + { id: 'skill_new' }, + ]), + ).resolves.toEqual([{ id: 'skill_old', versionId: 'skill_old-v1' }, { id: 'skill_new' }]); + }); + it('drops a new ref to a missing skill', async () => { givenSkills([{ skill: skillRow('skill_old', { projectId: PROJECT }), name: 'Pricing' }]); diff --git a/packages/cli/src/modules/agents/__tests__/skills-api.service.test.ts b/packages/cli/src/modules/agents/__tests__/skills-api.service.test.ts index af6b7e5b409..d902cc1025d 100644 --- a/packages/cli/src/modules/agents/__tests__/skills-api.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/skills-api.service.test.ts @@ -410,6 +410,7 @@ describe('SkillsApiService', () => { version: 3, created: true, skill: { name: 'Brand voice', description: 'd', instructions: 'New.' }, + followingAgentIds: ['agent-1'], }; it('saves the update for a user who may edit the skill', async () => { @@ -427,9 +428,10 @@ describe('SkillsApiService', () => { user.id, ); expect(skillService.canAccess).toHaveBeenCalledWith(user, visible[0], 'update'); + const { followingAgentIds: _following, ...response } = saved; expect(result).toEqual({ id: 'skill_a', - ...saved, + ...response, skillHash: getAgentSkillHash(saved.skill), }); }); diff --git a/packages/cli/src/modules/agents/__tests__/skills-hub-rollout.test.ts b/packages/cli/src/modules/agents/__tests__/skills-hub-rollout.test.ts new file mode 100644 index 00000000000..19f72720758 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/skills-hub-rollout.test.ts @@ -0,0 +1,45 @@ +import { SKILLS_FLAG } from '@n8n/api-types'; +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'vitest-mock-extended'; + +import type { PostHogClient } from '@/posthog'; + +import { SkillsHubRollout } from '../skills/skills-hub-rollout'; + +describe('SkillsHubRollout', () => { + const postHog = mock(); + let rollout: SkillsHubRollout; + + beforeEach(() => { + vi.resetAllMocks(); + rollout = new SkillsHubRollout(postHog, mock()); + }); + + it('is off until the flag is read', () => { + expect(rollout.isEnabled).toBe(false); + }); + + it('reads the flag for the instance once and keeps the value', async () => { + postHog.getFeatureFlagForInstance.mockResolvedValue(true); + + await rollout.init(); + postHog.getFeatureFlagForInstance.mockResolvedValue(false); + + expect(rollout.isEnabled).toBe(true); + expect(postHog.getFeatureFlagForInstance).toHaveBeenCalledTimes(1); + expect(postHog.getFeatureFlagForInstance).toHaveBeenCalledWith(SKILLS_FLAG); + expect(postHog.getFeatureFlags).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing', undefined], + ['false', false], + ['a variant string', 'test'], + ])('stays off when the flag is %s', async (_label, value) => { + postHog.getFeatureFlagForInstance.mockResolvedValue(value); + + await rollout.init(); + + expect(rollout.isEnabled).toBe(false); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/test-utils/skills-hub-flag.ts b/packages/cli/src/modules/agents/__tests__/test-utils/skills-hub-flag.ts new file mode 100644 index 00000000000..b31cf7ae25b --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/test-utils/skills-hub-flag.ts @@ -0,0 +1,14 @@ +import { Container } from '@n8n/di'; + +import { PostHogClient } from '@/posthog'; + +import { SkillsHubRollout } from '../../skills/skills-hub-rollout'; + +/** + * Sets the `125_context_skills` flag for the instance and reads it again, as a restart + * does. Tests that need the skills hub on or off call it instead of a config value. + */ +export async function setSkillsHubFlag(enabled: boolean): Promise { + vi.spyOn(Container.get(PostHogClient), 'getFeatureFlagForInstance').mockResolvedValue(enabled); + await Container.get(SkillsHubRollout).init(); +} diff --git a/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts b/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts index 01c2c551481..c6253de24b5 100644 --- a/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts +++ b/packages/cli/src/modules/agents/__tests__/test-utils/stored-agent-config.ts @@ -1,27 +1,125 @@ -import type { AgentJsonConfig, StoredAgentConfig } from '@n8n/api-types'; +import type { + AgentJsonConfig, + AgentJsonSkillConfig, + AgentSkill, + StoredAgentConfig, +} from '@n8n/api-types'; import { mock } from 'vitest-mock-extended'; -import { AgentSkillRefsService } from '../../agent-skill-refs.service'; +import type { AgentSkillRefsService } from '../../agent-skill-refs.service'; import type { AgentHistory } from '../../entities/agent-history.entity'; import type { Agent } from '../../entities/agent.entity'; import { composeJsonConfig } from '../../json-config/agent-config-composition'; -import type { AgentSkillRefs } from '../../json-config/agent-document'; -import type { AgentRepository } from '../../repositories/agent.repository'; +import { withoutLegacySkillRefs, type AgentSkillRefs } from '../../json-config/agent-document'; import type { AgentRunSource } from '../../utils/agent-published-snapshot'; /** - * A JSON document as the agent `schema` column stores it: the skill refs stay - * in the `skills` key, where the skill refs seam reads them. + * A JSON document as a stored config fixture. The fake skill refs seam of + * `createAgentSkillRefsService` reads the `skills` key of the fixture as the + * skill refs, so a caller test declares the refs in one place. The real seam + * reads the refs from the skills hub tables. */ export function storedAgentConfig(document: AgentJsonConfig): StoredAgentConfig { return document as unknown as StoredAgentConfig; } -/** The skill refs seam over a mocked repository. */ +export type FakeAgentSkillRefsService = ReturnType> & { + /** Skill bodies of the fake skills hub, keyed by skill id. */ + bodies: Record; + /** Draft refs written through the fake seam, keyed by agent id. */ + draftRefs: Map; + /** + * The skill version each published agent version pins, keyed by agent version id and + * then skill id. A revert pins the restored refs to these versions, as the real seam does. + */ + versionPins: Map>; +}; + +/** + * An in-memory skill refs seam for tests of its callers. A draft without + * written refs reads the refs of its `storedAgentConfig` fixture. Skill bodies + * come from `bodies`. + */ export function createAgentSkillRefsService( - agentRepository: AgentRepository = mock(), -): AgentSkillRefsService { - return new AgentSkillRefsService(agentRepository); + bodies: Record = {}, +): FakeAgentSkillRefsService { + const draftRefs = new Map(); + const versionPins = new Map>(); + const service = Object.assign(mock(), { + bodies, + draftRefs, + versionPins, + usesSkillsHub: true, + }); + const readDraftRefs = (agent: Pick): AgentJsonSkillConfig[] => + draftRefs.get(agent.id) ?? storedSkillRefs(agent); + const pickBodies = (refs: AgentSkillRefs) => + Object.fromEntries( + (refs ?? []).flatMap((ref) => + Object.hasOwn(service.bodies, ref.id) ? [[ref.id, service.bodies[ref.id]]] : [], + ), + ); + const writeRefs = (agent: Pick, refs: AgentSkillRefs) => { + draftRefs.set(agent.id, refs ?? []); + }; + + service.refsForDraft.mockImplementation(async (agent) => readDraftRefs(agent)); + const pinnedRefs = (versionId: string, refs: AgentJsonSkillConfig[]) => { + const pins = versionPins.get(versionId) ?? {}; + return refs.map(({ versionId: _dropped, ...ref }) => + Object.hasOwn(pins, ref.id) ? { ...ref, versionId: pins[ref.id] } : ref, + ); + }; + + // A published version's refs carry no `versionId`: its pins live in their own table. + service.refsForVersion.mockImplementation(async (version) => storedSkillRefs(version)); + service.readDraft.mockImplementation(async (agent) => { + const skillRefs = readDraftRefs(agent); + return { skillRefs, skills: pickBodies(skillRefs) }; + }); + service.readVersion.mockImplementation(async (version) => { + const skillRefs = storedSkillRefs(version); + return { skillRefs, skills: pickBodies(skillRefs) }; + }); + service.skillsForRefs.mockImplementation(async (_agent, refs) => pickBodies(refs)); + service.effectiveDraftRefs.mockImplementation(async (_agent, refs) => refs); + service.replaceDraftRefs.mockImplementation(async (agent, refs) => writeRefs(agent, refs)); + service.restoreDraftRefs.mockImplementation(async (agent, version, refs) => + writeRefs(agent, pinnedRefs(version.versionId, refs ?? [])), + ); + // A ref to a skill of the fake hub stays. A ref with only a body needs a creator, as + // in the real seam; the fake keeps its id instead of minting a new one. + service.createDraftRefs.mockImplementation(async (agent, refs, _bodies, creator) => { + writeRefs( + agent, + (refs ?? []).filter((ref) => Object.hasOwn(service.bodies, ref.id) || creator !== null), + ); + return readDraftRefs(agent); + }); + service.unpinDraftRef.mockImplementation(async (agent, skillId) => + writeRefs( + agent, + readDraftRefs(agent).map((ref) => + ref.id === skillId ? { type: ref.type, id: ref.id } : ref, + ), + ), + ); + // As the real seam: the legacy columns get the refs and bodies the draft runs. + service.versionSnapshot.mockImplementation(async (agent) => { + if (!agent.schema) return { schema: null, skills: null, pins: new Map() }; + const skillRefs = readDraftRefs(agent); + return { + schema: { + ...withoutLegacySkillRefs(agent.schema), + skills: skillRefs.map(({ id }) => ({ type: 'skill', id })), + } as unknown as StoredAgentConfig, + skills: pickBodies(skillRefs), + pins: new Map(skillRefs.map((ref) => [ref.id, ref.versionId ?? `${ref.id}-latest`])), + }; + }); + service.pinVersion.mockResolvedValue(undefined); + service.findOutdatedSkillIds.mockResolvedValue([]); + return service; } /** Fixture overrides, with `schema` given as a JSON document. */ @@ -33,20 +131,30 @@ export type AgentFixtureOverrides = WithDocumentSchema; export type AgentHistoryFixtureOverrides = WithDocumentSchema; -/** The skill refs that a `storedAgentConfig` fixture holds. */ -export function storedSkillRefs(agent: Pick): AgentSkillRefs { - const document = agent.schema as unknown as AgentJsonConfig | null; - return document?.skills; +/** The skill refs that a `storedAgentConfig` fixture holds, as the seam reads them: a list. */ +export function storedSkillRefs(source: object): AgentJsonSkillConfig[] { + if (!('schema' in source)) return []; + const document = source.schema as AgentJsonConfig | null; + return document?.skills ?? []; } -/** `composeJsonConfig` with the skill refs that a `storedAgentConfig` fixture holds. */ +/** + * `composeJsonConfig` with the skill refs that a `storedAgentConfig` fixture + * holds. Like the skill refs seam, a fixture without refs gives an empty list. + */ export function composeStoredJsonConfig( agent: Pick, ): AgentJsonConfig | null { return composeJsonConfig(agent, storedSkillRefs(agent)); } -/** A run source for an agent fixture, with the skill refs that its stored config holds. */ -export function storedRunSource(agent: Agent): AgentRunSource { - return { agent, skillRefs: storedSkillRefs(agent) }; +/** + * A run source for an agent fixture, with the skill refs that its stored + * config holds and the given skill bodies. + */ +export function storedRunSource( + agent: Agent, + skills: Record = {}, +): AgentRunSource { + return { agent, skillRefs: storedSkillRefs(agent), skills }; } diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index 6f3b6bb7070..49be52d14f4 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -22,7 +22,6 @@ import { AgentPolicyService } from './agent-policy.service'; import { AgentSaveCompletionService } from './agent-save-completion.service'; import { AgentSetupCompletionService } from './agent-setup-completion.service'; import { AgentSkillRefsService } from './agent-skill-refs.service'; -import { AgentSkillsService } from './agent-skills.service'; import type { Agent } from './entities/agent.entity'; import { syncAgentIntegrations } from './integrations/integrations-sync'; import { composeJsonConfig, decomposeJsonConfig } from './json-config/agent-config-composition'; @@ -63,7 +62,6 @@ export class AgentConfigService { private readonly logger: Logger, private readonly agentRepository: AgentRepository, private readonly agentTaskRepository: AgentTaskRepository, - private readonly agentSkillsService: AgentSkillsService, private readonly configPreparation: AgentConfigPreparationService, private readonly setupCompletionService: AgentSetupCompletionService, private readonly transactionRunner: TransactionRunner, @@ -144,6 +142,14 @@ export class AgentConfigService { config, clearOmitted, ); + if (replacement.nextSchema.skills !== undefined) { + // Validate, store and report the refs that are saved, not the submitted ones. + replacement.nextSchema.skills = await this.agentSkillRefs.effectiveDraftRefs( + entity, + replacement.nextSchema.skills, + {}, + ); + } await this.agentPolicyService.enforceSave( projectId, agentId, @@ -260,8 +266,13 @@ export class AgentConfigService { if (writesField(config, 'tools', clearOmitted)) { this.removeUnreferencedCustomTools(entity, config); } - if (writesField(config, 'skills', clearOmitted)) { - this.agentSkillsService.removeUnreferencedSkills(entity, config); + // With the hub off, bodies live on the agent row: drop the ones no ref keeps, as on + // master. With the hub on, a detached skill stays in the hub. + if (!this.agentSkillRefs.usesSkillsHub && writesField(config, 'skills', clearOmitted)) { + const referenced = new Set((config.skills ?? []).map((ref) => ref.id)); + entity.skills = Object.fromEntries( + Object.entries(entity.skills ?? {}).filter(([id]) => referenced.has(id)), + ); } } @@ -359,7 +370,6 @@ export class AgentConfigService { : []; pruneMissingConfigReferences(config, previousSchema, { tools: entity.tools ?? {}, - skills: entity.skills ?? {}, taskIds: new Set(existingTaskIds), }); const resolvedSubAgents = await this.reconcileSubAgentReferences(config, entity); diff --git a/packages/cli/src/modules/agents/agent-definition.service.ts b/packages/cli/src/modules/agents/agent-definition.service.ts index 2f92766fd96..fa4e3aa146d 100644 --- a/packages/cli/src/modules/agents/agent-definition.service.ts +++ b/packages/cli/src/modules/agents/agent-definition.service.ts @@ -40,29 +40,34 @@ export class AgentDefinitionService { const skillRefs = pending ? pending.skillRefs : await this.agentSkillRefs.refsForDraft(agent, ctx); + const skills = await this.agentSkillRefs.skillsForRefs(agent, skillRefs, ctx); return { - ...getAgentDefinitionContent(agent, skillRefs), + ...getAgentDefinitionContent(agent, skillRefs, skills), tasks: new Map(tasks.map((task) => [task.id, getAgentTaskBody(task)])), }; } async readVersion(version: AgentHistory, ctx: OperationContext = {}): Promise { const tasks = await this.taskSnapshotRepository.findByVersionId(version.versionId, ctx); - const skillRefs = await this.agentSkillRefs.refsForVersion(version, ctx); + const { skillRefs, skills } = await this.agentSkillRefs.readVersion(version, ctx); return { - ...getAgentDefinitionContent(version, skillRefs), + ...getAgentDefinitionContent(version, skillRefs, skills), tasks: new Map(tasks.map((task) => [task.taskId, getAgentTaskBody(task)])), }; } /** * Replace the draft atomically. Return whether task bodies changed. - * The caller sets the version and integrations. + * The caller sets the version and integrations. The skill bodies of the + * definition are not written: skills live in the skills hub. With + * `skillPinsFrom`, each skill ref is pinned to the version that this + * published version ran (revert). */ async replaceDraft( agent: Agent, definition: AgentDefinition, ctx: OperationContext = {}, + skillPinsFrom?: Pick, ): Promise { // Preserve workflow input names in the JSON configuration. const document = structuredClone(definition.schema); @@ -72,10 +77,13 @@ export class AgentDefinitionService { return await this.transactionRunner.run(ctx, async (txCtx) => { agent.schema = config; agent.tools = deepCopy(definition.tools); - agent.skills = deepCopy(definition.skills); if (agent.schema) agent.name = agent.schema.name; await saveAgentDraftFenced(this.agentRepository, agent, txCtx); - await this.agentSkillRefs.replaceDraftRefs(agent, skillRefs, txCtx); + if (skillPinsFrom) { + await this.agentSkillRefs.restoreDraftRefs(agent, skillPinsFrom, skillRefs, txCtx); + } else { + await this.agentSkillRefs.replaceDraftRefs(agent, skillRefs, txCtx); + } return await this.taskRepository.replaceForAgent(agent.id, definition.tasks, txCtx); }); } diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index 1ddf720d4d2..d5dce3b726d 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -4,7 +4,6 @@ import { isDraftIntegration, type AgentConfigValidationResponse, type AgentJsonConfig, - type AgentSkill, type AgentVersionListItemDto, type StoredAgentConfig, } from '@n8n/api-types'; @@ -19,12 +18,11 @@ import { import { Container, Service } from '@n8n/di'; import { TELEMETRY_EVENT } from '@n8n/telemetry'; import isEqual from 'lodash/isEqual'; -import { deepCopy, UserError } from 'n8n-workflow'; +import { UserError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { CredentialsService } from '@/credentials/credentials.service'; import { ConflictError, NotFoundError } from '@n8n/errors'; -import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids'; import { Telemetry } from '@/telemetry'; import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; @@ -48,7 +46,7 @@ import type { AgentHistory } from './entities/agent-history.entity'; import { AgentTask } from './entities/agent-task.entity'; import type { Agent } from './entities/agent.entity'; import { ChatIntegrationService } from './integrations/chat-integration.service'; -import { toAgentDocument, type AgentSkillRefs } from './json-config/agent-document'; +import { toAgentDocument } from './json-config/agent-document'; import { AgentHistoryRepository } from './repositories/agent-history.repository'; import { AgentTaskSnapshotRepository } from './repositories/agent-task-snapshot.repository'; import { AgentTaskRepository } from './repositories/agent-task.repository'; @@ -442,20 +440,28 @@ export class AgentPublishService { ): Promise { await this.enforceRevertPolicy(agent, context.user, version.schema); - const previousSchema = agent.schema - ? toAgentDocument(agent.schema, await this.agentSkillRefs.refsForDraft(agent, {})) - : null; + const previousSkillRefs = await this.agentSkillRefs.refsForDraft(agent, {}); + // With the hub on, the refs carry their pins. With it off, a revert can keep the ids + // and change the bodies, so compare the bodies too. + const previousSkills = structuredClone(agent.skills ?? {}); + const previousSchema = agent.schema ? toAgentDocument(agent.schema, previousSkillRefs) : null; const previousTools = agent.tools ?? {}; - const previousSkills = agent.skills ?? {}; - const { tasksChanged, nextSchema } = await this.transactionRunner.run({}, async (ctx) => { - const definition = await this.definitionService.readVersion(version, ctx); - definition.schema = draftSchemaFromVersion(definition.schema); - agent.versionId = nextVersionId; - return { - tasksChanged: await this.definitionService.replaceDraft(agent, definition, ctx), - nextSchema: definition.schema, - }; - }); + const { tasksChanged, nextSchema, nextSkillRefs } = await this.transactionRunner.run( + {}, + async (ctx) => { + const definition = await this.definitionService.readVersion(version, ctx); + definition.schema = draftSchemaFromVersion(definition.schema); + agent.versionId = nextVersionId; + // Each skill ref is pinned to the version that the published version ran. + // No skill row is written, so this needs no skill permission. + const changed = await this.definitionService.replaceDraft(agent, definition, ctx, version); + return { + tasksChanged: changed, + nextSchema: definition.schema, + nextSkillRefs: await this.agentSkillRefs.refsForDraft(agent, ctx), + }; + }, + ); const integrations = agent.integrations ?? []; await this.saveCompletion.configurationSaved( { @@ -466,7 +472,9 @@ export class AgentPublishService { by: context.modifiedBy, changedParts: diffAgentConfigParts(previousSchema, nextSchema, integrations, integrations, { tools: !isEqual(previousTools, agent.tools), - skills: !isEqual(previousSkills, agent.skills), + skills: + !isEqual(previousSkillRefs, nextSkillRefs) || + (!this.agentSkillRefs.usesSkillsHub && !isEqual(previousSkills, agent.skills ?? {})), tasks: tasksChanged, }), wasUnconfigured: false, @@ -566,27 +574,6 @@ export class AgentPublishService { ); } - private pickConfiguredSkillBodies( - config: StoredAgentConfig | null, - skillRefs: AgentSkillRefs, - skills: Record, - ): Record | null { - if (!config) return null; - - const missing = getMissingSkillIds(skillRefs, skills); - if (missing.length > 0) { - throw new UserError(`Cannot publish agent with missing skill bodies: ${missing.join(', ')}`); - } - - const snapshot: Record = {}; - for (const ref of skillRefs ?? []) { - const skill = skills[ref.id]; - if (skill) snapshot[ref.id] = deepCopy(skill); - } - - return snapshot; - } - private async readPublishTargetDocument( agent: Agent, targetHistory: AgentHistory | undefined, @@ -711,27 +698,30 @@ export class AgentPublishService { user: User, versionId: string, ) { - const skillRefs = await this.agentSkillRefs.refsForDraft(agent, ctx); try { - return await this.agentHistoryRepository.saveVersion( + const snapshot = await this.agentSkillRefs.versionSnapshot(agent, ctx); + const { schema, skills } = snapshot; + const history = await this.agentHistoryRepository.saveVersion( { versionId, agentId: agent.id, - // The stored config carries the skill refs of the draft into the version. - schema: agent.schema + schema: schema ? { - ...agent.schema, + ...schema, integrations: (agent.integrations ?? []).filter( (integration) => integration.type === N8N_CHAT_INTEGRATION_TYPE, ), } : null, tools: this.customToolsService.snapshotConfiguredTools(agent.schema, agent.tools ?? {}), - skills: this.pickConfiguredSkillBodies(agent.schema, skillRefs, agent.skills ?? {}), + skills, publishedBy: user, }, ctx, ); + // The skill pins need the `agent_history` row. + await this.agentSkillRefs.pinVersion(versionId, snapshot, ctx); + return history; } catch (error) { // Concurrent publishes of one draft can collide before they reach the revision fence. if (isUniqueConstraintError(error)) { diff --git a/packages/cli/src/modules/agents/agent-runnable-state.service.ts b/packages/cli/src/modules/agents/agent-runnable-state.service.ts index 74962cbc7c3..4376ded6e7c 100644 --- a/packages/cli/src/modules/agents/agent-runnable-state.service.ts +++ b/packages/cli/src/modules/agents/agent-runnable-state.service.ts @@ -1,3 +1,4 @@ +import type { AgentSkill } from '@n8n/api-types'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -42,23 +43,28 @@ export class AgentRunnableStateService { isRunnable: boolean; hasPublishHistory: boolean; configHash: string | null; + skills: Record; skillHashes: Record; + outdatedSkillIds: string[]; } > { + // The skill bodies the draft runs, from the skills hub, keyed by skill id. + const { skillRefs, skills } = await this.agentSkillRefs.readDraft(agent, {}); // Base hashes for the optimistic-concurrency checks on config and skill writes. - const configHash = getAgentConfigHash( - composeJsonConfig(agent, await this.agentSkillRefs.refsForDraft(agent, {})), - ); + const configHash = getAgentConfigHash(composeJsonConfig(agent, skillRefs)); const skillHashes = Object.fromEntries( - Object.entries(agent.skills ?? {}).map(([id, skill]) => [id, getAgentSkillHash(skill)]), + Object.entries(skills).map(([id, skill]) => [id, getAgentSkillHash(skill)]), ); + const outdatedSkillIds = await this.agentSkillRefs.findOutdatedSkillIds(skillRefs, skills, {}); if (draftValidation) { const hasPublishHistory = await this.agentPublishService.hasPublishHistory(agent.id); return Object.assign(agent, { isRunnable: true, hasPublishHistory, configHash, + skills, skillHashes, + outdatedSkillIds, }); } @@ -82,7 +88,9 @@ export class AgentRunnableStateService { isRunnable: validation.status === 'valid', hasPublishHistory, configHash, + skills, skillHashes, + outdatedSkillIds, }); } } diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index 29f9d2c3c21..41e9a4dfd8f 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -294,7 +294,7 @@ export class AgentRuntimeReconstructionService { ) {} async reconstructFromAgentEntity( - { agent: agentEntity, skillRefs }: AgentRunSource, + { agent: agentEntity, skillRefs, skills }: AgentRunSource, credentialProvider: CredentialProvider, runType: AgentRunTelemetryType, integrationType?: string, @@ -338,7 +338,7 @@ export class AgentRuntimeReconstructionService { unavailableTools = filtered.unavailable; } - const { toolDescriptors, toolCodeByName } = getAgentRuntimeAssets(agentEntity); + const { toolDescriptors, toolCodeByName } = getAgentRuntimeAssets(agentEntity, skills); const subAgentDelegation = await this.createSubAgentDelegationConfig( config, agentEntity.projectId, @@ -351,7 +351,7 @@ export class AgentRuntimeReconstructionService { credentialProvider, toolDescriptors, toolCodeByName, - skills: agentEntity.skills ?? {}, + skills, runtimeProfile: 'top-level', supportsHitl, runType, diff --git a/packages/cli/src/modules/agents/agent-skill-refs.service.ts b/packages/cli/src/modules/agents/agent-skill-refs.service.ts index 5c4233f3877..4b5a1f72777 100644 --- a/packages/cli/src/modules/agents/agent-skill-refs.service.ts +++ b/packages/cli/src/modules/agents/agent-skill-refs.service.ts @@ -1,76 +1,518 @@ import type { AgentJsonSkillConfig, - OpaqueStoredSkillRefs, + AgentSkill, + AgentSkillAvailability, StoredAgentConfig, } from '@n8n/api-types'; -import type { OperationContext } from '@n8n/db'; +import type { OperationContext, User } from '@n8n/db'; import { Service } from '@n8n/di'; -import { UnexpectedError } from 'n8n-workflow'; +import { deepCopy, UnexpectedError, UserError } from 'n8n-workflow'; import type { AgentHistory } from './entities/agent-history.entity'; import type { Agent } from './entities/agent.entity'; -import type { AgentSkillRefs } from './json-config/agent-document'; +import { withoutLegacySkillRefs, type AgentSkillRefs } from './json-config/agent-document'; import { AgentRepository } from './repositories/agent.repository'; +import { SkillRepository } from './repositories/skill.repository'; +import { SkillService, toAgentSkill, type SkillRef } from './skills/skill.service'; +import { SkillsHubRollout } from './skills/skills-hub-rollout'; +import { getAgentSkillHash } from './utils/agent-config-hash'; +import { getMissingSkillIds } from './utils/agent-missing-skill-ids'; +import { generateAgentResourceId } from './utils/agent-resource-id'; + +/** The `agent_history` skill columns of one publish, and the hub versions to pin. */ +export type PublishSnapshot = { + schema: StoredAgentConfig | null; + skills: Record | null; + /** Hub skill version id for each skill ref, in draft order. Empty with the hub off. */ + pins: Map; +}; + +/** Skill refs and the skill bodies they resolve to, keyed by skill id. */ +export interface AgentSkillsSnapshot { + skillRefs: AgentJsonSkillConfig[]; + skills: Record; +} /** * The one seam for the skill refs of an agent draft and of a published agent - * version. All other code reads and writes skill refs through this service. + * version, and for the skill bodies they resolve to. All other code reads and + * writes skill refs through this service. * - * The refs are kept in the legacy `skills` key of the stored `schema` JSON - * (`agents.schema`, `agent_history.schema`). Only this file reads or writes - * that key. + * With the skills hub on (the `125_context_skills` flag), the refs of a draft are the + * `agent_skill_dependency` rows and the refs of a published version are the + * `agent_version_skill_dependency` pins, in attach order. The skill bodies come + * from the skills hub. + * + * With the skills hub off, the refs are the `skills` key of the stored `schema` + * and the bodies are the `skills` column, as before the skills hub. A ref with + * `enabled: false` is hidden. Its body stays in the `skills` column. */ @Service() export class AgentSkillRefsService { - constructor(private readonly agentRepository: AgentRepository) {} + constructor( + private readonly skillRepository: SkillRepository, + private readonly skillService: SkillService, + private readonly agentRepository: AgentRepository, + private readonly rollout: SkillsHubRollout, + ) {} - /** Skill refs of the agent draft, in document order. */ - async refsForDraft( - agent: Pick, - _ctx: OperationContext, - ): Promise { - return readLegacySkillRefs(agent.schema); + get usesSkillsHub(): boolean { + return this.rollout.isEnabled; } - /** Skill refs of one published agent version, in document order. */ + /** Skill refs of the agent draft, in attach order. A pinned ref has `versionId`. */ + async refsForDraft( + agent: Pick, + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) return readLegacyRefs(agent.schema); + const rows = (await this.skillRepository.findDependencies([agent.id], ctx)).get(agent.id) ?? []; + return toSkillRefs( + rows.map(({ skillId, versionId }) => ({ id: skillId, ...(versionId ? { versionId } : {}) })), + ); + } + + /** + * Skill refs of one published agent version, in the order of the draft at + * publish. Every ref of a version is pinned, so the refs carry no `versionId`. + */ async refsForVersion( - version: Pick, - _ctx: OperationContext, - ): Promise { - return readLegacySkillRefs(version.schema); + version: Pick, + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) return readLegacyRefs(version.schema); + const skillIds = await this.skillRepository.findPinnedSkillIds(version.versionId, ctx); + return toSkillRefs(skillIds.map((id) => ({ id }))); + } + + /** The refs of the agent draft and the skill bodies the draft runs. */ + async readDraft( + agent: Pick, + ctx: OperationContext, + ): Promise { + const skillRefs = await this.refsForDraft(agent, ctx); + return { skillRefs, skills: await this.skillsForRefs(agent, skillRefs, ctx) }; + } + + /** The refs of one published agent version and the pinned skill bodies. */ + async readVersion( + version: Pick, + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) { + const skillRefs = readLegacyRefs(version.schema); + return { skillRefs, skills: pickBodies(version.skills ?? {}, skillRefs) }; + } + const pinned = await this.skillRepository.findPinned(version.versionId, ctx); + const skillRefs = toSkillRefs([...pinned.keys()].map((id) => ({ id }))); + const skills = Object.fromEntries( + [...pinned].map(([skillId, row]) => [skillId, toAgentSkill(row)]), + ); + return { skillRefs, skills }; + } + + /** + * The skill bodies that these draft refs run, keyed by skill id: the pinned + * version of a ref with `versionId`, else the latest saved version. + */ + async skillsForRefs( + agent: Pick, + skillRefs: AgentSkillRefs, + ctx: OperationContext, + ): Promise> { + if (!skillRefs?.length) return {}; + if (!this.usesSkillsHub) return pickBodies(agent.skills ?? {}, skillRefs); + return await this.skillService.resolveForAgentDraft(toServiceRefs(skillRefs), ctx); + } + + async findOutdatedSkillIds( + skillRefs: AgentJsonSkillConfig[], + skills: Record, + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) return []; + const pinnedSkillIds = skillRefs.flatMap((ref) => (ref.versionId ? [ref.id] : [])); + const latestSkills = await this.skillsForRefs( + { skills: {} }, + pinnedSkillIds.map((id) => ({ type: 'skill', id })), + ctx, + ); + return pinnedSkillIds.filter((id) => { + const latest = latestSkills[id]; + const pinned = skills[id]; + return ( + latest !== undefined && + pinned !== undefined && + getAgentSkillHash(latest) !== getAgentSkillHash(pinned) + ); + }); + } + + /** + * The refs that `replaceDraftRefs` would keep, without writing them: unknown ids + * are dropped, and a new ref that the agent may not attach throws. Callers use it + * to validate and report the refs that are saved, not the submitted ones. + */ + async effectiveDraftRefs( + agent: Pick, + refs: AgentJsonSkillConfig[], + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) { + const existingIds = new Set(readLegacyRefs(agent.schema).map((ref) => ref.id)); + return refs.filter( + (ref) => existingIds.has(ref.id) || Object.hasOwn(agent.skills ?? {}, ref.id), + ); + } + const checked = await this.skillService.checkRefs( + { id: agent.id, projectId: agent.projectId }, + toServiceRefs(refs), + ctx, + ); + return toSkillRefs(checked); } /** * Replace the skill refs of the agent draft. Call it in the same transaction * as `saveAgentDraftFenced`, with the same `ctx`, after the fenced save. - * The agent must have a config when `refs` is set. + * A new ref must be attachable to the agent and must not clash by name. A ref + * keeps its pin only when the draft already has that pin. */ - async replaceDraftRefs(agent: Agent, refs: AgentSkillRefs, ctx: OperationContext): Promise { - if (!agent.schema) { - if (refs === undefined) return; + async replaceDraftRefs( + agent: Pick, + refs: AgentSkillRefs, + ctx: OperationContext, + ): Promise { + if (!agent.schema && refs?.length) { throw new UnexpectedError('Cannot set skill refs on an agent without a config'); } - agent.schema = withLegacySkillRefs(agent.schema, refs); - await this.agentRepository.updateDraftSchema(agent, ctx); + if (!this.usesSkillsHub) { + if (!agent.schema) return; + const existingIds = new Set(readLegacyRefs(agent.schema).map((ref) => ref.id)); + const kept = (refs ?? []).filter( + (ref) => existingIds.has(ref.id) || Object.hasOwn(agent.skills ?? {}, ref.id), + ); + await this.writeLegacyDraft(agent, agent.schema, kept, agent.skills ?? {}, ctx); + return; + } + const checked = await this.skillService.checkRefs( + { id: agent.id, projectId: agent.projectId }, + toServiceRefs(refs ?? []), + ctx, + ); + await this.writeDependencies(agent.id, checked, ctx); + } + + /** + * Revert: set the refs of the agent draft to the refs of a published version, + * each pinned to the version that the published version ran. This writes no + * skill row and needs no skill permission. + */ + async restoreDraftRefs( + agent: Pick, + version: Pick, + refs: AgentSkillRefs, + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) { + if (!agent.schema) return; + await this.writeLegacyDraft( + agent, + agent.schema, + refs ?? [], + deepCopy(version.skills ?? {}), + ctx, + ); + return; + } + const restored = await this.skillService.restoreFromVersion( + version.versionId, + toServiceRefs(refs ?? []), + ctx, + ); + await this.writeDependencies(agent.id, restored, ctx); + } + + /** + * Remove the pin of one draft ref, so the draft follows the latest saved + * version of the skill. A save of the skill from this agent calls it. + */ + async unpinDraftRef( + agent: Pick, + skillId: string, + ctx: OperationContext, + ): Promise { + const refs = await this.refsForDraft(agent, ctx); + await this.writeDependencies( + agent.id, + refs.map(({ id, versionId }) => (id === skillId || !versionId ? { id } : { id, versionId })), + ctx, + ); + } + + /** + * Create with a config (duplicate, import, eval seeding). A ref to a hub + * skill stays a ref. A ref to an agent skill of another agent in the same + * project gets a copy. A ref with a body but no hub skill gets a new agent + * skill. Both new skills belong to this agent only, so deleting the agent + * deletes them. A new skill gets a new id, and the ref points to it. A ref + * with neither is dropped, and so is a ref with only a body when no user + * creates the skill. The new skills need no skill scope: they are part of the + * agent, and the caller already checked the agent permission. Returns the refs + * that the draft now has. + */ + async createDraftRefs( + agent: Pick, + refs: AgentSkillRefs, + bodies: Record, + creator: User | null, + ctx: OperationContext, + ): Promise { + if (!refs?.length) return []; + if (!this.usesSkillsHub) { + if (!agent.schema) return []; + const kept = refs.filter((ref) => Object.hasOwn(bodies, ref.id)); + await this.writeLegacyDraft(agent, agent.schema, kept, pickBodies(bodies, kept), ctx); + return readLegacyRefs(agent.schema); + } + const found = new Map( + ( + await this.skillRepository.findByIds( + refs.map((ref) => ref.id), + ctx, + ) + ).map((skill) => [skill.id, skill]), + ); + // Copy only the agent skills of agents in this project (a duplicate). Any other + // agent skill stays a plain ref, so `checkRefs` refuses it without reading its body. + const otherAgents = await this.skillRepository.findAgents( + [...found.values()].flatMap((skill) => + skill.agentId && skill.agentId !== agent.id ? [skill.agentId] : [], + ), + ctx, + ); + const otherAgentSkillIds = refs.flatMap((ref) => { + const skill = found.get(ref.id); + if (!skill?.agentId || skill.agentId === agent.id) return []; + return otherAgents.get(skill.agentId)?.projectId === agent.projectId ? [ref.id] : []; + }); + const otherAgentBodies = + otherAgentSkillIds.length > 0 + ? await this.skillService.resolveForAgentDraft( + otherAgentSkillIds.map((id) => ({ id })), + ctx, + ) + : {}; + const next: AgentJsonSkillConfig[] = []; + for (const ref of refs) { + const skill = found.get(ref.id); + const copiesAgentSkill = otherAgentSkillIds.includes(ref.id); + if (skill && !copiesAgentSkill) { + next.push({ type: 'skill', id: ref.id }); + continue; + } + const source = copiesAgentSkill ? otherAgentBodies : bodies; + const body = Object.hasOwn(source, ref.id) ? source[ref.id] : undefined; + // The new skill belongs to this agent only, so creating the agent is enough. + if (!body || creator === null) continue; + const id = await this.skillService.create( + { + // An inline body or a copy becomes a skill of this agent only, as the startup + // copy does. It takes its project from the agent, and goes with the agent. + target: { projectId: null, agentId: agent.id }, + skill: body, + source: 'agent', + createdById: creator.id, + }, + ctx, + ); + next.push({ type: 'skill', id }); + } + await this.replaceDraftRefs(agent, next, ctx); + return toSkillRefs(next); + } + + async createSkills( + agent: Pick, + skills: AgentSkill[], + creatorId: string, + availableTo: AgentSkillAvailability, + ctx: OperationContext, + ): Promise { + if (!this.usesSkillsHub) { + const bodies = { ...(agent.skills ?? {}) }; + const ids = skills.map((skill) => { + const id = generateAgentResourceId('skill', Object.keys(bodies)); + bodies[id] = skill; + return id; + }); + agent.skills = bodies; + await this.agentRepository.updateDraftSkills(agent, ctx); + return ids; + } + const ids: string[] = []; + for (const skill of skills) { + ids.push( + await this.skillService.create( + { + target: targetFor(agent, availableTo), + skill, + source: 'agent', + createdById: creatorId, + }, + ctx, + ), + ); + } + return ids; + } + + /** + * What a publish writes to `agent_history`. Both settings write the legacy columns in + * full: the skill refs in `schema.skills` and their bodies in `skills`. With the skills + * hub on, the bodies are the versions the draft runs, so a published agent keeps + * running after the flag is turned off. `pins` are the hub versions to pin once the + * `agent_history` row exists (see `pinVersion`); empty with the hub off. Call it in + * the publish transaction: it takes the publish lock on the skills. + */ + async versionSnapshot( + agent: Pick, + ctx: OperationContext, + ): Promise { + if (!agent.schema) return { schema: null, skills: null, pins: new Map() }; + if (!this.usesSkillsHub) { + const skillRefs = readLegacyRefs(agent.schema); + const skills = pickBodies(agent.skills ?? {}, skillRefs); + const missing = getMissingSkillIds(skillRefs, skills); + if (missing.length > 0) { + throw new UserError( + `Cannot publish agent with missing skill bodies: ${missing.join(', ')}`, + ); + } + return { schema: agent.schema, skills: deepCopy(skills), pins: new Map() }; + } + const refs = await this.refsForDraft(agent, ctx); + if (refs.length === 0) { + return { + schema: { + ...withoutLegacySkillRefs(agent.schema), + skills: [], + } as unknown as StoredAgentConfig, + skills: {}, + pins: new Map(), + }; + } + const { skills, versionByRef } = await this.skillService.snapshotForPublish( + toServiceRefs(refs), + ctx, + ); + const missing = refs.filter((ref) => !versionByRef.has(ref.id)).map((ref) => ref.id); + if (missing.length > 0) { + throw new UserError(`Cannot publish agent with missing skills: ${missing.join(', ')}`); + } + const schema = { + ...withoutLegacySkillRefs(agent.schema), + skills: refs.map(({ id }) => ({ type: 'skill', id })), + } as unknown as StoredAgentConfig; + return { schema, skills, pins: versionByRef }; + } + + /** + * Publish: pin each draft ref to the saved version it runs now. Call it in + * the publish transaction, after the `agent_history` row exists. + */ + /** Publish, after the `agent_history` row exists: pin the versions of `versionSnapshot`. */ + async pinVersion( + agentVersionId: string, + snapshot: PublishSnapshot, + ctx: OperationContext, + ): Promise { + if (snapshot.pins.size === 0) return; + await this.skillService.pinForPublish(agentVersionId, snapshot.pins, ctx); + } + + async availabilityOf( + skillId: string, + ctx: OperationContext, + ): Promise { + const [skill] = await this.skillRepository.findByIds([skillId], ctx); + if (!skill) return undefined; + if (skill.agentId) return 'agent'; + return skill.projectId ? 'project' : 'instance'; + } + + async setAvailability( + agent: Pick, + skillId: string, + availableTo: AgentSkillAvailability, + ctx: OperationContext, + ): Promise { + await this.skillService.changeTarget(skillId, targetFor(agent, availableTo), agent.id, ctx); + } + + private async writeLegacyDraft( + agent: Pick, + schema: StoredAgentConfig, + refs: AgentJsonSkillConfig[], + skills: Record, + ctx: OperationContext, + ): Promise { + const { skills: _previous, ...rest } = schema; + agent.schema = { + ...rest, + skills: refs.map(({ id }) => ({ type: 'skill', id })), + } as unknown as StoredAgentConfig; + agent.skills = skills; + await this.agentRepository.updateDraftSkills(agent, ctx); + } + + private async writeDependencies( + agentId: string, + refs: SkillRef[], + ctx: OperationContext, + ): Promise { + await this.skillRepository.replaceDependencies( + agentId, + refs.map((ref) => ({ skillId: ref.id, versionId: ref.versionId ?? null })), + ctx, + ); } } -/** - * The stored refs passed the config schema check when they were written, so - * an array check is sufficient here. - */ -function isLegacySkillRefList( - value: unknown, -): value is AgentJsonSkillConfig[] & OpaqueStoredSkillRefs { - return Array.isArray(value); +function targetFor(agent: Pick, availableTo: AgentSkillAvailability) { + return { + projectId: availableTo === 'project' ? agent.projectId : null, + agentId: availableTo === 'agent' ? agent.id : null, + }; } -function readLegacySkillRefs(schema: StoredAgentConfig | null): AgentSkillRefs { +function readLegacyRefs(schema: StoredAgentConfig | null): AgentJsonSkillConfig[] { const value: unknown = schema?.skills; - return isLegacySkillRefList(value) ? value : undefined; + if (!Array.isArray(value)) return []; + return value.flatMap((ref: { id?: unknown; enabled?: unknown }) => + typeof ref?.id === 'string' && ref.enabled !== false + ? [{ type: 'skill' as const, id: ref.id }] + : [], + ); } -function withLegacySkillRefs(schema: StoredAgentConfig, refs: AgentSkillRefs): StoredAgentConfig { - const { skills: _previous, ...rest } = schema; - return isLegacySkillRefList(refs) ? { ...rest, skills: refs } : rest; +function pickBodies( + bodies: Record, + refs: AgentJsonSkillConfig[], +): Record { + return Object.fromEntries( + refs.flatMap((ref) => (Object.hasOwn(bodies, ref.id) ? [[ref.id, bodies[ref.id]]] : [])), + ); +} + +function toServiceRefs(refs: AgentJsonSkillConfig[]): SkillRef[] { + return refs.map(({ id, versionId }) => (versionId ? { id, versionId } : { id })); +} + +function toSkillRefs(refs: Array<{ id: string; versionId?: string }>): AgentJsonSkillConfig[] { + return refs.map(({ id, versionId }) => ({ + type: 'skill' as const, + id, + ...(versionId ? { versionId } : {}), + })); } diff --git a/packages/cli/src/modules/agents/agent-skills.service.ts b/packages/cli/src/modules/agents/agent-skills.service.ts index 23935e0a786..a8c43d341f3 100644 --- a/packages/cli/src/modules/agents/agent-skills.service.ts +++ b/packages/cli/src/modules/agents/agent-skills.service.ts @@ -1,39 +1,47 @@ import { agentSkillSchema, - type AgentJsonConfig, type AgentJsonSkillConfig, + type AgentSkillAvailability, type AgentSkill, type AgentSkillMutationResponse, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { TransactionRunner } from '@n8n/db'; +import { TransactionRunner, type User } from '@n8n/db'; import { Service } from '@n8n/di'; import isEqual from 'lodash/isEqual'; import { UserError } from 'n8n-workflow'; -import { ConflictError, NotFoundError } from '@n8n/errors'; +import { ConflictError, ForbiddenError, NotFoundError } from '@n8n/errors'; +import { hasGlobalScope } from '@n8n/permissions'; +import { userHasScopes } from '@/permissions.ee/check-access'; import { type AgentMutationTelemetryContext, buildAgentMutationEvent, captureAgentMutation, - type AgentMutationSnapshot, } from './agent-modification-telemetry.service'; import { AgentSaveCompletionService } from './agent-save-completion.service'; import { AgentSkillRefsService } from './agent-skill-refs.service'; -import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils'; -import { Agent } from './entities/agent.entity'; -import { AgentRepository } from './repositories/agent.repository'; -import { getAgentOrThrow } from './utils/get-agent-or-throw'; -import { getAgentConfigHash, getAgentSkillHash } from './utils/agent-config-hash'; +import type { Agent } from './entities/agent.entity'; import { composeJsonConfig } from './json-config/agent-config-composition'; import type { AgentSkillRefs } from './json-config/agent-document'; -import { generateAgentResourceId } from './utils/agent-resource-id'; +import { AgentRepository } from './repositories/agent.repository'; +import { findIntroducedNameClashes, findNameClashGroups } from './skills/skill-names'; +import { SkillService } from './skills/skill.service'; +import { getAgentConfigHash, getAgentSkillHash } from './utils/agent-config-hash'; +import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils'; +import { getAgentOrThrow } from './utils/get-agent-or-throw'; import { applySkillInstructionEdits, type SkillInstructionEdit, } from './utils/skill-instruction-edits'; +/** + * Skill writes from one agent: the builder, the agent builder tools and MCP. + * The skills live in the skills hub. Create makes a hub skill in the agent's + * project and attaches it. Update saves the next version of the hub skill. + * Delete detaches the skill from the agent and keeps the hub skill. + */ @Service() export class AgentSkillsService { constructor( @@ -41,9 +49,11 @@ export class AgentSkillsService { private readonly agentRepository: AgentRepository, private readonly saveCompletion: AgentSaveCompletionService, private readonly agentSkillRefs: AgentSkillRefsService, + private readonly skillService: SkillService, private readonly transactionRunner: TransactionRunner, ) {} + /** The skills of the agent draft, keyed by skill id: the versions that the draft runs. */ async listSkills(agentId: string, projectId: string): Promise> { const entity = await getAgentOrThrow( this.agentRepository, @@ -51,33 +61,24 @@ export class AgentSkillsService { projectId, 'Agent not found', ); - - return entity.skills ?? {}; + // With the hub off, list every body on the agent row, as on master. + if (!this.agentSkillRefs.usesSkillsHub) return entity.skills ?? {}; + return (await this.agentSkillRefs.readDraft(entity, {})).skills; } async getSkill(agentId: string, projectId: string, skillId: string): Promise { const skills = await this.listSkills(agentId, projectId); - const skill = skills[skillId]; + const skill = Object.hasOwn(skills, skillId) ? skills[skillId] : undefined; if (!skill) throw new NotFoundError('Skill not found'); return skill; } - async createSkill( - agentId: string, - projectId: string, - skill: AgentSkill, - context: AgentMutationTelemetryContext, - ): Promise { - const { results } = await this.createSkillsBatch(agentId, projectId, [skill], false, context); - return results[0]; - } - /** - * Create multiple skill bodies and attach a config ref for each one, in one - * load/save/cache-clear. All-or-nothing: every skill is validated (including - * name uniqueness against existing skills and within the batch) before any - * mutation. + * Create skills in the skills hub and attach them to the agent, in one + * transaction. All-or-nothing: every skill is validated, and its name is + * checked against the agent's skills and the rest of the batch, before any + * write. */ async createAndAttachSkills( agentId: string, @@ -89,7 +90,6 @@ export class AgentSkillsService { agentId, projectId, skills, - true, context, ); return { @@ -103,22 +103,24 @@ export class AgentSkillsService { projectId: string, skill: AgentSkill, context: AgentMutationTelemetryContext, + availableTo: AgentSkillAvailability = 'project', ): Promise { - const { results } = await this.createSkillsBatch(agentId, projectId, [skill], true, context); + const { results } = await this.createSkillsBatch( + agentId, + projectId, + [skill], + context, + availableTo, + ); return results[0]; } - /** - * Shared implementation behind `createSkill`, `createAndAttachSkills`, and - * `createAndAttachSkill`. Rejects an empty batch before touching the - * repository. Saves once and clears the runtime cache once. - */ private async createSkillsBatch( agentId: string, projectId: string, skills: AgentSkill[], - attach: boolean, context: AgentMutationTelemetryContext, + availableTo: AgentSkillAvailability = 'project', ): Promise<{ results: AgentSkillMutationResponse[]; saved: Agent; skillRefs: AgentSkillRefs }> { if (skills.length === 0) { throw new UserError('At least one skill is required.'); @@ -130,48 +132,68 @@ export class AgentSkillsService { projectId, 'Agent not found', ); - if (attach && !entity.schema) throw new UserError('Agent has no JSON config yet.'); + if (!entity.schema) throw new UserError('Agent has no JSON config yet.'); - for (const skill of skills) { - this.validateSkill(skill); - } - this.assertBatchSkillNamesAreUnique(entity.skills ?? {}, skills); - - const previousSkillRefs = await this.agentSkillRefs.refsForDraft(entity, {}); - const previous = captureAgentMutation(entity, previousSkillRefs); - - const results = skills.map((skill) => ({ id: this.addSkill(entity, skill), skill })); - let skillRefs = previousSkillRefs; - if (attach) { - for (const { id } of results) skillRefs = this.attachSkillRef(skillRefs, id); + // With the hub off, skills keep `allowedTools` as on master. Hub skills ignore it. + const bodies = skills.map((skill) => { + const validated = this.validateSkill(skill); + return this.agentSkillRefs.usesSkillsHub ? toStoredSkill(validated) : validated; + }); + if (this.agentSkillRefs.usesSkillsHub) { + await this.assertCanCreateSkills(context.user, entity.projectId, availableTo); } - const saved = await this.saveSkillChanges( - entity, - projectId, - context, - previous, - previousSkillRefs, - attach ? { refs: skillRefs } : undefined, - ); + const previous = await this.agentSkillRefs.readDraft(entity, {}); + this.assertNoNameClashes(previous.skills, bodies); + const snapshot = captureAgentMutation(entity, previous.skillRefs); - this.logger.debug(attach ? 'Created and attached agent skill' : 'Created agent skills', { - agentId, - projectId, - skillIds: results.map((r) => r.id), + markAgentDraftDirty(entity); + const { ids, skillRefs } = await this.transactionRunner.run({}, async (ctx) => { + await saveAgentDraftFenced(this.agentRepository, entity, ctx); + const created = await this.agentSkillRefs.createSkills( + entity, + bodies, + context.user.id, + availableTo, + ctx, + ); + const nextRefs: AgentJsonSkillConfig[] = [ + ...(previous.skillRefs ?? []), + ...created.map((id) => ({ type: 'skill' as const, id })), + ]; + await this.agentSkillRefs.replaceDraftRefs(entity, nextRefs, ctx); + return { + ids: created, + skillRefs: await this.agentSkillRefs.refsForDraft(entity, ctx), + }; }); + await this.saveCompletion.bodySaved( + buildAgentMutationEvent(entity, skillRefs, projectId, context, snapshot, { skills: true }), + context.pushRef, + ); + + this.logger.debug('Created and attached agent skills', { agentId, projectId, skillIds: ids }); + return { - results: results.map((r) => ({ - ...r, - skillHash: getAgentSkillHash(r.skill), - versionId: saved.versionId, + results: ids.map((id, index) => ({ + id, + skill: bodies[index], + skillHash: getAgentSkillHash(bodies[index]), + versionId: entity.versionId, })), - saved, + saved: entity, skillRefs, }; } + /** + * Save the next version of a skill of this agent. The user needs the edit + * permission of the skill itself. Every agent that follows the skill runs + * the new version and shows unpublished changes. A ref of this agent that + * pins the latest version loses its pin, so this agent runs the new version + * too. A ref that pins an older version cannot be edited. + */ async updateSkill( agentId: string, projectId: string, @@ -180,6 +202,7 @@ export class AgentSkillsService { context: AgentMutationTelemetryContext, baseSkillHash?: string, instructionEdits?: SkillInstructionEdit[], + availableTo?: AgentSkillAvailability, ): Promise { const entity = await getAgentOrThrow( this.agentRepository, @@ -188,53 +211,126 @@ export class AgentSkillsService { 'Agent not found', ); - const existing = entity.skills?.[skillId]; + const skillRefs = (await this.agentSkillRefs.refsForDraft(entity, {})) ?? []; + const ref = skillRefs.find((candidate) => candidate.id === skillId); + const existing = !this.agentSkillRefs.usesSkillsHub + ? // With the hub off, any body on the agent row counts, with or without a ref, as on master. + legacyBody(entity, skillId) + : ref && (await this.agentSkillRefs.skillsForRefs(entity, [ref], {}))[skillId]; if (!existing) throw new NotFoundError('Skill not found'); if (baseSkillHash !== undefined && baseSkillHash !== getAgentSkillHash(existing)) { throw new ConflictError('Skill was changed elsewhere; reload to get the latest version'); } - const updated = { ...existing, ...updates }; + // With the hub off, an update still sets or clears `allowedTools`, as on master. + const { allowedTools, ...changes } = updates; + const updated: AgentSkill = { ...existing, ...changes }; + if (!this.agentSkillRefs.usesSkillsHub && 'allowedTools' in updates) { + if (allowedTools?.length) updated.allowedTools = allowedTools; + else delete updated.allowedTools; + } if (instructionEdits?.length) { if (updates.instructions !== undefined) { throw new UserError('Pass either instructions or instructionEdits, not both.'); } updated.instructions = applySkillInstructionEdits(existing.instructions, instructionEdits); } - if ('allowedTools' in updates && !updates.allowedTools?.length) delete updated.allowedTools; if ('references' in updates && !updates.references?.length) delete updated.references; this.validateSkill(updated); - this.assertSkillNameIsUnique(entity.skills ?? {}, updated.name, skillId); + + const nextAvailability = await this.availabilityChange(skillId, availableTo); + if (nextAvailability) { + await this.skillService.assertCanEdit(context.user, [skillId]); + await this.assertCanCreateSkills(context.user, entity.projectId, nextAvailability); + } if (isEqual(existing, updated)) { + if (nextAvailability) { + await this.agentSkillRefs.setAvailability(entity, skillId, nextAvailability, {}); + } return { id: skillId, - skill: updated, - skillHash: getAgentSkillHash(updated), + skill: existing, + skillHash: getAgentSkillHash(existing), versionId: entity.versionId, }; } - const skillRefs = await this.agentSkillRefs.refsForDraft(entity, {}); - const previous = captureAgentMutation(entity, skillRefs); + if (!this.agentSkillRefs.usesSkillsHub) { + return await this.updateLegacySkill(entity, skillRefs, skillId, updated, projectId, context); + } - entity.skills = { - ...(entity.skills ?? {}), - [skillId]: updated, - }; + const pinned = ref?.versionId !== undefined; + if (pinned) { + const latest = ( + await this.agentSkillRefs.skillsForRefs(entity, [{ type: 'skill', id: skillId }], {}) + )[skillId]; + if (!latest || getAgentSkillHash(latest) !== getAgentSkillHash(existing)) { + throw new UserError( + 'This agent uses an older version of this skill. Use the latest version first, then edit the skill. To use the latest version, remove versionId from the skill ref in the agent config.', + ); + } + } + await this.skillService.assertCanEdit(context.user, [skillId]); + const snapshot = captureAgentMutation(entity, skillRefs); - const saved = await this.saveSkillChanges(entity, projectId, context, previous, skillRefs); + if (pinned) markAgentDraftDirty(entity); + const saved = await this.transactionRunner.run({}, async (ctx) => { + if (nextAvailability) { + await this.agentSkillRefs.setAvailability(entity, skillId, nextAvailability, ctx); + } + if (pinned) { + await saveAgentDraftFenced(this.agentRepository, entity, ctx); + await this.agentSkillRefs.unpinDraftRef(entity, skillId, ctx); + } + return await this.skillService.save( + skillId, + { + name: updated.name, + description: updated.description, + instructions: updated.instructions, + references: updated.references ?? [], + baseSkillHash: getAgentSkillHash(existing), + }, + context.user.id, + ctx, + ); + }); - this.logger.debug('Updated agent skill', { agentId, projectId, skillId }); + await this.skillService.refreshFollowingAgents( + saved.followingAgentIds.filter((id) => id !== entity.id), + context.pushRef, + ); + const fresh = (await this.agentRepository.findByIdAndProjectId(entity.id, projectId)) ?? entity; + await this.saveCompletion.bodySaved( + buildAgentMutationEvent( + fresh, + await this.agentSkillRefs.refsForDraft(fresh, {}), + projectId, + context, + snapshot, + { skills: true }, + ), + context.pushRef, + ); + + this.logger.debug('Updated agent skill', { + agentId, + projectId, + skillId, + version: saved.version, + created: saved.created, + }); return { id: skillId, - skill: updated, - skillHash: getAgentSkillHash(updated), - versionId: saved.versionId, + skill: saved.skill, + skillHash: getAgentSkillHash(saved.skill), + versionId: fresh.versionId, }; } + /** Detach a skill from the agent. The skill stays in the skills hub. */ async deleteSkill( agentId: string, projectId: string, @@ -248,132 +344,126 @@ export class AgentSkillsService { 'Agent not found', ); - const skills = { ...(entity.skills ?? {}) }; - if (!skills[skillId]) throw new NotFoundError('Skill not found'); - const previousSkillRefs = await this.agentSkillRefs.refsForDraft(entity, {}); - const previous = captureAgentMutation(entity, previousSkillRefs); - - delete skills[skillId]; - entity.skills = skills; - - // A config without a skill list keeps no skill list after a delete. - await this.saveSkillChanges( - entity, - projectId, - context, - previous, - previousSkillRefs, - previousSkillRefs - ? { refs: previousSkillRefs.filter((ref) => ref.id !== skillId) } - : undefined, - ); - - 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]; + if (!previousSkillRefs?.some((ref) => ref.id === skillId)) { + throw new NotFoundError('Skill not found'); } - entity.skills = skills; + const snapshot = captureAgentMutation(entity, previousSkillRefs); + + markAgentDraftDirty(entity); + const skillRefs = await this.transactionRunner.run({}, async (ctx) => { + await saveAgentDraftFenced(this.agentRepository, entity, ctx); + await this.agentSkillRefs.replaceDraftRefs( + entity, + previousSkillRefs.filter((ref) => ref.id !== skillId), + ctx, + ); + return await this.agentSkillRefs.refsForDraft(entity, ctx); + }); + await this.saveCompletion.bodySaved( + buildAgentMutationEvent(entity, skillRefs, projectId, context, snapshot, { skills: true }), + context.pushRef, + ); + + this.logger.debug('Detached agent skill', { agentId, projectId, skillId }); } - private validateSkill(skill: AgentSkill): void { + private async availabilityChange( + skillId: string, + availableTo: AgentSkillAvailability | undefined, + ): Promise { + if (availableTo === undefined || !this.agentSkillRefs.usesSkillsHub) return undefined; + const current = await this.agentSkillRefs.availabilityOf(skillId, {}); + return current === availableTo ? undefined : availableTo; + } + + private async updateLegacySkill( + entity: Agent, + skillRefs: AgentJsonSkillConfig[], + skillId: string, + updated: AgentSkill, + projectId: string, + context: AgentMutationTelemetryContext, + ): Promise { + // Every body on the agent row counts, with or without a ref, as on master. + const { [skillId]: _self, ...others } = entity.skills ?? {}; + this.assertNoNameClashes(others, [updated]); + const snapshot = captureAgentMutation(entity, skillRefs); + entity.skills = { ...entity.skills, [skillId]: updated }; + markAgentDraftDirty(entity); + await this.transactionRunner.run({}, async (ctx) => { + await saveAgentDraftFenced(this.agentRepository, entity, ctx); + }); + await this.saveCompletion.bodySaved( + buildAgentMutationEvent(entity, skillRefs, projectId, context, snapshot, { skills: true }), + context.pushRef, + ); + this.logger.debug('Updated agent skill', { agentId: entity.id, projectId, skillId }); + return { + id: skillId, + skill: updated, + skillHash: getAgentSkillHash(updated), + versionId: entity.versionId, + }; + } + + private validateSkill(skill: AgentSkill): AgentSkill { const result = agentSkillSchema.safeParse(skill); if (!result.success) { throw new UserError( `Invalid agent skill: ${result.error.issues[0]?.message ?? 'Invalid skill'}`, ); } + return result.data; } - private addSkill(entity: Agent, skill: AgentSkill): string { - const skillId = generateAgentResourceId('skill', Object.keys(entity.skills ?? {})); - - entity.skills = { - ...(entity.skills ?? {}), - [skillId]: skill, - }; - - return skillId; - } - - private assertSkillNameIsUnique( - existing: Record, - name: string, - currentSkillId?: string, - ): void { - const normalizedName = this.normalizeSkillName(name); - const duplicate = Object.entries(existing ?? {}).find( - ([id, skill]) => - id !== currentSkillId && this.normalizeSkillName(skill.name) === normalizedName, - ); - if (duplicate) { - throw new UserError(`Agent already has a skill named "${name.trim()}".`); - } - } - - private normalizeSkillName(name: string): string { - return name.trim().toLowerCase(); - } - - private assertBatchSkillNamesAreUnique( - existing: Record, - skills: AgentSkill[], - ): void { - const seenNames = new Set(); - for (const skill of skills) { - this.assertSkillNameIsUnique(existing, skill.name); - const normalizedName = this.normalizeSkillName(skill.name); - if (seenNames.has(normalizedName)) { - throw new UserError(`Duplicate skill name in batch: "${skill.name.trim()}".`); - } - seenNames.add(normalizedName); - } - } - - private attachSkillRef(skillRefs: AgentSkillRefs, skillId: string): AgentJsonSkillConfig[] { - return [ - ...(skillRefs ?? []).filter((ref) => ref.id !== skillId), - { type: 'skill', id: skillId }, - ]; - } - - /** - * Write the skill refs only when the caller gives `nextSkillRefs`. - * Without `nextSkillRefs`, the refs stay as `currentSkillRefs`. - */ - private async saveSkillChanges( - entity: Agent, + private async assertCanCreateSkills( + user: User, projectId: string, - context: AgentMutationTelemetryContext, - previous: AgentMutationSnapshot, - currentSkillRefs: AgentSkillRefs, - nextSkillRefs?: { refs: AgentSkillRefs }, - ): Promise { - markAgentDraftDirty(entity); - const saved = await this.transactionRunner.run({}, async (ctx) => { - await saveAgentDraftFenced(this.agentRepository, entity, ctx); - if (nextSkillRefs) { - await this.agentSkillRefs.replaceDraftRefs(entity, nextSkillRefs.refs, ctx); + availableTo: AgentSkillAvailability, + ): Promise { + // A skill of one agent is part of that agent: the agent permission is enough. + if (availableTo === 'agent') return; + if (availableTo === 'instance') { + if (!hasGlobalScope(user, 'skill:create')) { + throw new ForbiddenError('You are not allowed to create skills for the whole instance.'); } - return entity; - }); - const savedSkillRefs = nextSkillRefs ? nextSkillRefs.refs : currentSkillRefs; - await this.saveCompletion.bodySaved( - buildAgentMutationEvent(saved, savedSkillRefs, projectId, context, previous, { - skills: true, - }), - context.pushRef, - ); - return saved; + return; + } + if (!(await userHasScopes(user, ['projectSkill:create'], false, { projectId }))) { + throw new ForbiddenError('You are not allowed to create skills for this project.'); + } + } + + /** New names must be easy to tell apart from the agent's skills and from each other. */ + private assertNoNameClashes(existing: Record, added: AgentSkill[]): void { + const quote = (names: string[]) => names.map((name) => name.trim()).join('", "'); + const existingNames = Object.values(existing).map((skill) => skill.name); + const clashes = added + .map((skill) => skill.name) + .filter((name) => findIntroducedNameClashes(existingNames, [name]).length > 0); + if (clashes.length > 0) { + throw new UserError(`Agent already has a skill with a name like "${quote(clashes)}".`); + } + // Two new skills of one batch can also clash with each other. + const inBatch = findNameClashGroups(added.map((skill) => skill.name)); + if (inBatch.length > 0) { + throw new UserError(`The new skills "${quote(inBatch[0])}" are hard to tell apart.`); + } } } + +function legacyBody(agent: Pick, skillId: string): AgentSkill | undefined { + const skills = agent.skills ?? {}; + return Object.hasOwn(skills, skillId) ? skills[skillId] : undefined; +} + +/** The fields a skill version stores. Skills ignore `allowedTools`. */ +function toStoredSkill(skill: AgentSkill): AgentSkill { + return { + name: skill.name, + description: skill.description, + instructions: skill.instructions, + ...(skill.references?.length ? { references: skill.references } : {}), + }; +} diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index 5134d2c087d..3d56ef80715 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -13,6 +13,7 @@ import { type AgentConfigValidationIssue, type AgentConfigValidationIssueCode, type AgentConfigValidationResponse, + type AgentConfigValidationWarning, type AgentIntegrationConfig, type AgentTaskConfig, type AgentJsonConfig, @@ -50,6 +51,7 @@ import { AgentRepository } from './repositories/agent.repository'; import { findWorkflowToolWorkflows } from './tools/workflow-tool-workflow-resolver'; import { extractAgentWorkflowRefs } from './utils/extract-agent-workflow-refs'; import { findHttpRequestToolUrlFromAiViolations } from './utils/node-tool-validation'; +import { findNameClashGroups } from './skills/skill-names'; type AgentValidationScope = 'runtime' | 'publish'; @@ -206,10 +208,11 @@ export class AgentValidationService { credentialProvider: CredentialProvider, scope: AgentValidationScope = 'publish', ): Promise { + const skills = await this.agentSkillRefs.skillsForRefs(agent, skillRefs, {}); return await this.validateDefinition( agent.id, projectId, - { ...getAgentDefinitionContent(agent, skillRefs), tasks }, + { ...getAgentDefinitionContent(agent, skillRefs, skills), tasks }, agent.integrations ?? [], credentialProvider, scope, @@ -272,7 +275,27 @@ export class AgentValidationService { } const issues = await this.collectIssues({ ...ctx, config: ctx.config }, scope); - return { status: issues.length === 0 ? 'valid' : 'invalid', issues }; + const warnings = this.collectSkillNameWarnings(ctx.config, ctx.skills); + return { + status: issues.length === 0 ? 'valid' : 'invalid', + issues, + ...(warnings.length > 0 ? { warnings } : {}), + }; + } + + /** Skill names that are hard to tell apart confuse the model, but do not block a run. */ + private collectSkillNameWarnings( + config: AgentJsonConfig, + skills: Record, + ): AgentConfigValidationWarning[] { + const names = (config.skills ?? []).flatMap((ref) => + Object.hasOwn(skills, ref.id) ? [skills[ref.id].name] : [], + ); + return findNameClashGroups(names).map((group) => ({ + code: 'similar_skill_names', + capability: { kind: 'skill' }, + names: group, + })); } private missingConfigIssues(): AgentConfigValidationIssue[] { diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts index 396ee8ad52b..d85a3018808 100644 --- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts @@ -777,7 +777,7 @@ export class AgentWorkflowExecutionService { const prepared = fromAgentDocument(config); agent.schema = prepared.config; agent.integrations = []; - return { runSource: { agent, skillRefs: prepared.skillRefs }, instrumentation }; + return { runSource: { ...runSource, agent, skillRefs: prepared.skillRefs }, instrumentation }; } private async streamCompiledWorkflowAgent( diff --git a/packages/cli/src/modules/agents/agents-skills.controller.ts b/packages/cli/src/modules/agents/agents-skills.controller.ts index 708805f7f67..0f257dcc80a 100644 --- a/packages/cli/src/modules/agents/agents-skills.controller.ts +++ b/packages/cli/src/modules/agents/agents-skills.controller.ts @@ -1,4 +1,4 @@ -import { CreateAgentSkillDto, UpdateAgentSkillDto } from '@n8n/api-types'; +import { CreateAgentSkillDto, UpdateAttachedAgentSkillDto } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import { Body, @@ -58,11 +58,18 @@ export class AgentsSkillsController { agentId, 'create skill for', ); - return await this.agentSkillsService.createAndAttachSkill(agentId, projectId, payload, { - user: req.user, - modifiedBy: 'user', - pushRef: req.headers?.['push-ref'], - }); + const { availableTo, ...skill } = payload; + return await this.agentSkillsService.createAndAttachSkill( + agentId, + projectId, + skill, + { + user: req.user, + modifiedBy: 'user', + pushRef: req.headers?.['push-ref'], + }, + availableTo, + ); } @Patch('/:agentId/skills/:skillId') @@ -72,10 +79,10 @@ export class AgentsSkillsController { _res: Response, @Param('agentId') agentId: string, @Param('skillId') skillId: string, - @Body payload: UpdateAgentSkillDto, + @Body payload: UpdateAttachedAgentSkillDto, ) { const { projectId } = req.params; - const { baseSkillHash, ...updates } = payload; + const { baseSkillHash, availableTo, ...updates } = payload; const clientId = req.headers?.['push-ref']; await this.collaborationService.validateAgentWriteLock( req.user.id, @@ -95,6 +102,8 @@ export class AgentsSkillsController { pushRef: req.headers?.['push-ref'], }, baseSkillHash, + undefined, + availableTo, ); } diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index d998a5b9f95..9bd203d0e51 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -11,6 +11,10 @@ export class AgentsModule implements ModuleInterface { const { SandboxSettingsService } = await import('@/services/sandbox-settings.service.js'); Container.get(SandboxSettingsService).registerCredentialUses(); + // Read the skills hub flag once, before anything reads or writes agent skills. + const { SkillsHubRollout } = await import('./skills/skills-hub-rollout.js'); + await Container.get(SkillsHubRollout).init(); + // Imported for its `@OnLifecycleEvent` registration, which happens at // class-decoration time — nothing else references this service. await import('./agent-workflow-tool-resume.service.js'); @@ -214,6 +218,7 @@ export class AgentsModule implements ModuleInterface { const { AgentsSettingsService } = await import('./agents-settings.service.js'); const { AiService } = await import('@/services/ai.service.js'); const { SandboxSettingsService } = await import('@/services/sandbox-settings.service.js'); + const { SkillsHubRollout } = await import('./skills/skills-hub-rollout.js'); const aiService = Container.get(AiService); const proxyEnabled = aiService.isProxyEnabled(); return { @@ -221,6 +226,7 @@ export class AgentsModule implements ModuleInterface { modules: [...config.modules], knowledgeBaseEnabled: Container.get(SandboxSettingsService).isAgentSandboxEnabled(), proxyEnabled, + skillsHubEnabled: Container.get(SkillsHubRollout).isEnabled, }; } diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 2ac96c69da0..75775c7bb5f 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -73,7 +73,12 @@ type CreateAgentOptions = { /** Create with this config instead of the empty draft, so eval thread seeding * can recreate an already-built agent in one insert. */ schema?: AgentJsonConfig; + /** Skill bodies keyed by the ref ids of `schema`. A ref to a skills hub skill + * stays a ref; a ref with only a body gets a new hub skill in this project. */ skills?: Record; + /** Creates the hub skills of refs with only a body when `user` is not set. + * Without either, those refs are dropped. */ + skillCreator?: User; /** Opaque custom tool bodies keyed by id (passthrough from the duplicate path; * validated when the source agent was authored). */ tools?: Record; @@ -148,6 +153,7 @@ export class AgentsService { defaultModel, schema, skills, + skillCreator, tools, user, }: CreateAgentOptions, @@ -167,7 +173,6 @@ export class AgentsService { projectId, schema: storedConfig, ...(integrations.length > 0 ? { integrations } : {}), - ...(skills ? { skills } : {}), ...(tools ? { tools: tools as Agent['tools'] } : {}), versionId: uuid(), availableInMCP, @@ -179,7 +184,13 @@ export class AgentsService { // client-minted id names instead of letting the primary key reject it. await this.transactionRunner.run({}, async (ctx) => { await this.agentRepository.insertNew(agent, ctx); - await this.agentSkillRefs.replaceDraftRefs(agent, skillRefs, ctx); + await this.agentSkillRefs.createDraftRefs( + agent, + skillRefs, + skills ?? {}, + user ?? skillCreator ?? null, + ctx, + ); }); saved = agent; } catch (error) { @@ -285,10 +296,10 @@ export class AgentsService { const mcpServers = (schema?.mcpServers ?? []).map((server) => ({ name: server.name })); - const skillRefs = await this.agentSkillRefs.refsForDraft(entity, {}); - const skills = (skillRefs ?? []).map((skill) => ({ + const draftSkills = await this.agentSkillRefs.readDraft(entity, {}); + const skills = (draftSkills.skillRefs ?? []).map((skill) => ({ id: skill.id, - name: entity.skills[skill.id]?.name ?? skill.id, + name: draftSkills.skills[skill.id]?.name ?? skill.id, })); const tasks = await this.getCapabilityTasks(entity); diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index dd644645de7..4f2e1004330 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -194,9 +194,6 @@ const createSkillInputSchema = z name: agentSkillSchema.shape.name.describe('Human-readable skill name'), description: agentSkillSchema.shape.description.describe(SKILL_DESCRIPTION_RULE), instructions: agentSkillSchema.shape.instructions.describe(SKILL_BODY_GUIDANCE), - allowedTools: agentSkillSchema.shape.allowedTools - .optional() - .describe('Exact target-agent tool names this skill is allowed to use.'), references: agentSkillSchema.shape.references .optional() .describe( @@ -235,7 +232,6 @@ const updateSkillFieldsSchema = z 'Targeted replacements in the current instructions, applied in order. Prefer this to ' + '`instructions` for a partial change. Do not pass both.', ), - allowedTools: agentSkillSchema.shape.allowedTools.unwrap().min(1).nullable().optional(), references: agentSkillSchema.shape.references .unwrap() .refine((references) => references.length > 0, 'Pass null to clear references.') @@ -260,7 +256,7 @@ const updateSkillInputSchema = z 'skillHash from the immediately preceding agent-context with type "skill" result for this skill.', ), updates: updateSkillFieldsSchema.describe( - 'Only the fields to change. Pass null for allowedTools or references to remove that field; empty arrays are invalid.', + 'Only the fields to change. Pass null for references to remove them; an empty array is invalid.', ), }) .strict(); @@ -1146,8 +1142,10 @@ export class AgentsBuilderToolsService { 'Update selected fields of an existing target-agent skill in place, preserving its id and ' + 'agent config reference. Requires baseSkillHash from the immediately preceding agent-context with type "skill" ' + 'result. To change part of the body, pass instructionEdits with oldText/newText pairs ' + - 'instead of rewriting instructions. Pass null for allowedTools to remove the tool restriction, ' + - 'or null for references to remove all references; empty arrays are invalid. Returns ' + + 'instead of rewriting instructions. Pass null for references to remove all references; an ' + + 'empty array is invalid. The skill is shared: the update saves its next version, and every ' + + 'agent draft that follows the latest version runs that version. Published versions keep ' + + 'the version they pinned. Returns ' + '{ ok: true, id, name, configMutated: true, agentId } or { ok: false, errors }. A failed edit ' + 'saves nothing: fix oldText so it matches exactly one place and retry with the same ' + 'baseSkillHash. On a stale skill error, ' + @@ -1157,10 +1155,9 @@ export class AgentsBuilderToolsService { .handler(async ({ skillId, baseSkillHash, updates }: UpdateSkillInput) => { const editorLock = await this.getEditorLockFailure(agentId); if (editorLock) return editorLock; - const { allowedTools, references, instructionEdits, ...requiredUpdates } = updates; + const { references, instructionEdits, ...requiredUpdates } = updates; const normalizedUpdates = { ...requiredUpdates, - ...(allowedTools !== undefined ? { allowedTools: allowedTools ?? undefined } : {}), ...(references !== undefined ? { references: references ?? undefined } : {}), }; @@ -1211,7 +1208,7 @@ export class AgentsBuilderToolsService { 'applicable section filled in with concrete, specific content. If you do not have enough domain ' + "detail to write a genuinely useful skill, derive it from the user's goal as stated assumptions " + 'listed in your summary; ask the user clarifying questions only when even a reasonable ' + - 'assumption is impossible. Use allowedTools only with exact target-agent tool names. Use references ' + + 'assumption is impossible. Use references ' + 'only for markdown supporting files under the references/ directory — references are not ' + 'automatically loaded, so instructions must say exactly when to load each one by path; scripts and ' + 'non-markdown linked files are not supported. Do not invent tool names or reference paths. Batch ' + diff --git a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts index f9ce7fa94b2..f968c142b36 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts @@ -74,6 +74,9 @@ Use \`patch_config\` with: - To attach an existing, unattached skill: if \`skills\` exists, append to \`/skills/-\`; if \`skills\` is missing, add \`/skills\` with an array. - Ref shape: \`{ "type": "skill", "id": "" }\`. +- A ref with \`versionId\` pins that saved version of the skill. To use the latest + version, remove \`versionId\` from the ref with \`patch_config\`. Do not remove + the skill and add it again. #### Remove An Existing Chat Integration diff --git a/packages/cli/src/modules/agents/builder/skills/target-skills.skill.ts b/packages/cli/src/modules/agents/builder/skills/target-skills.skill.ts index 04e20d1a64a..f5c3dd260ef 100644 --- a/packages/cli/src/modules/agents/builder/skills/target-skills.skill.ts +++ b/packages/cli/src/modules/agents/builder/skills/target-skills.skill.ts @@ -94,9 +94,9 @@ placeholder or vague skill. when you rewrite most of the body. If an edit fails, nothing changed: fix \`oldText\` so it matches exactly one place and retry with the same \`baseSkillHash\`. - Do not create a replacement skill. Pass \`null\` for \`allowedTools\` to remove the tool - restriction or for \`references\` to remove all references; do not pass empty - arrays. When replacing \`references\`, first read the content of every existing + Do not create a replacement skill. Pass \`null\` for \`references\` to remove + all references; do not pass an empty array. The skill can be shared with other + agents: an update saves its next version for every agent that uses it. When replacing \`references\`, first read the content of every existing reference that must be preserved, because the field is replaced as a whole. - Only call \`create_skills\` when no attached skill owns the capability. Fill the domain detail you need, deriving missing detail from the goal as stated @@ -118,8 +118,6 @@ placeholder or vague skill. ## Extended fields -- Use \`allowedTools\` only for exact tool names already attached to the target - agent that this skill may use. - Add \`references\` for longer markdown-only supporting files under \`references/...\`, such as rubrics, examples, policies, templates, or checklists. References are not automatically loaded when the skill loads. diff --git a/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts b/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts index 3264d563a23..3f921ea510d 100644 --- a/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts +++ b/packages/cli/src/modules/agents/instance-ai-agent-context.adapter.ts @@ -165,7 +165,9 @@ export class InstanceAiAgentContextAdapterService { const agent = await this.agentsService.findById(input.agentId, projectId); if (!agent) throw new UserError('Agent not found.'); - const config = composeJsonConfig(agent, await this.agentSkillRefs.refsForDraft(agent, {})); + // Refs only: the skill bodies are read in the `skills` case, which needs them. + const skillRefs = await this.agentSkillRefs.refsForDraft(agent, {}); + const config = composeJsonConfig(agent, skillRefs); switch (input.type) { case 'config': @@ -183,12 +185,14 @@ export class InstanceAiAgentContextAdapterService { configHash: getAgentConfigHash(config), }; case 'skills': { + // The skills hub keeps no skill on an agent that is not attached. + const skills = await this.agentSkillRefs.skillsForRefs(agent, skillRefs, {}); return { - skills: Object.entries(agent.skills ?? {}).map(([id, skill]) => ({ + skills: Object.entries(skills).map(([id, skill]) => ({ id, name: skill.name, description: skill.description, - attached: (config?.skills ?? []).some((ref) => ref.id === id), + attached: true, })), }; } diff --git a/packages/cli/src/modules/agents/json-config/agent-document.ts b/packages/cli/src/modules/agents/json-config/agent-document.ts index 84c611c51d5..4f8ad8cfcaa 100644 --- a/packages/cli/src/modules/agents/json-config/agent-document.ts +++ b/packages/cli/src/modules/agents/json-config/agent-document.ts @@ -1,9 +1,8 @@ import type { AgentJsonConfig, AgentJsonSkillConfig, StoredAgentConfig } from '@n8n/api-types'; /** - * Skill refs of one agent draft or one published version, in document order. - * `undefined` means that the config has no skill list. Keep this apart from an - * empty list, because the config hash sees the difference. + * Skill refs of a JSON document. `undefined` means that an inbound document + * has no skill list. The refs of a stored draft or version are always a list. */ export type AgentSkillRefs = AgentJsonSkillConfig[] | undefined; @@ -25,3 +24,12 @@ export function fromAgentDocument(document: AgentJsonConfig): { const { skills, ...config } = document; return { config, skillRefs: skills }; } + +/** + * A stored config without the legacy `skills` key. Configs written before the + * skills hub can still hold it. Nothing reads it, so do not copy it on. + */ +export function withoutLegacySkillRefs(config: StoredAgentConfig): StoredAgentConfig { + const { skills: _legacy, ...rest } = config; + return rest; +} diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index 7314479ac23..b3b6a3fc296 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -390,7 +390,6 @@ function getConfiguredSkillSource( >(); for (const ref of refs) { - if (ref.enabled === false) continue; if (seen.has(ref.id)) continue; seen.add(ref.id); const skill = skills[ref.id]; @@ -405,7 +404,6 @@ function getConfiguredSkillSource( name: skill.name, description: skill.description, instructions: skill.instructions, - ...(skill.allowedTools ? { allowedTools: skill.allowedTools } : {}), linkedFiles, }); } diff --git a/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts b/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts index beadccc9477..9f76ed30b62 100644 --- a/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts +++ b/packages/cli/src/modules/agents/json-config/prune-missing-config-references.ts @@ -2,19 +2,16 @@ import type { AgentJsonConfig } from '@n8n/api-types'; import type { AgentDefinition } from '../utils/agent-definition'; -/** Preserve existing and disabled refs while new refs require an available body. */ +/** + * Preserve existing and disabled refs while new refs require an available body. + * Skill refs are not pruned here: `AgentSkillRefsService.replaceDraftRefs` + * checks them against the skills hub. + */ export function pruneMissingConfigReferences( config: AgentJsonConfig, previousSchema: AgentJsonConfig | null, - available: Pick & { taskIds: ReadonlySet }, + available: Pick & { taskIds: ReadonlySet }, ): void { - if (config.skills !== undefined) { - const existingIds = new Set((previousSchema?.skills ?? []).map((ref) => ref.id)); - config.skills = config.skills.filter( - (ref) => - ref.enabled === false || existingIds.has(ref.id) || Object.hasOwn(available.skills, ref.id), - ); - } if (config.tools !== undefined) { const existingIds = new Set( (previousSchema?.tools ?? []).filter((ref) => ref.type === 'custom').map((ref) => ref.id), diff --git a/packages/cli/src/modules/agents/repositories/agent-history.repository.ts b/packages/cli/src/modules/agents/repositories/agent-history.repository.ts index 9d77ddbe368..4c08b1c5614 100644 --- a/packages/cli/src/modules/agents/repositories/agent-history.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-history.repository.ts @@ -40,7 +40,7 @@ export class AgentHistoryRepository extends BaseRepository { agentId: string; schema: StoredAgentConfig | null; tools: Agent['tools'] | null; - skills: Agent['skills'] | null; + skills?: Agent['skills'] | null; publishedBy: User | string; }, ctx: OperationContext = {}, @@ -60,7 +60,7 @@ export class AgentHistoryRepository extends BaseRepository { agentId: data.agentId, schema: data.schema as InsertShape['schema'], tools: data.tools as InsertShape['tools'], - skills: data.skills as InsertShape['skills'], + skills: (data.skills ?? null) as InsertShape['skills'], author, publishedById, }); diff --git a/packages/cli/src/modules/agents/repositories/agent.repository.ts b/packages/cli/src/modules/agents/repositories/agent.repository.ts index 2ac23989b4b..51f3b9232d4 100644 --- a/packages/cli/src/modules/agents/repositories/agent.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent.repository.ts @@ -676,11 +676,17 @@ export class AgentRepository extends BaseRepository { ids: string[], ctx: OperationContext = {}, ): Promise>> { - if (ids.length === 0) return []; - return await this.managerFor(ctx).find(Agent, { - where: { id: In(ids) }, - select: ['id', 'projectId'], - }); + const agents: Array> = []; + // In batches: a skill can have more followers than SQLite allows bound parameters. + for (const chunk of chunkIds([...new Set(ids)])) { + agents.push( + ...(await this.managerFor(ctx).find(Agent, { + where: { id: In(chunk) }, + select: ['id', 'projectId'], + })), + ); + } + return agents; } /** @@ -689,6 +695,20 @@ export class AgentRepository extends BaseRepository { * write does not check `revision`: call it only in the same transaction as a * won `saveDraftFenced`. */ + async updateDraftSkills( + agent: Pick, + ctx: OperationContext, + ): Promise { + // Write `updatedAt` too, with the value of the fenced save: without it, TypeORM + // sets `CURRENT_TIMESTAMP`, which drops the milliseconds on SQLite. + await this.managerFor(ctx) + .createQueryBuilder() + .update(Agent) + .set({ schema: agent.schema, skills: agent.skills, updatedAt: agent.updatedAt }) + .where('id = :id', { id: agent.id }) + .execute(); + } + async updateDraftSchema( agent: Pick, ctx: OperationContext, diff --git a/packages/cli/src/modules/agents/repositories/skill.repository.ts b/packages/cli/src/modules/agents/repositories/skill.repository.ts index 3c008c954ee..1201151dfa0 100644 --- a/packages/cli/src/modules/agents/repositories/skill.repository.ts +++ b/packages/cli/src/modules/agents/repositories/skill.repository.ts @@ -217,13 +217,17 @@ export class SkillRepository extends BaseRepository { return new Map(rows.map((row) => [row.version.id, row])); } - /** The pinned version of each skill of one published agent version, keyed by skill id. */ + /** + * The pinned version of each skill of one published agent version, keyed by skill id, + * in the order of the draft refs at publish. + */ async findPinned( agentVersionId: string, ctx: OperationContext = {}, ): Promise> { const pins = await this.managerFor(ctx).find(AgentVersionSkillDependency, { where: { agentVersionId }, + order: { createdAt: 'ASC' }, }); const versions = await this.findVersionsByIds( pins.map((pin) => pin.skillVersionId), @@ -237,6 +241,19 @@ export class SkillRepository extends BaseRepository { return result; } + /** + * The skill ids one published agent version pins, in the order of the draft refs at + * publish. Reads no version or file rows. + */ + async findPinnedSkillIds(agentVersionId: string, ctx: OperationContext = {}): Promise { + const pins = await this.managerFor(ctx).find(AgentVersionSkillDependency, { + select: ['skillId'], + where: { agentVersionId }, + order: { createdAt: 'ASC' }, + }); + return pins.map((pin) => pin.skillId); + } + /** The next version number of a skill: the highest version plus one, or 1. */ async nextVersionNumber(skillId: string, ctx: OperationContext = {}): Promise { const latest = await this.managerFor(ctx).findOne(SkillVersion, { @@ -280,12 +297,20 @@ export class SkillRepository extends BaseRepository { }); } + /** + * Inserts the pins of one publish in the given order. `createdAt` orders the skills of + * a published version, so each pin gets its own timestamp, like `replaceDependencies`. + */ async insertPins( pins: Array<{ agentVersionId: string; skillId: string; skillVersionId: string }>, ctx: OperationContext = {}, ): Promise { if (pins.length === 0) return; - await this.managerFor(ctx).insert(AgentVersionSkillDependency, pins); + const first = Date.now(); + await this.managerFor(ctx).insert( + AgentVersionSkillDependency, + pins.map((pin, index) => ({ ...pin, createdAt: new Date(first + index) })), + ); } /** @@ -443,6 +468,32 @@ export class SkillRepository extends BaseRepository { }; } + /** The agents that use the skill: a draft ref or a published pin. */ + async findUsingAgentIds(skillId: string, ctx: OperationContext = {}): Promise { + const manager = this.managerFor(ctx); + const drafts = await manager.find(AgentSkillDependency, { + select: ['agentId'], + where: { skillId }, + }); + const pins = await manager + .createQueryBuilder(AgentVersionSkillDependency, 'pin') + .innerJoin(AgentHistory, 'history', 'history.versionId = pin.agentVersionId') + .where('pin.skillId = :skillId', { skillId }) + .select('history.agentId', 'agentId') + .distinct(true) + .getRawMany<{ agentId: string }>(); + return unique([...drafts, ...pins].map((row) => row.agentId)); + } + + /** Moves a skill to a project, one agent or the instance. */ + async updateTarget(skillId: string, target: SkillTarget, ctx: OperationContext = {}) { + await this.managerFor(ctx).update( + Skill, + { id: skillId }, + { projectId: target.projectId, agentId: target.agentId, updatedAt: new Date() }, + ); + } + /** Distinct agents per skill, drafts and pins together. Skills nobody uses are left out. */ async countUsingAgents( skillIds: string[], diff --git a/packages/cli/src/modules/agents/skills.controller.ts b/packages/cli/src/modules/agents/skills.controller.ts index 3a9e5b48ac6..6435f184398 100644 --- a/packages/cli/src/modules/agents/skills.controller.ts +++ b/packages/cli/src/modules/agents/skills.controller.ts @@ -1,9 +1,20 @@ import { CreateSkillDto, ListSkillsQueryDto, SaveSkillDto } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; -import { Body, Delete, Get, Param, Patch, Post, Query, RestController } from '@n8n/decorators'; -import type { Response } from 'express'; +import { + Body, + Delete, + Get, + Middleware, + Param, + Patch, + Post, + Query, + RestController, +} from '@n8n/decorators'; +import type { NextFunction, Request, Response } from 'express'; import { SkillsApiService } from './skills/skills-api.service'; +import { SkillsHubRollout } from './skills/skills-hub-rollout'; /** * Skills for the settings page. No route scope: the scope of each skill decides who may @@ -11,7 +22,20 @@ import { SkillsApiService } from './skills/skills-api.service'; */ @RestController('/skills') export class SkillsController { - constructor(private readonly skillsApi: SkillsApiService) {} + constructor( + private readonly skillsApi: SkillsApiService, + private readonly rollout: SkillsHubRollout, + ) {} + + /** Runs before the request is validated, so every request answers 404 while the flag is off. */ + @Middleware() + requireSkillsHub(_req: Request, res: Response, next: NextFunction) { + if (!this.rollout.isEnabled) { + res.status(404).send('Not found'); + return; + } + next(); + } @Get('/') async list(req: AuthenticatedRequest, _res: Response, @Query query: ListSkillsQueryDto) { diff --git a/packages/cli/src/modules/agents/skills/skill.service.ts b/packages/cli/src/modules/agents/skills/skill.service.ts index f9ca051c611..aa656044fbd 100644 --- a/packages/cli/src/modules/agents/skills/skill.service.ts +++ b/packages/cli/src/modules/agents/skills/skill.service.ts @@ -35,6 +35,8 @@ export type SkillSaveResult = { created: boolean; /** The content of the latest version after the save. */ skill: AgentSkill; + /** Agents that follow the skill. A save in a caller's transaction does not refresh them. */ + followingAgentIds: string[]; }; /** @@ -134,6 +136,28 @@ export class SkillService { return new Map([...rows].map(([id, row]) => [id, row.version.name])); } + /** + * The name each ref runs under: its pinned version's name, else the latest. Works per + * ref, because one agent can pin a skill that another agent follows. + */ + private async runtimeNames( + refs: Array<{ id: string; versionId?: string | null }>, + ctx: OperationContext, + ): Promise<(ref: { id: string; versionId?: string | null }) => string | undefined> { + const latest = await this.latestNames( + refs.map((ref) => ref.id), + ctx, + ); + const pinned = await this.skillRepository.findVersionsByIds( + refs.flatMap((ref) => (ref.versionId ? [ref.versionId] : [])), + ctx, + ); + return (ref) => { + const pin = ref.versionId ? pinned.get(ref.versionId) : undefined; + return pin?.skill.id === ref.id ? pin.version.name : latest.get(ref.id); + }; + } + // ---------------------------------------------------------------- writes /** Creates a skill with its v1. Returns the new id. */ @@ -158,12 +182,16 @@ export class SkillService { * runs the new version, and each one that was in sync with its published version * shows unpublished changes. `baseSkillHash` is checked under the edit lock, so two * saves from the same base cannot both pass. + * + * When `ctx` has a transaction, the caller calls `refreshFollowingAgents` after its + * commit. `pushRef` is the tab that saved: it gets no reload push. */ async save( skillId: string, update: SkillUpdate, userId: string, ctx: OperationContext = {}, + pushRef?: string, ): Promise { const { saved, following } = await this.txRunner.run(ctx, async (txCtx) => { await this.skillRepository.lockForEdit([skillId], txCtx); @@ -182,7 +210,8 @@ export class SkillService { const content = toSkillContent(skill, latest.version.frontmatter); if (skillContentHash(content) === latest.version.contentHash) { const unchanged = { versionId: latest.version.id, version: latest.version.version }; - return { saved: { ...unchanged, created: false, skill: current }, following: [] }; + const result = { ...unchanged, created: false, skill: current, followingAgentIds: [] }; + return { saved: result, following: [] }; } if (skill.name !== latest.version.name) { await this.assertRenameKeepsNamesApart(skillId, latest.version.name, skill.name, txCtx); @@ -197,9 +226,10 @@ export class SkillService { ); const followingIds = await this.skillRepository.findFollowingAgentIds([skillId], txCtx); await this.agentRepository.markDraftChangedIfInSync(followingIds, txCtx); - return { saved: { versionId, version, created: true, skill }, following: followingIds }; + const result = { versionId, version, created: true, skill, followingAgentIds: followingIds }; + return { saved: result, following: followingIds }; }); - if (following.length > 0) await this.refreshAgents(following); + if (!ctx.trx) await this.refreshFollowingAgents(following, pushRef); this.logger.debug('Saved skill', { skillId, version: saved.version, @@ -209,18 +239,21 @@ export class SkillService { return saved; } - /** After a save: following agents drop their cached runtime and their editors reload. */ - private async refreshAgents(agentIds: string[]): Promise { + /** + * After a save commits: following agents drop their cached runtime and their editors + * reload, except the tab of `excludePushRef`. + */ + async refreshFollowingAgents(agentIds: string[], excludePushRef?: string): Promise { + if (agentIds.length === 0) return; // Imported on use: the runtime cache depends on services that will depend on this one. const { AgentRuntimeCacheService } = await import('../agent-runtime-cache.service.js'); const cache = Container.get(AgentRuntimeCacheService); for (const agentId of agentIds) cache.clearRuntimes(agentId); for (const agent of await this.agentRepository.findProjectIdsByIds(agentIds)) { - this.agentUpdateBroadcaster.notify({ - projectId: agent.projectId, - agentId: agent.id, - source: 'user', - }); + this.agentUpdateBroadcaster.notify( + { projectId: agent.projectId, agentId: agent.id, source: 'user' }, + excludePushRef, + ); } } @@ -235,12 +268,14 @@ export class SkillService { agents.map((agent) => agent.id), ctx, ); - const otherIds = (agent: (typeof agents)[number]) => - (refs.get(agent.id) ?? []).map((ref) => ref.skillId).filter((id) => id !== skillId); - const names = await this.latestNames(agents.flatMap(otherIds), ctx); + const otherRefs = (agent: (typeof agents)[number]) => + (refs.get(agent.id) ?? []) + .filter((ref) => ref.skillId !== skillId) + .map((ref) => ({ id: ref.skillId, versionId: ref.versionId })); + const nameOf = await this.runtimeNames(agents.flatMap(otherRefs), ctx); const clashing = agents.filter((agent) => renameIntroducesClash( - otherIds(agent).flatMap((id) => names.get(id) ?? []), + otherRefs(agent).flatMap((ref) => nameOf(ref) ?? []), oldName, newName, ), @@ -271,7 +306,10 @@ export class SkillService { return { skills: toRecord(rows), versionByRef }; } - /** Publish, step 2: after the `agent_history` row exists. */ + /** + * Publish, step 2: after the `agent_history` row exists. The pins keep the order of + * `versionByRef`, which is the order of the draft refs. + */ async pinForPublish( agentVersionId: string, versionByRef: Map, @@ -349,22 +387,24 @@ export class SkillService { (id) => !existingIds.has(id) && found.has(id), ); if (newIds.length === 0) return kept; + // Shared lock until the attach commits, so `changeTarget` cannot narrow a skill in between. + if (ctx.trx) await this.skillRepository.lockForPublish(newIds, ctx); - const keptExistingIds = [...new Set(kept.map((ref) => ref.id))].filter((id) => - existingIds.has(id), - ); - const names = await this.latestNames([...keptExistingIds, ...newIds], ctx); + const keptExisting = kept.filter((ref) => existingIds.has(ref.id)); + const newRefs = newIds.map((id) => ({ id })); + const nameOf = await this.runtimeNames([...keptExisting, ...newRefs], ctx); for (const id of newIds) { const skill = found.get(id); if (skill && !this.isAttachable(skill, agent)) { throw new UserError( - `Skill "${names.get(id) ?? id}" (${id}) is not available to this agent. An agent can use the skills of its own project, its own skills and instance skills.`, + `Skill "${nameOf({ id }) ?? id}" (${id}) is not available to this agent. An agent can use the skills of its own project, its own skills and instance skills.`, ); } } + // A kept ref runs its pinned version, so compare the name of that version. const clashes = findIntroducedNameClashes( - keptExistingIds.flatMap((id) => names.get(id) ?? []), - newIds.flatMap((id) => names.get(id) ?? []), + keptExisting.flatMap((ref) => nameOf(ref) ?? []), + newRefs.flatMap((ref) => nameOf(ref) ?? []), ); if (clashes.length > 0) { throw new UserError( @@ -374,6 +414,31 @@ export class SkillService { return kept; } + /** + * Moves a skill to a project, one agent or the instance. Refused while an agent other + * than `keepAgentId` uses it. The edit lock makes a concurrent attach wait, so the check + * and the move see the same agents. + */ + async changeTarget( + skillId: string, + target: SkillTarget, + keepAgentId: string, + ctx: OperationContext = {}, + ): Promise { + await this.txRunner.run(ctx, async (txCtx) => { + await this.skillRepository.lockForEdit([skillId], txCtx); + const others = (await this.skillRepository.findUsingAgentIds(skillId, txCtx)).filter( + (id) => id !== keepAgentId, + ); + if (others.length > 0) { + throw new ConflictError( + 'Other agents use this skill. Detach it from those agents first, then change where it can be added.', + ); + } + await this.skillRepository.updateTarget(skillId, target, txCtx); + }); + } + // ---------------------------------------------------------------- permission /** diff --git a/packages/cli/src/modules/agents/skills/skills-api.service.ts b/packages/cli/src/modules/agents/skills/skills-api.service.ts index 60e07ccf33d..b6e29aa4d7e 100644 --- a/packages/cli/src/modules/agents/skills/skills-api.service.ts +++ b/packages/cli/src/modules/agents/skills/skills-api.service.ts @@ -81,7 +81,11 @@ export class SkillsApiService { /** Saves the changes as the next version. Unchanged content creates nothing. */ async update(user: User, skillId: string, payload: SaveSkillDto): Promise { await this.requireAccess(user, skillId, 'update'); - const saved = await this.skillService.save(skillId, { ...payload }, user.id); + const { followingAgentIds: _following, ...saved } = await this.skillService.save( + skillId, + { ...payload }, + user.id, + ); return { id: skillId, ...saved, skillHash: getAgentSkillHash(saved.skill) }; } @@ -101,6 +105,11 @@ export class SkillsApiService { const agentProjectIds = await this.projectScopeService.getProjectIds(user, ['agent:read']); return { ...item, + // Name, description and number from the same row as the content, so a save that + // lands between the two reads cannot mix two versions. + name: latest.version.name, + description: latest.version.description, + latestVersion: latest.version.version, skill: content, skillHash: getAgentSkillHash(content), usedBy: await this.skillRepository.findUsage(skill.id, agentProjectIds ?? 'all'), diff --git a/packages/cli/src/modules/agents/skills/skills-hub-rollout.ts b/packages/cli/src/modules/agents/skills/skills-hub-rollout.ts new file mode 100644 index 00000000000..9b637253e4d --- /dev/null +++ b/packages/cli/src/modules/agents/skills/skills-hub-rollout.ts @@ -0,0 +1,30 @@ +import { SKILLS_FLAG } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; + +import { PostHogClient } from '@/posthog'; + +/** + * Whether agents use the skills hub, from the PostHog flag `125_context_skills` for the + * instance. `init` reads the flag once at startup and the value holds for the life of the + * process, so the startup copy and every read agree. A change needs a restart. PostHog + * down or diagnostics off means off, unless `N8N_FEATURE_FLAG_OVERRIDES` turns it on. + */ +@Service() +export class SkillsHubRollout { + private enabled = false; + + constructor( + private readonly postHogClient: PostHogClient, + private readonly logger: Logger, + ) {} + + async init(): Promise { + this.enabled = (await this.postHogClient.getFeatureFlagForInstance(SKILLS_FLAG)) === true; + this.logger.debug('Read the skills hub rollout flag', { enabled: this.enabled }); + } + + get isEnabled(): boolean { + return this.enabled; + } +} diff --git a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts index d63838f8e0d..f1a16ffcb8d 100644 --- a/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts +++ b/packages/cli/src/modules/agents/sub-agents/__tests__/sub-agent-source-resolver.test.ts @@ -13,6 +13,7 @@ import { type AgentFixtureOverrides, type AgentHistoryFixtureOverrides, createAgentSkillRefsService, + type FakeAgentSkillRefsService, } from '../../__tests__/test-utils/stored-agent-config'; const projectId = 'project-1'; @@ -27,6 +28,7 @@ const runnableConfig: AgentJsonConfig = { config: { maxIterations: 5, }, + skills: [], }; const customToolDescriptor: ToolDescriptor = { @@ -51,7 +53,6 @@ function makeAgentHistory(overrides: AgentHistoryFixtureOverrides = {}): AgentHi versionId, schema: runnableConfig, tools: {}, - skills: {}, ...overrides, } as unknown as AgentHistory; } @@ -65,7 +66,6 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { schema: runnableConfig, integrations: [], tools: {}, - skills: {}, activeVersionId: versionId, activeVersion: makeAgentHistory(), ...overrides, @@ -75,17 +75,15 @@ function makeAgent(overrides: AgentFixtureOverrides = {}): Agent { describe('SubAgentSourceResolver', () => { let agentRepository: Mocked; let agentHistoryRepository: Mocked; + let skillRefs: FakeAgentSkillRefsService; let resolver: SubAgentSourceResolver; beforeEach(() => { vi.clearAllMocks(); agentRepository = mock(); agentHistoryRepository = mock(); - resolver = new SubAgentSourceResolver( - agentRepository, - agentHistoryRepository, - createAgentSkillRefsService(agentRepository), - ); + skillRefs = createAgentSkillRefsService(); + resolver = new SubAgentSourceResolver(agentRepository, agentHistoryRepository, skillRefs); }); it('resolves the latest draft when no version is pinned', async () => { @@ -123,18 +121,21 @@ describe('SubAgentSourceResolver', () => { }); it('retains background configuration and tool bodies after a draft edit', async () => { + skillRefs.readDraft.mockResolvedValueOnce({ + skillRefs: [{ type: 'skill', id: 'original_skill' }], + skills: { + original_skill: { + name: 'Original skill', + description: 'Original description', + instructions: 'Original skill body', + }, + }, + }); agentRepository.findByIdAndProjectId.mockResolvedValue( makeAgent({ tools: { lookup: { descriptor: customToolDescriptor, code: 'original tool body' }, }, - skills: { - original_skill: { - name: 'Original skill', - description: 'Original description', - instructions: 'Original skill body', - }, - }, }), ); const original = await resolver.resolveForRuntime({ agentId }, { projectId }); @@ -241,6 +242,26 @@ describe('SubAgentSourceResolver', () => { }); it('resolves runtime assets from the draft, not the published version', async () => { + skillRefs.readDraft.mockResolvedValue({ + skillRefs: [{ type: 'skill', id: 'draft_skill' }], + skills: { + draft_skill: { + name: 'Draft skill', + description: 'Draft description', + instructions: 'Draft body', + }, + }, + }); + skillRefs.readVersion.mockResolvedValue({ + skillRefs: [{ type: 'skill', id: 'published_skill' }], + skills: { + published_skill: { + name: 'Published skill', + description: 'Published description', + instructions: 'Published body', + }, + }, + }); agentRepository.findByIdAndProjectId.mockResolvedValue( makeAgent({ tools: { @@ -249,13 +270,6 @@ describe('SubAgentSourceResolver', () => { descriptor: { ...customToolDescriptor, name: 'draft_only_tool' }, }, }, - skills: { - draft_skill: { - name: 'Draft skill', - description: 'Draft description', - instructions: 'Draft body', - }, - }, activeVersion: makeAgentHistory({ tools: { published_tool: { @@ -263,13 +277,6 @@ describe('SubAgentSourceResolver', () => { descriptor: { ...customToolDescriptor, name: 'published_tool' }, }, }, - skills: { - published_skill: { - name: 'Published skill', - description: 'Published description', - instructions: 'Published body', - }, - }, }), }), ); @@ -332,13 +339,13 @@ describe('SubAgentSourceResolver', () => { describe('skill refs round trip', () => { const draftRefs = [ { type: 'skill' as const, id: 'zeta' }, - { type: 'skill' as const, id: 'alpha', enabled: false }, + { type: 'skill' as const, id: 'alpha', versionId: '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f' }, ]; const publishedRefs = [ - { type: 'skill' as const, id: 'mid', enabled: true }, + { type: 'skill' as const, id: 'mid' }, { type: 'skill' as const, id: 'zeta' }, ]; - const pinnedRefs = [{ type: 'skill' as const, id: 'old', enabled: false }]; + const pinnedRefs = [{ type: 'skill' as const, id: 'old' }]; beforeEach(() => { agentRepository.findByIdAndProjectId.mockResolvedValue( diff --git a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts index 4f87a6dd1e2..d94c60e4e82 100644 --- a/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts +++ b/packages/cli/src/modules/agents/sub-agents/sub-agent-source-resolver.ts @@ -93,15 +93,14 @@ export class SubAgentSourceResolver { ); } + const { skillRefs, skills } = await this.agentSkillRefs.readVersion(version, {}); return { source: { sourceId: source.agentId, versionId: source.versionId, - config: this.toRunnableConfig( - toAgentDocument(version.schema, await this.agentSkillRefs.refsForVersion(version, {})), - ), + config: this.toRunnableConfig(toAgentDocument(version.schema, skillRefs)), }, - ...getAgentRuntimeAssets(version), + ...getAgentRuntimeAssets(version, skills), }; } @@ -113,18 +112,14 @@ export class SubAgentSourceResolver { ); } + const { skillRefs, skills } = await this.agentSkillRefs.readVersion(activeVersion, {}); return { source: { sourceId: source.agentId, versionId: activeVersion.versionId, - config: this.toRunnableConfig( - toAgentDocument( - activeVersion.schema, - await this.agentSkillRefs.refsForVersion(activeVersion, {}), - ), - ), + config: this.toRunnableConfig(toAgentDocument(activeVersion.schema, skillRefs)), }, - ...getAgentRuntimeAssets(activeVersion), + ...getAgentRuntimeAssets(activeVersion, skills), }; } @@ -132,14 +127,13 @@ export class SubAgentSourceResolver { throw new UserError(`Sub-agent "${source.agentId}" has no config`); } + const { skillRefs, skills } = await this.agentSkillRefs.readDraft(agent, {}); return { source: { sourceId: source.agentId, - config: this.toRunnableConfig( - toAgentDocument(agent.schema, await this.agentSkillRefs.refsForDraft(agent, {})), - ), + config: this.toRunnableConfig(toAgentDocument(agent.schema, skillRefs)), }, - ...getAgentRuntimeAssets(agent), + ...getAgentRuntimeAssets(agent, skills), }; } diff --git a/packages/cli/src/modules/agents/utils/agent-definition.ts b/packages/cli/src/modules/agents/utils/agent-definition.ts index 2398cc02cd2..bde89c16b5b 100644 --- a/packages/cli/src/modules/agents/utils/agent-definition.ts +++ b/packages/cli/src/modules/agents/utils/agent-definition.ts @@ -1,13 +1,15 @@ -import type { AgentJsonConfig, AgentTaskConfig } from '@n8n/api-types'; +import type { AgentJsonConfig, AgentSkill, AgentTaskConfig } from '@n8n/api-types'; import type { AgentHistory } from '../entities/agent-history.entity'; import type { Agent } from '../entities/agent.entity'; import { toAgentDocument, type AgentSkillRefs } from '../json-config/agent-document'; /** Published chat settings stay in schema. Credential integrations stay on the agent. */ -export interface AgentDefinition extends Pick { +export interface AgentDefinition extends Pick { /** The JSON document, with the skill refs. */ schema: AgentJsonConfig | null; + /** Skill bodies from the skills hub, keyed by skill id. */ + skills: Record; tasks: ReadonlyMap; } @@ -19,15 +21,16 @@ export interface PendingSkillRefs { skillRefs: AgentSkillRefs; } -/** Keep configuration and inline bodies from the same draft or version. */ +/** Keep configuration, inline bodies and skill bodies from the same draft or version. */ export function getAgentDefinitionContent( - source: Pick, + source: Pick, skillRefs: AgentSkillRefs, + skills: Record, ) { return { schema: source.schema ? toAgentDocument(source.schema, skillRefs) : null, tools: source.tools ?? {}, - skills: source.skills ?? {}, + skills, }; } diff --git a/packages/cli/src/modules/agents/utils/agent-missing-skill-ids.ts b/packages/cli/src/modules/agents/utils/agent-missing-skill-ids.ts index 28d8df714ca..39dedb76b50 100644 --- a/packages/cli/src/modules/agents/utils/agent-missing-skill-ids.ts +++ b/packages/cli/src/modules/agents/utils/agent-missing-skill-ids.ts @@ -11,7 +11,6 @@ export function getMissingSkillIds( const missing: string[] = []; for (const ref of refs) { - if (ref.enabled === false) continue; if (seen.has(ref.id)) continue; seen.add(ref.id); if (!skills[ref.id]) missing.push(ref.id); diff --git a/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts b/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts index b0eeab66e94..01899e826ae 100644 --- a/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts +++ b/packages/cli/src/modules/agents/utils/agent-published-snapshot.ts @@ -1,3 +1,4 @@ +import type { AgentSkill } from '@n8n/api-types'; import type { OperationContext } from '@n8n/db'; import { OperationalError } from 'n8n-workflow'; @@ -11,6 +12,8 @@ export interface AgentRunSource { /** The agent draft, or the agent with the content of its published version. */ agent: Agent; skillRefs: AgentSkillRefs; + /** Skill bodies from the skills hub, keyed by skill id: the draft's or the pinned ones. */ + skills: Record; } function getPublishedVersion(agentEntity: Agent): AgentHistory { @@ -29,7 +32,6 @@ function getPublishedAgentSnapshot(agentEntity: Agent): Agent { ...agentEntity, schema: activeVersion.schema, tools: activeVersion.tools ?? {}, - skills: activeVersion.skills ?? {}, } as Agent; } @@ -44,11 +46,11 @@ export async function resolveAgentRunSource( ctx: OperationContext, ): Promise { if (!usePublishedVersion) { - return { agent: agentEntity, skillRefs: await agentSkillRefs.refsForDraft(agentEntity, ctx) }; + return { agent: agentEntity, ...(await agentSkillRefs.readDraft(agentEntity, ctx)) }; } const activeVersion = getPublishedVersion(agentEntity); return { agent: getPublishedAgentSnapshot(agentEntity), - skillRefs: await agentSkillRefs.refsForVersion(activeVersion, ctx), + ...(await agentSkillRefs.readVersion(activeVersion, ctx)), }; } diff --git a/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts b/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts index eb6133752cb..d85487b2366 100644 --- a/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts +++ b/packages/cli/src/modules/agents/utils/agent-runtime-assets.ts @@ -1,11 +1,16 @@ import type { ToolDescriptor } from '@n8n/agents'; +import type { AgentSkill } from '@n8n/api-types'; import type { AgentHistory } from '../entities/agent-history.entity'; import type { Agent } from '../entities/agent.entity'; export type AgentRuntimeAssets = ReturnType; -export function getAgentRuntimeAssets(agent: Pick) { +/** `skills`: the skill bodies from the skills hub that the draft or version runs. */ +export function getAgentRuntimeAssets( + agent: Pick, + skills: Record, +) { const toolDescriptors: Record = {}; const toolCodeByName: Record = {}; @@ -14,5 +19,5 @@ export function getAgentRuntimeAssets(agent: Pick { settingsService.getEnabled.mockResolvedValue(true); Container.set(AgentsSettingsService, settingsService); Container.set(AgentRepository, { findByIdAndProjectId } as unknown as AgentRepository); + Container.set(AgentSkillRefsService, createAgentSkillRefsService()); Container.set(AgentRuntimeReconstructionService, { reconstructFromAgentEntity, } as unknown as AgentRuntimeReconstructionService); @@ -540,11 +543,13 @@ describe('EvalAgentExecutionService.executeWithLlmMock', () => { expect(rebuiltEntity.schema?.subAgents).toEqual({ agents: [{ agentId: 'child-1' }] }); }); - it('keeps the draft skill refs through pruning', async () => { + it('keeps the draft skill refs and their bodies through pruning', async () => { const skillRefs = [ { type: 'skill' as const, id: 'zeta' }, - { type: 'skill' as const, id: 'alpha', enabled: false }, + { type: 'skill' as const, id: 'alpha' }, ]; + const zeta = { name: 'Zeta', description: 'Zeta skill', instructions: 'Use zeta.' }; + Container.set(AgentSkillRefsService, createAgentSkillRefsService({ zeta })); findByIdAndProjectId.mockResolvedValue(makeEntity({ ...baseConfig, skills: skillRefs })); reconstructFromAgentEntity.mockResolvedValue({ agent: { generate: vi.fn().mockResolvedValue(makeGenerateResult()), close: vi.fn() }, @@ -555,6 +560,7 @@ describe('EvalAgentExecutionService.executeWithLlmMock', () => { const runSource = reconstructFromAgentEntity.mock.calls[0][0] as AgentRunSource; expect(runSource.skillRefs).toEqual(skillRefs); + expect(runSource.skills).toEqual({ zeta }); }); it('serves fallback web search and configured sub-agents through the instrumentation', async () => { diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts index b7649620ecf..cf04fc4224f 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts @@ -638,6 +638,7 @@ describe('EvalThreadRestoreService', () => { id: 'agent-original', schema: agent.config, skills: agent.skills, + skillCreator: evalUser, }); }); diff --git a/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts b/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts index 851a7fe0775..9fb89bcbe77 100644 --- a/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/agent-execution.service.ts @@ -201,7 +201,8 @@ export class EvalAgentExecutionService { return this.errorResult(`Agent ${agentId} has no JSON config to run.`); } - const skillRefs = await Container.get(AgentSkillRefsService).refsForDraft(agentEntity, {}); + const agentSkillRefs = Container.get(AgentSkillRefsService); + const skillRefs = await agentSkillRefs.refsForDraft(agentEntity, {}); const { config, skippedFeatures } = pruneConfigForEval( toAgentDocument(agentEntity.schema, skillRefs), ); @@ -356,7 +357,11 @@ export class EvalAgentExecutionService { let agent: RuntimeAgent; try { ({ agent } = await reconstruction.reconstructFromAgentEntity( - { agent: agentEntity, skillRefs: pruned.skillRefs }, + { + agent: agentEntity, + skillRefs: pruned.skillRefs, + skills: await agentSkillRefs.skillsForRefs(agentEntity, pruned.skillRefs, {}), + }, credentialProvider, 'test', undefined, diff --git a/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts b/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts index 095c3ad6acd..0a26d8a6237 100644 --- a/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts @@ -279,8 +279,9 @@ export class EvalThreadRestoreService { } } - /** Recreate each seed agent, config and skill bodies in one insert — an agent is - * a single row, so there are no skill files to write. Rolls back on failure. + /** Recreate each seed agent from its config. A seed skill body becomes a new + * skill of that agent only, and the agent ref points to the new id. Deleting + * the agent deletes the skill, so a rollback on failure leaves no skills behind. * Names are not uniquified as seed data tables' are; see `remapSeedArtifactIds`. */ async restoreAgents( agents: InstanceAiEvalSeedAgent[], @@ -315,7 +316,7 @@ export class EvalThreadRestoreService { actor: { kind: 'user', user }, id: agent.id, schema, - ...(agent.skills ? { skills: agent.skills } : {}), + ...(agent.skills ? { skills: agent.skills, skillCreator: user } : {}), }); created.push(agent.id); } diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index 94f98673cf3..3099718dfb7 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -75,7 +75,6 @@ import { AgentPolicyService } from '@/modules/agents/agent-policy.service'; import { createAgentSkillRefsService, storedAgentConfig, - storedSkillRefs, } from '../../agents/__tests__/test-utils/stored-agent-config'; const userHasScopesMock = userHasScopes as Mock; @@ -134,6 +133,8 @@ const agentEntity = (overrides: Record = {}): Agent => }) as unknown as Agent; describe('McpAgentToolsService', () => { + // One fake skill refs seam for the MCP service and the services it writes through. + const agentSkillRefs = createAgentSkillRefsService(); const telemetry = mockInstance(Telemetry); const credentialsService = mockInstance(CredentialsService); const agentsService = mockInstance(AgentsService); @@ -185,13 +186,14 @@ describe('McpAgentToolsService', () => { urlService, projectScopeService, new McpAgentSlackSetup(slackManagedSetup, slackManualSetup, mockLogger()), - createAgentSkillRefsService(), + agentSkillRefs, ); let tools: Map; let registerResource: Mock; beforeEach(() => { + agentSkillRefs.draftRefs.clear(); vi.clearAllMocks(); userHasScopesMock.mockResolvedValue(true); agentsService.findByIdForUser.mockResolvedValue(agentEntity()); @@ -253,13 +255,12 @@ describe('McpAgentToolsService', () => { mockLogger(), agentRepository, saveCompletion, - createAgentSkillRefsService(agentRepository), + agentSkillRefs, ); const configService = new AgentConfigService( mockLogger(), agentRepository, agentTaskRepository, - mock(), new AgentConfigPreparationService( localCredentialsService, workflowRepository, @@ -269,7 +270,7 @@ describe('McpAgentToolsService', () => { transactionRunner, saveCompletion, new AgentPolicyService(new PolicyEnforcementService()), - createAgentSkillRefsService(agentRepository), + agentSkillRefs, ); agentCustomToolsService.buildCustomTool.mockImplementation( async (agentId, projectId, code, descriptor, context, options) => @@ -1181,6 +1182,33 @@ describe('McpAgentToolsService', () => { }); }); + it('reports skill names that are hard to tell apart as a warning, not an error', async () => { + agentConfigService.validateConfig.mockResolvedValue({ valid: true, config: baseConfig }); + agentValidationService.validateLoadedAgentConfiguration.mockResolvedValue({ + status: 'valid', + issues: [], + warnings: [ + { + code: 'similar_skill_names', + capability: { kind: 'skill' }, + names: ['Brand voice', 'brand-voice'], + }, + ], + }); + + const result = await callTool('validate_agent', { agentId: 'agent-1' }); + + expect(result.structuredContent).toMatchObject({ + ok: true, + valid: true, + errors: [], + missing: [], + warnings: [ + 'Skills "Brand voice", "brand-voice" have names that are hard to tell apart. Rename one of them.', + ], + }); + }); + it('aggregates the schema error and deduplicated missing capability paths', async () => { agentConfigService.validateConfig.mockResolvedValue({ valid: false, @@ -1996,16 +2024,46 @@ describe('McpAgentToolsService', () => { expect(result.structuredContent.config).not.toHaveProperty('integrations'); }); + it('lists the pinned skills that have a newer version', async () => { + const pinnedRef = { + type: 'skill' as const, + id: 'brand_voice', + versionId: '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f', + }; + const skill = { name: 'Brand voice', description: 'Voice', instructions: 'Old text.' }; + agentsService.findByIdForUser.mockResolvedValue( + agentEntity({ + schema: storedAgentConfig({ ...baseConfig, skills: [pinnedRef] }), + tools: {}, + }), + ); + agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] } as never); + agentSkillsService.listSkills.mockResolvedValue({ brand_voice: skill }); + agentTaskService.list.mockResolvedValue([] as never); + agentSkillRefs.findOutdatedSkillIds.mockResolvedValueOnce(['brand_voice']); + + const result = await callTool('get_agent', { projectId: 'project-1', agentId: 'agent-1' }); + + expect(result.structuredContent).toMatchObject({ outdatedSkillIds: ['brand_voice'] }); + expect(agentSkillRefs.findOutdatedSkillIds).toHaveBeenCalledWith( + [pinnedRef], + { brand_voice: skill }, + {}, + ); + }); + it('returns the stored skill refs, and config.replace of that config keeps them', async () => { const skillRefs = [ { type: 'skill' as const, id: 'zeta' }, - { type: 'skill' as const, id: 'alpha', enabled: false }, - { type: 'skill' as const, id: 'mid', enabled: true }, + { + type: 'skill' as const, + id: 'alpha', + versionId: '0b2c6f1e-7d3a-4c1b-9f2e-5a6b7c8d9e0f', + }, + { type: 'skill' as const, id: 'mid' }, ]; - const skillBody = { name: 'Skill', description: 'A skill', instructions: 'Do it.' }; const agent = agentEntity({ schema: storedAgentConfig({ ...baseConfig, skills: skillRefs }), - skills: { zeta: skillBody, mid: { ...skillBody, name: 'Mid' } }, tools: {}, }); useRealCustomToolPersistence(agent); @@ -2032,7 +2090,7 @@ describe('McpAgentToolsService', () => { }); expect(written.structuredContent).toMatchObject({ ok: true, configHash }); - expect(storedSkillRefs(agent)).toEqual(skillRefs); + await expect(agentSkillRefs.refsForDraft(agent, {})).resolves.toEqual(skillRefs); }); it('returns an error result for an unknown agent', async () => { diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts index 092297bf99a..2d45a94cb06 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts @@ -95,10 +95,15 @@ directly on that object — there is no \`value\` wrapper. For example: integrations; use update_agent_integration for those. - config.patch: Set \`patch\` to an array of RFC 6902 operations (add, remove, replace, move, copy, test). Paths under /integrations are rejected; use update_agent_integration for those. -- skill.upsert: Set \`skill\` to the complete skill body. Omit \`skillId\` to create and attach a new - skill, or pass it to replace an existing skill body. When replacing, also pass \`baseSkillHash\` - from get_agent's \`skillHashes\` so a skill edited elsewhere in the meantime is not overwritten. -- skill.delete: Set \`skillId\` to the skill to delete; its config reference is removed. +- skill.upsert: Set \`skill\` to the complete skill body. Omit \`skillId\` to create a new skill in + the Agent's project and attach it, or pass it to save the next version of an attached skill. A + skill can be shared: every Agent that uses it runs the new version. When replacing, also pass + \`baseSkillHash\` from get_agent's \`skillHashes\` so a skill edited elsewhere in the meantime is + not overwritten. Skills ignore \`allowedTools\`. +- skill.delete: Set \`skillId\` to the skill to detach. The skill stays available to other Agents. +- A skill ref with \`versionId\` pins that saved version of the skill. get_agent lists the pinned + skills that have a newer version in \`outdatedSkillIds\`. To use the latest version, remove + \`versionId\` from the ref with config.patch. Do not delete and add the skill again. - task.upsert: Set \`task\` to the complete task body. Omit \`taskId\` to create and attach a new scheduled task, or pass it to replace an existing one. \`enabled\` controls the task config reference. On a replace, an omitted \`timezone\` keeps the zone the task already has; send \`null\` to move it diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 9fcfd33c86f..e802285a206 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -1121,6 +1121,11 @@ export class McpAgentToolsService { this.agentSkillsService.listSkills(agentId, projectId), this.agentTaskService.list(agentId), ]); + const outdatedSkillIds = await this.agentSkillRefs.findOutdatedSkillIds( + config.skills ?? [], + skills, + {}, + ); const taskEnabled = new Map((config.tasks ?? []).map((task) => [task.id, task.enabled])); // The hash covers the full persisted config (integrations included) so it @@ -1148,6 +1153,7 @@ export class McpAgentToolsService { skillHashes: Object.fromEntries( Object.entries(skills).map(([id, skill]) => [id, getAgentSkillHash(skill)]), ), + outdatedSkillIds, tasks: tasks.map((task) => ({ ...task, enabled: taskEnabled.get(task.id) ?? false })), customTools: Object.entries(agent.tools ?? {}).map(([id, tool]) => ({ id, @@ -1644,10 +1650,18 @@ export class McpAgentToolsService { ), ), ]; + const warnings = (configuration.warnings ?? []).flatMap((warning) => + warning.code === 'similar_skill_names' + ? [ + `Skills ${warning.names.map((name) => `"${name}"`).join(', ')} have names that are hard to tell apart. Rename one of them.`, + ] + : [], + ); return { valid: errors.length === 0 && missing.length === 0, errors, missing, + ...(warnings.length > 0 ? { warnings } : {}), }; } diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-agent-package.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-agent-package.integration.test.ts index 8a023b63d53..29bfa91b288 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-agent-package.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-agent-package.integration.test.ts @@ -9,11 +9,19 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { mock } from 'vitest-mock-extended'; +import { setSkillsHubFlag } from '@/modules/agents/__tests__/test-utils/skills-hub-flag'; +import { AgentSkillDependency } from '@/modules/agents/entities/agent-skill-dependency.entity'; +import { AgentVersionSkillDependency } from '@/modules/agents/entities/agent-version-skill-dependency.entity'; import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { SkillFile } from '@/modules/agents/entities/skill-file.entity'; +import { SkillVersion } from '@/modules/agents/entities/skill-version.entity'; import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; import { AgentTaskSnapshotRepository } from '@/modules/agents/repositories/agent-task-snapshot.repository'; import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; +import { SkillRepository } from '@/modules/agents/repositories/skill.repository'; +import { withoutLegacySkillRefs } from '@/modules/agents/json-config/agent-document'; +import { toSkillContent } from '@/modules/agents/skills/skill.service'; import type { NodeTypes } from '@/node-types'; import { N8nPackageParser } from '../engine/n8n-package-parser'; @@ -40,22 +48,34 @@ let agentRepository: AgentRepository; let historyRepository: AgentHistoryRepository; let taskRepository: AgentTaskRepository; let taskSnapshotRepository: AgentTaskSnapshotRepository; +let skillRepository: SkillRepository; let directory: string; async function readSourceRecords() { + const { manager } = skillRepository; return await Promise.all([ agentRepository.find({ order: { id: 'ASC' } }), historyRepository.find({ order: { versionId: 'ASC' } }), taskRepository.find({ order: { id: 'ASC' } }), taskSnapshotRepository.find({ order: { versionId: 'ASC', taskId: 'ASC' } }), + // The skills hub rows of the agents, so an export that changes them fails too. + skillRepository.find({ order: { id: 'ASC' } }), + manager.find(SkillVersion, { order: { id: 'ASC' } }), + manager.find(SkillFile, { order: { skillVersionId: 'ASC', path: 'ASC' } }), + manager.find(AgentSkillDependency, { order: { agentId: 'ASC', skillId: 'ASC' } }), + manager.find(AgentVersionSkillDependency, { + order: { agentVersionId: 'ASC', skillId: 'ASC' }, + }), ]); } beforeAll(async () => { + await setSkillsHubFlag(true); await testModules.loadModules(['agents']); await testDb.init(); agentRepository = Container.get(AgentRepository); historyRepository = Container.get(AgentHistoryRepository); + skillRepository = Container.get(SkillRepository); taskRepository = Container.get(AgentTaskRepository); taskSnapshotRepository = Container.get(AgentTaskSnapshotRepository); exporter = Container.get(AgentExporter); @@ -63,13 +83,35 @@ beforeAll(async () => { const fixture = looseAgentsFixture(); for (const entry of fixture.manifest.agents) { const draft = serializedAgentSchema.parse(fixture.files[`${entry.target}/agent.json`]); + // A skill of a saved agent is a skills hub skill: it has its own id, no + // `enabled` flag on the ref, and no `allowedTools`. + const skillId = `${draft.id}_skill`; + const { allowedTools: _dropped, ...draftSkill } = draft.skills['shared-skill']; + draft.skills = { [skillId]: draftSkill }; + draft.config = { + ...AgentJsonConfigSchema.parse(draft.config), + skills: [{ type: 'skill', id: skillId }], + }; const published = structuredClone(draft); published.config = { ...AgentJsonConfigSchema.parse(published.config), instructions: 'Published configuration', integrations: [{ type: 'n8n_chat', credentialId: '' }], }; - published.skills['shared-skill'].instructions = 'Published skill'; + published.skills[skillId].instructions = 'Published skill'; + // v1 is the published text, v2 the text the draft follows. + await skillRepository.createSkill( + { + id: skillId, + target: { projectId: project.id, agentId: null }, + source: 'ui', + createdById: null, + }, + toSkillContent(published.skills[skillId]), + ); + const publishedSkillVersionId = (await skillRepository.findLatestSaved([skillId])).get(skillId)! + .version.id; + await skillRepository.insertSavedVersion(skillId, 2, toSkillContent(draftSkill), null); published.tools.shared_tool.code = 'throw new Error("Published tool must not run");'; published.tasks[`${entry.id}_task`] = { name: 'Published schedule', @@ -81,8 +123,7 @@ beforeAll(async () => { id: draft.id, name: draft.name, projectId: project.id, - schema: draft.config && storedAgentConfig(draft.config), - skills: draft.skills, + schema: draft.config && withoutLegacySkillRefs(storedAgentConfig(draft.config)), tools: draft.tools, availableInMCP: draft.availableInMCP, integrations: [{ type: 'slack', credentialId: 'current-slack' }], @@ -96,11 +137,14 @@ beforeAll(async () => { const history = await historyRepository.saveVersion({ versionId: `${agent.id}_published`, agentId: agent.id, - schema: storedAgentConfig(published.config), - skills: published.skills, + schema: withoutLegacySkillRefs(storedAgentConfig(published.config)), tools: published.tools, publishedBy: 'Package test', }); + await skillRepository.replaceDependencies(agent.id, [{ skillId }]); + await skillRepository.insertPins([ + { agentVersionId: history.versionId, skillId, skillVersionId: publishedSkillVersionId }, + ]); await taskSnapshotRepository.saveForVersion( Object.entries(published.tasks).map(([taskId, task]) => ({ versionId: history.versionId, diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-agent-selection.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-agent-selection.integration.test.ts index c6d2feca7ec..b07166a8dd2 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-agent-selection.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-agent-selection.integration.test.ts @@ -101,7 +101,6 @@ async function publish(agent: Agent) { versionId: `${agent.id}_published`, publishedBy: 'Package test', schema: definition.schema && storedAgentConfig(definition.schema), - skills: definition.skills, tools: definition.tools, }); await repository.setActiveVersionFenced(agent.id, agent.revision, { diff --git a/packages/cli/src/modules/n8n-packages/__tests__/fixtures/agent-package-fixtures.ts b/packages/cli/src/modules/n8n-packages/__tests__/fixtures/agent-package-fixtures.ts index d30d22c28fb..27820d9502b 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/fixtures/agent-package-fixtures.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/fixtures/agent-package-fixtures.ts @@ -54,7 +54,7 @@ function agentFiles(entry: ManifestEntry, availableInMCP: boolean): Record { const expected = input.files[`${target}/agent.json`] as SerializedAgent; expect(parsed.config).toMatchObject({ model: '', - skills: [{ id: 'shared-skill', enabled: false }], + skills: [{ id: 'shared-skill' }], tools: [{ id: 'shared_tool', enabled: false, requireApproval: true }], tasks: [{ id: `${parsed.sourceAgentId}_task`, enabled: false }], mcpServers: [{ name: 'Reference', url: '' }], diff --git a/packages/cli/test/integration/agents/agent-conversation-state.test.ts b/packages/cli/test/integration/agents/agent-conversation-state.test.ts index d8e01bd551d..1f464e01739 100644 --- a/packages/cli/test/integration/agents/agent-conversation-state.test.ts +++ b/packages/cli/test/integration/agents/agent-conversation-state.test.ts @@ -69,7 +69,6 @@ describe('Agent conversation state', () => { projectId: project.id, integrations: [], tools: {}, - skills: {}, }), ); }); diff --git a/packages/cli/test/integration/agents/agent-skills-hub.test.ts b/packages/cli/test/integration/agents/agent-skills-hub.test.ts new file mode 100644 index 00000000000..dc0fcfd9afc --- /dev/null +++ b/packages/cli/test/integration/agents/agent-skills-hub.test.ts @@ -0,0 +1,488 @@ +/** + * Agents read and write their skills through the skills hub (CONTEXT-225). + * A draft keeps its skill refs in `agent_skill_dependency`, a published + * version keeps its pins in `agent_version_skill_dependency`, and the skill text lives in + * `skill_version`. These tests run the agent services against real rows. + */ +import type { AgentJsonConfig, AgentSkill } from '@n8n/api-types'; +import { + createTeamProject, + linkUserToProject, + mockInstance, + testDb, + testModules, +} from '@n8n/backend-test-utils'; +import { TransactionRunner, type Project, type User } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { setSkillsHubFlag } from '@/modules/agents/__tests__/test-utils/skills-hub-flag'; +import { AgentPublishService } from '@/modules/agents/agent-publish.service'; +import { AgentRuntimeCacheService } from '@/modules/agents/agent-runtime-cache.service'; +import { AgentSkillRefsService } from '@/modules/agents/agent-skill-refs.service'; +import { AgentSkillsService } from '@/modules/agents/agent-skills.service'; +import { AgentTaskService } from '@/modules/agents/agent-task.service'; +import { AgentUpdateBroadcaster } from '@/modules/agents/agent-update-broadcaster'; +import { AgentValidationService } from '@/modules/agents/agent-validation.service'; +import { AgentsService } from '@/modules/agents/agents.service'; +import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'; +import type { Agent } from '@/modules/agents/entities/agent.entity'; +import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; +import { SkillRepository } from '@/modules/agents/repositories/skill.repository'; +import { SkillService, toAgentSkill } from '@/modules/agents/skills/skill.service'; +import { + markAgentDraftDirty, + saveAgentDraftFenced, +} from '@/modules/agents/utils/agent-draft.utils'; +import { getAgentSkillHash } from '@/modules/agents/utils/agent-config-hash'; +import { Telemetry } from '@/telemetry'; + +import { createMember, createOwner } from '../shared/db/users'; + +const config: AgentJsonConfig = { name: 'Agent', model: '', instructions: 'Help.' }; + +function body(name: string, instructions = `${name} instructions.`): AgentSkill { + return { name, description: `${name} description`, instructions }; +} + +describe('Agent skills in the skills hub', () => { + let owner: User; + let editor: User; + let project: Project; + let agentsService: AgentsService; + let agentSkillsService: AgentSkillsService; + let publishService: AgentPublishService; + let skillRefs: AgentSkillRefsService; + let skillService: SkillService; + let agentRepository: AgentRepository; + const broadcaster = mockInstance(AgentUpdateBroadcaster); + + beforeAll(async () => { + await setSkillsHubFlag(true); + mockInstance(Telemetry); + mockInstance(AgentRuntimeCacheService); + mockInstance(AgentTaskService).requestReconcile.mockResolvedValue(undefined); + const validation = mockInstance(AgentValidationService); + validation.validateAgentEntityConfiguration.mockResolvedValue({ status: 'valid', issues: [] }); + await testModules.loadModules(['agents']); + await testDb.init(); + await Container.get(AgentsSettingsService).setEnabled(true); + owner = await createOwner(); + editor = await createMember(); + agentsService = Container.get(AgentsService); + agentSkillsService = Container.get(AgentSkillsService); + publishService = Container.get(AgentPublishService); + skillRefs = Container.get(AgentSkillRefsService); + skillService = Container.get(SkillService); + agentRepository = Container.get(AgentRepository); + }); + + beforeEach(async () => { + project = await createTeamProject(); + await linkUserToProject(editor, project, 'project:editor'); + }); + + afterEach(async () => { + await agentRepository.delete({}); + await Container.get(SkillRepository).delete({}); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + const actor = () => ({ kind: 'user' as const, user: owner }); + + async function createSkill( + skill: AgentSkill, + target: { projectId: string | null } = { projectId: project.id }, + ) { + return await skillService.create({ + target: { projectId: target.projectId, agentId: null }, + skill, + source: 'ui', + createdById: owner.id, + }); + } + + /** Saves new instructions on top of the latest version, as an editor does. */ + async function saveInstructions(skillId: string, instructions: string) { + const latest = (await Container.get(SkillRepository).findLatestSaved([skillId])).get(skillId); + if (!latest) throw new Error(`Skill ${skillId} not found`); + const baseSkillHash = getAgentSkillHash(toAgentSkill(latest)); + return await skillService.save(skillId, { instructions, baseSkillHash }, owner.id); + } + + async function createAgent(skillIds: string[], projectId = project.id) { + return await agentsService.create(projectId, 'Agent', { + actor: actor(), + schema: { ...config, skills: skillIds.map((id) => ({ type: 'skill', id })) }, + }); + } + + async function reload(agent: Agent) { + return await agentRepository.findOneByOrFail({ id: agent.id }); + } + + async function publish(agent: Agent) { + await publishService.publishAgent(agent.id, agent.projectId, owner, { + by: 'user', + trigger: 'explicit', + }); + return await agentRepository.findByIdAndProjectId(agent.id, agent.projectId); + } + + async function instructionsOf(skillsRecord: Record) { + return Object.fromEntries( + Object.entries(skillsRecord).map(([id, skill]) => [id, skill.instructions]), + ); + } + + it('lists the skills of a draft and of its published version in the same attach order', async () => { + const zeta = await createSkill(body('Zeta')); + const alpha = await createSkill(body('Alpha')); + const mid = await createSkill(body('Mid')); + const agent = await createAgent([zeta, alpha, mid]); + + const published = await publish(agent); + const draftRefs = await skillRefs.refsForDraft(agent, {}); + const publishedRefs = await skillRefs.refsForVersion(published!.activeVersion!, {}); + + expect(draftRefs?.map((ref) => ref.id)).toEqual([zeta, alpha, mid]); + expect(publishedRefs?.map((ref) => ref.id)).toEqual([zeta, alpha, mid]); + const { skills } = await skillRefs.readVersion(published!.activeVersion!, {}); + expect(Object.keys(skills)).toEqual([zeta, alpha, mid]); + }); + + it('keeps the attach order when the refs are written in another order', async () => { + const first = await createSkill(body('First')); + const second = await createSkill(body('Second')); + const agent = await reload(await createAgent([first, second])); + + // As a config save does: advance the draft and write the refs in one transaction. + markAgentDraftDirty(agent); + await Container.get(TransactionRunner).run({}, async (ctx) => { + await saveAgentDraftFenced(agentRepository, agent, ctx); + await skillRefs.replaceDraftRefs( + agent, + [ + { type: 'skill', id: second }, + { type: 'skill', id: first }, + ], + ctx, + ); + }); + + expect((await skillRefs.refsForDraft(agent, {}))?.map((ref) => ref.id)).toEqual([ + first, + second, + ]); + }); + + it('marks every following agent as changed on a shared skill save, and a published agent keeps its pinned text', async () => { + const shared = await createSkill(body('Shared', 'Version one.')); + const first = (await publish(await createAgent([shared])))!; + const second = (await publish(await createAgent([shared])))!; + expect(first.versionId).toBe(first.activeVersionId); + + await agentSkillsService.updateSkill( + first.id, + project.id, + shared, + { instructions: 'Version two.' }, + { user: owner, modifiedBy: 'user' }, + ); + + for (const agent of [first, second]) { + const stored = await reload(agent); + expect(stored.versionId).not.toBe(stored.activeVersionId); + const draft = await skillRefs.readDraft(stored, {}); + expect(await instructionsOf(draft.skills)).toEqual({ [shared]: 'Version two.' }); + const version = await skillRefs.readVersion(agent.activeVersion!, {}); + expect(await instructionsOf(version.skills)).toEqual({ [shared]: 'Version one.' }); + } + expect(broadcaster.notify).toHaveBeenCalledWith( + expect.objectContaining({ agentId: second.id }), + undefined, + ); + }); + + it('reverts to the pinned versions without changing the hub', async () => { + const skill = await createSkill(body('Skill', 'Published text.')); + const agent = (await publish(await createAgent([skill])))!; + await saveInstructions(skill, 'Newer text.'); + const latestBefore = (await Container.get(SkillRepository).findLatestSaved([skill])).get(skill); + + await publishService.revertToPublishedAgent(agent.id, project.id, owner, 'user'); + + const refs = await skillRefs.refsForDraft(agent, {}); + const pinned = (await Container.get(SkillRepository).findPinned(agent.activeVersionId!)).get( + skill, + ); + expect(refs).toEqual([{ type: 'skill', id: skill, versionId: pinned?.version.id }]); + const draft = await skillRefs.readDraft(agent, {}); + expect(await instructionsOf(draft.skills)).toEqual({ [skill]: 'Published text.' }); + const latestAfter = (await Container.get(SkillRepository).findLatestSaved([skill])).get(skill); + expect(latestAfter?.version.id).toBe(latestBefore?.version.id); + }); + + it('reverts an agent that pins an instance skill the user cannot edit', async () => { + const instanceSkill = await createSkill(body('Instance'), { projectId: null }); + const agent = (await publish(await createAgent([instanceSkill])))!; + await saveInstructions(instanceSkill, 'Changed by an admin.'); + + await publishService.revertToPublishedAgent(agent.id, project.id, editor, 'user'); + + const refs = await skillRefs.refsForDraft(agent, {}); + expect(refs?.[0]).toMatchObject({ id: instanceSkill, versionId: expect.any(String) }); + await expect( + agentSkillsService.updateSkill( + agent.id, + project.id, + instanceSkill, + { instructions: 'Edited by the editor.' }, + { user: editor, modifiedBy: 'user' }, + ), + ).rejects.toThrow('This agent uses an older version of this skill.'); + }); + + it('creates, updates and detaches a skill from the agent builder', async () => { + const agent = await createAgent([]); + const context = { user: editor, modifiedBy: 'builder' as const }; + + const created = await agentSkillsService.createAndAttachSkill( + agent.id, + project.id, + body('Triage'), + context, + ); + const [hubSkill] = await Container.get(SkillRepository).findByIds([created.id]); + expect(hubSkill).toMatchObject({ projectId: project.id, agentId: null, source: 'agent' }); + expect(await skillRefs.refsForDraft(agent, {})).toEqual([{ type: 'skill', id: created.id }]); + + const updated = await agentSkillsService.updateSkill( + agent.id, + project.id, + created.id, + { instructions: 'Sort tickets by urgency.' }, + context, + created.skillHash, + ); + expect(updated.skill.instructions).toBe('Sort tickets by urgency.'); + await expect( + agentSkillsService.updateSkill( + agent.id, + project.id, + created.id, + { instructions: 'Stale edit.' }, + context, + created.skillHash, + ), + ).rejects.toThrow('Skill was changed elsewhere'); + + await agentSkillsService.deleteSkill(agent.id, project.id, created.id, context); + expect(await skillRefs.refsForDraft(agent, {})).toEqual([]); + expect(await Container.get(SkillRepository).findByIds([created.id])).toHaveLength(1); + }); + + it('keeps the published skills readable after the flag is turned off', async () => { + const skill = await createSkill(body('Published', 'Published text.')); + const published = (await publish(await createAgent([skill])))!; + await saveInstructions(skill, 'Newer text.'); + const version = published.activeVersion!; + + await setSkillsHubFlag(false); + try { + // With the flag off, the legacy reader uses the columns that publish wrote. + const { skillRefs: refs, skills } = await skillRefs.readVersion(version, {}); + expect(refs).toEqual([{ type: 'skill', id: skill }]); + expect(await instructionsOf(skills)).toEqual({ [skill]: 'Published text.' }); + } finally { + await setSkillsHubFlag(true); + } + }); + + it('keeps skills on the agent row when the skills hub is off', async () => { + await setSkillsHubFlag(false); + try { + const agent = await createAgent([]); + const context = { user: editor, modifiedBy: 'builder' as const }; + + const created = await agentSkillsService.createAndAttachSkill( + agent.id, + project.id, + body('Triage'), + context, + ); + await agentSkillsService.updateSkill( + agent.id, + project.id, + created.id, + { instructions: 'Sort tickets by urgency.' }, + context, + created.skillHash, + ); + const published = (await publish(await reload(agent)))!; + await agentSkillsService.deleteSkill(agent.id, project.id, created.id, context); + await publishService.revertToPublishedAgent(agent.id, project.id, owner, 'user'); + + const row = await reload(agent); + expect(row.schema?.skills).toEqual([{ type: 'skill', id: created.id }]); + expect(row.skills[created.id]?.instructions).toBe('Sort tickets by urgency.'); + expect(published.activeVersion?.skills?.[created.id]?.instructions).toBe( + 'Sort tickets by urgency.', + ); + expect(await Container.get(SkillRepository).findByIds([created.id])).toEqual([]); + expect(await skillRefs.refsForDraft(row, {})).toEqual([{ type: 'skill', id: created.id }]); + } finally { + await setSkillsHubFlag(true); + } + }); + + it('keeps a skill for this agent only, and copies it for a duplicate', async () => { + const agent = await createAgent([]); + const context = { user: editor, modifiedBy: 'user' as const }; + const created = await agentSkillsService.createAndAttachSkill( + agent.id, + project.id, + body('Private'), + context, + 'agent', + ); + const [hubSkill] = await Container.get(SkillRepository).findByIds([created.id]); + expect(hubSkill).toMatchObject({ projectId: null, agentId: agent.id }); + + const other = await createAgent([]); + await expect( + skillRefs.replaceDraftRefs(other, [{ type: 'skill', id: created.id }], {}), + ).rejects.toThrow('is not available to this agent'); + + const duplicate = await agentsService.create(project.id, 'Copy', { + actor: actor(), + schema: { ...config, skills: [{ type: 'skill', id: created.id }] }, + user: owner, + }); + const [copyRef] = await skillRefs.refsForDraft(duplicate, {}); + expect(copyRef.id).not.toBe(created.id); + const [copy] = await Container.get(SkillRepository).findByIds([copyRef.id]); + expect(copy).toMatchObject({ projectId: null, agentId: duplicate.id }); + const { skills } = await skillRefs.readDraft(duplicate, {}); + expect(Object.values(skills)).toEqual([body('Private')]); + }); + + it('changes where a skill can be added only while no other agent uses it', async () => { + const agent = await createAgent([]); + const context = { user: editor, modifiedBy: 'user' as const }; + const created = await agentSkillsService.createAndAttachSkill( + agent.id, + project.id, + body('Movable'), + context, + ); + + const update = async (availableTo: 'project' | 'agent' | 'instance') => + await agentSkillsService.updateSkill( + agent.id, + project.id, + created.id, + {}, + context, + undefined, + undefined, + availableTo, + ); + + await update('agent'); + expect((await Container.get(SkillRepository).findByIds([created.id]))[0]).toMatchObject({ + projectId: null, + agentId: agent.id, + }); + await update('project'); + const other = await createAgent([created.id]); + await expect(update('agent')).rejects.toThrow('Other agents use this skill.'); + expect((await Container.get(SkillRepository).findByIds([created.id]))[0]).toMatchObject({ + agentId: null, + }); + + // A published pin counts too, after the draft no longer refers to the skill. + await publish(other); + await skillRefs.replaceDraftRefs(await reload(other), [], {}); + expect(await skillRefs.refsForDraft(await reload(other), {})).toEqual([]); + await expect(update('agent')).rejects.toThrow('Other agents use this skill.'); + }); + + it('shares the skills of a duplicated agent', async () => { + const skill = await createSkill(body('Shared')); + const original = await createAgent([skill]); + const { skills } = await skillRefs.readDraft(original, {}); + + const duplicate = await agentsService.create(project.id, 'Copy', { + actor: actor(), + user: owner, + schema: { ...config, skills: [{ type: 'skill', id: skill }] }, + skills, + }); + + expect(await skillRefs.refsForDraft(duplicate, {})).toEqual([{ type: 'skill', id: skill }]); + expect(await Container.get(SkillRepository).findByIds([skill])).toHaveLength(1); + }); + + it('refuses a duplicate into another project', async () => { + const skill = await createSkill(body('Project skill')); + const original = await createAgent([skill]); + const { skills } = await skillRefs.readDraft(original, {}); + const otherProject = await createTeamProject(); + + const duplicate = agentsService.create(otherProject.id, 'Copy', { + actor: actor(), + user: owner, + schema: { ...config, skills: [{ type: 'skill', id: skill }] }, + skills, + }); + + await expect(duplicate).rejects.toThrow(UserError); + await expect(duplicate).rejects.toThrow('is not available to this agent'); + expect(await agentRepository.findBy({ projectId: otherProject.id })).toEqual([]); + }); + + it('refuses an agent skill of another project and does not copy it', async () => { + const otherProject = await createTeamProject(); + const source = await createAgent([], otherProject.id); + const secret = await skillService.create({ + target: { projectId: null, agentId: source.id }, + skill: body('Secret skill'), + source: 'agent', + createdById: owner.id, + }); + + const attempt = agentsService.create(project.id, 'Copy', { + actor: actor(), + user: owner, + schema: { ...config, skills: [{ type: 'skill', id: secret }] }, + skills: {}, + }); + + await expect(attempt).rejects.toThrow('is not available to this agent'); + const agentSkills = await Container.get(SkillRepository).findBy({ source: 'agent' }); + expect(agentSkills.map((skill) => skill.id)).toEqual([secret]); + }); + + it('creates an agent skill for a seeded ref that has only a body, and deletes it with the agent', async () => { + const agent = await agentsService.create(project.id, 'Seeded', { + actor: actor(), + schema: { ...config, skills: [{ type: 'skill', id: 'seed_skill' }] }, + skills: { seed_skill: body('Seeded skill') }, + skillCreator: owner, + }); + + const { skillRefs: refs, skills } = await skillRefs.readDraft(agent, {}); + expect(refs).toHaveLength(1); + expect(refs?.[0].id).not.toBe('seed_skill'); + expect(Object.values(skills)).toEqual([body('Seeded skill')]); + const [hubSkill] = await Container.get(SkillRepository).findByIds([refs![0].id]); + expect(hubSkill).toMatchObject({ projectId: null, agentId: agent.id, source: 'agent' }); + + await agentsService.delete(agent.id, project.id); + expect(await Container.get(SkillRepository).findByIds([refs![0].id])).toEqual([]); + }); +}); diff --git a/packages/cli/test/integration/agents/agent-thread-grant.repository.test.ts b/packages/cli/test/integration/agents/agent-thread-grant.repository.test.ts index 3f6640fcfce..b99c55b0a3a 100644 --- a/packages/cli/test/integration/agents/agent-thread-grant.repository.test.ts +++ b/packages/cli/test/integration/agents/agent-thread-grant.repository.test.ts @@ -53,7 +53,6 @@ describe('Agent thread grants', () => { projectId: project.id, integrations: [], tools: {}, - skills: {}, }), ); for (const id of ['parent', 'child', 'sibling', 'new-thread']) { diff --git a/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts b/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts index 2240d6ea37a..4afb89b0ccc 100644 --- a/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts +++ b/packages/cli/test/integration/agents/n8n-chat-page.api.test.ts @@ -44,7 +44,6 @@ describe('n8n Chat page HTTP routes', () => { schema: { name, model, instructions: 'Help' }, integrations: [chatIntegration], tools: {}, - skills: {}, versionId: randomUUID(), }), ); @@ -61,7 +60,6 @@ describe('n8n Chat page HTTP routes', () => { ...(subAgents ? { subAgents: { agents: subAgents } } : {}), }, tools: {}, - skills: {}, publishedBy: 'test', }); await agentRepository.update({ id: agent.id }, { activeVersionId: versionId }); diff --git a/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts b/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts index 2eceee9cd5b..9a162d9acf9 100644 --- a/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts +++ b/packages/cli/test/integration/agents/n8n-chat-production.api.test.ts @@ -37,7 +37,6 @@ describe('production n8n Chat HTTP route', () => { schema: { name: 'Agent', model: 'openai:gpt-4o-mini', instructions: 'Help' }, integrations: [chatIntegration], tools: {}, - skills: {}, versionId: randomUUID(), }), ); @@ -56,7 +55,6 @@ describe('production n8n Chat HTTP route', () => { integrations: enabled ? [chatIntegration] : [], }, tools: {}, - skills: {}, publishedBy: 'test', }); await Container.get(AgentRepository).update({ id: agentId }, { activeVersionId: versionId }); diff --git a/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts b/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts index 74d63a17fa2..b4e24a5d284 100644 --- a/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts +++ b/packages/cli/test/integration/agents/n8n-chat-production.model.test.ts @@ -252,7 +252,6 @@ describe.skipIf(!enabled)('production n8n Chat with a real model', () => { projectId: project.id, schema: storedAgentConfig(schema), tools, - skills: {}, integrations: [chatIntegration], versionId: randomUUID(), }), @@ -263,7 +262,6 @@ describe.skipIf(!enabled)('production n8n Chat with a real model', () => { agentId: agent.id, schema: storedAgentConfig({ ...schema, integrations: [chatIntegration] }), tools, - skills: {}, publishedBy: owner, }); await repository.update({ id: agent.id }, { activeVersionId: versionId }); diff --git a/packages/cli/test/integration/agents/skill.repository.test.ts b/packages/cli/test/integration/agents/skill.repository.test.ts index fe6b2607bea..19626098c27 100644 --- a/packages/cli/test/integration/agents/skill.repository.test.ts +++ b/packages/cli/test/integration/agents/skill.repository.test.ts @@ -80,7 +80,6 @@ describe('SkillRepository', () => { projectId: options.projectId ?? project.id, integrations: [], tools: {}, - skills: {}, versionId, }), ); @@ -443,6 +442,20 @@ describe('SkillRepository', () => { expect([...pinned.keys()]).toEqual([id]); expect(pinned.get(id)?.version).toMatchObject({ id: v1, version: 1 }); }); + + it('keeps the pins of one publish in the order they were given', async () => { + const zeta = await createSkill(); + const alpha = await createSkill(); + const agentVersionId = await publish((await createAgent('Support')).id); + + await skills.insertPins([ + { agentVersionId, skillId: zeta, skillVersionId: await latestVersionId(zeta) }, + { agentVersionId, skillId: alpha, skillVersionId: await latestVersionId(alpha) }, + ]); + + expect([...(await skills.findPinned(agentVersionId)).keys()]).toEqual([zeta, alpha]); + expect(await skills.findPinnedSkillIds(agentVersionId)).toEqual([zeta, alpha]); + }); }); describe('dependencies', () => { @@ -618,6 +631,41 @@ describe('SkillRepository', () => { }); }); + describe('findUsingAgentIds and updateTarget', () => { + it('lists the agents with a draft ref or a published pin, once each', async () => { + const id = await createSkill({ projectId: project.id }); + const drafting = await createAgent('Drafting'); + await skills.replaceDependencies(drafting.id, [{ skillId: id }]); + const published = await createAgent('Published'); + await skills.insertPins([ + { + agentVersionId: await publish(published.id), + skillId: id, + skillVersionId: await latestVersionId(id), + }, + ]); + await skills.replaceDependencies(published.id, [{ skillId: id }]); + + expect((await skills.findUsingAgentIds(id)).sort()).toEqual( + [drafting.id, published.id].sort(), + ); + }); + + it('moves a skill to one agent and back to the instance', async () => { + const id = await createSkill({ projectId: project.id }); + const agent = await createAgent('Agent'); + + await skills.updateTarget(id, { projectId: null, agentId: agent.id }); + expect((await skills.findByIds([id]))[0]).toMatchObject({ + projectId: null, + agentId: agent.id, + }); + + await skills.updateTarget(id, { projectId: null, agentId: null }); + expect((await skills.findByIds([id]))[0]).toMatchObject({ projectId: null, agentId: null }); + }); + }); + describe('findProjectNames', () => { it('maps project ids to names', async () => { const names = await skills.findProjectNames([project.id, project.id, otherProject.id]); diff --git a/packages/cli/test/integration/agents/skills.controller.test.ts b/packages/cli/test/integration/agents/skills.controller.test.ts index 395bebb4814..82440777bef 100644 --- a/packages/cli/test/integration/agents/skills.controller.test.ts +++ b/packages/cli/test/integration/agents/skills.controller.test.ts @@ -3,6 +3,7 @@ import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { randomUUID } from 'node:crypto'; +import { setSkillsHubFlag } from '@/modules/agents/__tests__/test-utils/skills-hub-flag'; import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { SkillRepository } from '@/modules/agents/repositories/skill.repository'; @@ -43,6 +44,20 @@ describe('SkillsController', () => { await linkUserToProject(viewerUser, project, 'project:viewer'); }); + beforeEach(async () => { + await setSkillsHubFlag(true); + }); + + it('hides the skills API when the skills hub is off', async () => { + await setSkillsHubFlag(false); + + await owner.get('/skills').expect(404); + await owner.post('/skills').send({ scope: 'instance', skill: body }).expect(404); + // The flag is checked before the body, so a malformed request also answers 404. + await owner.post('/skills').send({ scope: 'nope' }).expect(404); + await owner.get('/skills?take=-1').expect(404); + }); + afterEach(async () => { await Container.get(AgentRepository).delete({}); await Container.get(SkillRepository).delete({}); @@ -78,7 +93,6 @@ describe('SkillsController', () => { projectId, integrations: [], tools: {}, - skills: {}, versionId, }), ); diff --git a/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts index cb97000c35d..9884bc76da8 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution-thread-n8n-chat.repository.test.ts @@ -42,7 +42,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => { projectId, integrations: [], tools: {}, - skills: {}, } as Partial), ); agentId = agent.id; @@ -145,7 +144,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => { projectId, integrations: {}, tools: {}, - skills: {}, } as Partial), ); const inScope = await createThread({ ownerId: owner.id }); @@ -175,7 +173,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => { personalisation: { icon: 'bot', gradient: { from: '#000000', to: '#FFFFFF' } }, }, tools: null, - skills: null, }); await agentRepo.update({ id: agentId }, { activeVersionId: versionId }); const thread = await createThread({ ownerId: owner.id }); @@ -285,7 +282,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => { projectId, integrations: {}, tools: {}, - skills: {}, } as Partial), ); const thread = await createThread({ ownerId: owner.id, agentId: otherAgent.id }); @@ -325,7 +321,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => { projectId, integrations: [], tools: {}, - skills: {}, } as Partial), ); const lightThread = await createThread({ ownerId: owner.id }); @@ -388,7 +383,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => { projectId: otherProject.id, integrations: {}, tools: {}, - skills: {}, } as Partial), ); const inScope = await createThread({ ownerId: owner.id }); diff --git a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts index 303dacb67da..02af9ba9316 100644 --- a/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent-execution.repository.test.ts @@ -141,7 +141,6 @@ describe('AgentExecutionRepository', () => { projectId, integrations: [], tools: {}, - skills: {}, } as Partial); await agentRepo.save(agent); agentId = agent.id; @@ -1048,7 +1047,6 @@ describe('AgentExecutionRepository', () => { projectId, integrations: [], tools: {}, - skills: {}, }), ); const childThread = await createThread({ @@ -3674,7 +3672,6 @@ describe('AgentExecutionRepository', () => { projectId, integrations: [], tools: {}, - skills: {}, }), ); const { executionService, txRunner } = recordingServices(); diff --git a/packages/cli/test/integration/database/repositories/agent.repository.test.ts b/packages/cli/test/integration/database/repositories/agent.repository.test.ts index 81f4fea4d19..f1bfc2f2de4 100644 --- a/packages/cli/test/integration/database/repositories/agent.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/agent.repository.test.ts @@ -7,6 +7,7 @@ import { ConflictError } from '@n8n/errors'; import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; +import { setSkillsHubFlag } from '@/modules/agents/__tests__/test-utils/skills-hub-flag'; import type { CredentialsService } from '@/credentials/credentials.service'; import { AgentConfigPreparationService } from '@/modules/agents/agent-config-preparation.service'; import { AgentConfigService } from '@/modules/agents/agent-config.service'; @@ -14,11 +15,11 @@ import { AgentDefinitionService } from '@/modules/agents/agent-definition.servic import type { AgentPolicyService } from '@/modules/agents/agent-policy.service'; import type { AgentSaveCompletionService } from '@/modules/agents/agent-save-completion.service'; import type { AgentSetupCompletionService } from '@/modules/agents/agent-setup-completion.service'; -import type { AgentSkillsService } from '@/modules/agents/agent-skills.service'; import { AgentTaskService } from '@/modules/agents/agent-task.service'; import type { Agent } from '@/modules/agents/entities/agent.entity'; import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service'; import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; +import { SkillRepository } from '@/modules/agents/repositories/skill.repository'; import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository'; import { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import { getAgentConfigHash } from '@/modules/agents/utils/agent-config-hash'; @@ -48,7 +49,6 @@ describe('AgentRepository', () => { schema: { name: 'Test Agent', model: 'm', instructions: 'i' }, integrations: [], tools: {}, - skills: {}, versionId: 'version-1', activeVersionId: null, ...overrides, @@ -63,7 +63,6 @@ describe('AgentRepository', () => { author: 'test', schema: null, tools: null, - skills: null, }); } @@ -83,6 +82,8 @@ describe('AgentRepository', () => { }); afterEach(async () => { + // The rollout keeps the flag it read, so set it back off for the next test. + await setSkillsHubFlag(false); vi.restoreAllMocks(); await agentHistoryRepo.delete({}); await agentRepo.delete({}); @@ -179,7 +180,6 @@ describe('AgentRepository', () => { mockLogger(), agentRepo, taskRepo, - mock(), new AgentConfigPreparationService( credentials, mock(), @@ -221,7 +221,23 @@ describe('AgentRepository', () => { }); it('replaces the complete draft and preserves existing task creation times', async () => { + await setSkillsHubFlag(true); const agent = await createAgent(); + await Container.get(SkillRepository).createSkill( + { + id: 'writing', + target: { projectId, agentId: null }, + source: 'ui', + createdById: null, + }, + { + name: 'Writing', + description: 'Write notes', + instructions: 'Use short sentences', + frontmatter: null, + files: [], + }, + ); const createdAt = new Date('2025-01-01T00:00:00.000Z'); await taskRepo.insert([ { id: 'task-keep', agentId: agent.id, ...taskBody, createdAt }, @@ -268,15 +284,19 @@ describe('AgentRepository', () => { }; const restore = async () => await definitionService.replaceDraft(agent, definition); expect(await restore()).toBe(true); - expect(await agentRepo.findById(agent.id)).toMatchObject({ + const { skills: skillRefs, ...storedConfig } = definition.schema!; + const stored = await agentRepo.findById(agent.id); + expect(stored).toMatchObject({ name: 'Restored agent', - schema: definition.schema, + schema: storedConfig, tools: definition.tools, - skills: definition.skills, revision: 1, - // The skill refs write keeps the time of the fenced save. updatedAt: agent.updatedAt, }); + expect(stored?.schema).not.toHaveProperty('skills'); + // The skill refs live in the dependency rows. The skill body stays in the hub. + const agentSkillRefs = Container.get(AgentSkillRefsService); + await expect(agentSkillRefs.refsForDraft(agent, {})).resolves.toEqual(skillRefs); const tasks = await taskRepo.findByAgentId(agent.id); expect(tasks).toHaveLength(2); expect(tasks).toEqual( @@ -315,7 +335,7 @@ describe('AgentRepository', () => { await definitionService.replaceDraft(agent, { schema: agent.schema ? toAgentDocument(agent.schema, storedSkillRefs(agent)) : null, tools: agent.tools, - skills: agent.skills, + skills: {}, tasks: new Map([['task-1', { ...taskBody, objective: 'Restored objective' }]]), }); return task; @@ -397,7 +417,6 @@ describe('AgentRepository', () => { name: original.name, schema: original.schema, tools: original.tools, - skills: original.skills, revision: original.revision, }); expect(await taskRepo.findByAgentId(agent.id)).toMatchObject([{ id: 'own-task' }]); @@ -417,7 +436,7 @@ describe('AgentRepository', () => { // Setup validation reads pending edits against their stored base revision. agent.schema = { name: 'Pending edit', model: '', instructions: 'Pending instructions' }; await expect(definitionService.readDraft(agent)).resolves.toEqual({ - schema: agent.schema, + schema: { ...agent.schema, skills: [] }, tools: {}, skills: {}, tasks: new Map([['task-1', taskBody]]), @@ -459,7 +478,6 @@ describe('AgentRepository', () => { ? { ...agent.schema, integrations: [{ type: 'n8n_chat', credentialId: '' }] } : null, tools: {}, - skills: {}, publishedBy: 'test', }); await agentRepo.update({ id: agent.id }, { activeVersionId: firstVersion }); @@ -473,7 +491,6 @@ describe('AgentRepository', () => { agentId: agent.id, schema: agent.schema, tools: {}, - skills: {}, publishedBy: 'test', }); await agentRepo.update({ id: agent.id }, { activeVersionId: secondVersion }); @@ -630,7 +647,6 @@ describe('AgentRepository', () => { agentId: agent.id, schema: agent.schema, tools: null, - skills: null, publishedBy: 'Someone Else', }); await agentRepo.update({ id: agent.id }, { activeVersionId: 'version-1' }); @@ -861,7 +877,6 @@ describe('AgentRepository', () => { author: 'test', schema: storedAgentConfig(publishedSchema(snapshotIntegrations)), tools: null, - skills: null, }); await agentRepo.update({ id: agent.id }, { activeVersionId: versionId }); return (await agentRepo.findById(agent.id)) as Agent; @@ -1034,7 +1049,6 @@ describe('AgentRepository', () => { integrations: snapshotIntegrations, }, tools: null, - skills: null, }); await agentRepo.update({ id: agent.id }, { activeVersionId: versionId }); return (await agentRepo.findById(agent.id)) as Agent; diff --git a/packages/cli/test/integration/public-api/n8n-packages-export-agents.test.ts b/packages/cli/test/integration/public-api/n8n-packages-export-agents.test.ts index a42eebc6555..4ca7cbc610d 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-export-agents.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-export-agents.test.ts @@ -14,6 +14,7 @@ import { InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import type { Readable } from 'node:stream'; +import { setSkillsHubFlag } from '@/modules/agents/__tests__/test-utils/skills-hub-flag'; import type { Agent } from '@/modules/agents/entities/agent.entity'; import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository'; import { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository'; @@ -36,6 +37,8 @@ import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration import { createProjectVariable } from '@test-integration/db/variables'; import { setupTestServer } from '@test-integration/utils'; import { storedAgentConfig } from '@/modules/agents/__tests__/test-utils/stored-agent-config'; +import { SkillRepository } from '@/modules/agents/repositories/skill.repository'; +import { toSkillContent } from '@/modules/agents/skills/skill.service'; beforeAll(async () => await testModules.loadModules(['n8n-packages', 'agents'])); const server = setupTestServer({ endpointGroups: ['publicApi'] }); @@ -47,7 +50,8 @@ let dependency: Agent; let workflow: WorkflowEntity; let repository: AgentRepository; -beforeAll(() => { +beforeAll(async () => { + await setSkillsHubFlag(true); repository = Container.get(AgentRepository); Container.get(InstanceSettings).markAsLeader(); }); @@ -79,6 +83,7 @@ afterEach(async () => { await repository.delete({}); await Container.get(AgentHistoryRepository).delete({}); await Container.get(AgentTaskRepository).delete({}); + await Container.get(SkillRepository).delete({}); await testDb.truncate([ 'WorkflowEntity', 'WorkflowHistory', @@ -128,7 +133,7 @@ async function download(body: Partial, user = owner) { } it('downloads an Agent with its skills, tools, tasks, and dependencies', async () => { - const { config, tasks, ...content } = serializedAgentSchema.parse( + const { config, tasks, skills, ...content } = serializedAgentSchema.parse( looseAgentsFixture().files['agents/support/agent.json'], ); agent = await repository.save({ @@ -136,6 +141,19 @@ it('downloads an Agent with its skills, tools, tasks, and dependencies', async ( ...content, schema: config && storedAgentConfig(config), }); + const [skillId] = Object.keys(skills); + const { allowedTools: _dropped, ...skill } = skills[skillId]; + const skillRepository = Container.get(SkillRepository); + await skillRepository.createSkill( + { + id: skillId, + target: { projectId: project.id, agentId: null }, + source: 'ui', + createdById: null, + }, + toSkillContent(skill), + ); + await skillRepository.replaceDependencies(agent.id, [{ skillId }]); await Container.get(AgentTaskRepository).save( Object.entries(tasks).map(([id, task]) => ({ id, agentId: agent.id, ...task })), ); @@ -160,8 +178,8 @@ it('downloads an Agent with its skills, tools, tasks, and dependencies', async ( expect(parsed).toMatchObject({ sourceAgentId: agent.id, availableInMCP: true, - config: { skills: [{ enabled: false }], tasks: [{ enabled: false }] }, - skills: content.skills, + config: { skills: [{ type: 'skill', id: skillId }], tasks: [{ enabled: false }] }, + skills: { [skillId]: skill }, tools: content.tools, tasks: { [`${agent.id}_task`]: { cronExpression: '0 9 * * *', timezone: null } }, }); @@ -239,7 +257,6 @@ it.each(['published-strict', 'ignore-unpublished'] as const)( versionId: 'published-version', publishedBy: 'Package test', schema: storedAgentConfig(schema), - skills: {}, tools: {}, }); await repository.update(agent.id, { activeVersionId: 'published-version' }); diff --git a/packages/cli/test/integration/scheduling/agent-task-multi-main.test.ts b/packages/cli/test/integration/scheduling/agent-task-multi-main.test.ts index da94904d108..d6183a0298a 100644 --- a/packages/cli/test/integration/scheduling/agent-task-multi-main.test.ts +++ b/packages/cli/test/integration/scheduling/agent-task-multi-main.test.ts @@ -75,7 +75,6 @@ describe('agent tasks across two mains over one database', () => { schema: { name: 'Scheduled agent', model: 'm', instructions: 'i' }, integrations: [], tools: {}, - skills: {}, versionId, activeVersionId: null, } as Partial), @@ -86,7 +85,6 @@ describe('agent tasks across two mains over one database', () => { author: 'test', schema: null, tools: null, - skills: null, }); await snapshotRepo.saveForVersion([ { diff --git a/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts b/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts index edd8b85f184..710015ea3cb 100644 --- a/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts @@ -95,7 +95,6 @@ async function seedIndexedAgent( }), integrations: [], tools: {}, - skills: {}, versionId: 'draft-version-1', }); await agentRepo.save(agent); @@ -106,7 +105,6 @@ async function seedIndexedAgent( agentId: agent.id, schema: agent.schema, tools: agent.tools, - skills: agent.skills, publishedBy: user, }); agent.activeVersionId = publishedVersionId; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index f9f152115a4..c93cabbccc4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -8821,6 +8821,8 @@ "agents.builder.tools.type.custom": "Custom tool", "agents.builder.tools.remove": "Remove", "agents.builder.tools.back": "Back to tools", + "agents.builder.skills.outdated.title": "This agent uses an older version", + "agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. To get the latest version, remove the skill and add it again.", "agents.builder.skills.title": "Skills", "agents.builder.skills.description": "How this agent handles specific tasks", "agents.builder.skills.count": "{count} skill applied | {count} skills applied", @@ -8834,12 +8836,6 @@ "agents.builder.skills.name.placeholder": "Summarize meetings", "agents.builder.skills.description.label": "When to use this skill", "agents.builder.skills.description.placeholder": "Use when summarizing meetings or notes", - "agents.builder.skills.allowedTools.label": "Allowed tools", - "agents.builder.skills.allowedTools.placeholder": "load_workflow, search_docs", - "agents.builder.skills.allowedTools.remove": "Remove {tool}", - "agents.builder.skills.allowedTools.addModal.title": "Add allowed tool", - "agents.builder.skills.allowedTools.addModal.empty": "Attach tools to this agent before adding allowed tools.", - "agents.builder.skills.allowedTools.addModal.allSelected": "All attached tools are already allowed.", "agents.builder.skills.instructions.label": "What the skill does", "agents.builder.skills.instructions.placeholder": "Describe what the agent should do, step by step…", "agents.builder.skills.instructions.characterCount": "{count} / {max} characters", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index d382e44e384..4f876336201 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -5541,7 +5541,6 @@ describe('AgentBuilderView — three-column shell', () => { name: 'meeting_summary', description: 'Use when extracting decisions from meeting notes', instructions: 'Extract decisions, risks, and action items.', - allowedTools: ['load_workflow'], references: [ { path: 'references/guide.md', @@ -5549,6 +5548,8 @@ describe('AgentBuilderView — three-column shell', () => { }, ], }; + // Skills drop allowed tools: the edit keeps everything else. + const editedSkill = { ...updatedSkill, allowedTools: ['load_workflow'] }; intendedConfig = { name: 'Agent One', instructions: 'You are a helpful assistant.', @@ -5576,9 +5577,9 @@ describe('AgentBuilderView — three-column shell', () => { await nextTick(); const modalData = openModalWithDataMock.mock.calls[0][0].data as { - onConfirm: (payload: { id: string; skill: typeof updatedSkill }) => void; + onConfirm: (payload: { id: string; skill: typeof editedSkill }) => void; }; - modalData.onConfirm({ id: 'summarize_notes', skill: updatedSkill }); + modalData.onConfirm({ id: 'summarize_notes', skill: editedSkill }); await nextTick(); expect( diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts index c8f0688823b..a94e7f0e011 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts @@ -334,7 +334,7 @@ describe('AgentCapabilitiesSection', () => { }, ); - it('toggles skills and sub-agents without removing their configuration', async () => { + it('toggles sub-agents but not skills, without removing their configuration', async () => { const config: AgentJsonConfig = { ...configWithMcpServers([]), subAgents: { @@ -356,7 +356,6 @@ describe('AgentCapabilitiesSection', () => { skills: [ { id: 'skill-1', - enabled: false, skill: { name: 'Triage', description: '', instructions: '' }, }, ], @@ -365,14 +364,16 @@ describe('AgentCapabilitiesSection', () => { ); await flushPromises(); const skill = wrapper.get('[data-testid="agent-capabilities-skill-row"]'); - expect(skill.attributes('aria-description')).toBe('agents.builder.capabilities.deactivated'); + expect(skill.attributes('aria-description')).toBeUndefined(); await skill.trigger('click'); expect(wrapper.emitted('open-skill')).toEqual([['skill-1']]); + // A skill has no on/off toggle: its menu only removes it. await skill.trigger('contextmenu'); - await userEvent.click( - await screen.findByRole('menuitem', { name: 'agents.builder.contextMenu.activate' }), - ); - expect(wrapper.emitted('toggle-skill')).toEqual([[{ id: 'skill-1', enabled: true }]]); + await screen.findByRole('menuitem', { name: 'agents.builder.contextMenu.remove' }); + expect( + screen.queryByRole('menuitem', { name: 'agents.builder.contextMenu.activate' }), + ).not.toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); const subAgent = wrapper.findAll('[data-testid="agent-capabilities-sub-agent-row"]')[0]; expect(subAgent.attributes('aria-description')).toBe('agents.builder.capabilities.deactivated'); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts index 41e2f11a84f..50a10d8bd7c 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts @@ -45,13 +45,11 @@ function renderModal({ onConfirm = vi.fn(), skill, skillId, - availableTools, existingSkillNames, }: { onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void; skill?: AgentSkill; skillId?: string; - availableTools?: Array<{ name: string; label: string }>; existingSkillNames?: string[]; } = {}) { const renderComponent = createComponentRenderer(AgentSkillModal, { @@ -82,7 +80,6 @@ function renderModal({ agentId: 'a1', skill, skillId, - availableTools, existingSkillNames, onConfirm, }, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts index 85c0c8e41de..c489f9d0587 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts @@ -1,6 +1,5 @@ -import { flushPromises, mount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { defineComponent, h, onMounted } from 'vue'; -import { nextTick } from 'vue'; import { describe, expect, it, vi } from 'vitest'; import { AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH, @@ -169,27 +168,8 @@ describe('AgentSkillViewer', () => { expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([false]); }); - it('updates allowed tools from the picker and chips', async () => { + it('shows no allowed tools field', () => { const wrapper = mountViewer({ - skill: { - name: 'Research', - description: 'Use for research', - instructions: 'Main body', - }, - availableTools: [{ name: 'load_workflow', label: 'Load workflow', icon: 'workflow' }], - }); - - await wrapper.find('[data-testid="agent-skill-add-allowed-tool"]').trigger('click'); - await flushPromises(); - await nextTick(); - - const option = document.querySelector('[data-testid="agent-skill-allowed-tool-option"]'); - expect(option).toBeInTheDocument(); - await (option as HTMLElement).click(); - await nextTick(); - - expect(wrapper.emitted('update:skill')?.at(-1)).toEqual([{ allowedTools: ['load_workflow'] }]); - await wrapper.setProps({ skill: { name: 'Research', description: 'Use for research', @@ -198,13 +178,7 @@ describe('AgentSkillViewer', () => { }, }); - expect(wrapper.find('[data-testid="agent-skill-allowed-tool-chip"]').text()).toContain( - 'Load workflow', - ); - - await wrapper.find('[data-testid="agent-skill-allowed-tool-remove"]').trigger('click'); - - expect(wrapper.emitted('update:skill')?.at(-1)).toEqual([{ allowedTools: undefined }]); + expect(wrapper.find('[data-testid="agent-skill-allowed-tools"]').exists()).toBe(false); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts new file mode 100644 index 00000000000..6837afd28ed --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts @@ -0,0 +1,60 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import AgentChipButton from '../components/AgentChipButton.vue'; +import AgentSkillsSection from '../components/AgentSkillsSection.vue'; + +enableAutoUnmount(afterEach); + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), +})); + +const showMessage = vi.fn(); +vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage }) })); + +const skill = { name: 'Skill', description: 'A skill', instructions: 'Do it.' }; + +describe('AgentSkillsSection', () => { + it('warns on the chips of skills that use an older version', () => { + const wrapper = mount(AgentSkillsSection, { + props: { + skills: [ + { id: 'outdated', skill: { ...skill, name: 'Outdated' } }, + { id: 'current', skill: { ...skill, name: 'Current' } }, + ], + outdatedSkillIds: ['outdated'], + }, + }); + + const [outdated, current] = wrapper.findAllComponents(AgentChipButton); + expect(outdated.props('warning')).toBe(true); + expect(outdated.props('warningReasons')).toEqual(['agents.builder.skills.outdated.warning']); + expect(outdated.find('[data-testid="agent-chip-warning-icon"]').exists()).toBe(true); + expect(current.props('warning')).toBe(false); + expect(current.find('[data-testid="agent-chip-warning-icon"]').exists()).toBe(false); + }); + + it('does not open an outdated skill and tells the user to add it again', async () => { + const wrapper = mount(AgentSkillsSection, { + props: { + skills: [ + { id: 'outdated', skill: { ...skill, name: 'Outdated' } }, + { id: 'current', skill: { ...skill, name: 'Current' } }, + ], + outdatedSkillIds: ['outdated'], + }, + }); + const [outdated, current] = wrapper.findAllComponents(AgentChipButton); + + await outdated.trigger('click'); + await current.trigger('click'); + + expect(wrapper.emitted('open-skill')).toEqual([['current']]); + expect(showMessage).toHaveBeenCalledWith({ + type: 'warning', + title: 'agents.builder.skills.outdated.title', + message: 'agents.builder.skills.outdated.warning', + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts index 3a10a1c75e4..395be09cd05 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts @@ -151,7 +151,6 @@ describe('useAgentCapabilitiesActions — tools modal host seam', () => { type SkillModalData = { skillId?: string; existingSkillNames?: string[]; - availableTools?: Array<{ name: string; label: string; icon?: string }>; onConfirm: (payload: { id?: string; skill: AgentSkill }) => void; onRemove?: (skillId: string) => void; }; @@ -418,13 +417,16 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => { expect(createAgentSkill).not.toHaveBeenCalled(); }); - it('filters allowedTools to configured tool names before persisting', () => { - const { uiStore, actions, createSkill } = setupLocal(); + it('drops allowedTools before persisting and offers no tool list', () => { + const { uiStore, actions, createSkill } = setupLocal({ + tools: [{ type: 'node', name: 'darwin' } as AgentJsonToolConfig], + }); actions.onOpenAddSkillModal(); const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; + expect(modalData).not.toHaveProperty('availableTools'); modalData.onConfirm({ - skill: { ...triage, name: 'Second Skill', allowedTools: ['not_a_configured_tool'] }, + skill: { ...triage, name: 'Second Skill', allowedTools: ['darwin'] }, }); expect(createSkill).toHaveBeenCalledWith( @@ -432,59 +434,6 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => { ); }); - it('includes MCP server names in skill availableTools', () => { - const { uiStore, actions } = setupLocal({ - tools: [{ type: 'node', name: 'darwin' } as AgentJsonToolConfig], - mcpServers: [ - { - name: 'Notion mcp', - url: 'https://mcp.notion.example', - transport: 'streamableHttp', - authentication: 'none', - toolPermissions: { - categories: { read: 'always_allow', write: 'require_approval' }, - }, - }, - ], - }); - - actions.onOpenAddSkillModal(); - const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; - - expect(modalData.availableTools).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'darwin', icon: 'globe' }), - expect.objectContaining({ name: 'Notion mcp', icon: 'mcp' }), - ]), - ); - }); - - it('keeps MCP server names in allowedTools on persist', () => { - const { uiStore, actions, createSkill } = setupLocal({ - mcpServers: [ - { - name: 'Notion mcp', - url: 'https://mcp.notion.example', - transport: 'streamableHttp', - authentication: 'none', - toolPermissions: { - categories: { read: 'always_allow', write: 'require_approval' }, - }, - }, - ], - }); - - actions.onOpenAddSkillModal(); - const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData; - modalData.onConfirm({ - skill: { ...triage, name: 'Second Skill', allowedTools: ['Notion mcp'] }, - }); - - expect(createSkill).toHaveBeenCalledWith( - expect.objectContaining({ allowedTools: ['Notion mcp'] }), - ); - }); - it('rejects an add whose name duplicates an applied skill (case-insensitive, trimmed)', () => { const { uiStore, actions, createSkill } = setupLocal(); diff --git a/packages/frontend/editor-ui/src/features/agents/agent.types.ts b/packages/frontend/editor-ui/src/features/agents/agent.types.ts index 9ac6bf9e0fe..40e80ec0cc9 100644 --- a/packages/frontend/editor-ui/src/features/agents/agent.types.ts +++ b/packages/frontend/editor-ui/src/features/agents/agent.types.ts @@ -44,5 +44,6 @@ export type Agent = { skills: Record; configHash?: string | null; skillHashes?: Record; + outdatedSkillIds?: string[]; activeVersion: AgentVersion | null; }; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue index c33a178a116..fc26891300e 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderEditorColumn.vue @@ -96,7 +96,6 @@ const emit = defineEmits<{ 'add-skill': []; 'remove-tool': [index: number]; 'remove-skill': [id: string]; - 'toggle-skill': [payload: { id: string; enabled: boolean }]; 'upload-files': [files: File[]]; 'delete-file': [file: AgentFileDto]; 'add-vector-store': []; @@ -192,15 +191,14 @@ defineExpose({ onSetupTaskAction }); data-testid="agent-skills-panel" > diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue index a63941307ab..ef5c58690a8 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCapabilitiesSection.vue @@ -48,7 +48,7 @@ const props = withDefaults( config: AgentJsonConfig | null; tools: AgentJsonToolRef[]; customTools?: Record; - skills: Array<{ id: string; skill: AgentSkill; enabled?: boolean }>; + skills: Array<{ id: string; skill: AgentSkill }>; supportsActivation?: boolean; disabled?: boolean; projectId: string; @@ -88,7 +88,6 @@ const emit = defineEmits<{ 'add-skill': []; 'remove-tool': [index: number]; 'remove-skill': [id: string]; - 'toggle-skill': [payload: { id: string; enabled: boolean }]; 'toggle-task': [payload: { id: string; enabled: boolean }]; 'tasks-changed': []; 'update:config': [updates: Partial]; @@ -697,13 +696,11 @@ function openExistingSubAgentModal(subAgent: { ( name: props.data.skill?.name ?? getDefaultSkillName(), description: props.data.skill?.description ?? '', instructions: props.data.skill?.instructions ?? '', - ...(props.data.skill?.allowedTools ? { allowedTools: props.data.skill.allowedTools } : {}), ...(props.data.skill?.references ? { references: props.data.skill.references } : {}), }), ); @@ -157,10 +155,7 @@ function onSkillUpdate(updates: Partial) { } function normalizeSkill(skill: AgentSkill): AgentSkill { - return normalizeAgentSkillForSave( - skill, - props.data.availableTools?.map((tool) => tool.name), - ); + return normalizeAgentSkillForSave(skill); } function onAddReference() { @@ -223,7 +218,6 @@ function onSave() { name: skill.value.name.trim(), description: skill.value.description.trim(), instructions: skill.value.instructions, - ...(skill.value.allowedTools ? { allowedTools: skill.value.allowedTools } : {}), ...(skill.value.references ? { references: skill.value.references } : {}), }); @@ -283,7 +277,6 @@ function onRemove() { /> >; selectedPath?: string; showValidationWarnings?: boolean; }>(), { - availableTools: () => [], disabled: false, selectedPath: SKILL_FILE, showValidationWarnings: false, @@ -59,7 +39,6 @@ const emit = defineEmits<{ const i18n = useI18n(); const description = ref(props.skill.description); const referenceFileName = ref(''); -const addToolDialogOpen = ref(false); const formValidation = reactive({ description: false, referenceName: true, @@ -139,24 +118,6 @@ const isSkillFileSelected = computed(() => props.selectedPath === SKILL_FILE); const selectedReference = computed(() => (props.skill.references ?? []).find((reference) => reference.path === props.selectedPath), ); -const availableToolsByName = computed( - () => new Map(props.availableTools.map((tool) => [tool.name, tool])), -); -const selectedAllowedTools = computed(() => - (props.skill.allowedTools ?? []).map((toolName) => { - const availableTool = availableToolsByName.value.get(toolName); - return { - name: toolName, - label: availableTool?.label || formatToolNameForDisplay(toolName) || toolName, - icon: availableTool?.icon ?? 'wrench', - }; - }), -); -const hasAllowedTools = computed(() => selectedAllowedTools.value.length > 0); -const addableAllowedTools = computed(() => { - const selected = new Set(props.skill.allowedTools ?? []); - return props.availableTools.filter((tool) => !selected.has(tool.name)); -}); const selectedReferenceCharacterCount = computed(() => i18n.baseText('agents.builder.skills.references.characterCount' as BaseTextKey, { interpolate: { @@ -205,22 +166,6 @@ function onInstructionsInput(value: string) { emit('update:skill', { instructions: value }); } -function updateAllowedTools(allowedTools: string[]) { - emit('update:skill', { allowedTools: allowedTools.length > 0 ? allowedTools : undefined }); -} - -function onAddAllowedTool(toolName: string) { - const allowedTools = props.skill.allowedTools ?? []; - if (!allowedTools.includes(toolName)) { - updateAllowedTools([...allowedTools, toolName]); - } - addToolDialogOpen.value = false; -} - -function onRemoveAllowedTool(toolName: string) { - updateAllowedTools((props.skill.allowedTools ?? []).filter((name) => name !== toolName)); -} - function replaceReference(updated: AgentSkillReference, currentPath = updated.path) { emit('update:skill', { references: (props.skill.references ?? []).map((reference) => @@ -365,63 +310,6 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true }); - -
- -
- - - - {{ tool.label }} - - - - - - - - - -
-
-
- - -
- - {{ i18n.baseText('agents.builder.skills.allowedTools.addModal.empty' as BaseTextKey) }} - - - {{ - i18n.baseText('agents.builder.skills.allowedTools.addModal.allSelected' as BaseTextKey) - }} - - - {{ tool.label }} - -
-
@@ -534,50 +392,6 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true }); } } -.allowedTools { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--spacing--2xs); - margin-top: var(--spacing--5xs); -} - -.allowedToolChip { - display: inline-flex; - align-items: center; - gap: var(--spacing--2xs); - height: var(--height--md); - max-width: min(12rem, 100%); - padding: var(--spacing--xs) var(--spacing--4xs) var(--spacing--xs) var(--spacing--xs); - border: var(--border); - border-radius: var(--radius--full); - background: light-dark(var(--background--surface), var(--background--subtle)); - box-shadow: var(--shadow--xs); -} - -.allowedToolLabel { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: var(--font-weight--medium); -} - -.allowedToolRemove { - --button--color--background-hover: transparent; - --button--color--background-active: transparent; -} - -.allowedToolOptions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing--2xs); -} - -.allowedToolOption { - max-width: min(12rem, 100%); -} - .editorLabel { display: flex; flex-direction: column; diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSkillsSection.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSkillsSection.vue index 5846ba25f23..e9f622d2462 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSkillsSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSkillsSection.vue @@ -1,5 +1,6 @@