mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
6610987057
commit
fe07e84d0f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user