feat(cli): auto-init agent memory on first message [LET-7779] (#1231)
This commit is contained in:
@@ -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> = {
|
||||
|
||||
3
src/agent/prompts/auto_init_reminder.txt
Normal file
3
src/agent/prompts/auto_init_reminder.txt
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
127
src/tests/cli/auto-init.test.ts
Normal file
127
src/tests/cli/auto-init.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user