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:
210
src/cli/App.tsx
210
src/cli/App.tsx
@@ -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
|
||||
|
||||
273
src/headless.ts
273
src/headless.ts
@@ -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
69
src/reminders/catalog.ts
Normal 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
320
src/reminders/engine.ts
Normal 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
44
src/reminders/state.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({");
|
||||
});
|
||||
});
|
||||
|
||||
49
src/tests/headless/reminder-wiring.test.ts
Normal file
49
src/tests/headless/reminder-wiring.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
34
src/tests/reminders/catalog.test.ts
Normal file
34
src/tests/reminders/catalog.test.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
72
src/tests/reminders/engine-parity.test.ts
Normal file
72
src/tests/reminders/engine-parity.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
70
src/tests/reminders/permission-mode.test.ts
Normal file
70
src/tests/reminders/permission-mode.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
54
src/tests/reminders/skills-recovery.test.ts
Normal file
54
src/tests/reminders/skills-recovery.test.ts
Normal 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>");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user