From a284a31f975f0081c5f04646e4f7dc65152f8d10 Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:08:30 -0800 Subject: [PATCH] feat(cli): auto-init agent memory on first message [LET-7779] (#1231) --- src/agent/promptAssets.ts | 2 + src/agent/prompts/auto_init_reminder.txt | 3 + src/cli/App.tsx | 63 ++++++++++- src/cli/helpers/initCommand.ts | 33 ++++++ src/reminders/catalog.ts | 8 +- src/reminders/engine.ts | 10 ++ src/reminders/state.ts | 2 + src/tests/cli/auto-init.test.ts | 127 +++++++++++++++++++++++ 8 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 src/agent/prompts/auto_init_reminder.txt create mode 100644 src/tests/cli/auto-init.test.ts diff --git a/src/agent/promptAssets.ts b/src/agent/promptAssets.ts index 1ae02a7..e31a727 100644 --- a/src/agent/promptAssets.ts +++ b/src/agent/promptAssets.ts @@ -1,6 +1,7 @@ // Additional system prompts for /system command import approvalRecoveryAlert from "./prompts/approval_recovery_alert.txt"; +import autoInitReminder from "./prompts/auto_init_reminder.txt"; import anthropicPrompt from "./prompts/claude.md"; import codexPrompt from "./prompts/codex.md"; import geminiPrompt from "./prompts/gemini.md"; @@ -38,6 +39,7 @@ export const REMEMBER_PROMPT = rememberPrompt; export const MEMORY_CHECK_REMINDER = memoryCheckReminder; export const MEMORY_REFLECTION_REMINDER = memoryReflectionReminder; export const APPROVAL_RECOVERY_PROMPT = approvalRecoveryAlert; +export const AUTO_INIT_REMINDER = autoInitReminder; export const INTERRUPT_RECOVERY_ALERT = interruptRecoveryAlert; export const MEMORY_PROMPTS: Record = { diff --git a/src/agent/prompts/auto_init_reminder.txt b/src/agent/prompts/auto_init_reminder.txt new file mode 100644 index 0000000..8a00902 --- /dev/null +++ b/src/agent/prompts/auto_init_reminder.txt @@ -0,0 +1,3 @@ + +A background agent is initializing this agent's memory system. Briefly let the user know that memory is being set up in the background, then respond to their message normally. + \ No newline at end of file diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 27d9101..7f3ff82 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -220,6 +220,7 @@ import { parsePatchOperations } from "./helpers/formatArgsDisplay"; import { buildLegacyInitMessage, buildMemoryInitRuntimePrompt, + fireAutoInit, gatherGitContext, hasActiveInitSubagent, } from "./helpers/initCommand"; @@ -1047,6 +1048,13 @@ export default function App({ import("./helpers/conversationSwitchAlert").ConversationSwitchContext | null >(null); + // Pending auto-init for newly created agents — consumed on first user message. + // A Set so multiple agents created before any message is sent are all tracked. + const autoInitPendingAgentIdsRef = useRef>(new Set()); + // Tracks whether we've already consumed the startup agentProvenance.isNew flag, + // so agent switches later in the session don't re-queue auto-init. + const startupAutoInitConsumedRef = useRef(false); + // Track previous prop values to detect actual prop changes (not internal state changes) const prevInitialAgentIdRef = useRef(initialAgentId); const prevInitialAgentStateRef = useRef(initialAgentState); @@ -6228,6 +6236,11 @@ export default function App({ ); await enableMemfsIfCloud(agent.id); + // Queue auto-init for first message if memfs is enabled + if (settingsManager.isMemfsEnabled(agent.id)) { + autoInitPendingAgentIdsRef.current.add(agent.id); + } + // Update project settings with new agent await updateProjectSettings({ lastAgent: agent.id }); @@ -6246,10 +6259,13 @@ export default function App({ // Build success message with hints const agentUrl = `https://app.letta.com/projects/default-project/agents/${agent.id}`; + const memfsTip = settingsManager.isMemfsEnabled(agent.id) + ? "Memory will be auto-initialized on your first message." + : "Tip: use /init to initialize your agent's memory system!"; const successOutput = [ `Created **${agent.name || agent.id}** (use /pin to save)`, `⎿ ${agentUrl}`, - `⎿ Tip: use /init to initialize your agent's memory system!`, + `⎿ ${memfsTip}`, ].join("\n"); cmd.finish(successOutput, true); const successItem: StaticItem = { @@ -9176,6 +9192,9 @@ export default function App({ // Special handling for /init command if (trimmed === "/init") { + // Manual /init supersedes pending auto-init for this agent + autoInitPendingAgentIdsRef.current.delete(agentId); + const cmd = commandRunner.start(msg, "Gathering project context..."); // Check for pending approvals before either path @@ -9217,7 +9236,7 @@ export default function App({ onComplete: ({ success, error }) => { const msg = success ? "Built a memory palace of you. Visit it with /palace." - : `Memory initialization failed: ${error}`; + : `Memory initialization failed: ${error || "Unknown error"}`; appendTaskNotificationEvents([msg]); }, }); @@ -9376,6 +9395,26 @@ export default function App({ } } + // Auto-init: fire background init on first message for newly created agents. + // Only remove from the pending set after a confirmed launch so that a blocked + // attempt (e.g. another /init subagent in flight) preserves the entry for retry. + if (autoInitPendingAgentIdsRef.current.has(agentId) && !isSystemOnly) { + try { + const fired = await fireAutoInit(agentId, ({ success, error }) => { + const msg = success + ? "Built a memory palace of you. Visit it with /palace." + : `Memory initialization failed: ${error || "Unknown error"}`; + appendTaskNotificationEvents([msg]); + }); + if (fired) { + autoInitPendingAgentIdsRef.current.delete(agentId); + sharedReminderStateRef.current.pendingAutoInitReminder = true; + } + } catch { + // Non-blocking: swallow failures so the user's message still goes through + } + } + // Build message content from display value (handles placeholders for text/images) const contentParts = overrideContentParts ?? buildMessageContentFromDisplay(msg); @@ -12499,6 +12538,26 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa }); }, [estimatedLiveHeight, terminalRows]); + // Queue auto-init for startup-created agents (--new-agent, --import, profile selector "new"). + // The consumed ref ensures this fires at most once per app lifetime, so later + // agent switches (which change agentId but leave agentProvenance stale) don't + // accidentally re-queue auto-init for an existing agent. This also means if + // the user switches away from the startup agent and back, auto-init won't + // re-queue — that's intentional (init is a one-shot at creation time). + useEffect(() => { + if ( + loadingState === "ready" && + agentProvenance?.isNew && + agentId && + !startupAutoInitConsumedRef.current + ) { + startupAutoInitConsumedRef.current = true; + if (settingsManager.isMemfsEnabled(agentId)) { + autoInitPendingAgentIdsRef.current.add(agentId); + } + } + }, [loadingState, agentProvenance, agentId]); + // Commit welcome snapshot once when ready for fresh sessions (no history) // Wait for agentProvenance to be available for new agents (continueSession=false) useEffect(() => { diff --git a/src/cli/helpers/initCommand.ts b/src/cli/helpers/initCommand.ts index 22b4543..c810169 100644 --- a/src/cli/helpers/initCommand.ts +++ b/src/cli/helpers/initCommand.ts @@ -6,7 +6,9 @@ */ import { execSync } from "node:child_process"; +import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; +import { settingsManager } from "../../settings-manager"; import { getSnapshot as getSubagentSnapshot } from "./subagentState"; // ── Guard ────────────────────────────────────────────────── @@ -107,6 +109,37 @@ Instructions: `.trim(); } +/** + * Fire auto-init for a newly created agent. + * Returns true if init was spawned, false if skipped (guard / memfs disabled). + */ +export async function fireAutoInit( + agentId: string, + onComplete: (result: { success: boolean; error?: string }) => void, +): Promise { + if (hasActiveInitSubagent()) return false; + if (!settingsManager.isMemfsEnabled(agentId)) return false; + + const gitContext = gatherGitContext(); + const initPrompt = buildMemoryInitRuntimePrompt({ + agentId, + workingDirectory: process.cwd(), + memoryDir: getMemoryFilesystemRoot(agentId), + gitContext, + }); + + const { spawnBackgroundSubagentTask } = await import("../../tools/impl/Task"); + spawnBackgroundSubagentTask({ + subagentType: "init", + prompt: initPrompt, + description: "Initializing memory", + silentCompletion: true, + onComplete, + }); + + return true; +} + /** Message for the primary agent via processConversation (legacy non-MemFS path). */ export function buildLegacyInitMessage(args: { gitContext: string; diff --git a/src/reminders/catalog.ts b/src/reminders/catalog.ts index af6a12e..99a7956 100644 --- a/src/reminders/catalog.ts +++ b/src/reminders/catalog.ts @@ -13,7 +13,8 @@ export type SharedReminderId = | "reflection-step-count" | "reflection-compaction" | "command-io" - | "toolset-change"; + | "toolset-change" + | "auto-init"; export interface SharedReminderDefinition { id: SharedReminderId; @@ -74,6 +75,11 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray = description: "Client-side toolset change context", modes: ["interactive"], }, + { + id: "auto-init", + description: "Auto-init background onboarding notification", + modes: ["interactive"], + }, ]; export const SHARED_REMINDER_IDS = SHARED_REMINDER_CATALOG.map( diff --git a/src/reminders/engine.ts b/src/reminders/engine.ts index c1dfef2..55668e9 100644 --- a/src/reminders/engine.ts +++ b/src/reminders/engine.ts @@ -256,6 +256,15 @@ async function buildReflectionCompactionReminder( return buildCompactionMemoryReminder(context.agent.id); } +async function buildAutoInitReminder( + context: SharedReminderContext, +): Promise { + if (!context.state.pendingAutoInitReminder) return null; + context.state.pendingAutoInitReminder = false; + const { AUTO_INIT_REMINDER } = await import("../agent/promptAssets.js"); + return AUTO_INIT_REMINDER; +} + const MAX_COMMAND_REMINDERS_PER_TURN = 10; const MAX_TOOLSET_REMINDERS_PER_TURN = 5; const MAX_COMMAND_INPUT_CHARS = 2000; @@ -372,6 +381,7 @@ export const sharedReminderProviders: Record< "reflection-compaction": buildReflectionCompactionReminder, "command-io": buildCommandIoReminder, "toolset-change": buildToolsetChangeReminder, + "auto-init": buildAutoInitReminder, }; export function assertSharedReminderCoverage(): void { diff --git a/src/reminders/state.ts b/src/reminders/state.ts index b0800e4..d1e7190 100644 --- a/src/reminders/state.ts +++ b/src/reminders/state.ts @@ -29,6 +29,7 @@ export interface SharedReminderState { turnCount: number; pendingSkillsReinject: boolean; pendingReflectionTrigger: boolean; + pendingAutoInitReminder: boolean; pendingCommandIoReminders: CommandIoReminder[]; pendingToolsetChangeReminders: ToolsetChangeReminder[]; } @@ -44,6 +45,7 @@ export function createSharedReminderState(): SharedReminderState { turnCount: 0, pendingSkillsReinject: false, pendingReflectionTrigger: false, + pendingAutoInitReminder: false, pendingCommandIoReminders: [], pendingToolsetChangeReminders: [], }; diff --git a/src/tests/cli/auto-init.test.ts b/src/tests/cli/auto-init.test.ts new file mode 100644 index 0000000..633d224 --- /dev/null +++ b/src/tests/cli/auto-init.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +describe("auto-init wiring", () => { + const readSource = (relativePath: string) => + readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + "utf-8", + ); + + test("fireAutoInit is exported from initCommand.ts", () => { + const helperSource = readSource("../../cli/helpers/initCommand.ts"); + expect(helperSource).toContain("export async function fireAutoInit("); + }); + + test("App.tsx uses a Set to track multiple pending agent IDs", () => { + const appSource = readSource("../../cli/App.tsx"); + expect(appSource).toContain("autoInitPendingAgentIdsRef"); + expect(appSource).toContain("new Set()"); + }); + + test("App.tsx uses agentProvenance?.isNew for startup path", () => { + const appSource = readSource("../../cli/App.tsx"); + expect(appSource).toContain("agentProvenance?.isNew"); + }); + + test("App.tsx checks .has(agentId) as agent ID match guard in onSubmit", () => { + const appSource = readSource("../../cli/App.tsx"); + expect(appSource).toContain( + "autoInitPendingAgentIdsRef.current.has(agentId)", + ); + }); + + test("auto-init is registered in catalog and engine", () => { + const catalogSource = readSource("../../reminders/catalog.ts"); + const engineSource = readSource("../../reminders/engine.ts"); + + expect(catalogSource).toContain('"auto-init"'); + expect(engineSource).toContain('"auto-init"'); + expect(engineSource).toContain("buildAutoInitReminder"); + }); + + test("pendingAutoInitReminder is in state interface and factory", () => { + const stateSource = readSource("../../reminders/state.ts"); + + expect(stateSource).toContain("pendingAutoInitReminder: boolean"); + expect(stateSource).toContain("pendingAutoInitReminder: false"); + }); +}); + +describe("auto-init lifecycle guards", () => { + const readSource = (relativePath: string) => + readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + "utf-8", + ); + + test("startup effect uses a consumed ref to fire at most once", () => { + const appSource = readSource("../../cli/App.tsx"); + + // The consumed ref must exist + expect(appSource).toContain("startupAutoInitConsumedRef"); + + // The guard check must appear before the assignment in the source. + // This ensures the effect tests the consumed ref before marking it consumed. + const guardIdx = appSource.indexOf("!startupAutoInitConsumedRef.current"); + const assignIdx = appSource.indexOf( + "startupAutoInitConsumedRef.current = true", + ); + expect(guardIdx).toBeGreaterThan(-1); + expect(assignIdx).toBeGreaterThan(-1); + expect(guardIdx).toBeLessThan(assignIdx); + }); + + test("onSubmit only removes from pending set after confirmed launch (fired === true)", () => { + const appSource = readSource("../../cli/App.tsx"); + + // Find the auto-init block in onSubmit — starts with the .has() check + const blockStart = appSource.indexOf( + "autoInitPendingAgentIdsRef.current.has(agentId)", + ); + expect(blockStart).toBeGreaterThan(-1); + + // Extract enough of the block to cover the clearing logic + const block = appSource.slice(blockStart, blockStart + 600); + + // The delete must happen AFTER checking `fired`, not before fireAutoInit + const firedCheck = block.indexOf("if (fired)"); + const setDelete = block.indexOf( + "autoInitPendingAgentIdsRef.current.delete(agentId)", + ); + expect(firedCheck).toBeGreaterThan(-1); + expect(setDelete).toBeGreaterThan(-1); + expect(setDelete).toBeGreaterThan(firedCheck); + }); + + test("manual /init clears pending auto-init for current agent", () => { + const appSource = readSource("../../cli/App.tsx"); + + // The /init handler must delete the current agent from the pending set + const initHandlerIdx = appSource.indexOf('trimmed === "/init"'); + expect(initHandlerIdx).toBeGreaterThan(-1); + + const afterInit = appSource.slice(initHandlerIdx, initHandlerIdx + 400); + expect(afterInit).toContain( + "autoInitPendingAgentIdsRef.current.delete(agentId)", + ); + }); + + test("fireAutoInit returns false (not throw) when init subagent is active", () => { + const helperSource = readSource("../../cli/helpers/initCommand.ts"); + + // The guard must return false, not throw + const fnBody = helperSource.slice( + helperSource.indexOf("async function fireAutoInit("), + ); + const guardIdx = fnBody.indexOf("hasActiveInitSubagent()"); + expect(guardIdx).toBeGreaterThan(-1); + + // The return false must follow the guard, confirming it's a soft skip + const returnFalseIdx = fnBody.indexOf("return false", guardIdx); + expect(returnFalseIdx).toBeGreaterThan(-1); + // Should be on the same or next line (within a small window) + expect(returnFalseIdx - guardIdx).toBeLessThan(40); + }); +});