feat: inject toolset swap and slash-command context reminders (#1022)

This commit is contained in:
Charles Packer
2026-02-18 18:15:38 -08:00
committed by GitHub
parent f90244de82
commit a0a5bbfc72
10 changed files with 458 additions and 16 deletions

View File

@@ -85,6 +85,8 @@ import {
import { buildSharedReminderParts } from "../reminders/engine";
import {
createSharedReminderState,
enqueueCommandIoReminder,
enqueueToolsetChangeReminder,
resetSharedReminderState,
syncReminderStateFromContextTracker,
} from "../reminders/state";
@@ -95,6 +97,7 @@ import {
analyzeToolApproval,
checkToolPermission,
executeTool,
getToolNames,
releaseToolExecutionContext,
savePermissionRule,
type ToolExecutionResult,
@@ -117,7 +120,11 @@ import {
setActiveCommandId as setActiveProfileCommandId,
validateProfileLoad,
} from "./commands/profile";
import { type CommandHandle, createCommandRunner } from "./commands/runner";
import {
type CommandFinishedEvent,
type CommandHandle,
createCommandRunner,
} from "./commands/runner";
import { AgentSelector } from "./components/AgentSelector";
// ApprovalDialog removed - all approvals now render inline
import { ApprovalPreview } from "./components/ApprovalPreview";
@@ -2281,14 +2288,47 @@ export default function App({
commitEligibleLines(b);
}, [commitEligibleLines]);
const recordCommandReminder = useCallback((event: CommandFinishedEvent) => {
const input = event.input.trim();
if (!input.startsWith("/")) {
return;
}
enqueueCommandIoReminder(sharedReminderStateRef.current, {
input,
output: event.output,
success: event.success,
});
}, []);
const maybeRecordToolsetChangeReminder = useCallback(
(params: {
source: string;
previousToolset: string | null;
newToolset: string | null;
previousTools: string[];
newTools: string[];
}) => {
const toolsetChanged = params.previousToolset !== params.newToolset;
const previousSnapshot = params.previousTools.join("\n");
const nextSnapshot = params.newTools.join("\n");
const toolsChanged = previousSnapshot !== nextSnapshot;
if (!toolsetChanged && !toolsChanged) {
return;
}
enqueueToolsetChangeReminder(sharedReminderStateRef.current, params);
},
[],
);
const commandRunner = useMemo(
() =>
createCommandRunner({
buffersRef,
refreshDerived,
createId: uid,
onCommandFinished: recordCommandReminder,
}),
[refreshDerived],
[recordCommandReminder, refreshDerived],
);
const startOverlayCommand = useCallback(
@@ -10034,6 +10074,8 @@ ${SYSTEM_REMINDER_CLOSE}
const persistedToolsetPreference =
settingsManager.getToolsetPreference(agentId);
const previousToolsetSnapshot = currentToolset;
const previousToolNamesSnapshot = getToolNames();
let toolsetNoticeLine: string | null = null;
if (persistedToolsetPreference === "auto") {
@@ -10048,11 +10090,25 @@ ${SYSTEM_REMINDER_CLOSE}
"Auto toolset selected: switched to " +
toolsetName +
". Use /toolset to set a manual override.";
maybeRecordToolsetChangeReminder({
source: "/model (auto toolset)",
previousToolset: previousToolsetSnapshot,
newToolset: toolsetName,
previousTools: previousToolNamesSnapshot,
newTools: getToolNames(),
});
} else {
const { forceToolsetSwitch } = await import("../tools/toolset");
if (currentToolset !== persistedToolsetPreference) {
await forceToolsetSwitch(persistedToolsetPreference, agentId);
setCurrentToolset(persistedToolsetPreference);
maybeRecordToolsetChangeReminder({
source: "/model (manual toolset override)",
previousToolset: previousToolsetSnapshot,
newToolset: persistedToolsetPreference,
previousTools: previousToolNamesSnapshot,
newTools: getToolNames(),
});
}
setCurrentToolsetPreference(persistedToolsetPreference);
toolsetNoticeLine =
@@ -10093,6 +10149,7 @@ ${SYSTEM_REMINDER_CLOSE}
consumeOverlayCommand,
currentToolset,
isAgentBusy,
maybeRecordToolsetChangeReminder,
resetPendingReasoningCycle,
withCommandLock,
],
@@ -10297,6 +10354,8 @@ ${SYSTEM_REMINDER_CLOSE}
const { forceToolsetSwitch, switchToolsetForModel } = await import(
"../tools/toolset"
);
const previousToolsetSnapshot = currentToolset;
const previousToolNamesSnapshot = getToolNames();
if (toolsetId === "auto") {
const modelHandle =
@@ -10317,6 +10376,13 @@ ${SYSTEM_REMINDER_CLOSE}
settingsManager.setToolsetPreference(agentId, "auto");
setCurrentToolsetPreference("auto");
setCurrentToolset(derivedToolset);
maybeRecordToolsetChangeReminder({
source: "/toolset",
previousToolset: previousToolsetSnapshot,
newToolset: derivedToolset,
previousTools: previousToolNamesSnapshot,
newTools: getToolNames(),
});
cmd.finish(
`Toolset mode set to auto (currently ${derivedToolset}).`,
true,
@@ -10328,6 +10394,13 @@ ${SYSTEM_REMINDER_CLOSE}
settingsManager.setToolsetPreference(agentId, toolsetId);
setCurrentToolsetPreference(toolsetId);
setCurrentToolset(toolsetId);
maybeRecordToolsetChangeReminder({
source: "/toolset",
previousToolset: previousToolsetSnapshot,
newToolset: toolsetId,
previousTools: previousToolNamesSnapshot,
newTools: getToolNames(),
});
cmd.finish(
`Switched toolset to ${toolsetId} (manual override)`,
true,
@@ -10342,9 +10415,11 @@ ${SYSTEM_REMINDER_CLOSE}
agentId,
commandRunner,
consumeOverlayCommand,
currentToolset,
currentModelHandle,
isAgentBusy,
llmConfig,
maybeRecordToolsetChangeReminder,
withCommandLock,
],
);

View File

@@ -24,12 +24,22 @@ export type CommandHandle = {
fail: (output: string) => void;
};
export type CommandFinishedEvent = {
id: string;
input: string;
output: string;
success: boolean;
dimOutput?: boolean;
preformatted?: boolean;
};
type CreateId = (prefix: string) => string;
type RunnerDeps = {
buffersRef: MutableRefObject<Buffers>;
refreshDerived: () => void;
createId: CreateId;
onCommandFinished?: (event: CommandFinishedEvent) => void;
};
function upsertCommandLine(
@@ -56,13 +66,33 @@ export function createCommandRunner({
buffersRef,
refreshDerived,
createId,
onCommandFinished,
}: RunnerDeps) {
function getHandle(id: string, input: string): CommandHandle {
const update = (updateData: CommandUpdate) => {
const previous = buffersRef.current.byId.get(id);
const wasFinished =
previous?.kind === "command" && previous.phase === "finished";
upsertCommandLine(buffersRef.current, id, input, updateData);
if (!buffersRef.current.order.includes(id)) {
buffersRef.current.order.push(id);
}
const next = buffersRef.current.byId.get(id);
const becameFinished =
!wasFinished && next?.kind === "command" && next.phase === "finished";
if (becameFinished) {
onCommandFinished?.({
id,
input: next.input,
output: next.output,
success: next.success !== false,
dimOutput: next.dimOutput,
preformatted: next.preformatted,
});
}
refreshDerived();
};

View File

@@ -9,7 +9,9 @@ export type SharedReminderId =
| "permission-mode"
| "plan-mode"
| "reflection-step-count"
| "reflection-compaction";
| "reflection-compaction"
| "command-io"
| "toolset-change";
export interface SharedReminderDefinition {
id: SharedReminderId;
@@ -50,6 +52,16 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray<SharedReminderDefinition> =
"Compaction-triggered reflection reminder/auto-launch behavior",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "command-io",
description: "Recent slash command input/output context",
modes: ["interactive"],
},
{
id: "toolset-change",
description: "Client-side toolset change context",
modes: ["interactive"],
},
];
export const SHARED_REMINDER_IDS = SHARED_REMINDER_CATALOG.map(

View File

@@ -242,6 +242,107 @@ async function buildReflectionCompactionReminder(
return buildCompactionMemoryReminder(context.agent.id);
}
const MAX_COMMAND_REMINDERS_PER_TURN = 10;
const MAX_TOOLSET_REMINDERS_PER_TURN = 5;
const MAX_COMMAND_INPUT_CHARS = 2000;
const MAX_COMMAND_OUTPUT_CHARS = 4000;
const MAX_TOOL_LIST_CHARS = 3000;
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function truncate(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
return `${value.slice(0, maxChars)}... [truncated]`;
}
function formatToolList(tools: string[]): string {
const uniqueTools = Array.from(new Set(tools));
if (uniqueTools.length === 0) {
return "(none)";
}
return truncate(uniqueTools.join(", "), MAX_TOOL_LIST_CHARS);
}
async function buildCommandIoReminder(
context: SharedReminderContext,
): Promise<string | null> {
if (context.state.pendingCommandIoReminders.length === 0) {
return null;
}
const queued = context.state.pendingCommandIoReminders.splice(0);
const recent = queued.slice(-MAX_COMMAND_REMINDERS_PER_TURN);
const dropped = queued.length - recent.length;
const commandBlocks = recent.map((entry) => {
const status = entry.success ? "success" : "error";
const safeInput = escapeXml(truncate(entry.input, MAX_COMMAND_INPUT_CHARS));
const safeOutput = escapeXml(
truncate(entry.output || "(no output)", MAX_COMMAND_OUTPUT_CHARS),
);
return `<user-command>
<user-command-input>${safeInput}</user-command-input>
<user-command-output>${safeOutput}</user-command-output>
<user-command-status>${status}</user-command-status>
</user-command>`;
});
const droppedLine =
dropped > 0 ? `\nOmitted ${dropped} older command event(s).` : "";
return `${SYSTEM_REMINDER_OPEN}
The following slash commands were executed in the Letta Code harness since your last user message.
Treat these as execution context from the CLI, not new user requests.${droppedLine}
${commandBlocks.join("\n")}
${SYSTEM_REMINDER_CLOSE}
`;
}
async function buildToolsetChangeReminder(
context: SharedReminderContext,
): Promise<string | null> {
if (context.state.pendingToolsetChangeReminders.length === 0) {
return null;
}
const queued = context.state.pendingToolsetChangeReminders.splice(0);
const recent = queued.slice(-MAX_TOOLSET_REMINDERS_PER_TURN);
const dropped = queued.length - recent.length;
const changeBlocks = recent.map((entry) => {
const source = escapeXml(entry.source);
const previousToolset = escapeXml(entry.previousToolset ?? "unknown");
const newToolset = escapeXml(entry.newToolset ?? "unknown");
const previousTools = escapeXml(formatToolList(entry.previousTools));
const newTools = escapeXml(formatToolList(entry.newTools));
return `<toolset-change>
<source>${source}</source>
<previous-toolset>${previousToolset}</previous-toolset>
<new-toolset>${newToolset}</new-toolset>
<previous-tools>${previousTools}</previous-tools>
<new-tools>${newTools}</new-tools>
</toolset-change>`;
});
const droppedLine =
dropped > 0 ? `\nOmitted ${dropped} older toolset change event(s).` : "";
return `${SYSTEM_REMINDER_OPEN}
The user just changed your toolset (specifically, client-side tools that are attached to the Letta Code harness, which may be a subset of your total tools).${droppedLine}
${changeBlocks.join("\n")}
${SYSTEM_REMINDER_CLOSE}
`;
}
export const sharedReminderProviders: Record<
SharedReminderId,
SharedReminderProvider
@@ -252,6 +353,8 @@ export const sharedReminderProviders: Record<
"plan-mode": buildPlanModeReminder,
"reflection-step-count": buildReflectionStepReminder,
"reflection-compaction": buildReflectionCompactionReminder,
"command-io": buildCommandIoReminder,
"toolset-change": buildToolsetChangeReminder,
};
export function assertSharedReminderCoverage(): void {

View File

@@ -1,6 +1,22 @@
import type { ContextTracker } from "../cli/helpers/contextTracker";
import type { PermissionMode } from "../permissions/mode";
const MAX_PENDING_INTERACTION_REMINDERS = 25;
export interface CommandIoReminder {
input: string;
output: string;
success: boolean;
}
export interface ToolsetChangeReminder {
source: string;
previousToolset: string | null;
newToolset: string | null;
previousTools: string[];
newTools: string[];
}
export interface SharedReminderState {
hasSentSessionContext: boolean;
hasInjectedSkillsReminder: boolean;
@@ -10,6 +26,8 @@ export interface SharedReminderState {
turnCount: number;
pendingSkillsReinject: boolean;
pendingReflectionTrigger: boolean;
pendingCommandIoReminders: CommandIoReminder[];
pendingToolsetChangeReminders: ToolsetChangeReminder[];
}
export function createSharedReminderState(): SharedReminderState {
@@ -22,6 +40,8 @@ export function createSharedReminderState(): SharedReminderState {
turnCount: 0,
pendingSkillsReinject: false,
pendingReflectionTrigger: false,
pendingCommandIoReminders: [],
pendingToolsetChangeReminders: [],
};
}
@@ -42,3 +62,25 @@ export function syncReminderStateFromContextTracker(
contextTracker.pendingReflectionTrigger = false;
}
}
function pushBounded<T>(items: T[], entry: T): void {
items.push(entry);
if (items.length <= MAX_PENDING_INTERACTION_REMINDERS) {
return;
}
items.splice(0, items.length - MAX_PENDING_INTERACTION_REMINDERS);
}
export function enqueueCommandIoReminder(
state: SharedReminderState,
reminder: CommandIoReminder,
): void {
pushBounded(state.pendingCommandIoReminders, reminder);
}
export function enqueueToolsetChangeReminder(
state: SharedReminderState,
reminder: ToolsetChangeReminder,
): void {
pushBounded(state.pendingToolsetChangeReminders, reminder);
}

View File

@@ -75,6 +75,27 @@ describe("commandRunner", () => {
});
expect(buffers.order).toEqual(["cmd-1"]);
});
test("onCommandFinished fires once on running->finished transition", () => {
const buffers = createBuffers();
const buffersRef = { current: buffers };
const finishedEvents: Array<{ input: string; output: string }> = [];
const runner = createCommandRunner({
buffersRef,
refreshDerived: () => {},
createId: () => "cmd-1",
onCommandFinished: (event) => {
finishedEvents.push({ input: event.input, output: event.output });
},
});
const cmd = runner.start("/model", "Opening model selector...");
cmd.update({ output: "Still opening...", phase: "running" });
cmd.finish("Switched", true);
cmd.finish("Switched again", true);
expect(finishedEvents).toEqual([{ input: "/model", output: "Switched" }]);
});
});
describe("command input preservation in handlers", () => {

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
function readAppSource(): string {
const appPath = fileURLToPath(new URL("../../cli/App.tsx", import.meta.url));
return readFileSync(appPath, "utf-8");
}
describe("interaction reminder wiring", () => {
test("command runner finish events are wired into shared reminder state", () => {
const source = readAppSource();
expect(source).toContain("const recordCommandReminder = useCallback(");
expect(source).toContain(
"enqueueCommandIoReminder(sharedReminderStateRef.current",
);
expect(source).toContain("onCommandFinished: recordCommandReminder");
});
test("model/toolset handlers enqueue toolset change reminder snapshots", () => {
const source = readAppSource();
expect(source).toContain(
"const maybeRecordToolsetChangeReminder = useCallback(",
);
expect(source).toContain(
"const previousToolNamesSnapshot = getToolNames();",
);
expect(source).toContain('source: "/model (auto toolset)"');
expect(source).toContain('source: "/model (manual toolset override)"');
expect(source).toContain('source: "/toolset"');
expect(source).toContain(
"enqueueToolsetChangeReminder(sharedReminderStateRef.current",
);
});
});

View File

@@ -18,14 +18,29 @@ describe("shared reminder catalog", () => {
expect(unique.size).toBe(SHARED_REMINDER_IDS.length);
});
test("all reminders target all runtime modes", () => {
for (const reminder of SHARED_REMINDER_CATALOG) {
expect(reminder.modes).toContain("interactive");
expect(reminder.modes).toContain("headless-one-shot");
expect(reminder.modes).toContain("headless-bidirectional");
test("every runtime mode has at least one reminder", () => {
const modes: Array<
"interactive" | "headless-one-shot" | "headless-bidirectional"
> = ["interactive", "headless-one-shot", "headless-bidirectional"];
for (const mode of modes) {
expect(
SHARED_REMINDER_CATALOG.some((entry) => entry.modes.includes(mode)),
).toBe(true);
}
});
test("command and toolset reminders are interactive-only", () => {
const commandReminder = SHARED_REMINDER_CATALOG.find(
(entry) => entry.id === "command-io",
);
const toolsetReminder = SHARED_REMINDER_CATALOG.find(
(entry) => entry.id === "toolset-change",
);
expect(commandReminder?.modes).toEqual(["interactive"]);
expect(toolsetReminder?.modes).toEqual(["interactive"]);
});
test("provider ids and catalog ids stay in lockstep", () => {
expect(Object.keys(sharedReminderProviders).sort()).toEqual(
[...SHARED_REMINDER_IDS].sort(),

View File

@@ -1,7 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { SkillSource } from "../../agent/skills";
import type { ReflectionSettings } from "../../cli/helpers/memoryReminder";
import { SHARED_REMINDER_IDS } from "../../reminders/catalog";
import {
SHARED_REMINDER_CATALOG,
SHARED_REMINDER_IDS,
type SharedReminderId,
type SharedReminderMode,
} from "../../reminders/catalog";
import {
buildSharedReminderParts,
sharedReminderProviders,
@@ -11,6 +16,12 @@ import { createSharedReminderState } from "../../reminders/state";
const originalProviders = { ...sharedReminderProviders };
const providerMap = sharedReminderProviders;
function reminderIdsForMode(mode: SharedReminderMode): SharedReminderId[] {
return SHARED_REMINDER_CATALOG.filter((entry) =>
entry.modes.includes(mode),
).map((entry) => entry.id);
}
afterEach(() => {
for (const reminderId of SHARED_REMINDER_IDS) {
providerMap[reminderId] = originalProviders[reminderId];
@@ -58,15 +69,23 @@ describe("shared reminder parity", () => {
state: createSharedReminderState(),
});
expect(interactive.appliedReminderIds).toEqual(SHARED_REMINDER_IDS);
expect(oneShot.appliedReminderIds).toEqual(SHARED_REMINDER_IDS);
expect(bidirectional.appliedReminderIds).toEqual(SHARED_REMINDER_IDS);
expect(interactive.parts.map((part) => part.text)).toEqual(
SHARED_REMINDER_IDS,
expect(interactive.appliedReminderIds).toEqual(
reminderIdsForMode("interactive"),
);
expect(oneShot.appliedReminderIds).toEqual(
reminderIdsForMode("headless-one-shot"),
);
expect(bidirectional.appliedReminderIds).toEqual(
reminderIdsForMode("headless-bidirectional"),
);
expect(interactive.parts.map((part) => part.text)).toEqual(
reminderIdsForMode("interactive"),
);
expect(oneShot.parts.map((part) => part.text)).toEqual(
reminderIdsForMode("headless-one-shot"),
);
expect(oneShot.parts.map((part) => part.text)).toEqual(SHARED_REMINDER_IDS);
expect(bidirectional.parts.map((part) => part.text)).toEqual(
SHARED_REMINDER_IDS,
reminderIdsForMode("headless-bidirectional"),
);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test";
import {
type SharedReminderContext,
sharedReminderProviders,
} from "../../reminders/engine";
import {
createSharedReminderState,
enqueueCommandIoReminder,
enqueueToolsetChangeReminder,
type SharedReminderState,
} from "../../reminders/state";
function baseContext(
state: SharedReminderState,
mode: SharedReminderContext["mode"] = "interactive",
): SharedReminderContext {
return {
mode,
agent: {
id: "agent-1",
name: "Agent 1",
description: null,
lastRunAt: null,
},
state,
sessionContextReminderEnabled: false,
reflectionSettings: {
trigger: "off",
behavior: "reminder",
stepCount: 25,
},
skillSources: [],
resolvePlanModeReminder: () => "",
};
}
describe("interaction reminders", () => {
test("command-io provider renders escaped command input/output and drains queue", async () => {
const state = createSharedReminderState();
enqueueCommandIoReminder(state, {
input: '/model && echo "<unsafe>"',
output: "Models dialog dismissed <ok>",
success: true,
});
const reminder = await sharedReminderProviders["command-io"](
baseContext(state),
);
expect(reminder).toContain("<user-command-input>");
expect(reminder).toContain("&lt;unsafe&gt;");
expect(reminder).toContain("<user-command-output>");
expect(reminder).toContain("&lt;ok&gt;");
expect(state.pendingCommandIoReminders).toHaveLength(0);
});
test("toolset-change provider renders previous/new toolset and drains queue", async () => {
const state = createSharedReminderState();
enqueueToolsetChangeReminder(state, {
source: "/toolset",
previousToolset: "default",
newToolset: "codex",
previousTools: ["Read", "Write"],
newTools: ["ReadFile", "ApplyPatch", "ShellCommand"],
});
const reminder = await sharedReminderProviders["toolset-change"](
baseContext(state),
);
expect(reminder).toContain("<source>/toolset</source>");
expect(reminder).toContain("<previous-toolset>default</previous-toolset>");
expect(reminder).toContain("<new-toolset>codex</new-toolset>");
expect(reminder).toContain("<previous-tools>Read, Write</previous-tools>");
expect(reminder).toContain(
"<new-tools>ReadFile, ApplyPatch, ShellCommand</new-tools>",
);
expect(state.pendingToolsetChangeReminders).toHaveLength(0);
});
test("interaction reminder providers return null when there is no queued data", async () => {
const state = createSharedReminderState();
const commandReminder = await sharedReminderProviders["command-io"](
baseContext(state),
);
const toolsetReminder = await sharedReminderProviders["toolset-change"](
baseContext(state),
);
expect(commandReminder).toBeNull();
expect(toolsetReminder).toBeNull();
});
});