balance out mode switching

This commit is contained in:
Riqwan Thamir 2026-09-28 01:46:55 +02:00
parent e313492269
commit 3b04e3732e
No known key found for this signature in database
15 changed files with 530 additions and 76 deletions

View File

@ -224,6 +224,7 @@ export type {
RuntimeSkillReferenceLocation,
RuntimeSkillRegistry,
RuntimeSkillRegistryEntry,
RuntimeSkillModeState,
RuntimeSkillSource,
RuntimeSkillStateScope,
RuntimeSkillStateStore,

View File

@ -427,6 +427,74 @@ describe('active skills', () => {
expect(active.instructions()).toBeUndefined();
});
it('anchors skills restored by a mode switch to the pinned switch result', async () => {
const modeSource = createRuntimeSkillSource([
{
id: 'agent-rules',
name: 'agent-rules',
description: 'Build agents.',
instructions: 'Agent rules.',
recommendedMode: 'agents',
},
]);
const switchCall = (toolCallId: string, mode: string) => ({
id: `${toolCallId}-message`,
role: 'assistant' as const,
content: [
{
type: 'tool-call' as const,
toolName: 'switch_mode',
toolCallId,
input: { mode },
state: 'resolved' as const,
output: { status: 'switched', mode },
},
],
});
const memory = new InMemoryMemory();
const active = new ActiveSkills(
modeSource,
'assistant',
memory.skillState,
new Set(['agents', 'build']),
);
const list = new AgentMessageList();
list.addResponse([
switchCall('to-agents', 'agents'),
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'load_skill',
toolCallId: 'agent-load',
input: { skillId: 'agent-rules' },
state: 'resolved',
output: { success: true },
},
],
},
]);
await active.restore(list, scope);
await active.enterMode('agents', { toolCallId: 'to-agents' });
await active.enterMode('build');
expect(list.activeSkillIds).toEqual([]);
// The switch back masks the history and pins only the switch message.
list.addResponse([switchCall('back-to-agents', 'agents')]);
await active.enterMode('agents', { toolCallId: 'back-to-agents' });
mask(list);
list.pinModeSwitch('back-to-agents-message');
expect(list.activeSkillIds).toEqual(['agent-rules']);
expect(active.instructions()).toBeUndefined();
const switchResult = active
.modelMessages(list.forLlm('').messages, list)
.flatMap((message) => (message.role === 'tool' ? message.content : []))
.find((part) => part.type === 'tool-result' && part.toolCallId === 'back-to-agents');
expect(JSON.stringify(switchResult)).toContain('Agent rules.');
});
it('records concurrent loads without dropping either skill', async () => {
const memory = new InMemoryMemory();
const active = new ActiveSkills(source, 'assistant', memory.skillState);

View File

@ -6534,7 +6534,7 @@ describe('tool systemInstruction merging', () => {
expect(afterFirst).not.toContain('Always confirm before using deferred_capability.');
});
it("keeps a mode-scoped tool's systemInstruction out of the cached system message across a mode switch", async () => {
it("delivers a mode-scoped tool's systemInstruction on the switch result, not the system prompt", async () => {
const coreTool: BuiltTool = {
name: 'core_tool',
description: 'Core tool',
@ -6576,20 +6576,26 @@ describe('tool systemInstruction merging', () => {
await runtime.generate('switch to mode b');
const calls = generateText.mock.calls as Array<
[{ instructions: Array<{ content: string }> | { content: string } }]
[
{
instructions: Array<{ content: string }> | { content: string };
messages: Array<{ role: string; content: unknown }>;
},
]
>;
const systemParts = (index: number) => {
const system = calls[index][0].instructions;
return Array.isArray(system) ? system.map((entry) => entry.content) : [system.content];
};
const [beforeFirst, ...beforeRest] = systemParts(0);
const [afterFirst, ...afterRest] = systemParts(1);
expect(beforeFirst).toContain('Core rule.');
expect(afterFirst).toBe(beforeFirst);
expect([beforeFirst, ...beforeRest].join('')).not.toContain('Mode b rule.');
expect(afterFirst).not.toContain('Mode b rule.');
expect(afterRest.join('')).toContain('Mode b rule.');
expect(systemParts(0).join('')).toContain('Core rule.');
expect(systemParts(1)).toEqual(systemParts(0));
expect(systemParts(1).join('')).not.toContain('Mode b rule.');
const switchResult = calls[1][0].messages
.filter((message) => message.role === 'tool')
.flatMap((message) => message.content as Array<{ toolCallId?: string }>)
.find((part) => part.toolCallId === 'tc-switch');
expect(JSON.stringify(switchResult)).toContain('Mode b rule.');
});
});

View File

@ -65,6 +65,7 @@ import { generateThreadTitle } from '../memory/title-generation';
import { AgentMessageList, type SerializedMessageList } from '../model/message-list';
import { supportsSplitSystemMessages, type FetchFn } from '../model/model-factory';
import { createModelTokenCounter } from '../model/model-token-counter';
import { appendToToolResult } from '../model/tool-result-text';
import {
applyRuntimeCacheBreakpoints,
buildInstructionPromptCacheOptions,
@ -229,6 +230,7 @@ export class AgentRuntime {
config.skillSource,
config.name,
config.memory?.skillState,
new Set(Object.keys(config.toolModes?.modes ?? {})),
);
}
const tokenCounter = createModelTokenCounter(config.model);
@ -236,6 +238,10 @@ export class AgentRuntime {
this.runId = config.runId ?? generateRunId();
const toolModeManager = config.toolModes ? new ToolModeManager(config.toolModes) : undefined;
this.toolModeManager = toolModeManager;
const activeSkills = this.activeSkills;
if (toolModeManager && activeSkills) {
toolModeManager.onSwitch(async (mode, anchor) => await activeSkills.enterMode(mode, anchor));
}
if (config.deferredTools && config.deferredTools.length > 0) {
this.deferredToolManager = new DeferredToolManager(config.deferredTools, {
...config.toolSearch,
@ -818,6 +824,8 @@ export class AgentRuntime {
const { list, options, abortScope, pendingResume } = ctx;
await this.activeSkills?.restore(list, options?.persistence);
this.context.hydrateToolStateFromList(list);
// A run can start in another mode than the one the saved skills belong to.
if (this.toolModeManager) await this.activeSkills?.enterMode(this.toolModeManager.mode);
// Inject a model-facing note for any MCP servers that failed to connect
// during build(). The agent can mention the outage to the user when
// relevant; the note is system-message only and never persisted.
@ -948,6 +956,7 @@ export class AgentRuntime {
hasTools,
effectiveInstructions,
volatileInstructions,
modeRules,
staticToolCacheName,
} = this.context.buildToolLoopContext(
staticLoopContext.aiProviderTools,
@ -967,11 +976,14 @@ export class AgentRuntime {
supportsSplitSystemMessages(this.config.model),
this.activeSkills?.instructions(),
);
const skillMessages = this.activeSkills?.modelMessages(messages, list) ?? messages;
// Runtime breakpoints (conversation history, static tools) are per-call
// only — never persisted back to the message list or tool set.
const cached = applyRuntimeCacheBreakpoints({
system,
messages: this.activeSkills?.modelMessages(messages, list) ?? messages,
messages: modeRules
? appendToToolResult(skillMessages, modeRules.toolCallId, modeRules.text)
: skillMessages,
aiTools,
promptCaching: this.config.promptCaching,
modelId: this.modelIdString,

View File

@ -113,8 +113,14 @@ export class RuntimeContextBuilder {
const allTools = { ...aiTools, ...aiProviderTools };
const aiToolCount = Object.keys(allTools).length;
const toolMap = buildToolMap(allUserTools);
const { instructions: effectiveInstructions, volatileInstructions } =
this.composeEffectiveInstructions(allUserTools);
const modeSwitchToolCallId = list
? this.toolModeManager?.findCurrentModeSwitch(list.llmVisibleMessages())
: undefined;
const {
instructions: effectiveInstructions,
volatileInstructions,
modeInstructions,
} = this.composeEffectiveInstructions(allUserTools, modeSwitchToolCallId !== undefined);
return {
toolMap,
@ -125,6 +131,10 @@ export class RuntimeContextBuilder {
[volatileInstructions, this.toolModeManager?.instructions()]
.filter((value): value is string => Boolean(value))
.join('\n\n') || undefined,
modeRules:
modeSwitchToolCallId && modeInstructions
? { toolCallId: modeSwitchToolCallId, text: modeInstructions }
: undefined,
staticToolCacheName: this.getStaticToolCacheName(allUserTools),
};
}
@ -272,18 +282,30 @@ export class RuntimeContextBuilder {
* or changing it on a mode switch, would invalidate the whole prefix
* (OpenAI's automatic cache, and the Anthropic breakpoint). Sent as the
* uncached system message instead (see `buildSystemMessages`).
* - `modeInstructions`: fragments from tools bound to the current tool mode,
* when `anchorModeRules` is set. The runtime appends them to the result of
* the `switch_mode` call that entered the mode. The system prompt then
* stays the same in every mode, and a return to a mode whose switch
* history is masked does not rewrite it.
*/
private composeEffectiveInstructions(tools: BuiltTool[]): {
private composeEffectiveInstructions(
tools: BuiltTool[],
anchorModeRules: boolean,
): {
instructions: string;
volatileInstructions: string | undefined;
modeInstructions: string | undefined;
} {
const loadedToolNames = new Set(
this.deferredToolManager?.getLoadedTools().map((t) => t.name) ?? [],
);
const isVolatile = (tool: BuiltTool) =>
loadedToolNames.has(tool.name) || (this.toolModeManager?.isModeScoped(tool.name) ?? false);
const stableFragments: string[] = [];
const volatileFragments: string[] = [];
const bucketOf = (tool: BuiltTool): 'stable' | 'volatile' | 'mode' => {
if (this.toolModeManager?.isModeScoped(tool.name)) {
return anchorModeRules ? 'mode' : 'volatile';
}
return loadedToolNames.has(tool.name) ? 'volatile' : 'stable';
};
const fragments = { stable: [] as string[], volatile: [] as string[], mode: [] as string[] };
for (const tool of tools) {
if (
typeof tool.systemInstruction !== 'string' ||
@ -291,19 +313,19 @@ export class RuntimeContextBuilder {
) {
continue;
}
(isVolatile(tool) ? volatileFragments : stableFragments).push(tool.systemInstruction);
fragments[bucketOf(tool)].push(tool.systemInstruction);
}
// Define the untrusted-data boundary ahead of the first wrapped result.
// Goes in the cached block unless every untrusted tool is volatile,
// mirroring the fragment split above.
const untrustedTools = tools.filter((tool) => tool.outputTrust === 'untrusted');
if (untrustedTools.length > 0) {
const target = untrustedTools.some((tool) => !isVolatile(tool))
? stableFragments
: volatileFragments;
target.unshift(UNTRUSTED_OUTPUT_DOCTRINE);
}
// Define the untrusted-data boundary ahead of the first wrapped result,
// in the earliest bucket that holds an untrusted tool.
const untrustedBuckets = new Set(
tools.filter((tool) => tool.outputTrust === 'untrusted').map(bucketOf),
);
const doctrineBucket = (['stable', 'volatile', 'mode'] as const).find((bucket) =>
untrustedBuckets.has(bucket),
);
if (doctrineBucket) fragments[doctrineBucket].unshift(UNTRUSTED_OUTPUT_DOCTRINE);
const stableFragments = fragments.stable;
const userInstructions = this.config.instructions;
const stableBlock = wrapBuiltInRules(stableFragments);
@ -315,7 +337,8 @@ export class RuntimeContextBuilder {
return {
instructions,
volatileInstructions: wrapBuiltInRules(volatileFragments),
volatileInstructions: wrapBuiltInRules(fragments.volatile),
modeInstructions: wrapBuiltInRules(fragments.mode),
};
}

View File

@ -8,7 +8,7 @@ import {
uniqueStrings,
} from './memory-lifecycle';
import { normalizeObservationLogReflection } from './observation-log-reflector';
import type { RuntimeSkillStateStore } from '../../skills/types';
import type { RuntimeSkillModeState, RuntimeSkillStateStore } from '../../skills/types';
import type {
BuiltEpisodicMemoryCaptureStore,
BuiltEpisodicMemoryStore,
@ -90,7 +90,10 @@ export class InMemoryMemory
private threads = new Map<string, Thread>();
private messagesByThread = new Map<string, StoredMessage[]>();
private skillsByThread = new Map<string, Map<string, string[]>>();
private skillsByThread = new Map<
string,
Map<string, { skillIds: string[]; modeState?: RuntimeSkillModeState }>
>();
readonly skillState: RuntimeSkillStateStore = {
load: async ({ threadId, resourceId, agentName }) =>
@ -98,14 +101,27 @@ export class InMemoryMemory
this.skillsByThread
.get(threadId)
?.get(JSON.stringify([resourceId, agentName]))
?.slice(),
?.skillIds.slice(),
),
save: async ({ threadId, resourceId, agentName }, ids) => {
const states = this.skillsByThread.get(threadId) ?? new Map<string, string[]>();
states.set(JSON.stringify([resourceId, agentName]), [...ids]);
save: async ({ threadId, resourceId, agentName }, ids, modeState) => {
const states =
this.skillsByThread.get(threadId) ??
new Map<string, { skillIds: string[]; modeState?: RuntimeSkillModeState }>();
const key = JSON.stringify([resourceId, agentName]);
const next = modeState ?? states.get(key)?.modeState;
states.set(key, {
skillIds: [...ids],
...(next ? { modeState: structuredClone(next) } : {}),
});
this.skillsByThread.set(threadId, states);
await Promise.resolve();
},
loadModeState: async ({ threadId, resourceId, agentName }) => {
const state = this.skillsByThread
.get(threadId)
?.get(JSON.stringify([resourceId, agentName]))?.modeState;
return await Promise.resolve(state ? structuredClone(state) : undefined);
},
};
private observationLogByScope = new Map<string, ObservationLogEntry[]>();

View File

@ -0,0 +1,55 @@
import type { ModelMessage, ToolResultPart } from 'ai';
/**
* Append trusted text parts after a tool result's own output. Text and JSON
* outputs become content parts so the appended text is a separate block from
* the (possibly untrusted-wrapped) tool output. Error outputs are left alone.
*/
export function appendToolResultText(part: ToolResultPart, texts: string[]): ToolResultPart {
const extra = texts.map((text) => ({ type: 'text' as const, text }));
const { output } = part;
switch (output.type) {
case 'content':
return { ...part, output: { type: 'content', value: [...output.value, ...extra] } };
case 'text':
return {
...part,
output: { type: 'content', value: [{ type: 'text', text: output.value }, ...extra] },
};
case 'json':
return {
...part,
output: {
type: 'content',
value: [{ type: 'text', text: JSON.stringify(output.value) }, ...extra],
},
};
default:
return part;
}
}
/** Append `text` to the result of the tool call `toolCallId`. Other messages are returned as-is. */
export function appendToToolResult(
messages: ModelMessage[],
toolCallId: string,
text: string,
): ModelMessage[] {
return messages.map((message) => {
if (message.role !== 'tool') return message;
if (!message.content.some((part) => isResultFor(part, toolCallId))) return message;
return {
...message,
content: message.content.map((part) =>
isResultFor(part, toolCallId) ? appendToolResultText(part, [text]) : part,
),
};
});
}
function isResultFor(
part: Extract<ModelMessage, { role: 'tool' }>['content'][number],
toolCallId: string,
): part is ToolResultPart {
return part.type === 'tool-result' && part.toolCallId === toolCallId;
}

View File

@ -4,12 +4,15 @@ import { findReferenceByPath, formatActiveSkill } from '../../skills/tools';
import {
SKILL_LOAD_TOOL_NAME,
type RuntimeSkillContent,
type RuntimeSkillModeState,
type RuntimeSkillRegistry,
type RuntimeSkillSource,
type RuntimeSkillStateScope,
type RuntimeSkillStateStore,
} from '../../skills/types';
import type { AgentPersistenceOptions } from '../../types/sdk/agent';
import type { AgentMessageList } from '../model/message-list';
import { appendToolResultText } from '../model/tool-result-text';
/**
* Keeps trusted skill instructions available without invalidating the cached
@ -41,14 +44,27 @@ export class ActiveSkills {
private scope?: RuntimeSkillStateScope;
private pendingSave = Promise.resolve();
/** The tool mode that the active skills belong to; undefined until a mode is entered. */
private mode?: string;
/** Active skill IDs for each tool mode of the thread, so a return restores them. */
private readonly modeSkillIds = new Map<string, string[]>();
/**
* @param modeNames Tool modes that the agent configures. A skill whose
* `recommendedMode` is not one of them stays active in every mode.
*/
constructor(
private readonly source: RuntimeSkillSource,
private readonly agentName: string,
private readonly store?: RuntimeSkillStateStore,
private readonly modeNames: ReadonlySet<string> = new Set(),
) {}
async restore(list: AgentMessageList, persistence?: AgentPersistenceOptions): Promise<void> {
this.loaded.clear();
this.modeSkillIds.clear();
this.mode = undefined;
this.list = list;
this.scope = persistence
? {
@ -57,6 +73,16 @@ export class ActiveSkills {
agentName: this.agentName,
}
: undefined;
const modeState =
this.scope && this.modeNames.size > 0
? await this.store?.loadModeState?.(this.scope)
: undefined;
if (modeState) {
this.mode = modeState.mode;
for (const [mode, ids] of Object.entries(modeState.modeSkillIds)) {
this.modeSkillIds.set(mode, [...ids]);
}
}
const stored =
list.activeSkillIds === undefined && this.scope
? await this.store?.load(this.scope)
@ -99,6 +125,74 @@ export class ActiveSkills {
return skill;
}
/**
* Make the active skills match `mode`. The skills of the mode that is left
* are saved for that mode. Active skills that belong to another mode are
* deactivated. The skills saved for `mode` are activated again. Skills with
* no mode stay active. Returns the IDs that are active afterwards.
*
* `anchor` is the tool call that switched the mode. Every active skill is
* stamped on it. The mode switch can mask the results that activated the
* skills, and the pinned switch result keeps the skills out of `system`.
*/
async enterMode(mode: string, anchor?: { toolCallId: string }): Promise<string[]> {
const list = this.list;
if (!list) throw new Error('Active skills must be restored before a mode change');
if (this.mode === mode) {
this.stampAll(list, anchor);
return [...this.loaded.keys()];
}
const previous = this.mode;
const current = [...this.loaded.keys()];
if (previous !== undefined) {
this.modeSkillIds.set(previous, current);
} else {
// Without a saved mode, set aside each skill under its own mode.
for (const id of current) {
const skillMode = this.skillMode(id);
if (skillMode === undefined || skillMode === mode) continue;
this.modeSkillIds.set(skillMode, [...(this.modeSkillIds.get(skillMode) ?? []), id]);
}
}
const next = [
...new Set([
...current.filter((id) => {
const skillMode = this.skillMode(id);
return skillMode === undefined || skillMode === mode;
}),
...(this.modeSkillIds.get(mode) ?? []),
]),
];
const registered = new Set(this.source.registry.skills.map(({ id }) => id));
const kept = new Map<string, RuntimeSkillContent>();
for (const id of next) {
if (!registered.has(id)) continue;
const skill = this.loaded.get(id) ?? (await this.source.loadSkill(id));
if (skill) kept.set(id, skill);
}
this.loaded.clear();
for (const [id, skill] of kept) this.loaded.set(id, skill);
this.mode = mode;
list.activeSkillIds = [...this.loaded.keys()];
this.stampAll(list, anchor);
await this.persist();
return [...this.loaded.keys()];
}
private stampAll(list: AgentMessageList, anchor: { toolCallId: string } | undefined): void {
if (!anchor) return;
for (const id of this.loaded.keys()) list.stampActivatedSkill(anchor.toolCallId, id);
}
/** The configured tool mode that a skill belongs to; a reference inherits its owner's mode. */
private skillMode(skillId: string): string | undefined {
const mode = resolveSkillMode(this.source.registry, skillId);
return mode !== undefined && this.modeNames.has(mode) ? mode : undefined;
}
toolDependencies(): string[] {
return [
...new Set([...this.loaded.values()].flatMap((skill) => skill.dependencies?.tools ?? [])),
@ -294,38 +388,37 @@ export class ActiveSkills {
const store = this.store;
if (!scope || !store) return;
const ids = [...(this.list?.activeSkillIds ?? [])];
const modeState = this.modeState(ids);
// Concurrent tool calls must not overwrite a newer activation with an older set.
const save = this.pendingSave.then(async () => await store.save(scope, ids));
const save = this.pendingSave.then(async () => await store.save(scope, ids, modeState));
this.pendingSave = save.catch(() => undefined);
await save;
}
/** The saved mode state, with the current mode's entry set to `activeIds`. */
private modeState(activeIds: string[]): RuntimeSkillModeState | undefined {
if (this.mode === undefined) return undefined;
this.modeSkillIds.set(this.mode, [...activeIds]);
return { mode: this.mode, modeSkillIds: Object.fromEntries(this.modeSkillIds) };
}
}
/**
* Append trusted text parts after a tool result's own output. Text and JSON
* outputs become content parts so the skill text is a separate block from the
* (possibly untrusted-wrapped) tool output. Error outputs are left alone.
* The `recommendedMode` of a skill, or of the owner of a reference skill.
* Returns undefined when neither declares a mode.
*/
function appendToolResultText(part: ToolResultPart, texts: string[]): ToolResultPart {
const extra = texts.map((text) => ({ type: 'text' as const, text }));
const { output } = part;
switch (output.type) {
case 'content':
return { ...part, output: { type: 'content', value: [...output.value, ...extra] } };
case 'text':
return {
...part,
output: { type: 'content', value: [{ type: 'text', text: output.value }, ...extra] },
};
case 'json':
return {
...part,
output: {
type: 'content',
value: [{ type: 'text', text: JSON.stringify(output.value) }, ...extra],
},
};
default:
return part;
export function resolveSkillMode(
registry: RuntimeSkillRegistry,
skillId: string,
): string | undefined {
const seen = new Set<string>();
let id: string | undefined = skillId;
while (id !== undefined && !seen.has(id)) {
seen.add(id);
const entry = registry.skills.find((skill) => skill.id === id);
if (!entry) return undefined;
if (entry.recommendedMode) return entry.recommendedMode;
id = entry.parents?.[0];
}
return undefined;
}

View File

@ -6,6 +6,7 @@ import type { AgentDbMessage } from '../../../types/sdk/message';
import type { ToolModesConfig } from '../../../types/sdk/tool-modes';
import type { AgentRuntimeConfig } from '../../loop/agent-runtime';
import { RuntimeContextBuilder } from '../../loop/runtime-context';
import { AgentMessageList } from '../../model/message-list';
import { SWITCH_MODE_TOOL_NAME, ToolModeManager } from '../tool-mode-manager';
const fakeModel = { doGenerate: vi.fn() } as unknown as LanguageModel;
@ -71,6 +72,19 @@ describe('ToolModeManager', () => {
expect(manager.isVisible('build-workflow')).toBe(false);
});
it('passes the switch tool call to the switch listener', async () => {
const manager = new ToolModeManager(config);
const listener = vi.fn().mockResolvedValue(['builder']);
manager.onSwitch(listener);
const output = await manager
.getControllerTool()
.handler?.({ mode: 'debug' }, { toolCallId: 'switch-1' } as never);
expect(listener).toHaveBeenCalledWith('debug', { toolCallId: 'switch-1' });
expect(output).toMatchObject({ activeSkills: ['builder'] });
});
it('reports unchanged when the model switches to the current mode', () => {
const manager = new ToolModeManager(config);
@ -205,6 +219,82 @@ describe('RuntimeContextBuilder with tool modes', () => {
expect(volatileInstructions).toContain('Your current tool mode is "debug"');
expect(effectiveInstructions).not.toContain('tool mode');
});
describe('mode tool rules', () => {
function makeRulesBuilder() {
const manager = new ToolModeManager({ ...config, announceMode: false });
const withRule = (name: string, rule: string): BuiltTool => ({
...makeTool(name),
systemInstruction: rule,
});
const builder = new RuntimeContextBuilder(
{
name: 'agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Test agent.',
tools: [
withRule('ask-user', 'Ask rule.'),
withRule('executions', 'Executions rule.'),
makeTool('build-workflow'),
],
},
undefined,
manager,
);
return { manager, builder };
}
function listWithSwitch(mode: string): AgentMessageList {
const list = new AgentMessageList();
list.addResponse([
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: `to-${mode}`,
toolName: SWITCH_MODE_TOOL_NAME,
input: { mode },
state: 'resolved',
output: { status: 'switched', mode },
},
],
},
]);
return list;
}
it('anchors the current mode rules to the switch that entered the mode', () => {
const { manager, builder } = makeRulesBuilder();
manager.switchTo('debug');
const context = builder.buildToolLoopContext(
{},
undefined,
undefined,
listWithSwitch('debug'),
);
expect(context.effectiveInstructions).toContain('Ask rule.');
expect(context.volatileInstructions).toBeUndefined();
expect(context.modeRules).toEqual({
toolCallId: 'to-debug',
text: '<built_in_rules>\n- Executions rule.\n</built_in_rules>',
});
});
it('keeps the mode rules in the system prompt without a switch into the current mode', () => {
const { manager, builder } = makeRulesBuilder();
manager.switchTo('debug');
for (const list of [new AgentMessageList(), listWithSwitch('build')]) {
const context = builder.buildToolLoopContext({}, undefined, undefined, list);
expect(context.modeRules).toBeUndefined();
expect(context.volatileInstructions).toContain('Executions rule.');
}
});
});
});
describe('Agent.toolModes validation', () => {

View File

@ -23,11 +23,21 @@ const switchModeOutputSchema = z.object({
status: z.enum(['switched', 'unchanged']),
mode: z.string(),
tools: z.array(z.string()),
activeSkills: z.array(z.string()).optional(),
message: z.string(),
});
type SwitchModeOutput = z.infer<typeof switchModeOutputSchema>;
/**
* Runs when `switch_mode` changes the mode. `anchor` is the `switch_mode` call.
* Returns the skill IDs that are active in the new mode.
*/
export type ToolModeSwitchListener = (
mode: string,
anchor?: { toolCallId: string },
) => Promise<string[] | undefined>;
/**
* Tracks the active tool mode of one run. Tools that a mode names are visible
* only in that mode. Tools that no mode names are visible in every mode.
@ -44,6 +54,8 @@ export class ToolModeManager {
/** Set by a switch and cleared when the runtime takes it at a loop boundary. */
private switchPending = false;
private switchListener?: ToolModeSwitchListener;
constructor(private readonly config: ToolModesConfig) {
const modeNames = Object.keys(config.modes);
if (modeNames.length === 0) throw new Error('Tool modes need at least one mode');
@ -66,6 +78,11 @@ export class ToolModeManager {
return this.switchTool;
}
/** Registers the listener that `switch_mode` awaits after a mode change. */
onSwitch(listener: ToolModeSwitchListener): void {
this.switchListener = listener;
}
/** Adds a pick-the-mode-first hint to the skill and tool discovery tools; returns other tools as-is. */
withModeHint(tool: BuiltTool): BuiltTool {
const hint = MODE_HINTS[tool.name];
@ -138,6 +155,26 @@ export class ToolModeManager {
return undefined;
}
/**
* The tool-call ID of the latest `switch_mode` call in `messages` that
* changed the mode. Returns undefined when that call did not enter the
* current mode, or when `messages` hold no such call.
*/
findCurrentModeSwitch(messages: readonly AgentDbMessage[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!('content' in message) || !Array.isArray(message.content)) continue;
for (let j = message.content.length - 1; j >= 0; j--) {
const block: unknown = message.content[j];
if (!this.isResolvedSwitchCall(block) || block.canceled) continue;
const output = block.output;
if (!isRecord(output) || output.status !== 'switched') continue;
return output.mode === this.currentMode ? block.toolCallId : undefined;
}
}
return undefined;
}
/**
* Restore the mode from this run's messages, so a resumed run continues in
* the mode it had when it suspended. Earlier runs start from the initial mode.
@ -174,9 +211,17 @@ export class ToolModeManager {
description: `Switch the tool mode. Each mode adds its own tools to the tools that every mode has. Switch only when the current mode does not have a tool that you need.\n${modeList}`,
inputSchema,
outputSchema: switchModeOutputSchema,
handler: async (input) => {
handler: async (input, ctx) => {
const { mode } = inputSchema.parse(input);
return await Promise.resolve(this.switchTo(mode));
const result = this.switchTo(mode);
if (result.status !== 'switched' || !this.switchListener) return result;
const activeSkills = await this.switchListener(
mode,
ctx.toolCallId ? { toolCallId: ctx.toolCallId } : undefined,
);
if (!activeSkills) return result;
const skills = activeSkills.length > 0 ? activeSkills.join(', ') : 'none';
return { ...result, activeSkills, message: `${result.message} Active skills: ${skills}.` };
},
};
}

View File

@ -23,6 +23,7 @@ export type {
RuntimeSkillReferenceLocation,
RuntimeSkillRegistry,
RuntimeSkillRegistryEntry,
RuntimeSkillModeState,
RuntimeSkillSource,
RuntimeSkillStateScope,
RuntimeSkillStateStore,

View File

@ -196,10 +196,29 @@ export interface RuntimeSkillStateScope {
agentName: string;
}
/**
* Active skill IDs for each tool mode of a thread. A mode switch sets aside the
* skills that the new mode does not need and restores them when the thread
* returns to their mode.
*/
export interface RuntimeSkillModeState {
/** The mode that the saved active skill IDs belong to. */
mode: string;
/** Active skill IDs for each mode the thread has used, including `mode`. */
modeSkillIds: Record<string, string[]>;
}
/** Stores active IDs separately from conversation text that memory can compact. */
export interface RuntimeSkillStateStore {
load(scope: RuntimeSkillStateScope): Promise<string[] | undefined>;
save(scope: RuntimeSkillStateScope, skillIds: string[]): Promise<void>;
/** Omitting `modeState` keeps the stored mode state unchanged. */
save(
scope: RuntimeSkillStateScope,
skillIds: string[],
modeState?: RuntimeSkillModeState,
): Promise<void>;
/** Stores that do not implement this cannot restore skills set aside by a mode switch. */
loadModeState?(scope: RuntimeSkillStateScope): Promise<RuntimeSkillModeState | undefined>;
}
export interface RuntimeSkillValidationError {

View File

@ -45,7 +45,7 @@ For built-in providers, the setup service recognizes `ANTHROPIC_API_KEY`,
| `N8N_INSTANCE_AI_THINKING_ENABLED` | boolean | `true` | Extended thinking / reasoning. When `false`, reasoning is not enabled on the model. |
| `N8N_INSTANCE_AI_NODE_CONTEXT_ENABLED` | boolean | `false` | Force-enable canvas node context. `false` falls back to the PostHog flag. |
| `N8N_INSTANCE_AI_FOLDER_EXPLORATION_ENABLED` | boolean | `false` | Force-enable folder exploration (folder attribution and folder scoping on `workflows(action="list")`). `false` falls back to the PostHog flag. |
| `N8N_INSTANCE_AI_TOOL_MODES_ENABLED` | boolean | `false` | Force-enable tool modes. The orchestrator binds only the tools of its current mode and calls `switch_mode` to change it. `false` falls back to the PostHog flag. |
| `N8N_INSTANCE_AI_TOOL_MODES_ENABLED` | boolean | `false` | Force-enable tool modes. The orchestrator binds only the tools of its current mode and calls `switch_mode` to change it. A switch keeps only the active skills of the new mode (a skill's `recommended_mode`, or its owner's) and restores the skills that the thread had active in that mode before. `false` falls back to the PostHog flag. |
| `N8N_INSTANCE_AI_BROWSER_USE_ENABLED` | boolean | `true` | Computer Use browser tooling, used for credential setup. |
| `N8N_INSTANCE_AI_ACTIVATION_CAPPED` | boolean | `false` | Activation capping. |
| `N8N_INSTANCE_AI_ACTIVATION_LOCK_MESSAGE_THRESHOLD` | number | `1` | Assistant messages that must be sent, in addition to instance activation, before an activation lock applies. |

View File

@ -237,6 +237,10 @@ Your tools are grouped into modes. Only the tools of the active mode are bound,
**Pick the mode first.** Before you load any skill or call any other tool, decide which mode fits the user's request. When it is not the active mode, call \`switch_mode\` first, and only then load the skill for that mode. A skill loaded in the wrong mode names tools you cannot call.
**Call \`switch_mode\` alone.** Never call \`switch_mode\` in parallel with other tool calls. Make it the only tool call in its response. Wait for its result before you call the tools or load the skills of the new mode.
**Switching modes is expensive.** Each switch replaces the bound tools, so the next model call cannot reuse the cached prompt. Finish all the work that the current mode can do before you switch. Do not switch back and forth for small steps.
${modes}
Switch again when the work moves to another mode (for example, to \`build\` for a workflow that an Agent needs, then back to \`agents\`). Do not switch for a reply that needs no tools.${discovery ? `\n\n${discovery}` : ''}

View File

@ -57,6 +57,12 @@ const activeSkillStatesSchema = z.array(
agentName: z.string(),
resourceId: z.string(),
skillIds: z.array(z.string()),
modeState: z
.object({
mode: z.string(),
modeSkillIds: z.record(z.string(), z.array(z.string())),
})
.optional(),
}),
);
@ -201,23 +207,38 @@ export class TypeORMAgentMemory
(state) => state.resourceId === resourceId && state.agentName === agentName,
)?.skillIds;
},
save: async ({ threadId, resourceId, agentName }, skillIds) => {
save: async ({ threadId, resourceId, agentName }, skillIds, modeState) => {
const updated = await this.patchThread({
threadId,
update: (thread) => ({
metadata: {
...thread.metadata,
activeSkillStates: [
...activeSkillStates(thread.metadata).filter(
(state) => state.resourceId !== resourceId || state.agentName !== agentName,
),
{ resourceId, agentName, skillIds },
],
},
}),
update: (thread) => {
const states = activeSkillStates(thread.metadata);
const isScope = (state: (typeof states)[number]) =>
state.resourceId === resourceId && state.agentName === agentName;
const nextModeState = modeState ?? states.find(isScope)?.modeState;
return {
metadata: {
...thread.metadata,
activeSkillStates: [
...states.filter((state) => !isScope(state)),
{
resourceId,
agentName,
skillIds,
...(nextModeState ? { modeState: nextModeState } : {}),
},
],
},
};
},
});
if (!updated) throw new UnexpectedError('Cannot save active skills for a missing thread');
},
loadModeState: async ({ threadId, resourceId, agentName }) => {
const thread = await this.getThread(threadId);
return activeSkillStates(thread?.metadata).find(
(state) => state.resourceId === resourceId && state.agentName === agentName,
)?.modeState;
},
};
private readonly threadMutationQueues = new Map<string, Promise<unknown>>();