refactor: Unify reminder management across interactive and headless modes (#1001)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
Devansh Jain
2026-02-18 15:44:59 -08:00
committed by GitHub
parent 5b12a30293
commit b322a46a43
13 changed files with 907 additions and 395 deletions

View File

@@ -82,6 +82,12 @@ import {
type RalphState,
ralphMode,
} from "../ralph/mode";
import { buildSharedReminderParts } from "../reminders/engine";
import {
createSharedReminderState,
resetSharedReminderState,
syncReminderStateFromContextTracker,
} from "../reminders/state";
import { updateProjectSettings } from "../settings";
import { settingsManager } from "../settings-manager";
import { telemetry } from "../telemetry";
@@ -191,13 +197,10 @@ import {
import { formatCompact } from "./helpers/format";
import { parsePatchOperations } from "./helpers/formatArgsDisplay";
import {
buildCompactionMemoryReminder,
buildMemoryReminder,
getReflectionSettings,
parseMemoryPreference,
type ReflectionSettings,
reflectionSettingsToLegacyMode,
shouldFireStepCountTrigger,
} from "./helpers/memoryReminder";
import {
type QueuedMessage,
@@ -1503,33 +1506,16 @@ export default function App({
// Show exit stats on exit (double Ctrl+C)
const [showExitStats, setShowExitStats] = useState(false);
// Track if we've sent the session context for this CLI session
const hasSentSessionContextRef = useRef(false);
const sharedReminderStateRef = useRef(createSharedReminderState());
// Track if we've set the conversation summary for this new conversation
// Initialized to true for resumed conversations (they already have context)
const hasSetConversationSummaryRef = useRef(resumedExistingConversation);
// Store first user query for conversation summary
const firstUserQueryRef = useRef<string | null>(null);
// Track skills injection state (LET-7353)
const discoveredSkillsRef = useRef<import("../agent/skills").Skill[] | null>(
null,
);
const hasInjectedSkillsRef = useRef(false);
const resetBootstrapReminderState = useCallback(() => {
hasSentSessionContextRef.current = false;
hasInjectedSkillsRef.current = false;
discoveredSkillsRef.current = null;
resetSharedReminderState(sharedReminderStateRef.current);
}, []);
// Track conversation turn count for periodic memory reminders
const turnCountRef = useRef(0);
// Track last notified permission mode to detect changes
const lastNotifiedModeRef = useRef<PermissionMode>("default");
// Static items (things that are done rendering and can be frozen)
const [staticItems, setStaticItems] = useState<StaticItem[]>([]);
@@ -2778,8 +2764,10 @@ export default function App({
// Git-backed memory: check status periodically (fire-and-forget).
// Runs every N turns to detect uncommitted changes or unpushed commits.
const isIntervalTurn =
turnCountRef.current > 0 &&
turnCountRef.current % MEMFS_CONFLICT_CHECK_INTERVAL === 0;
sharedReminderStateRef.current.turnCount > 0 &&
sharedReminderStateRef.current.turnCount %
MEMFS_CONFLICT_CHECK_INTERVAL ===
0;
if (isIntervalTurn && !memfsGitCheckInFlightRef.current) {
memfsGitCheckInFlightRef.current = true;
@@ -5150,9 +5138,6 @@ export default function App({
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
// Reset turn counter for memory reminders when switching agents
turnCountRef.current = 0;
// Update agent state - also update ref immediately for any code that runs before re-render
agentIdRef.current = targetAgentId;
setAgentId(targetAgentId);
@@ -5291,9 +5276,6 @@ export default function App({
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
// Reset turn counter for memory reminders
turnCountRef.current = 0;
// Update agent state
agentIdRef.current = agent.id;
setAgentId(agent.id);
@@ -6622,9 +6604,6 @@ export default function App({
// Ensure bootstrap reminders are re-injected for the new conversation.
resetBootstrapReminderState();
// Reset turn counter for memory reminders
turnCountRef.current = 0;
// Re-run SessionStart hooks for new conversation
sessionHooksRanRef.current = false;
runSessionStartHooks(
@@ -6703,9 +6682,6 @@ export default function App({
// Ensure bootstrap reminders are re-injected for the new conversation.
resetBootstrapReminderState();
// Reset turn counter for memory reminders
turnCountRef.current = 0;
// Re-run SessionStart hooks for new conversation
sessionHooksRanRef.current = false;
runSessionStartHooks(
@@ -8088,9 +8064,6 @@ ${SYSTEM_REMINDER_CLOSE}`;
const contentParts =
overrideContentParts ?? buildMessageContentFromDisplay(msg);
// Prepend plan mode reminder if in plan mode
const planModeReminder = getPlanModeReminder();
// Prepend ralph mode reminder if in ralph mode
let ralphModeReminder = "";
if (ralphMode.getState().isActive) {
@@ -8106,30 +8079,6 @@ ${SYSTEM_REMINDER_CLOSE}`;
}
}
// Prepend session context on first message of CLI session (if enabled)
let sessionContextReminder = "";
const sessionContextEnabled = settingsManager.getSetting(
"sessionContextEnabled",
);
if (
!hasSentSessionContextRef.current &&
sessionContextEnabled &&
sessionContextReminderEnabled
) {
const { buildSessionContext } = await import(
"./helpers/sessionContext"
);
sessionContextReminder = buildSessionContext({
agentInfo: {
id: agentId,
name: agentName,
description: agentDescription,
lastRunAt: agentLastRunAt,
},
});
hasSentSessionContextRef.current = true;
}
// Inject SessionStart hook feedback (stdout on exit 2) into first message only
let sessionStartHookFeedback = "";
if (sessionStartFeedbackRef.current.length > 0) {
@@ -8155,24 +8104,6 @@ ${SYSTEM_REMINDER_CLOSE}
const reflectionSettings = getReflectionSettings();
const memfsEnabledForAgent = settingsManager.isMemfsEnabled(agentId);
const shouldFireStepTrigger = shouldFireStepCountTrigger(
turnCountRef.current,
reflectionSettings,
);
let memoryReminderContent = "";
if (
shouldFireStepTrigger &&
(reflectionSettings.behavior === "reminder" || !memfsEnabledForAgent)
) {
// Step-count reminder mode (or non-memfs fallback)
memoryReminderContent = await buildMemoryReminder(
turnCountRef.current,
agentId,
);
}
// Increment turn count for next iteration
turnCountRef.current += 1;
// Build git memory sync reminder if uncommitted changes or unpushed commits
let memoryGitReminder = "";
@@ -8198,20 +8129,6 @@ ${SYSTEM_REMINDER_CLOSE}
pendingGitReminderRef.current = null;
}
// Build permission mode change alert if mode changed since last notification
let permissionModeAlert = "";
const currentMode = permissionMode.getMode();
if (currentMode !== lastNotifiedModeRef.current) {
const modeDescriptions: Record<PermissionMode, string> = {
default: "Normal approval flow.",
acceptEdits: "File edits auto-approved.",
plan: "Read-only mode. Focus on exploration and planning.",
bypassPermissions: "All tools auto-approved. Bias toward action.",
};
permissionModeAlert = `${SYSTEM_REMINDER_OPEN}Permission mode changed to: ${currentMode}. ${modeDescriptions[currentMode]}${SYSTEM_REMINDER_CLOSE}\n\n`;
lastNotifiedModeRef.current = currentMode;
}
// Combine reminders with content as separate text parts.
// This preserves each reminder boundary in the API payload.
// Note: Task notifications now come through messageQueue directly (added by messageQueueBridge)
@@ -8257,56 +8174,28 @@ ${SYSTEM_REMINDER_CLOSE}
return false;
}
};
pushReminder(sessionContextReminder);
// Inject available skills as system-reminder (LET-7353)
// Discover each turn so on-disk skill changes can trigger reinjection.
{
const {
discoverSkills: discover,
SKILLS_DIR: defaultDir,
formatSkillsAsSystemReminder,
} = await import("../agent/skills");
const { getSkillsDirectory, getSkillSources } = await import(
"../agent/context"
);
const previousSkillsReminder = discoveredSkillsRef.current
? formatSkillsAsSystemReminder(discoveredSkillsRef.current)
: null;
let latestSkills = discoveredSkillsRef.current ?? [];
try {
const skillsDir =
getSkillsDirectory() || join(process.cwd(), defaultDir);
const { skills } = await discover(skillsDir, agentId, {
sources: getSkillSources(),
});
latestSkills = skills;
} catch {
// Keep the previous snapshot when discovery fails.
}
discoveredSkillsRef.current = latestSkills;
const latestSkillsReminder = formatSkillsAsSystemReminder(
discoveredSkillsRef.current,
);
if (
previousSkillsReminder !== null &&
previousSkillsReminder !== latestSkillsReminder
) {
contextTrackerRef.current.pendingSkillsReinject = true;
}
const needsSkillsReinject =
contextTrackerRef.current.pendingSkillsReinject;
if (!hasInjectedSkillsRef.current || needsSkillsReinject) {
if (latestSkillsReminder) {
pushReminder(latestSkillsReminder);
}
hasInjectedSkillsRef.current = true;
contextTrackerRef.current.pendingSkillsReinject = false;
}
syncReminderStateFromContextTracker(
sharedReminderStateRef.current,
contextTrackerRef.current,
);
const { getSkillSources } = await import("../agent/context");
const { parts: sharedReminderParts } = await buildSharedReminderParts({
mode: "interactive",
agent: {
id: agentId,
name: agentName,
description: agentDescription,
lastRunAt: agentLastRunAt,
},
state: sharedReminderStateRef.current,
sessionContextReminderEnabled,
reflectionSettings,
skillSources: getSkillSources(),
resolvePlanModeReminder: getPlanModeReminder,
maybeLaunchReflectionSubagent,
});
for (const part of sharedReminderParts) {
reminderParts.push(part);
}
// Build conversation switch alert if a switch is pending (behind feature flag)
@@ -8325,41 +8214,10 @@ ${SYSTEM_REMINDER_CLOSE}
pendingConversationSwitchRef.current = null;
pushReminder(sessionStartHookFeedback);
pushReminder(permissionModeAlert);
pushReminder(conversationSwitchAlert);
pushReminder(planModeReminder);
pushReminder(ralphModeReminder);
pushReminder(bashCommandPrefix);
pushReminder(userPromptSubmitHookFeedback);
pushReminder(memoryReminderContent);
// Step-count auto-launch mode: fire reflection in background on interval.
if (
shouldFireStepTrigger &&
reflectionSettings.trigger === "step-count" &&
reflectionSettings.behavior === "auto-launch"
) {
await maybeLaunchReflectionSubagent("step-count");
}
// Consume compaction-triggered reflection behavior on next user turn.
if (contextTrackerRef.current.pendingReflectionTrigger) {
contextTrackerRef.current.pendingReflectionTrigger = false;
if (reflectionSettings.trigger === "compaction-event") {
if (
reflectionSettings.behavior === "auto-launch" &&
memfsEnabledForAgent
) {
await maybeLaunchReflectionSubagent("compaction-event");
} else {
const compactionReminderContent =
await buildCompactionMemoryReminder(agentId);
pushReminder(compactionReminderContent);
}
}
}
pushReminder(memoryGitReminder);
const messageContent =
reminderParts.length > 0

View File

@@ -33,6 +33,7 @@ import {
toLines,
} from "./cli/helpers/accumulator";
import { classifyApprovals } from "./cli/helpers/approvalClassification";
import { createContextTracker } from "./cli/helpers/contextTracker";
import { formatErrorDetails } from "./cli/helpers/errorFormatter";
import {
getReflectionSettings,
@@ -54,6 +55,14 @@ import {
mergeQueuedTurnInput,
type QueuedTurnInput,
} from "./queue/turnQueueRuntime";
import {
buildSharedReminderParts,
prependReminderPartsToContent,
} from "./reminders/engine";
import {
createSharedReminderState,
syncReminderStateFromContextTracker,
} from "./reminders/state";
import { settingsManager } from "./settings-manager";
import {
isHeadlessAutoAllowTool,
@@ -94,40 +103,6 @@ const LLM_API_ERROR_MAX_RETRIES = 3;
const CONVERSATION_BUSY_MAX_RETRIES = 1; // Only retry once, fail on 2nd 409
const CONVERSATION_BUSY_RETRY_DELAY_MS = 2500; // 2.5 seconds
export function prependSkillsReminderToContent(
content: MessageCreate["content"],
skillsReminder: string,
): MessageCreate["content"] {
if (!skillsReminder) {
return content;
}
if (typeof content === "string") {
return `${skillsReminder}\n\n${content}`;
}
if (Array.isArray(content)) {
return [
{
type: "text",
text: `${skillsReminder}\n\n`,
},
...content,
] as MessageCreate["content"];
}
return content;
}
export function shouldReinjectSkillsAfterCompaction(lines: Line[]): boolean {
return lines.some(
(line) =>
line.kind === "event" &&
line.eventType === "compaction" &&
line.phase === "finished" &&
(line.summary !== undefined || line.stats !== undefined),
);
}
export type BidirectionalQueuedInput = QueuedTurnInput<
MessageCreate["content"]
>;
@@ -1177,6 +1152,9 @@ export async function handleHeadlessCommand(
console.log(JSON.stringify(initEvent));
}
const reminderContextTracker = createContextTracker();
const sharedReminderState = createSharedReminderState();
// Helper to resolve any pending approvals before sending user input
const resolveAllPendingApprovals = async () => {
const { getResumeData } = await import("./agent/check-approval");
@@ -1321,16 +1299,23 @@ export async function handleHeadlessCommand(
approvalMessages,
{ agentId: agent.id },
);
if (outputFormat === "stream-json") {
// Consume quickly but don't emit message frames to stdout
for await (const _ of approvalStream) {
// no-op
}
} else {
await drainStreamWithResume(
approvalStream,
createBuffers(agent.id),
() => {},
const drainResult = await drainStreamWithResume(
approvalStream,
createBuffers(agent.id),
() => {},
undefined,
undefined,
undefined,
reminderContextTracker,
);
// If the approval drain errored or was cancelled, abort rather than
// looping back and re-fetching approvals (which would restart the cycle).
if (
drainResult.stopReason === "error" ||
drainResult.stopReason === "cancelled"
) {
throw new Error(
`Approval drain ended with stop reason: ${drainResult.stopReason}`,
);
}
}
@@ -1343,7 +1328,6 @@ export async function handleHeadlessCommand(
}
// Build message content with reminders
const { permissionMode } = await import("./permissions/mode");
const contentParts: MessageCreate["content"] = [];
const pushPart = (text: string) => {
if (!text) return;
@@ -1363,59 +1347,56 @@ ${SYSTEM_REMINDER_CLOSE}
pushPart(systemReminder);
}
// Inject available skills as system-reminder (LET-7353)
{
const {
discoverSkills,
SKILLS_DIR: defaultDir,
formatSkillsAsSystemReminder,
} = await import("./agent/skills");
const { getSkillsDirectory } = await import("./agent/context");
const { join } = await import("node:path");
try {
const skillsDir = getSkillsDirectory() || join(process.cwd(), defaultDir);
const { skills } = await discoverSkills(skillsDir, agent.id, {
sources: resolvedSkillSources,
});
const skillsReminder = formatSkillsAsSystemReminder(skills);
if (skillsReminder) {
pushPart(skillsReminder);
}
// Pre-load specific skills' full content (used by subagents with skills: field)
if (preLoadSkillsRaw) {
const { readFile: readFileAsync } = await import("node:fs/promises");
const skillIds = preLoadSkillsRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const loadedContents: string[] = [];
for (const skillId of skillIds) {
const skill = skills.find((s) => s.id === skillId);
if (skill?.path) {
try {
const content = await readFileAsync(skill.path, "utf-8");
loadedContents.push(`<${skillId}>\n${content}\n</${skillId}>`);
} catch {
// Skill file not readable, skip
}
}
}
if (loadedContents.length > 0) {
pushPart(
`<loaded_skills>\n${loadedContents.join("\n\n")}\n</loaded_skills>`,
);
}
}
} catch {
// Skills discovery failed, skip
}
syncReminderStateFromContextTracker(
sharedReminderState,
reminderContextTracker,
);
const lastRunAt = (agent as { last_run_completion?: string })
.last_run_completion;
const { parts: sharedReminderParts } = await buildSharedReminderParts({
mode: "headless-one-shot",
agent: {
id: agent.id,
name: agent.name,
description: agent.description,
lastRunAt: lastRunAt ?? null,
},
state: sharedReminderState,
sessionContextReminderEnabled: systemInfoReminderEnabled,
reflectionSettings: effectiveReflectionSettings,
skillSources: resolvedSkillSources,
resolvePlanModeReminder: async () => {
const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets");
return PLAN_MODE_REMINDER;
},
});
for (const part of sharedReminderParts) {
pushPart(part.text);
}
// Add plan mode reminder if in plan mode (highest priority)
if (permissionMode.getMode() === "plan") {
const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets");
pushPart(PLAN_MODE_REMINDER);
// Pre-load specific skills' full content (used by subagents with skills: field)
if (preLoadSkillsRaw) {
const { readFile: readFileAsync } = await import("node:fs/promises");
const skillIds = preLoadSkillsRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const loadedContents: string[] = [];
for (const skillId of skillIds) {
const skillPath = sharedReminderState.skillPathById[skillId];
if (!skillPath) continue;
try {
const content = await readFileAsync(skillPath, "utf-8");
loadedContents.push(`<${skillId}>\n${content}\n</${skillId}>`);
} catch {
// Skill file not readable, skip
}
}
if (loadedContents.length > 0) {
pushPart(
`<loaded_skills>\n${loadedContents.join("\n\n")}\n</loaded_skills>`,
);
}
}
// Add user prompt
@@ -1739,6 +1720,7 @@ ${SYSTEM_REMINDER_CLOSE}
undefined,
undefined,
streamJsonHook,
reminderContextTracker,
);
stopReason = result.stopReason;
approvals = result.approvals || [];
@@ -1751,6 +1733,10 @@ ${SYSTEM_REMINDER_CLOSE}
stream,
buffers,
() => {}, // No UI refresh needed in headless mode
undefined,
undefined,
undefined,
reminderContextTracker,
);
stopReason = result.stopReason;
approvals = result.approvals || [];
@@ -2271,12 +2257,8 @@ async function runBidirectionalMode(
// Track current operation for interrupt support
let currentAbortController: AbortController | null = null;
// Skills reminder lifecycle in bidirectional mode:
// - Inject once on first user turn
// - Reinject only after compaction completion or skills diff
let hasInjectedSkillsReminder = false;
let pendingSkillsReinject = false;
let cachedSkillsReminder: string | null = null;
const reminderContextTracker = createContextTracker();
const sharedReminderState = createSharedReminderState();
// Resolve pending approvals for this conversation before retrying user input.
const resolveAllPendingApprovals = async () => {
@@ -2394,11 +2376,23 @@ async function runBidirectionalMode(
approvalMessages,
{ agentId: agent.id },
);
await drainStreamWithResume(
const drainResult = await drainStreamWithResume(
approvalStream,
createBuffers(agent.id),
() => {},
undefined,
undefined,
undefined,
reminderContextTracker,
);
if (
drainResult.stopReason === "error" ||
drainResult.stopReason === "cancelled"
) {
throw new Error(
`Approval drain ended with stop reason: ${drainResult.stopReason}`,
);
}
}
};
@@ -2795,48 +2789,33 @@ async function runBidirectionalMode(
let sawStreamError = false; // Track if we emitted an error during streaming
let preStreamTransientRetries = 0;
// Inject available skills as system-reminder for bidirectional mode (LET-7353).
// Discover each turn so skill file changes are naturally picked up.
let enrichedContent = userContent;
try {
const {
discoverSkills: discover,
SKILLS_DIR: defaultDir,
formatSkillsAsSystemReminder,
} = await import("./agent/skills");
const { getSkillsDirectory } = await import("./agent/context");
const { join } = await import("node:path");
const skillsDir =
getSkillsDirectory() || join(process.cwd(), defaultDir);
const { skills } = await discover(skillsDir, agent.id, {
sources: skillSources,
});
const latestSkillsReminder = formatSkillsAsSystemReminder(skills);
// Trigger reinjection when the available-skills block changed on disk.
if (
cachedSkillsReminder !== null &&
latestSkillsReminder !== cachedSkillsReminder
) {
pendingSkillsReinject = true;
}
cachedSkillsReminder = latestSkillsReminder;
const shouldInjectSkillsReminder =
!hasInjectedSkillsReminder || pendingSkillsReinject;
if (shouldInjectSkillsReminder && latestSkillsReminder) {
enrichedContent = prependSkillsReminderToContent(
enrichedContent,
latestSkillsReminder,
);
}
if (shouldInjectSkillsReminder) {
hasInjectedSkillsReminder = true;
pendingSkillsReinject = false;
}
} catch {
// Skills discovery failed, skip
}
syncReminderStateFromContextTracker(
sharedReminderState,
reminderContextTracker,
);
const lastRunAt = (agent as { last_run_completion?: string })
.last_run_completion;
const { parts: sharedReminderParts } = await buildSharedReminderParts({
mode: "headless-bidirectional",
agent: {
id: agent.id,
name: agent.name,
description: agent.description,
lastRunAt: lastRunAt ?? null,
},
state: sharedReminderState,
sessionContextReminderEnabled: systemInfoReminderEnabled,
reflectionSettings,
skillSources,
resolvePlanModeReminder: async () => {
const { PLAN_MODE_REMINDER } = await import("./agent/promptAssets");
return PLAN_MODE_REMINDER;
},
});
const enrichedContent = prependReminderPartsToContent(
userContent,
sharedReminderParts,
);
// Initial input is the user message
let currentInput: MessageCreate[] = [
@@ -3005,6 +2984,7 @@ async function runBidirectionalMode(
currentAbortController?.signal,
undefined,
streamJsonHook,
reminderContextTracker,
);
const stopReason = result.stopReason;
lastStopReason = stopReason; // Track for result subtype
@@ -3176,9 +3156,6 @@ async function runBidirectionalMode(
// Emit result
const durationMs = performance.now() - startTime;
const lines = toLines(buffers);
if (shouldReinjectSkillsAfterCompaction(lines)) {
pendingSkillsReinject = true;
}
const reversed = [...lines].reverse();
const lastAssistant = reversed.find(
(line) =>

69
src/reminders/catalog.ts Normal file
View File

@@ -0,0 +1,69 @@
export type SharedReminderMode =
| "interactive"
| "headless-one-shot"
| "headless-bidirectional";
export type SharedReminderId =
| "session-context"
| "skills"
| "permission-mode"
| "plan-mode"
| "reflection-step-count"
| "reflection-compaction";
export interface SharedReminderDefinition {
id: SharedReminderId;
description: string;
modes: SharedReminderMode[];
}
export const SHARED_REMINDER_CATALOG: ReadonlyArray<SharedReminderDefinition> =
[
{
id: "session-context",
description: "First-turn device/agent/git context",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "skills",
description: "Available skills system reminder (with reinjection)",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "permission-mode",
description: "Permission mode reminder",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "plan-mode",
description: "Plan mode behavioral reminder",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "reflection-step-count",
description: "Step-count reflection reminder/auto-launch behavior",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
{
id: "reflection-compaction",
description:
"Compaction-triggered reflection reminder/auto-launch behavior",
modes: ["interactive", "headless-one-shot", "headless-bidirectional"],
},
];
export const SHARED_REMINDER_IDS = SHARED_REMINDER_CATALOG.map(
(entry) => entry.id,
);
const SHARED_REMINDER_BY_ID = new Map<
SharedReminderId,
SharedReminderDefinition
>(SHARED_REMINDER_CATALOG.map((entry) => [entry.id, entry]));
export function reminderEnabledInMode(
id: SharedReminderId,
mode: SharedReminderMode,
): boolean {
return SHARED_REMINDER_BY_ID.get(id)?.modes.includes(mode) ?? false;
}

320
src/reminders/engine.ts Normal file
View File

@@ -0,0 +1,320 @@
import { join } from "node:path";
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import { getSkillsDirectory } from "../agent/context";
import {
discoverSkills,
formatSkillsAsSystemReminder,
SKILLS_DIR,
type SkillSource,
} from "../agent/skills";
import {
buildCompactionMemoryReminder,
buildMemoryReminder,
type ReflectionSettings,
shouldFireStepCountTrigger,
} from "../cli/helpers/memoryReminder";
import { buildSessionContext } from "../cli/helpers/sessionContext";
import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../constants";
import { permissionMode } from "../permissions/mode";
import { settingsManager } from "../settings-manager";
import {
SHARED_REMINDER_CATALOG,
type SharedReminderId,
type SharedReminderMode,
} from "./catalog";
import type { SharedReminderState } from "./state";
type ReflectionTriggerSource = "step-count" | "compaction-event";
export interface AgentReminderContext {
id: string;
name: string | null;
description?: string | null;
lastRunAt?: string | null;
serverUrl?: string;
}
export interface SharedReminderContext {
mode: SharedReminderMode;
agent: AgentReminderContext;
state: SharedReminderState;
sessionContextReminderEnabled: boolean;
reflectionSettings: ReflectionSettings;
skillSources: SkillSource[];
resolvePlanModeReminder: () => string | Promise<string>;
maybeLaunchReflectionSubagent?: (
triggerSource: ReflectionTriggerSource,
) => Promise<boolean>;
}
export type ReminderTextPart = { type: "text"; text: string };
export interface SharedReminderBuildResult {
parts: ReminderTextPart[];
appliedReminderIds: SharedReminderId[];
}
type SharedReminderProvider = (
context: SharedReminderContext,
) => Promise<string | null>;
async function buildSessionContextReminder(
context: SharedReminderContext,
): Promise<string | null> {
if (
!context.sessionContextReminderEnabled ||
context.state.hasSentSessionContext
) {
return null;
}
if (!settingsManager.getSetting("sessionContextEnabled")) {
return null;
}
const reminder = buildSessionContext({
agentInfo: {
id: context.agent.id,
name: context.agent.name,
description: context.agent.description,
lastRunAt: context.agent.lastRunAt,
},
serverUrl: context.agent.serverUrl,
});
context.state.hasSentSessionContext = true;
return reminder || null;
}
async function buildSkillsReminder(
context: SharedReminderContext,
): Promise<string | null> {
const previousSkillsReminder = context.state.cachedSkillsReminder;
// Keep a stable empty baseline so a later successful discovery can diff
// against "" and trigger reinjection, even after an earlier discovery failure.
let latestSkillsReminder = previousSkillsReminder ?? "";
try {
const skillsDir = getSkillsDirectory() || join(process.cwd(), SKILLS_DIR);
const { skills } = await discoverSkills(skillsDir, context.agent.id, {
sources: context.skillSources,
});
latestSkillsReminder = formatSkillsAsSystemReminder(skills);
context.state.skillPathById = Object.fromEntries(
skills
.filter(
(skill) => typeof skill.path === "string" && skill.path.length > 0,
)
.map((skill) => [skill.id, skill.path as string]),
);
} catch {
// Keep previous snapshot when discovery fails.
}
if (
previousSkillsReminder !== null &&
previousSkillsReminder !== latestSkillsReminder
) {
context.state.pendingSkillsReinject = true;
}
context.state.cachedSkillsReminder = latestSkillsReminder;
const shouldInject =
!context.state.hasInjectedSkillsReminder ||
context.state.pendingSkillsReinject;
if (!shouldInject) {
return null;
}
context.state.hasInjectedSkillsReminder = true;
context.state.pendingSkillsReinject = false;
return latestSkillsReminder || null;
}
async function buildPlanModeReminder(
context: SharedReminderContext,
): Promise<string | null> {
if (permissionMode.getMode() !== "plan") {
return null;
}
const reminder = await context.resolvePlanModeReminder();
return reminder || null;
}
const PERMISSION_MODE_DESCRIPTIONS = {
default: "Normal approval flow.",
acceptEdits: "File edits auto-approved.",
plan: "Read-only mode. Focus on exploration and planning.",
bypassPermissions: "All tools auto-approved. Bias toward action.",
} as const;
async function buildPermissionModeReminder(
context: SharedReminderContext,
): Promise<string | null> {
const currentMode = permissionMode.getMode();
const previousMode = context.state.lastNotifiedPermissionMode;
const shouldEmit = (() => {
if (context.mode === "interactive") {
if (previousMode === null) {
// First turn: only remind if in a non-default mode (e.g. bypassPermissions).
return currentMode !== "default";
}
return previousMode !== currentMode;
}
return previousMode !== currentMode;
})();
context.state.lastNotifiedPermissionMode = currentMode;
if (!shouldEmit) {
return null;
}
const description =
PERMISSION_MODE_DESCRIPTIONS[
currentMode as keyof typeof PERMISSION_MODE_DESCRIPTIONS
] ?? "Permission behavior updated.";
const prefix =
previousMode === null
? "Permission mode active"
: "Permission mode changed to";
return `${SYSTEM_REMINDER_OPEN}${prefix}: ${currentMode}. ${description}${SYSTEM_REMINDER_CLOSE}\n\n`;
}
async function buildReflectionStepReminder(
context: SharedReminderContext,
): Promise<string | null> {
const shouldFireStepTrigger = shouldFireStepCountTrigger(
context.state.turnCount,
context.reflectionSettings,
);
const memfsEnabled = settingsManager.isMemfsEnabled(context.agent.id);
let reminder: string | null = null;
if (shouldFireStepTrigger) {
if (context.reflectionSettings.behavior === "reminder" || !memfsEnabled) {
reminder = await buildMemoryReminder(
context.state.turnCount,
context.agent.id,
);
} else {
if (context.maybeLaunchReflectionSubagent) {
await context.maybeLaunchReflectionSubagent("step-count");
} else {
reminder = await buildMemoryReminder(
context.state.turnCount,
context.agent.id,
);
}
}
}
// Keep turn-based cadence aligned across modes by incrementing once per user turn.
context.state.turnCount += 1;
return reminder;
}
async function buildReflectionCompactionReminder(
context: SharedReminderContext,
): Promise<string | null> {
if (!context.state.pendingReflectionTrigger) {
return null;
}
context.state.pendingReflectionTrigger = false;
if (context.reflectionSettings.trigger !== "compaction-event") {
return null;
}
const memfsEnabled = settingsManager.isMemfsEnabled(context.agent.id);
if (context.reflectionSettings.behavior === "auto-launch" && memfsEnabled) {
if (context.maybeLaunchReflectionSubagent) {
await context.maybeLaunchReflectionSubagent("compaction-event");
return null;
}
}
return buildCompactionMemoryReminder(context.agent.id);
}
export const sharedReminderProviders: Record<
SharedReminderId,
SharedReminderProvider
> = {
"session-context": buildSessionContextReminder,
skills: buildSkillsReminder,
"permission-mode": buildPermissionModeReminder,
"plan-mode": buildPlanModeReminder,
"reflection-step-count": buildReflectionStepReminder,
"reflection-compaction": buildReflectionCompactionReminder,
};
export function assertSharedReminderCoverage(): void {
const catalogIds = new Set(SHARED_REMINDER_CATALOG.map((entry) => entry.id));
const providerIds = new Set(Object.keys(sharedReminderProviders));
for (const id of catalogIds) {
if (!providerIds.has(id)) {
throw new Error(`Missing shared reminder provider for "${id}"`);
}
}
for (const id of providerIds) {
if (!catalogIds.has(id as SharedReminderId)) {
throw new Error(`Shared reminder provider "${id}" is not in catalog`);
}
}
}
assertSharedReminderCoverage();
export async function buildSharedReminderParts(
context: SharedReminderContext,
): Promise<SharedReminderBuildResult> {
const parts: ReminderTextPart[] = [];
const appliedReminderIds: SharedReminderId[] = [];
for (const reminder of SHARED_REMINDER_CATALOG) {
if (!reminder.modes.includes(context.mode)) {
continue;
}
const provider = sharedReminderProviders[reminder.id];
const text = await provider(context);
if (!text) {
continue;
}
parts.push({ type: "text", text });
appliedReminderIds.push(reminder.id);
}
return { parts, appliedReminderIds };
}
export function prependReminderPartsToContent(
content: MessageCreate["content"],
reminderParts: ReminderTextPart[],
): MessageCreate["content"] {
if (reminderParts.length === 0) {
return content;
}
if (typeof content === "string") {
return [
...reminderParts,
{ type: "text", text: content },
] as MessageCreate["content"];
}
if (Array.isArray(content)) {
return [...reminderParts, ...content] as MessageCreate["content"];
}
return content;
}

44
src/reminders/state.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { ContextTracker } from "../cli/helpers/contextTracker";
import type { PermissionMode } from "../permissions/mode";
export interface SharedReminderState {
hasSentSessionContext: boolean;
hasInjectedSkillsReminder: boolean;
cachedSkillsReminder: string | null;
skillPathById: Record<string, string>;
lastNotifiedPermissionMode: PermissionMode | null;
turnCount: number;
pendingSkillsReinject: boolean;
pendingReflectionTrigger: boolean;
}
export function createSharedReminderState(): SharedReminderState {
return {
hasSentSessionContext: false,
hasInjectedSkillsReminder: false,
cachedSkillsReminder: null,
skillPathById: {},
lastNotifiedPermissionMode: null,
turnCount: 0,
pendingSkillsReinject: false,
pendingReflectionTrigger: false,
};
}
export function resetSharedReminderState(state: SharedReminderState): void {
Object.assign(state, createSharedReminderState());
}
export function syncReminderStateFromContextTracker(
state: SharedReminderState,
contextTracker: ContextTracker,
): void {
if (contextTracker.pendingSkillsReinject) {
state.pendingSkillsReinject = true;
contextTracker.pendingSkillsReinject = false;
}
if (contextTracker.pendingReflectionTrigger) {
state.pendingReflectionTrigger = true;
contextTracker.pendingReflectionTrigger = false;
}
}

View File

@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
describe("bootstrap reminder reset wiring", () => {
test("defines helper that clears session, skills, and discovery cache", () => {
test("defines helper that resets shared reminder state", () => {
const appPath = fileURLToPath(
new URL("../../cli/App.tsx", import.meta.url),
);
@@ -12,9 +12,12 @@ describe("bootstrap reminder reset wiring", () => {
expect(source).toContain(
"const resetBootstrapReminderState = useCallback(() => {",
);
expect(source).toContain("hasSentSessionContextRef.current = false;");
expect(source).toContain("hasInjectedSkillsRef.current = false;");
expect(source).toContain("discoveredSkillsRef.current = null;");
expect(source).toContain(
"resetSharedReminderState(sharedReminderStateRef.current);",
);
expect(source).not.toContain("hasSentSessionContextRef.current = false;");
expect(source).not.toContain("hasInjectedSkillsRef.current = false;");
expect(source).not.toContain("discoveredSkillsRef.current = null;");
});
test("invokes helper for all conversation/agent switch entry points", () => {

View File

@@ -3,20 +3,26 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
describe("reflection auto-launch wiring", () => {
test("handles step-count and compaction-event auto-launch modes", () => {
test("routes step-count and compaction-event auto-launch through shared reminder engine", () => {
const appPath = fileURLToPath(
new URL("../../cli/App.tsx", import.meta.url),
);
const source = readFileSync(appPath, "utf-8");
const enginePath = fileURLToPath(
new URL("../../reminders/engine.ts", import.meta.url),
);
const appSource = readFileSync(appPath, "utf-8");
const engineSource = readFileSync(enginePath, "utf-8");
expect(source).toContain("const maybeLaunchReflectionSubagent = async");
expect(source).toContain(
'await maybeLaunchReflectionSubagent("step-count")',
expect(appSource).toContain("const maybeLaunchReflectionSubagent = async");
expect(appSource).toContain("hasActiveReflectionSubagent()");
expect(appSource).toContain("spawnBackgroundSubagentTask({");
expect(appSource).toContain("maybeLaunchReflectionSubagent,");
expect(engineSource).toContain(
'await context.maybeLaunchReflectionSubagent("step-count")',
);
expect(source).toContain(
'await maybeLaunchReflectionSubagent("compaction-event")',
expect(engineSource).toContain(
'await context.maybeLaunchReflectionSubagent("compaction-event")',
);
expect(source).toContain("hasActiveReflectionSubagent()");
expect(source).toContain("spawnBackgroundSubagentTask({");
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
describe("headless shared reminder wiring", () => {
test("one-shot mode builds shared reminders with system-info flag", () => {
const headlessPath = fileURLToPath(
new URL("../../headless.ts", import.meta.url),
);
const source = readFileSync(headlessPath, "utf-8");
expect(source).toContain('mode: "headless-one-shot"');
expect(source).toContain(
"sessionContextReminderEnabled: systemInfoReminderEnabled",
);
});
test("bidirectional mode builds shared reminders with plan-mode resolver", () => {
const headlessPath = fileURLToPath(
new URL("../../headless.ts", import.meta.url),
);
const source = readFileSync(headlessPath, "utf-8");
expect(source).toContain('mode: "headless-bidirectional"');
expect(source).toContain("resolvePlanModeReminder: async () => {");
expect(source).toContain("const { PLAN_MODE_REMINDER } = await import");
});
test("all headless drains pass context tracker for compaction-driven reminder state", () => {
const headlessPath = fileURLToPath(
new URL("../../headless.ts", import.meta.url),
);
const source = readFileSync(headlessPath, "utf-8");
expect(source).toContain("syncReminderStateFromContextTracker(");
expect(source).toContain("reminderContextTracker");
});
test("one-shot approval drain uses shared stream processor", () => {
const headlessPath = fileURLToPath(
new URL("../../headless.ts", import.meta.url),
);
const source = readFileSync(headlessPath, "utf-8");
expect(source).toContain("const approvalStream = await sendMessageStream(");
expect(source).toContain("await drainStreamWithResume(");
expect(source).not.toContain("for await (const _ of approvalStream)");
});
});

View File

@@ -1,21 +1,19 @@
import { describe, expect, test } from "bun:test";
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import type { Line } from "../../cli/helpers/accumulator";
import {
prependSkillsReminderToContent,
shouldReinjectSkillsAfterCompaction,
} from "../../headless";
import { prependReminderPartsToContent } from "../../reminders/engine";
describe("headless skills reminder helpers", () => {
test("prepends reminder to string user content", () => {
const result = prependSkillsReminderToContent(
"hello",
"<skills>demo</skills>",
);
expect(result).toBe("<skills>demo</skills>\n\nhello");
describe("headless shared reminder content helpers", () => {
test("prepends reminder text to string user content as parts array", () => {
const result = prependReminderPartsToContent("hello", [
{ type: "text", text: "<skills>demo</skills>" },
]);
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) return;
expect(result[0]).toEqual({ type: "text", text: "<skills>demo</skills>" });
expect(result[1]).toEqual({ type: "text", text: "hello" });
});
test("prepends reminder as a text part for multimodal user content", () => {
test("prepends reminder parts for multimodal user content", () => {
const multimodal = [
{ type: "text", text: "what is in this image?" },
{
@@ -24,60 +22,18 @@ describe("headless skills reminder helpers", () => {
},
] as unknown as Exclude<MessageCreate["content"], string>;
const result = prependSkillsReminderToContent(
const result = prependReminderPartsToContent(
multimodal as MessageCreate["content"],
"<skills>demo</skills>",
[{ type: "text", text: "<skills>demo</skills>" }],
);
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) return;
expect(result[0]).toEqual({
type: "text",
text: "<skills>demo</skills>\n\n",
text: "<skills>demo</skills>",
});
expect(result[1]).toEqual(multimodal[0]);
expect(result[2]).toEqual(multimodal[1]);
});
test("does not reinject on compaction start event", () => {
const lines: Line[] = [
{
kind: "event",
id: "evt-1",
eventType: "compaction",
eventData: {},
phase: "running",
},
];
expect(shouldReinjectSkillsAfterCompaction(lines)).toBe(false);
});
test("reinjection triggers after compaction completion", () => {
const withSummary: Line[] = [
{
kind: "event",
id: "evt-2",
eventType: "compaction",
eventData: {},
phase: "finished",
summary: "Compacted old messages",
},
];
expect(shouldReinjectSkillsAfterCompaction(withSummary)).toBe(true);
const withStatsOnly: Line[] = [
{
kind: "event",
id: "evt-3",
eventType: "compaction",
eventData: {},
phase: "finished",
stats: {
contextTokensBefore: 12000,
contextTokensAfter: 7000,
},
},
];
expect(shouldReinjectSkillsAfterCompaction(withStatsOnly)).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import {
SHARED_REMINDER_CATALOG,
SHARED_REMINDER_IDS,
} from "../../reminders/catalog";
import {
assertSharedReminderCoverage,
sharedReminderProviders,
} from "../../reminders/engine";
describe("shared reminder catalog", () => {
test("provider coverage matches catalog", () => {
expect(() => assertSharedReminderCoverage()).not.toThrow();
});
test("catalog ids are unique", () => {
const unique = new Set(SHARED_REMINDER_IDS);
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("provider ids and catalog ids stay in lockstep", () => {
expect(Object.keys(sharedReminderProviders).sort()).toEqual(
[...SHARED_REMINDER_IDS].sort(),
);
});
});

View File

@@ -0,0 +1,72 @@
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 {
buildSharedReminderParts,
sharedReminderProviders,
} from "../../reminders/engine";
import { createSharedReminderState } from "../../reminders/state";
const originalProviders = { ...sharedReminderProviders };
const providerMap = sharedReminderProviders;
afterEach(() => {
for (const reminderId of SHARED_REMINDER_IDS) {
providerMap[reminderId] = originalProviders[reminderId];
}
});
describe("shared reminder parity", () => {
test("shared reminder order is identical across interactive and headless modes", async () => {
for (const reminderId of SHARED_REMINDER_IDS) {
providerMap[reminderId] = async () => reminderId;
}
const reflectionSettings: ReflectionSettings = {
trigger: "off",
behavior: "reminder",
stepCount: 25,
};
const base = {
agent: {
id: "agent-1",
name: "Agent 1",
description: "test",
lastRunAt: null,
},
sessionContextReminderEnabled: true,
reflectionSettings,
skillSources: [] as SkillSource[],
resolvePlanModeReminder: () => "plan",
};
const interactive = await buildSharedReminderParts({
...base,
mode: "interactive",
state: createSharedReminderState(),
});
const oneShot = await buildSharedReminderParts({
...base,
mode: "headless-one-shot",
state: createSharedReminderState(),
});
const bidirectional = await buildSharedReminderParts({
...base,
mode: "headless-bidirectional",
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(oneShot.parts.map((part) => part.text)).toEqual(SHARED_REMINDER_IDS);
expect(bidirectional.parts.map((part) => part.text)).toEqual(
SHARED_REMINDER_IDS,
);
});
});

View File

@@ -0,0 +1,70 @@
import { afterEach, describe, expect, test } from "bun:test";
import { permissionMode } from "../../permissions/mode";
import {
type SharedReminderContext,
sharedReminderProviders,
} from "../../reminders/engine";
import { createSharedReminderState } from "../../reminders/state";
function baseContext(
mode: SharedReminderContext["mode"],
): SharedReminderContext {
return {
mode,
agent: {
id: "agent-1",
name: "Agent 1",
description: null,
lastRunAt: null,
},
state: createSharedReminderState(),
sessionContextReminderEnabled: true,
reflectionSettings: {
trigger: "off",
behavior: "reminder",
stepCount: 25,
},
skillSources: [],
resolvePlanModeReminder: () => "",
};
}
afterEach(() => {
permissionMode.setMode("default");
});
describe("shared permission-mode reminder", () => {
test("emits on first headless turn", async () => {
permissionMode.setMode("default");
const provider = sharedReminderProviders["permission-mode"];
const reminder = await provider(baseContext("headless-one-shot"));
expect(reminder).toContain("Permission mode active: default");
});
test("interactive does not emit on first turn in default mode", async () => {
permissionMode.setMode("default");
const provider = sharedReminderProviders["permission-mode"];
const context = baseContext("interactive");
const first = await provider(context);
expect(first).toBeNull();
permissionMode.setMode("bypassPermissions");
const second = await provider(context);
expect(second).toContain("Permission mode changed to: bypassPermissions");
});
test("interactive emits on first turn in bypassPermissions mode", async () => {
permissionMode.setMode("bypassPermissions");
const provider = sharedReminderProviders["permission-mode"];
const reminder = await provider(baseContext("interactive"));
expect(reminder).toContain("Permission mode active: bypassPermissions");
});
test("interactive emits on first turn in acceptEdits mode", async () => {
permissionMode.setMode("acceptEdits");
const provider = sharedReminderProviders["permission-mode"];
const reminder = await provider(baseContext("interactive"));
expect(reminder).toContain("Permission mode active: acceptEdits");
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import type { SharedReminderContext } from "../../reminders/engine";
import { sharedReminderProviders } from "../../reminders/engine";
import { createSharedReminderState } from "../../reminders/state";
function buildContext(): SharedReminderContext {
return {
mode: "interactive",
agent: {
id: "agent-1",
name: "Agent 1",
description: null,
lastRunAt: null,
},
state: createSharedReminderState(),
sessionContextReminderEnabled: true,
reflectionSettings: {
trigger: "off",
behavior: "reminder",
stepCount: 25,
},
skillSources: ["bundled"],
resolvePlanModeReminder: () => "",
};
}
describe("shared skills reminder", () => {
test("recovers from discovery failure and reinjects after next successful discovery", async () => {
const provider = sharedReminderProviders.skills;
const context = buildContext();
const mutableProcess = process as typeof process & { cwd: () => string };
const originalCwd = mutableProcess.cwd;
try {
mutableProcess.cwd = () => {
throw new Error("cwd unavailable for test");
};
const first = await provider(context);
expect(first).toBeNull();
expect(context.state.hasInjectedSkillsReminder).toBe(true);
expect(context.state.cachedSkillsReminder).toBe("");
} finally {
mutableProcess.cwd = originalCwd;
}
const second = await provider(context);
expect(second).not.toBeNull();
expect(context.state.pendingSkillsReinject).toBe(false);
if (second) {
expect(second).toContain("<system-reminder>");
}
});
});