mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(core): Switch agent skills to the skills hub (no-changelog) (#40860)
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io> Co-authored-by: Roman Makowiak <roman.makowiak@n8n.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
29de1c63ae
commit
94bdf85a8a
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: '' } })
|
||||
|
|
|
|||
|
|
@ -58,8 +58,24 @@ export const agentConfigValidationIssueSchema = z.object({
|
|||
});
|
||||
export type AgentConfigValidationIssue = z.infer<typeof agentConfigValidationIssueSchema>;
|
||||
|
||||
/** 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<typeof agentConfigValidationWarningSchema>;
|
||||
|
||||
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<typeof agentConfigValidationResponseSchema>;
|
||||
|
|
|
|||
|
|
@ -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<typeof AgentJsonConfigSchema>;
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<typeof agentSkillAvailabilitySchema>;
|
||||
|
||||
export const createAgentSkillSchema = agentSkillSchema.extend({
|
||||
availableTo: agentSkillAvailabilitySchema.optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof agentSkillSchema>) {
|
||||
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<typeof createAgentSkillSchema>) {
|
||||
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<typeof updateAttachedAgentSkillSchema>) {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<AgentRepository>();
|
||||
const agentTaskRepository = mock<AgentTaskRepository>();
|
||||
const agentSkillsService = mock<AgentSkillsService>();
|
||||
const skillRefs = createAgentSkillRefsService();
|
||||
const runtimeCacheService = mock<AgentRuntimeCacheService>();
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
|
|
@ -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>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ function makeService() {
|
|||
agentUpdateBroadcaster,
|
||||
modificationTelemetry,
|
||||
),
|
||||
createAgentSkillRefsService(agentRepository),
|
||||
createAgentSkillRefsService(),
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ function makeService(sandboxEnabled = false) {
|
|||
backgroundJobRepository,
|
||||
backgroundJobs,
|
||||
settingsService,
|
||||
createAgentSkillRefsService(agentRepository),
|
||||
createAgentSkillRefsService(),
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ function setup(options: SetupOptions = {}) {
|
|||
new AgentModificationTelemetryService(telemetry),
|
||||
credentialsService,
|
||||
setupCompletionService,
|
||||
createAgentSkillRefsService(agentRepository),
|
||||
createAgentSkillRefsService(),
|
||||
),
|
||||
agent,
|
||||
row,
|
||||
|
|
|
|||
|
|
@ -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<ToolDescriptor>({ 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 () => {
|
||||
|
|
|
|||
|
|
@ -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<AgentSkillRefsService>();
|
||||
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<AgentPublishService>();
|
||||
publishService.hasPublishHistory.mockResolvedValue(false);
|
||||
const service = new AgentRunnableStateService(
|
||||
mock<CredentialsService>(),
|
||||
mock<AgentValidationService>(),
|
||||
publishService,
|
||||
skillRefs,
|
||||
);
|
||||
const agent = { id: 'agent-1', schema: null, integrations: [] } as unknown as Agent;
|
||||
|
||||
const result = await service.addRunnableState(agent, 'project-1', mock<User>(), {
|
||||
status: 'valid',
|
||||
issues: [],
|
||||
});
|
||||
|
||||
expect(skillRefs.findOutdatedSkillIds).toHaveBeenCalledWith(draft.skillRefs, draft.skills, {});
|
||||
expect(result.outdatedSkillIds).toEqual(['pinned_older']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<GlobalConfig>;
|
||||
let telemetry: Mocked<Telemetry>;
|
||||
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<WorkflowRepository>(),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<Skill>({ id: skillId, agentId: null }),
|
||||
version: mock<SkillVersion>({
|
||||
id: versionId,
|
||||
skillId,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
instructions: `${name} instructions`,
|
||||
}),
|
||||
files: [] as SkillFile[],
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentSkillRefsService', () => {
|
||||
let agentRepository: ReturnType<typeof mock<AgentRepository>>;
|
||||
let skillRepository: Mocked<SkillRepository>;
|
||||
let skillService: Mocked<SkillService>;
|
||||
let agentRepository: Mocked<AgentRepository>;
|
||||
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<SkillRepository>();
|
||||
skillService = mock<SkillService>();
|
||||
agentRepository = mock<AgentRepository>();
|
||||
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<User>({ 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<Skill>({ 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<Skill>({ 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<Skill>({ 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<Skill>({ 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -170,7 +170,7 @@ describe('AgentTaskService', () => {
|
|||
),
|
||||
settingsService,
|
||||
transactionRunner,
|
||||
createAgentSkillRefsService(agentRepository),
|
||||
createAgentSkillRefsService(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ function makeCredentialProvider(
|
|||
}
|
||||
|
||||
function makeService() {
|
||||
const skillRefs = createAgentSkillRefsService();
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
agentRepository.hasRevision.mockResolvedValue(true);
|
||||
const agentSkillsService = mock<AgentSkillsService>();
|
||||
|
|
@ -87,14 +88,15 @@ function makeService() {
|
|||
agentTaskSnapshotRepository,
|
||||
agentRepository,
|
||||
mock<TransactionRunner>(),
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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<AgentsSettingsService>();
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
const executionService = mock<AgentExecutionService>();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ function makeController({
|
|||
credentialsService = mock<CredentialsService>(),
|
||||
agentDefaultModelResolverService = mock<AgentDefaultModelResolverService>(),
|
||||
collaborationService = mock<CollaborationService>(),
|
||||
skillRefs = createAgentSkillRefsService(),
|
||||
}: {
|
||||
agentsService?: Mocked<
|
||||
Pick<
|
||||
|
|
@ -42,12 +43,13 @@ function makeController({
|
|||
credentialsService?: Mocked<CredentialsService>;
|
||||
agentDefaultModelResolverService?: Mocked<AgentDefaultModelResolverService>;
|
||||
collaborationService?: Mocked<CollaborationService>;
|
||||
skillRefs?: ReturnType<typeof createAgentSkillRefsService>;
|
||||
} = {}) {
|
||||
const agentRunnableStateService = new AgentRunnableStateService(
|
||||
credentialsService,
|
||||
agentValidationService,
|
||||
agentPublishService,
|
||||
createAgentSkillRefsService(),
|
||||
skillRefs,
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -264,15 +266,19 @@ describe('AgentsController agent resource', () => {
|
|||
mock<Pick<AgentsService, 'findById' | 'findByProjectId' | 'findByProjectIdPaginated'>>();
|
||||
const agentPublishService = mock<AgentPublishService>();
|
||||
const agentValidationService = mock<AgentValidationService>();
|
||||
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' }),
|
||||
|
|
|
|||
|
|
@ -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> = {}): Agent {
|
|||
}
|
||||
|
||||
function makeService() {
|
||||
const skillRefs = createAgentSkillRefsService();
|
||||
const agentRepository = mock<AgentRepository>();
|
||||
const projectRelationRepository = mock<ProjectRelationRepository>();
|
||||
const agentKnowledgeService = mock<AgentKnowledgeService>();
|
||||
|
|
@ -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<User>({ 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<Agent>),
|
||||
);
|
||||
agentTaskRepository.findByAgentId.mockResolvedValue([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<AttachableWorkflowsService>();
|
||||
const mcpRegistryService = mock<McpRegistryService>();
|
||||
const agentsToolsService = mock<AgentsToolsService>();
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<PostHogClient>();
|
||||
let rollout: SkillsHubRollout;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
rollout = new SkillsHubRollout(postHog, mock<Logger>());
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
vi.spyOn(Container.get(PostHogClient), 'getFeatureFlagForInstance').mockResolvedValue(enabled);
|
||||
await Container.get(SkillsHubRollout).init();
|
||||
}
|
||||
|
|
@ -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<typeof mock<AgentSkillRefsService>> & {
|
||||
/** Skill bodies of the fake skills hub, keyed by skill id. */
|
||||
bodies: Record<string, AgentSkill>;
|
||||
/** Draft refs written through the fake seam, keyed by agent id. */
|
||||
draftRefs: Map<string, AgentJsonSkillConfig[]>;
|
||||
/**
|
||||
* 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<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<AgentRepository>(),
|
||||
): AgentSkillRefsService {
|
||||
return new AgentSkillRefsService(agentRepository);
|
||||
bodies: Record<string, AgentSkill> = {},
|
||||
): FakeAgentSkillRefsService {
|
||||
const draftRefs = new Map<string, AgentJsonSkillConfig[]>();
|
||||
const versionPins = new Map<string, Record<string, string>>();
|
||||
const service = Object.assign(mock<AgentSkillRefsService>(), {
|
||||
bodies,
|
||||
draftRefs,
|
||||
versionPins,
|
||||
usesSkillsHub: true,
|
||||
});
|
||||
const readDraftRefs = (agent: Pick<Agent, 'id'>): 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<Agent, 'id'>, 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<Agent>;
|
|||
|
||||
export type AgentHistoryFixtureOverrides = WithDocumentSchema<AgentHistory>;
|
||||
|
||||
/** The skill refs that a `storedAgentConfig` fixture holds. */
|
||||
export function storedSkillRefs(agent: Pick<Agent, 'schema'>): 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<Agent, 'schema' | 'integrations'>,
|
||||
): 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<string, AgentSkill> = {},
|
||||
): AgentRunSource {
|
||||
return { agent, skillRefs: storedSkillRefs(agent), skills };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<AgentDefinition> {
|
||||
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<AgentHistory, 'versionId' | 'skills'>,
|
||||
): Promise<boolean> {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string, AgentSkill>,
|
||||
): Record<string, AgentSkill> | 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<string, AgentSkill> = {};
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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<string, AgentSkill>;
|
||||
skillHashes: Record<string, string>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, AgentSkill> | null;
|
||||
/** Hub skill version id for each skill ref, in draft order. Empty with the hub off. */
|
||||
pins: Map<string, string>;
|
||||
};
|
||||
|
||||
/** Skill refs and the skill bodies they resolve to, keyed by skill id. */
|
||||
export interface AgentSkillsSnapshot {
|
||||
skillRefs: AgentJsonSkillConfig[];
|
||||
skills: Record<string, AgentSkill>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Agent, 'id' | 'schema'>,
|
||||
_ctx: OperationContext,
|
||||
): Promise<AgentSkillRefs> {
|
||||
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<Agent, 'id' | 'schema'>,
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentJsonSkillConfig[]> {
|
||||
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<AgentHistory, 'versionId' | 'schema'>,
|
||||
_ctx: OperationContext,
|
||||
): Promise<AgentSkillRefs> {
|
||||
return readLegacySkillRefs(version.schema);
|
||||
version: Pick<AgentHistory, 'versionId' | 'schema' | 'skills'>,
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentJsonSkillConfig[]> {
|
||||
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<Agent, 'id' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentSkillsSnapshot> {
|
||||
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<AgentHistory, 'versionId' | 'schema' | 'skills'>,
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentSkillsSnapshot> {
|
||||
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<Agent, 'skills'>,
|
||||
skillRefs: AgentSkillRefs,
|
||||
ctx: OperationContext,
|
||||
): Promise<Record<string, AgentSkill>> {
|
||||
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<string, AgentSkill>,
|
||||
ctx: OperationContext,
|
||||
): Promise<string[]> {
|
||||
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<Agent, 'id' | 'projectId' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
refs: AgentJsonSkillConfig[],
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentJsonSkillConfig[]> {
|
||||
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<void> {
|
||||
if (!agent.schema) {
|
||||
if (refs === undefined) return;
|
||||
async replaceDraftRefs(
|
||||
agent: Pick<Agent, 'id' | 'projectId' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
refs: AgentSkillRefs,
|
||||
ctx: OperationContext,
|
||||
): Promise<void> {
|
||||
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<Agent, 'id' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
version: Pick<AgentHistory, 'versionId' | 'skills'>,
|
||||
refs: AgentSkillRefs,
|
||||
ctx: OperationContext,
|
||||
): Promise<void> {
|
||||
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<Agent, 'id' | 'schema'>,
|
||||
skillId: string,
|
||||
ctx: OperationContext,
|
||||
): Promise<void> {
|
||||
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<Agent, 'id' | 'projectId' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
refs: AgentSkillRefs,
|
||||
bodies: Record<string, AgentSkill>,
|
||||
creator: User | null,
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentJsonSkillConfig[]> {
|
||||
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<Agent, 'id' | 'projectId' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
skills: AgentSkill[],
|
||||
creatorId: string,
|
||||
availableTo: AgentSkillAvailability,
|
||||
ctx: OperationContext,
|
||||
): Promise<string[]> {
|
||||
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<Agent, 'id' | 'schema' | 'skills'>,
|
||||
ctx: OperationContext,
|
||||
): Promise<PublishSnapshot> {
|
||||
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<void> {
|
||||
if (snapshot.pins.size === 0) return;
|
||||
await this.skillService.pinForPublish(agentVersionId, snapshot.pins, ctx);
|
||||
}
|
||||
|
||||
async availabilityOf(
|
||||
skillId: string,
|
||||
ctx: OperationContext,
|
||||
): Promise<AgentSkillAvailability | undefined> {
|
||||
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<Agent, 'id' | 'projectId'>,
|
||||
skillId: string,
|
||||
availableTo: AgentSkillAvailability,
|
||||
ctx: OperationContext,
|
||||
): Promise<void> {
|
||||
await this.skillService.changeTarget(skillId, targetFor(agent, availableTo), agent.id, ctx);
|
||||
}
|
||||
|
||||
private async writeLegacyDraft(
|
||||
agent: Pick<Agent, 'id' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
schema: StoredAgentConfig,
|
||||
refs: AgentJsonSkillConfig[],
|
||||
skills: Record<string, AgentSkill>,
|
||||
ctx: OperationContext,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<Agent, 'id' | 'projectId'>, 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<string, AgentSkill>,
|
||||
refs: AgentJsonSkillConfig[],
|
||||
): Record<string, AgentSkill> {
|
||||
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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, AgentSkill>> {
|
||||
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<AgentSkill> {
|
||||
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<AgentSkillMutationResponse> {
|
||||
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<AgentSkillMutationResponse> {
|
||||
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<AgentSkillMutationResponse> {
|
||||
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<AgentSkillAvailability | undefined> {
|
||||
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<AgentSkillMutationResponse> {
|
||||
// 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<string, AgentSkill>,
|
||||
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<string, AgentSkill>,
|
||||
skills: AgentSkill[],
|
||||
): void {
|
||||
const seenNames = new Set<string>();
|
||||
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<Agent> {
|
||||
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<void> {
|
||||
// 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<string, AgentSkill>, 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<Agent, 'skills'>, 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AgentConfigValidationResponse> {
|
||||
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<string, AgentSkill>,
|
||||
): 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[] {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, AgentSkill>;
|
||||
/** 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<string, unknown>;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ' +
|
||||
|
|
|
|||
|
|
@ -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": "<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AgentDefinition, 'tools' | 'skills'> & { taskIds: ReadonlySet<string> },
|
||||
available: Pick<AgentDefinition, 'tools'> & { taskIds: ReadonlySet<string> },
|
||||
): 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),
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export class AgentHistoryRepository extends BaseRepository<AgentHistory> {
|
|||
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<AgentHistory> {
|
|||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -676,11 +676,17 @@ export class AgentRepository extends BaseRepository<Agent> {
|
|||
ids: string[],
|
||||
ctx: OperationContext = {},
|
||||
): Promise<Array<Pick<Agent, 'id' | 'projectId'>>> {
|
||||
if (ids.length === 0) return [];
|
||||
return await this.managerFor(ctx).find(Agent, {
|
||||
where: { id: In(ids) },
|
||||
select: ['id', 'projectId'],
|
||||
});
|
||||
const agents: Array<Pick<Agent, 'id' | 'projectId'>> = [];
|
||||
// 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<Agent> {
|
|||
* write does not check `revision`: call it only in the same transaction as a
|
||||
* won `saveDraftFenced`.
|
||||
*/
|
||||
async updateDraftSkills(
|
||||
agent: Pick<Agent, 'id' | 'schema' | 'skills' | 'updatedAt'>,
|
||||
ctx: OperationContext,
|
||||
): Promise<void> {
|
||||
// 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<Agent, 'id' | 'schema' | 'updatedAt'>,
|
||||
ctx: OperationContext,
|
||||
|
|
|
|||
|
|
@ -217,13 +217,17 @@ export class SkillRepository extends BaseRepository<Skill> {
|
|||
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<Map<string, ResolvedSkillRow>> {
|
||||
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<Skill> {
|
|||
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<string[]> {
|
||||
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<number> {
|
||||
const latest = await this.managerFor(ctx).findOne(SkillVersion, {
|
||||
|
|
@ -280,12 +297,20 @@ export class SkillRepository extends BaseRepository<Skill> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<Skill> {
|
|||
};
|
||||
}
|
||||
|
||||
/** The agents that use the skill: a draft ref or a published pin. */
|
||||
async findUsingAgentIds(skillId: string, ctx: OperationContext = {}): Promise<string[]> {
|
||||
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[],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<SkillSaveResult> {
|
||||
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<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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<string, string>,
|
||||
|
|
@ -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<void> {
|
||||
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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<SkillSaveResponse> {
|
||||
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'),
|
||||
|
|
|
|||
30
packages/cli/src/modules/agents/skills/skills-hub-rollout.ts
Normal file
30
packages/cli/src/modules/agents/skills/skills-hub-rollout.ts
Normal file
|
|
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AgentRepository>;
|
||||
let agentHistoryRepository: Mocked<AgentHistoryRepository>;
|
||||
let skillRefs: FakeAgentSkillRefsService;
|
||||
let resolver: SubAgentSourceResolver;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
agentRepository = mock<AgentRepository>();
|
||||
agentHistoryRepository = mock<AgentHistoryRepository>();
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Agent, 'tools' | 'skills'> {
|
||||
export interface AgentDefinition extends Pick<Agent, 'tools'> {
|
||||
/** The JSON document, with the skill refs. */
|
||||
schema: AgentJsonConfig | null;
|
||||
/** Skill bodies from the skills hub, keyed by skill id. */
|
||||
skills: Record<string, AgentSkill>;
|
||||
tasks: ReadonlyMap<string, AgentTaskConfig>;
|
||||
}
|
||||
|
||||
|
|
@ -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<AgentHistory, 'schema' | 'tools' | 'skills'>,
|
||||
source: Pick<AgentHistory, 'schema' | 'tools'>,
|
||||
skillRefs: AgentSkillRefs,
|
||||
skills: Record<string, AgentSkill>,
|
||||
) {
|
||||
return {
|
||||
schema: source.schema ? toAgentDocument(source.schema, skillRefs) : null,
|
||||
tools: source.tools ?? {},
|
||||
skills: source.skills ?? {},
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, AgentSkill>;
|
||||
}
|
||||
|
||||
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<AgentRunSource> {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof getAgentRuntimeAssets>;
|
||||
|
||||
export function getAgentRuntimeAssets(agent: Pick<Agent | AgentHistory, 'tools' | 'skills'>) {
|
||||
/** `skills`: the skill bodies from the skills hub that the draft or version runs. */
|
||||
export function getAgentRuntimeAssets(
|
||||
agent: Pick<Agent | AgentHistory, 'tools'>,
|
||||
skills: Record<string, AgentSkill>,
|
||||
) {
|
||||
const toolDescriptors: Record<string, ToolDescriptor> = {};
|
||||
const toolCodeByName: Record<string, string> = {};
|
||||
|
||||
|
|
@ -14,5 +19,5 @@ export function getAgentRuntimeAssets(agent: Pick<Agent | AgentHistory, 'tools'
|
|||
toolCodeByName[toolEntry.descriptor.name] = toolEntry.code;
|
||||
}
|
||||
|
||||
return { toolDescriptors, toolCodeByName, skills: agent.skills ?? {} };
|
||||
return { toolDescriptors, toolCodeByName, skills };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { AgentsSettingsService } from '@/modules/agents/agents-settings.service'
|
|||
import type { Agent as AgentEntity } from '@/modules/agents/entities/agent.entity';
|
||||
import type { AgentRunSource } from '@/modules/agents/utils/agent-published-snapshot';
|
||||
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
import { AgentSkillRefsService } from '@/modules/agents/agent-skill-refs.service';
|
||||
import { createAgentSkillRefsService } from '@/modules/agents/__tests__/test-utils/stored-agent-config';
|
||||
import { createAgentCredentialProvider } from '@/modules/agents/utils/agent-credential-provider';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
|
||||
|
|
@ -140,6 +142,7 @@ describe('EvalAgentExecutionService.executeWithLlmMock', () => {
|
|||
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 () => {
|
||||
|
|
|
|||
|
|
@ -638,6 +638,7 @@ describe('EvalThreadRestoreService', () => {
|
|||
id: 'agent-original',
|
||||
schema: agent.config,
|
||||
skills: agent.skills,
|
||||
skillCreator: evalUser,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}): 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<string, RegisteredTool>;
|
||||
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<AgentSkillsService>(),
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function agentFiles(entry: ManifestEntry, availableInMCP: boolean): Record<strin
|
|||
name: entry.name,
|
||||
model: '',
|
||||
instructions: '',
|
||||
skills: [{ type: 'skill', id: skillId, enabled: false }],
|
||||
skills: [{ type: 'skill', id: skillId }],
|
||||
tools: [{ type: 'custom', id: toolId, enabled: false, requireApproval: true }],
|
||||
tasks: [{ type: 'task', id: taskId, enabled: false }],
|
||||
mcpServers: [
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ describe('N8nPackageParser.getAgents', () => {
|
|||
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: '' }],
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ describe('Agent conversation state', () => {
|
|||
projectId: project.id,
|
||||
integrations: [],
|
||||
tools: {},
|
||||
skills: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
488
packages/cli/test/integration/agents/agent-skills-hub.test.ts
Normal file
488
packages/cli/test/integration/agents/agent-skills-hub.test.ts
Normal file
|
|
@ -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<string, AgentSkill>) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -53,7 +53,6 @@ describe('Agent thread grants', () => {
|
|||
projectId: project.id,
|
||||
integrations: [],
|
||||
tools: {},
|
||||
skills: {},
|
||||
}),
|
||||
);
|
||||
for (const id of ['parent', 'child', 'sibling', 'new-thread']) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => {
|
|||
projectId,
|
||||
integrations: [],
|
||||
tools: {},
|
||||
skills: {},
|
||||
} as Partial<Agent>),
|
||||
);
|
||||
agentId = agent.id;
|
||||
|
|
@ -145,7 +144,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => {
|
|||
projectId,
|
||||
integrations: {},
|
||||
tools: {},
|
||||
skills: {},
|
||||
} as Partial<Agent>),
|
||||
);
|
||||
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<Agent>),
|
||||
);
|
||||
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<Agent>),
|
||||
);
|
||||
const lightThread = await createThread({ ownerId: owner.id });
|
||||
|
|
@ -388,7 +383,6 @@ describe('AgentExecutionThreadRepository n8n Chat queries', () => {
|
|||
projectId: otherProject.id,
|
||||
integrations: {},
|
||||
tools: {},
|
||||
skills: {},
|
||||
} as Partial<Agent>),
|
||||
);
|
||||
const inScope = await createThread({ ownerId: owner.id });
|
||||
|
|
|
|||
|
|
@ -141,7 +141,6 @@ describe('AgentExecutionRepository', () => {
|
|||
projectId,
|
||||
integrations: [],
|
||||
tools: {},
|
||||
skills: {},
|
||||
} as Partial<Agent>);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<AgentSkillsService>(),
|
||||
new AgentConfigPreparationService(
|
||||
credentials,
|
||||
mock<WorkflowRepository>(),
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ExportPackageRequestDto>, 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' });
|
||||
|
|
|
|||
|
|
@ -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<Agent>),
|
||||
|
|
@ -86,7 +85,6 @@ describe('agent tasks across two mains over one database', () => {
|
|||
author: 'test',
|
||||
schema: null,
|
||||
tools: null,
|
||||
skills: null,
|
||||
});
|
||||
await snapshotRepo.saveForVersion([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -44,5 +44,6 @@ export type Agent = {
|
|||
skills: Record<string, AgentSkill>;
|
||||
configHash?: string | null;
|
||||
skillHashes?: Record<string, string>;
|
||||
outdatedSkillIds?: string[];
|
||||
activeVersion: AgentVersion | null;
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user