fix(core): Keep skill activation and memory compaction from breaking the prompt cache (no-changelog) (#40245)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Riqwan Thamir 2026-10-05 10:48:35 +00:00 committed by GitHub
parent 6610987057
commit fe07e84d0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 341 additions and 32 deletions

View File

@ -85,8 +85,24 @@ carrying a skill never rewrites the cached prefix. The `<active_skills>` 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

View File

@ -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.',

View File

@ -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: '<active_skills>\nBuild one workflow.\n</active_skills>' };
it('places skills in their own cached message between the base and volatile messages', () => {
const system = buildSystemMessages(
'Base instructions',
'<observations>\n* Some memory.\n</observations>',
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<observations>\n* Some memory.\n</observations>' },
]);
});
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',
'<observations>\n* Some memory.\n</observations>',
cacheOptions,
undefined,
undefined,
false,
{ ...skills, providerOptions: cacheOptions },
);
expect(system).toEqual({
role: 'system',
content: [
'Base instructions',
skills.content,
'<observations>\n* Some memory.\n</observations>',
].join('\n\n'),
providerOptions: cacheOptions,
});
});
});
// ---------------------------------------------------------------------------
// Input / response messages use existing createdAt as a hint
// ---------------------------------------------------------------------------

View File

@ -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>): 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);
});
});

View File

@ -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,

View File

@ -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({

View File

@ -4,6 +4,7 @@ import {
applyRuntimeCacheBreakpoints,
buildCallPromptCacheOptions,
buildInstructionPromptCacheOptions,
buildSkillInstructionCacheOptions,
getEffectiveAnthropicCacheTtl,
mergeProviderOptions,
} from '../prompt-cache';
@ -26,6 +27,65 @@ function makeTool(providerOptions?: Record<string, unknown>): 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(

View File

@ -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,
};

View File

@ -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,

View File

@ -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

View File

@ -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)),
};
}

View File

@ -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[];

View File

@ -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

View File

@ -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"');