feat(cli): auto-init agent memory on first message [LET-7779] (#1231)

This commit is contained in:
Devansh Jain
2026-03-03 20:08:30 -08:00
committed by GitHub
parent 0d741d389d
commit a284a31f97
8 changed files with 245 additions and 3 deletions

View File

@@ -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<string, string> = {

View File

@@ -0,0 +1,3 @@
<system-reminder>
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.
</system-reminder>

View File

@@ -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<Set<string>>(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(() => {

View File

@@ -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<boolean> {
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;

View File

@@ -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<SharedReminderDefinition> =
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(

View File

@@ -256,6 +256,15 @@ async function buildReflectionCompactionReminder(
return buildCompactionMemoryReminder(context.agent.id);
}
async function buildAutoInitReminder(
context: SharedReminderContext,
): Promise<string | null> {
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 {

View File

@@ -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: [],
};

View File

@@ -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);
});
});