From fe07e84d0fc91d9699bc38da07e9cff5f5c34078 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 5 Oct 2026 10:48:35 +0000 Subject: [PATCH] fix(core): Keep skill activation and memory compaction from breaking the prompt cache (no-changelog) (#40245) Co-authored-by: Cursor --- packages/@n8n/agents/docs/prompt-caching.md | 20 +++++- .../runtime/__tests__/agent-runtime.test.ts | 48 +++++++++++++- .../runtime/__tests__/message-list.test.ts | 62 +++++++++++++++++++ .../__tests__/skill-dependency-tools.test.ts | 54 ++++++++++++++++ .../agents/src/runtime/loop/agent-runtime.ts | 7 +-- .../src/runtime/loop/runtime-context.ts | 18 ++++-- .../model/__tests__/prompt-cache.test.ts | 60 ++++++++++++++++++ .../agents/src/runtime/model/message-list.ts | 44 +++++++++++-- .../agents/src/runtime/model/prompt-cache.ts | 18 ++++++ .../src/runtime/skills/active-skills.ts | 6 -- .../runtime/skills/skill-dependency-tools.ts | 22 +++++++ packages/@n8n/agents/src/skills/types.ts | 2 +- .../@n8n/instance-ai/skills/planning/SKILL.md | 8 +-- .../skills/__tests__/runtime-skills.test.ts | 4 +- 14 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 packages/@n8n/agents/src/runtime/__tests__/skill-dependency-tools.test.ts create mode 100644 packages/@n8n/agents/src/runtime/skills/skill-dependency-tools.ts diff --git a/packages/@n8n/agents/docs/prompt-caching.md b/packages/@n8n/agents/docs/prompt-caching.md index 88cf4f36bab..af0e3a7aa01 100644 --- a/packages/@n8n/agents/docs/prompt-caching.md +++ b/packages/@n8n/agents/docs/prompt-caching.md @@ -85,8 +85,24 @@ carrying a skill never rewrites the cached prefix. The `` block is a recovery path. `instructions()` moves a skill into it only when no visible, successfully resolved tool result can carry it: observational memory masked the result, the activating call failed, or no record of the activation -exists. When observational memory masked the result, it already rewrote the -prefix, so the move costs no extra cache invalidation. +exists. + +The block is not part of the base instructions. `buildSystemMessages` sends it +as its own system message, after the base instructions and before the volatile +message. The tools and base instructions in front of it stay byte-identical, so +they stay cached when a skill moves into the block. The block reuses the +instruction cache options, so it gets its own breakpoint and stays cached when +memory compacts again later. `buildSkillInstructionCacheOptions` drops that +breakpoint when it would leave no slot for the conversation breakpoint next to +caller breakpoints on tools, history messages, and their content parts. +Providers without split system messages get the block merged after the base +instructions, as before. + +Tools that a registered skill lists in `dependencies.tools` are active for the +whole run. `AgentRuntime` moves them out of the deferred set at construction. +If they loaded only when the skill activated, the tool list would change +mid-conversation. Anthropic renders tools first, so that change invalidates the +whole cached prefix. Other prefix-stability hygiene, already true or verified: tool ordering is append-only (`getCurrentTools()` only ever appends), and none of the current diff --git a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts index 95e79945175..73f70d18ea8 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/agent-runtime.test.ts @@ -1,3 +1,4 @@ +import type { ProviderOptions } from '@ai-sdk/provider-utils'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import { sleep } from '@n8n/utils/sleep'; import * as aiModule from 'ai'; @@ -8480,12 +8481,14 @@ describe('AgentRuntime — mid-run observation', () => { deferredTools?: BuiltTool[]; checkpointStorage?: CheckpointStore; model?: ModelConfig; + instructionProviderOptions?: ProviderOptions; }, ): AgentRuntime { return new AgentRuntime({ name: 'mid-run-agent', model: extra?.model ?? 'openai/gpt-4o-mini', instructions: 'You are a test assistant.', + instructionProviderOptions: extra?.instructionProviderOptions, memory, tools: extra?.tools ?? [makeStepTool()], deferredTools: extra?.deferredTools, @@ -8603,8 +8606,46 @@ describe('AgentRuntime — mid-run observation', () => { expect(JSON.stringify(capturedCall(2).messages)).not.toContain('Wait for a real execution'); }); + it('keeps the base instructions unchanged when compaction moves a skill into the system prompt', async () => { + const source = createRuntimeSkillSource([ + { + id: 'builder', + name: 'builder', + description: 'Build workflows.', + instructions: 'Wait for a real execution before extending the workflow.', + }, + ]); + const cacheOptions = { anthropic: { cacheControl: { type: 'ephemeral' } } }; + const runtime = buildMidRunRuntime(new InMemoryMemory(), { + skillSource: source, + tools: createRuntimeSkillTools(source), + model: 'anthropic/claude-sonnet-4-5', + instructionProviderOptions: cacheOptions, + }); + generateText + .mockResolvedValueOnce( + makeGenerateWithToolCall('load-builder', 'load_skill', { skillId: 'builder' }), + ) + .mockResolvedValueOnce(makeGenerateSuccess('Please test the first workflow.')); + + await runtime.generate('Build it', { persistence: PERSISTENCE }); + await runtime.dispose(); + + const before = capturedCall(0).instructions; + const after = capturedCall(1).instructions; + if (Array.isArray(before) || !Array.isArray(after)) throw new Error('Unexpected system shape'); + expect(after[0]).toEqual(before); + expect(after[1]).toEqual({ + role: 'system', + content: expect.stringContaining('Wait for a real execution'), + providerOptions: cacheOptions, + }); + expect(after[2].content).toContain('Mid-run observation captured.'); + expect(after[2]).not.toHaveProperty('providerOptions'); + }); + it.each(['load_skill', 'inspect_node'])( - 'activates skill tool dependencies after %s and restores them on the next turn', + 'keeps skill tool dependencies in the tool list before and after %s', async (activationTool) => { const source = createRuntimeSkillSource([ { @@ -8642,8 +8683,9 @@ describe('AgentRuntime — mid-run observation', () => { await runtime.generate('Build it', { persistence: PERSISTENCE }); await runtime.dispose(); - expect(capturedCall(0).tools).not.toHaveProperty('catalog'); - expect(capturedCall(1).tools).toHaveProperty('catalog'); + // The tool list must not change when the skill activates, or the cached prompt is rewritten. + expect(Object.keys(capturedCall(1).tools)).toEqual(Object.keys(capturedCall(0).tools)); + expect(capturedCall(0).tools).toHaveProperty('catalog'); expect(capturedCall(1).tools).not.toHaveProperty('optional_tool'); expect(flattenInstructions(capturedCall(1).instructions)).toContain( 'Choose a model from the catalog.', diff --git a/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts b/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts index f0b6560ecba..563a9554c7a 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts @@ -360,6 +360,68 @@ describe('buildSystemMessages — volatile tool-instruction fragments', () => { }); }); +describe('buildSystemMessages — recovered skill instructions', () => { + const cacheOptions = { anthropic: { cacheControl: { type: 'ephemeral' as const } } }; + const skills = { content: '\nBuild one workflow.\n' }; + + it('places skills in their own cached message between the base and volatile messages', () => { + const system = buildSystemMessages( + 'Base instructions', + '\n* Some memory.\n', + cacheOptions, + undefined, + undefined, + true, + { ...skills, providerOptions: cacheOptions }, + ); + + expect(system).toEqual([ + { role: 'system', content: 'Base instructions', providerOptions: cacheOptions }, + { role: 'system', content: `\n\n${skills.content}`, providerOptions: cacheOptions }, + { role: 'system', content: '\n\n\n* Some memory.\n' }, + ]); + }); + + it('leaves the skills message uncached when it has no cache options', () => { + const system = buildSystemMessages( + 'Base instructions', + undefined, + cacheOptions, + undefined, + undefined, + true, + skills, + ); + + expect(system).toEqual([ + { role: 'system', content: 'Base instructions', providerOptions: cacheOptions }, + { role: 'system', content: `\n\n${skills.content}` }, + ]); + }); + + it('merges skills after the base instructions when split messages are unsupported', () => { + const system = buildSystemMessages( + 'Base instructions', + '\n* Some memory.\n', + cacheOptions, + undefined, + undefined, + false, + { ...skills, providerOptions: cacheOptions }, + ); + + expect(system).toEqual({ + role: 'system', + content: [ + 'Base instructions', + skills.content, + '\n* Some memory.\n', + ].join('\n\n'), + providerOptions: cacheOptions, + }); + }); +}); + // --------------------------------------------------------------------------- // Input / response messages use existing createdAt as a hint // --------------------------------------------------------------------------- diff --git a/packages/@n8n/agents/src/runtime/__tests__/skill-dependency-tools.test.ts b/packages/@n8n/agents/src/runtime/__tests__/skill-dependency-tools.test.ts new file mode 100644 index 00000000000..5101af5f4a7 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/__tests__/skill-dependency-tools.test.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +import { createRuntimeSkillSource } from '../../skills/registry'; +import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; +import type { BuiltTool } from '../../types/sdk/tool'; +import { activateSkillDependencyTools } from '../skills/skill-dependency-tools'; + +function makeTool(name: string): BuiltTool { + return { + name, + description: `Tool ${name}`, + inputSchema: z.object({}), + handler: async () => await Promise.resolve({}), + }; +} + +const skillSource = createRuntimeSkillSource([ + { + id: 'model-selection', + name: 'model-selection', + description: 'Choose a model.', + instructions: 'Search the catalog.', + dependencies: { tools: ['search_models', 'unregistered'] }, + }, +]); + +function makeConfig(overrides: Partial): AgentRuntimeConfig { + return { name: 'agent', model: 'anthropic/claude-sonnet-4-5', instructions: '', ...overrides }; +} + +describe('activateSkillDependencyTools', () => { + it('moves deferred skill dependencies after the active tools', () => { + const active = makeTool('active'); + const searchModels = makeTool('search_models'); + const other = makeTool('other'); + + const config = activateSkillDependencyTools( + makeConfig({ skillSource, tools: [active], deferredTools: [searchModels, other] }), + ); + + expect(config.tools).toEqual([active, searchModels]); + expect(config.deferredTools).toEqual([other]); + }); + + it('returns the same config when no deferred tool is a skill dependency', () => { + const original = makeConfig({ skillSource, deferredTools: [makeTool('other')] }); + expect(activateSkillDependencyTools(original)).toBe(original); + }); + + it('returns the same config without a skill source', () => { + const original = makeConfig({ deferredTools: [makeTool('search_models')] }); + expect(activateSkillDependencyTools(original)).toBe(original); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index f7de929b014..83143657b78 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -61,6 +61,7 @@ import { AgentMessageList, type SerializedMessageList } from '../model/message-l import { createModelTokenCounter } from '../model/model-token-counter'; import { getEffectiveAnthropicCacheTtl } from '../model/prompt-cache'; import { ActiveSkills } from '../skills/active-skills'; +import { activateSkillDependencyTools } from '../skills/skill-dependency-tools'; import { BackgroundTaskTracker } from '../state/background-task-tracker'; import { AgentEventBus, type AgentAbortScope } from '../state/event-bus'; import { generateRunId, RunStateManager, StaleResumeError } from '../state/run-state'; @@ -164,7 +165,8 @@ export class AgentRuntime { private toolExecutor: ToolCallExecutor; private activeSkills?: ActiveSkills; - constructor(config: AgentRuntimeConfig) { + constructor(runtimeConfig: AgentRuntimeConfig) { + const config = activateSkillDependencyTools(runtimeConfig); this.config = config; // Keep full tool results when the memory backend cannot persist active skill IDs. if (config.skillSource && (!config.memory || config.memory.skillState)) { @@ -1013,9 +1015,6 @@ export class AgentRuntime { private async prepareModelCall(ctx: PreparedLoopContext) { const { list, options, abortScope, staticContext } = ctx; - for (const toolName of this.activeSkills?.toolDependencies() ?? []) { - this.deferredToolManager?.load(toolName); - } const tools = this.context.buildToolLoopContext( staticContext.aiProviderTools, options?.persistence, diff --git a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts index 629cc694c9c..62e3102026f 100644 --- a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts +++ b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts @@ -28,6 +28,7 @@ import { applyRuntimeCacheBreakpoints, buildCallPromptCacheOptions, buildInstructionPromptCacheOptions, + buildSkillInstructionCacheOptions, mergeProviderOptions, } from '../model/prompt-cache'; import { @@ -165,14 +166,23 @@ export class RuntimeContextBuilder { .map((value) => value?.trim()) .filter((value): value is string => Boolean(value)) .join('\n\n'); + const skillContent = activeSkills?.instructions(); const { system, messages } = list.forLlm( - // Skill content changes only on activation. Keep it cached when memory compacts. - [tools.effectiveInstructions, activeSkills?.instructions()] - .filter(Boolean) - .join('\n\n'), + tools.effectiveInstructions, instructionProviderOptions, combinedVolatileInstructions || undefined, supportsSplitSystemMessages(this.config.model), + skillContent + ? { + content: skillContent, + cacheOptions: (messages) => + buildSkillInstructionCacheOptions( + instructionProviderOptions, + tools.aiTools, + messages, + ), + } + : undefined, ); // Cache breakpoints apply to this call only. Do not change stored messages or tools. const cached = applyRuntimeCacheBreakpoints({ diff --git a/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts b/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts index 38c59fc28b2..04f35753546 100644 --- a/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts +++ b/packages/@n8n/agents/src/runtime/model/__tests__/prompt-cache.test.ts @@ -4,6 +4,7 @@ import { applyRuntimeCacheBreakpoints, buildCallPromptCacheOptions, buildInstructionPromptCacheOptions, + buildSkillInstructionCacheOptions, getEffectiveAnthropicCacheTtl, mergeProviderOptions, } from '../prompt-cache'; @@ -26,6 +27,65 @@ function makeTool(providerOptions?: Record): ToolSet[string] { return { inputSchema: {}, providerOptions } as ToolSet[string]; } +describe('buildSkillInstructionCacheOptions', () => { + it('reuses the instruction cache options', () => { + expect( + buildSkillInstructionCacheOptions(ANTHROPIC_CACHE_CONTROL, { a: makeTool() }, [ + makeUserMessage('hi'), + ]), + ).toEqual(ANTHROPIC_CACHE_CONTROL); + }); + + it('returns undefined when the instructions carry no Anthropic breakpoint', () => { + expect(buildSkillInstructionCacheOptions(undefined, {}, [])).toBeUndefined(); + expect( + buildSkillInstructionCacheOptions({ openai: { promptCacheKey: 'key' } }, {}, []), + ).toBeUndefined(); + }); + + it('keeps a slot for the conversation breakpoint next to caller tool breakpoints', () => { + expect( + buildSkillInstructionCacheOptions( + ANTHROPIC_CACHE_CONTROL, + { a: makeTool(ANTHROPIC_CACHE_CONTROL) }, + [], + ), + ).toEqual(ANTHROPIC_CACHE_CONTROL); + expect( + buildSkillInstructionCacheOptions( + ANTHROPIC_CACHE_CONTROL, + { a: makeTool(ANTHROPIC_CACHE_CONTROL), b: makeTool(ANTHROPIC_CACHE_CONTROL) }, + [], + ), + ).toBeUndefined(); + }); + + it('counts caller breakpoints on messages and content parts', () => { + const markedPart = { + role: 'user', + content: [{ type: 'text', text: 'b', providerOptions: ANTHROPIC_CACHE_CONTROL }], + } as ModelMessage; + expect( + buildSkillInstructionCacheOptions(ANTHROPIC_CACHE_CONTROL, {}, [ + makeUserMessage('a', ANTHROPIC_CACHE_CONTROL), + ]), + ).toEqual(ANTHROPIC_CACHE_CONTROL); + expect( + buildSkillInstructionCacheOptions(ANTHROPIC_CACHE_CONTROL, {}, [ + makeUserMessage('a', ANTHROPIC_CACHE_CONTROL), + markedPart, + ]), + ).toBeUndefined(); + expect( + buildSkillInstructionCacheOptions( + ANTHROPIC_CACHE_CONTROL, + { a: makeTool(ANTHROPIC_CACHE_CONTROL) }, + [makeUserMessage('a', ANTHROPIC_CACHE_CONTROL)], + ), + ).toBeUndefined(); + }); +}); + describe('buildInstructionPromptCacheOptions — Anthropic', () => { it('defaults to a 1h cache breakpoint when enabled with no ttl override', () => { expect( diff --git a/packages/@n8n/agents/src/runtime/model/message-list.ts b/packages/@n8n/agents/src/runtime/model/message-list.ts index 093651e8c59..2e21a5de512 100644 --- a/packages/@n8n/agents/src/runtime/model/message-list.ts +++ b/packages/@n8n/agents/src/runtime/model/message-list.ts @@ -39,6 +39,11 @@ export type LlmContext = { * cache breakpoint (and OpenAI's automatic prefix cache) on nearly every * call, for no future read. Providers that do not support multiple system * messages receive one merged message instead. + * + * `skillInstructions` (recovered active skills) sit between the two, in their + * own message with their own cache options. They change only when a skill + * moves out of the conversation, so the base instructions and the tools in + * front of them stay cached when that happens. */ export function buildSystemMessages( baseInstructions: string, @@ -47,35 +52,53 @@ export function buildSystemMessages( volatileInstructions?: string, mcpConnectionNote?: string, splitSystemMessages = true, + skillInstructions?: { content: string; providerOptions?: ProviderOptions }, ): SystemModelMessage | SystemModelMessage[] { const cacheOptions = instructionProviderOptions ? { providerOptions: instructionProviderOptions } : {}; + const skillContent = skillInstructions?.content.trim(); const volatileSections = [ volatileInstructions?.trim(), mcpConnectionNote?.trim(), observationLogMemory?.trim(), ].filter((s): s is string => Boolean(s)); - if (volatileSections.length === 0 || !splitSystemMessages) { + if (!splitSystemMessages || (!skillContent && volatileSections.length === 0)) { return { role: 'system', - content: [baseInstructions, ...volatileSections].join('\n\n'), + content: [ + baseInstructions, + ...(skillContent ? [skillContent] : []), + ...volatileSections, + ].join('\n\n'), ...cacheOptions, }; } - return [ + const messages: SystemModelMessage[] = [ { role: 'system', content: baseInstructions, ...cacheOptions, }, - { + ]; + if (skillContent) { + messages.push({ + role: 'system', + content: `\n\n${skillContent}`, + ...(skillInstructions?.providerOptions + ? { providerOptions: skillInstructions.providerOptions } + : {}), + }); + } + if (volatileSections.length > 0) { + messages.push({ role: 'system', content: `\n\n${volatileSections.join('\n\n')}`, - }, - ]; + }); + } + return messages; } type MessageSource = 'history' | 'input' | 'response'; @@ -347,6 +370,11 @@ export class AgentMessageList { instructionProviderOptions?: ProviderOptions, volatileInstructions?: string, splitSystemMessages = true, + skillInstructions?: { + content: string; + /** Resolved from the conversation messages, which can hold caller breakpoints. */ + cacheOptions?: (messages: ModelMessage[]) => ProviderOptions | undefined; + }, ): LlmContext { const messages = toAiMessages( filterLlmMessages(stripOrphanedToolMessages(this.llmVisibleMessages())), @@ -364,6 +392,10 @@ export class AgentMessageList { volatileInstructions, this.mcpConnectionNote, splitSystemMessages, + skillInstructions && { + content: skillInstructions.content, + providerOptions: skillInstructions.cacheOptions?.(messages), + }, ), messages, }; diff --git a/packages/@n8n/agents/src/runtime/model/prompt-cache.ts b/packages/@n8n/agents/src/runtime/model/prompt-cache.ts index 73dcaf0cdc4..a61524ec697 100644 --- a/packages/@n8n/agents/src/runtime/model/prompt-cache.ts +++ b/packages/@n8n/agents/src/runtime/model/prompt-cache.ts @@ -153,6 +153,24 @@ export function buildInstructionPromptCacheOptions( return buildAnthropicCacheControl(config); } +/** + * Cache options for the recovered-skills system message. It reuses the + * instruction breakpoint, so it is cached only when the instructions are. + * Undefined when the extra breakpoint would leave no slot for the + * conversation breakpoint next to the caller's tool and message breakpoints. + */ +export function buildSkillInstructionCacheOptions( + instructionProviderOptions: ProviderOptions | undefined, + aiTools: ToolSet, + messages: ModelMessage[], +): ProviderOptions | undefined { + if (!hasAnthropicCacheControl(instructionProviderOptions)) return undefined; + const callerBreakpoints = countAnthropicBreakpoints([], aiTools, messages); + // Instructions + skills + conversation breakpoint. + if (callerBreakpoints + 3 > MAX_ANTHROPIC_CACHE_BREAKPOINTS) return undefined; + return instructionProviderOptions; +} + /** OpenAI call-level cache options (routing key + retention). Undefined for non-OpenAI models or when disabled. */ export function buildCallPromptCacheOptions( config: PromptCachingConfig | undefined, diff --git a/packages/@n8n/agents/src/runtime/skills/active-skills.ts b/packages/@n8n/agents/src/runtime/skills/active-skills.ts index 6e292302bb1..66a356fec7e 100644 --- a/packages/@n8n/agents/src/runtime/skills/active-skills.ts +++ b/packages/@n8n/agents/src/runtime/skills/active-skills.ts @@ -99,12 +99,6 @@ export class ActiveSkills { return skill; } - toolDependencies(): string[] { - return [ - ...new Set([...this.loaded.values()].flatMap((skill) => skill.dependencies?.tools ?? [])), - ]; - } - /** * The active skills the anchored path cannot deliver on this call, as one * block for the top-level system prompt. A skill enters the block only when diff --git a/packages/@n8n/agents/src/runtime/skills/skill-dependency-tools.ts b/packages/@n8n/agents/src/runtime/skills/skill-dependency-tools.ts new file mode 100644 index 00000000000..dea5ea12172 --- /dev/null +++ b/packages/@n8n/agents/src/runtime/skills/skill-dependency-tools.ts @@ -0,0 +1,22 @@ +import type { AgentRuntimeConfig } from '../../types/runtime/agent-runtime'; + +/** + * Move the deferred tools that a registered skill depends on into the + * always-active toolset. Loading them only when the skill activates would + * change the tool list mid-conversation. Anthropic renders tools first, so + * that change rewrites the whole cached prompt. + */ +export function activateSkillDependencyTools(config: AgentRuntimeConfig): AgentRuntimeConfig { + const deferredTools = config.deferredTools ?? []; + const dependencyNames = new Set( + config.skillSource?.registry.skills.flatMap((skill) => skill.dependencies?.tools ?? []), + ); + const dependencyTools = deferredTools.filter((tool) => dependencyNames.has(tool.name)); + if (dependencyTools.length === 0) return config; + + return { + ...config, + tools: [...(config.tools ?? []), ...dependencyTools], + deferredTools: deferredTools.filter((tool) => !dependencyNames.has(tool.name)), + }; +} diff --git a/packages/@n8n/agents/src/skills/types.ts b/packages/@n8n/agents/src/skills/types.ts index b1ce440105c..a91581b03c0 100644 --- a/packages/@n8n/agents/src/skills/types.ts +++ b/packages/@n8n/agents/src/skills/types.ts @@ -62,7 +62,7 @@ export interface RuntimeSkillMcpServerDependency { } export interface RuntimeSkillDependenciesContract { - /** Activate matching deferred tools while the skill is active. Tools must be registered on the agent. */ + /** Keep matching deferred tools active for the whole run, so activating the skill never changes the tool list. Tools must be registered on the agent. */ tools?: string[]; secrets?: string[]; mcpServers?: RuntimeSkillMcpServerDependency[]; diff --git a/packages/@n8n/instance-ai/skills/planning/SKILL.md b/packages/@n8n/instance-ai/skills/planning/SKILL.md index 5b7f7b95a15..d4b82d5f06b 100644 --- a/packages/@n8n/instance-ai/skills/planning/SKILL.md +++ b/packages/@n8n/instance-ai/skills/planning/SKILL.md @@ -6,8 +6,8 @@ dependencies: description: >- ONLY for coordinated multi-artifact work: multiple workflows with dependencies, shared data-table schema/migration across tasks, or the user explicitly asked - to review a plan first. Load create-tasks via load_tool before calling it - (search "create tasks" if not visible). Do NOT use for new one-off workflows, + to review a plan first. If create-tasks is not visible, load it via load_tool + (search "create tasks"). Do NOT use for new one-off workflows, single-workflow edits, verification-only requests, or standalone data-table ops — use workflow-builder or data-table-manager instead. recommended_tools: @@ -27,8 +27,8 @@ Use this skill to design a dependency-aware task graph in the orchestrator and submit it with `create-tasks`. Do not spawn another agent and do not use incremental plan item tools. -Before calling `create-tasks`, load it via `load_tool` (search "create tasks" if -it is not visible). +If `create-tasks` is not visible, load it via `load_tool` (search "create tasks") +before you call it. ## When NOT to use this skill diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts index 0bcf934d042..f125610386c 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts @@ -428,7 +428,7 @@ describe('Instance AI runtime skills', () => { 'research', 'ask-user', ]); - expect(skill?.description).toContain('Load create-tasks via load_tool before calling it'); + expect(skill?.description).toContain('If create-tasks is not visible, load it via load_tool'); expect(skill?.description).toContain('Do NOT use for new one-off workflows'); const loaded = await source.loadSkill('planning'); @@ -436,7 +436,7 @@ describe('Instance AI runtime skills', () => { expect(loaded?.instructions).toContain('Consult the knowledge base before planning'); expect(loaded?.instructions).toContain('never load `templates/index.json` wholesale'); expect(loaded?.instructions).toContain( - 'Before calling `create-tasks`, load it via `load_tool`', + 'If `create-tasks` is not visible, load it via `load_tool`', ); expect(loaded?.instructions).toContain('Do not call `create-tasks` just to get approval'); expect(loaded?.instructions).toContain('planningContext.source: "planning-skill"');