From 52e7a5af3abf00e610fb6ccd22cf90c300bf8cef Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:56:58 -0800 Subject: [PATCH] feat: split init into shallow and deep tiers [LET-7770] (#1258) --- src/cli/App.tsx | 80 +++++++++++- src/cli/helpers/initCommand.ts | 25 ++++ src/reminders/catalog.ts | 7 + src/reminders/engine.ts | 25 ++++ src/reminders/state.ts | 4 + src/tests/cli/auto-init.test.ts | 16 ++- .../cli/init-background-subagent.test.ts | 41 ++++++ src/tests/cli/memoryReminder.test.ts | 123 ++++++++++++++++++ src/tests/cli/task-notification-flush.test.ts | 6 +- 9 files changed, 316 insertions(+), 11 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index bd508bb..986c795 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1728,6 +1728,21 @@ export default function App({ const [showExitStats, setShowExitStats] = useState(false); const sharedReminderStateRef = useRef(createSharedReminderState()); + // Per-agent init progression — survives agent/conversation switches unlike SharedReminderState. + const initProgressByAgentRef = useRef( + new Map(), + ); + const updateInitProgress = ( + forAgentId: string, + update: Partial<{ shallowCompleted: boolean; deepFired: boolean }>, + ) => { + const progress = initProgressByAgentRef.current.get(forAgentId) ?? { + shallowCompleted: false, + deepFired: false, + }; + Object.assign(progress, update); + initProgressByAgentRef.current.set(forAgentId, progress); + }; // Track if we've set the conversation summary for this new conversation // Initialized to true for resumed conversations (they already have context) @@ -9198,9 +9213,6 @@ 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 @@ -9229,6 +9241,7 @@ export default function App({ workingDirectory: process.cwd(), memoryDir: getMemoryFilesystemRoot(agentId), gitContext, + depth: "deep", }); const { spawnBackgroundSubagentTask } = await import( @@ -9240,6 +9253,9 @@ export default function App({ description: "Initializing memory", silentCompletion: true, onComplete: ({ success, error }) => { + if (success) { + updateInitProgress(agentId, { deepFired: true }); + } const msg = success ? "Built a memory palace of you. Visit it with /palace." : `Memory initialization failed: ${error || "Unknown error"}`; @@ -9247,6 +9263,9 @@ export default function App({ }, }); + // Clear pending auto-init only after spawn succeeded + autoInitPendingAgentIdsRef.current.delete(agentId); + cmd.finish( "Learning about you and your codebase in the background. You'll be notified when ready.", true, @@ -9272,6 +9291,7 @@ export default function App({ } } else { // Legacy path: primary agent processConversation + autoInitPendingAgentIdsRef.current.delete(agentId); setCommandRunning(true); try { cmd.finish( @@ -9407,6 +9427,9 @@ export default function App({ if (autoInitPendingAgentIdsRef.current.has(agentId) && !isSystemOnly) { try { const fired = await fireAutoInit(agentId, ({ success, error }) => { + if (success) { + updateInitProgress(agentId, { shallowCompleted: true }); + } const msg = success ? "Built a memory palace of you. Visit it with /palace." : `Memory initialization failed: ${error || "Unknown error"}`; @@ -9542,10 +9565,59 @@ ${SYSTEM_REMINDER_CLOSE} return false; } }; + const maybeLaunchDeepInitSubagent = async () => { + if (!memfsEnabledForAgent) return false; + if (hasActiveInitSubagent()) return false; + try { + const gitContext = gatherGitContext(); + const initPrompt = buildMemoryInitRuntimePrompt({ + agentId, + workingDirectory: process.cwd(), + memoryDir: getMemoryFilesystemRoot(agentId), + gitContext, + depth: "deep", + }); + const { spawnBackgroundSubagentTask } = await import( + "../tools/impl/Task" + ); + spawnBackgroundSubagentTask({ + subagentType: "init", + prompt: initPrompt, + description: "Deep memory initialization", + silentCompletion: true, + onComplete: ({ success, error }) => { + if (success) { + updateInitProgress(agentId, { deepFired: true }); + } + const msg = success + ? "Built a memory palace of you. Visit it with /palace." + : `Deep memory initialization failed: ${error || "Unknown error"}`; + appendTaskNotificationEvents([msg]); + }, + }); + debugLog("memory", "Auto-launched deep init subagent"); + return true; + } catch (error) { + debugWarn( + "memory", + `Failed to auto-launch deep init subagent: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return false; + } + }; syncReminderStateFromContextTracker( sharedReminderStateRef.current, contextTrackerRef.current, ); + // Hydrate init progression from the per-agent map into the shared state + // so the deep-init provider sees the correct flags for the current agent. + const initProgress = initProgressByAgentRef.current.get(agentId); + sharedReminderStateRef.current.shallowInitCompleted = + initProgress?.shallowCompleted ?? false; + sharedReminderStateRef.current.deepInitFired = + initProgress?.deepFired ?? false; const { getSkillSources } = await import("../agent/context"); const { parts: sharedReminderParts } = await buildSharedReminderParts({ mode: "interactive", @@ -9561,11 +9633,11 @@ ${SYSTEM_REMINDER_CLOSE} skillSources: getSkillSources(), resolvePlanModeReminder: getPlanModeReminder, maybeLaunchReflectionSubagent, + maybeLaunchDeepInitSubagent, }); for (const part of sharedReminderParts) { reminderParts.push(part); } - // Build conversation switch alert if a switch is pending (behind feature flag) let conversationSwitchAlert = ""; if ( diff --git a/src/cli/helpers/initCommand.ts b/src/cli/helpers/initCommand.ts index c810169..64064e4 100644 --- a/src/cli/helpers/initCommand.ts +++ b/src/cli/helpers/initCommand.ts @@ -76,6 +76,25 @@ ${recentCommits} } } +// ── Depth instructions ──────────────────────────────────── + +const SHALLOW_INSTRUCTIONS = ` +Shallow init — fast project basics only (~5 tool calls max): +- Only read: CLAUDE.md, AGENTS.md, package.json/pyproject.toml/Cargo.toml, README.md (first 100 lines), top-level directory listing +- Detect user identity from the git context provided above (already in the prompt — no extra calls) +- Run one git call: git log --format="%an <%ae>" | sort -u | head -5 +- Write exactly 4 files: project/overview.md, project/commands.md, project/conventions.md, human/identity.md +- Skip: deep directory exploration, architecture mapping, config analysis, historical sessions, persona files, reflection/checkpoint phase +`.trim(); + +const DEEP_INSTRUCTIONS = ` +Deep init — full exploration (follow the initializing-memory skill fully): +- Read all existing memory files first — do NOT recreate what already exists +- Then follow the full initializing-memory skill as your operating guide +- Expand and deepen existing shallow files, add new ones to reach 15-25 target +- If shallow init already ran, build on its output rather than starting over +`.trim(); + // ── Prompt builders ──────────────────────────────────────── /** Prompt for the background init subagent (MemFS path). */ @@ -84,7 +103,9 @@ export function buildMemoryInitRuntimePrompt(args: { workingDirectory: string; memoryDir: string; gitContext: string; + depth?: "shallow" | "deep"; }): string { + const depth = args.depth ?? "deep"; return ` The user ran /init for the current project. @@ -92,6 +113,7 @@ Runtime context: - parent_agent_id: ${args.agentId} - working_directory: ${args.workingDirectory} - memory_dir: ${args.memoryDir} +- research_depth: ${depth} Git/project context: ${args.gitContext} @@ -99,6 +121,8 @@ ${args.gitContext} Task: Initialize or reorganize the parent agent's filesystem-backed memory for this project. +${depth === "shallow" ? SHALLOW_INSTRUCTIONS : DEEP_INSTRUCTIONS} + Instructions: - Use the pre-loaded initializing-memory skill as your operating guide - Inspect existing memory before editing @@ -126,6 +150,7 @@ export async function fireAutoInit( workingDirectory: process.cwd(), memoryDir: getMemoryFilesystemRoot(agentId), gitContext, + depth: "shallow", }); const { spawnBackgroundSubagentTask } = await import("../../tools/impl/Task"); diff --git a/src/reminders/catalog.ts b/src/reminders/catalog.ts index 99a7956..cba51f3 100644 --- a/src/reminders/catalog.ts +++ b/src/reminders/catalog.ts @@ -12,6 +12,7 @@ export type SharedReminderId = | "plan-mode" | "reflection-step-count" | "reflection-compaction" + | "deep-init" | "command-io" | "toolset-change" | "auto-init"; @@ -65,6 +66,12 @@ export const SHARED_REMINDER_CATALOG: ReadonlyArray = "Compaction-triggered reflection reminder/auto-launch behavior", modes: ["interactive", "headless-one-shot", "headless-bidirectional"], }, + { + id: "deep-init", + description: + "Auto-launch deep memory init after shallow init + turn gate", + modes: ["interactive"], + }, { id: "command-io", description: "Recent slash command input/output context", diff --git a/src/reminders/engine.ts b/src/reminders/engine.ts index 55668e9..bd83589 100644 --- a/src/reminders/engine.ts +++ b/src/reminders/engine.ts @@ -46,6 +46,7 @@ export interface SharedReminderContext { maybeLaunchReflectionSubagent?: ( triggerSource: ReflectionTriggerSource, ) => Promise; + maybeLaunchDeepInitSubagent?: () => Promise; } export type ReminderTextPart = { type: "text"; text: string }; @@ -265,6 +266,29 @@ async function buildAutoInitReminder( return AUTO_INIT_REMINDER; } +// Disabled: deep init at turn 8 + reflection at turn 10 is too chaotic. +// Re-enable once both subagent prompts are tuned to coexist. +const DEEP_INIT_AUTO_LAUNCH_ENABLED = false; + +async function maybeLaunchDeepInit( + context: SharedReminderContext, +): Promise { + if (!DEEP_INIT_AUTO_LAUNCH_ENABLED) return null; + if (!context.state.shallowInitCompleted) return null; + if (context.state.deepInitFired) return null; + if (context.state.turnCount < 8) return null; + + const memfsEnabled = settingsManager.isMemfsEnabled(context.agent.id); + if (!memfsEnabled) return null; + + if (context.maybeLaunchDeepInitSubagent) { + // Don't latch deepInitFired here — it's set in the onComplete callback + // only on success, so a failed deep init allows automatic retry. + await context.maybeLaunchDeepInitSubagent(); + } + return null; +} + const MAX_COMMAND_REMINDERS_PER_TURN = 10; const MAX_TOOLSET_REMINDERS_PER_TURN = 5; const MAX_COMMAND_INPUT_CHARS = 2000; @@ -379,6 +403,7 @@ export const sharedReminderProviders: Record< "plan-mode": buildPlanModeReminder, "reflection-step-count": buildReflectionStepReminder, "reflection-compaction": buildReflectionCompactionReminder, + "deep-init": maybeLaunchDeepInit, "command-io": buildCommandIoReminder, "toolset-change": buildToolsetChangeReminder, "auto-init": buildAutoInitReminder, diff --git a/src/reminders/state.ts b/src/reminders/state.ts index d1e7190..4f5dd4c 100644 --- a/src/reminders/state.ts +++ b/src/reminders/state.ts @@ -32,6 +32,8 @@ export interface SharedReminderState { pendingAutoInitReminder: boolean; pendingCommandIoReminders: CommandIoReminder[]; pendingToolsetChangeReminders: ToolsetChangeReminder[]; + shallowInitCompleted: boolean; + deepInitFired: boolean; } export function createSharedReminderState(): SharedReminderState { @@ -48,6 +50,8 @@ export function createSharedReminderState(): SharedReminderState { pendingAutoInitReminder: false, pendingCommandIoReminders: [], pendingToolsetChangeReminders: [], + shallowInitCompleted: false, + deepInitFired: false, }; } diff --git a/src/tests/cli/auto-init.test.ts b/src/tests/cli/auto-init.test.ts index 633d224..6c48401 100644 --- a/src/tests/cli/auto-init.test.ts +++ b/src/tests/cli/auto-init.test.ts @@ -83,7 +83,7 @@ describe("auto-init lifecycle guards", () => { expect(blockStart).toBeGreaterThan(-1); // Extract enough of the block to cover the clearing logic - const block = appSource.slice(blockStart, blockStart + 600); + const block = appSource.slice(blockStart, blockStart + 1200); // The delete must happen AFTER checking `fired`, not before fireAutoInit const firedCheck = block.indexOf("if (fired)"); @@ -95,17 +95,23 @@ describe("auto-init lifecycle guards", () => { expect(setDelete).toBeGreaterThan(firedCheck); }); - test("manual /init clears pending auto-init for current agent", () => { + test("manual /init clears pending auto-init for current agent after spawn", () => { const appSource = readSource("../../cli/App.tsx"); - // The /init handler must delete the current agent from the pending set + // The /init handler must delete the current agent from the pending set, + // but only after the background subagent has been spawned (inside the try). const initHandlerIdx = appSource.indexOf('trimmed === "/init"'); expect(initHandlerIdx).toBeGreaterThan(-1); - const afterInit = appSource.slice(initHandlerIdx, initHandlerIdx + 400); - expect(afterInit).toContain( + // Search from the /init handler to the end of the block + const afterInit = appSource.slice(initHandlerIdx); + const spawnIdx = afterInit.indexOf("spawnBackgroundSubagentTask({"); + const deleteIdx = afterInit.indexOf( "autoInitPendingAgentIdsRef.current.delete(agentId)", ); + expect(spawnIdx).toBeGreaterThan(-1); + expect(deleteIdx).toBeGreaterThan(-1); + expect(deleteIdx).toBeGreaterThan(spawnIdx); }); test("fireAutoInit returns false (not throw) when init subagent is active", () => { diff --git a/src/tests/cli/init-background-subagent.test.ts b/src/tests/cli/init-background-subagent.test.ts index 66a3df5..2525639 100644 --- a/src/tests/cli/init-background-subagent.test.ts +++ b/src/tests/cli/init-background-subagent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { buildMemoryInitRuntimePrompt } from "../../cli/helpers/initCommand"; describe("init background subagent wiring", () => { const readSource = (relativePath: string) => @@ -70,4 +71,44 @@ describe("init background subagent wiring", () => { ); expect(indexSource).toContain("initAgentMd"); }); + + const baseArgs = { + agentId: "test-agent", + workingDirectory: "/tmp/test", + memoryDir: "/tmp/test/.memory", + gitContext: "## Git context\nsome git info", + }; + + test('buildMemoryInitRuntimePrompt includes "research_depth: shallow" when depth is "shallow"', () => { + const prompt = buildMemoryInitRuntimePrompt({ + ...baseArgs, + depth: "shallow", + }); + expect(prompt).toContain("research_depth: shallow"); + expect(prompt).toContain("Shallow init"); + expect(prompt).not.toContain("Deep init"); + }); + + test('buildMemoryInitRuntimePrompt includes "research_depth: deep" when depth is "deep"', () => { + const prompt = buildMemoryInitRuntimePrompt({ + ...baseArgs, + depth: "deep", + }); + expect(prompt).toContain("research_depth: deep"); + expect(prompt).toContain("Deep init"); + expect(prompt).not.toContain("Shallow init"); + }); + + test('buildMemoryInitRuntimePrompt defaults to "deep" when depth is omitted', () => { + const prompt = buildMemoryInitRuntimePrompt(baseArgs); + expect(prompt).toContain("research_depth: deep"); + expect(prompt).toContain("Deep init"); + }); + + test("App.tsx contains maybeLaunchDeepInitSubagent", () => { + const appSource = readSource("../../cli/App.tsx"); + expect(appSource).toContain("maybeLaunchDeepInitSubagent"); + expect(appSource).toContain("Deep memory initialization"); + expect(appSource).toContain('depth: "deep"'); + }); }); diff --git a/src/tests/cli/memoryReminder.test.ts b/src/tests/cli/memoryReminder.test.ts index 5003856..aed57f1 100644 --- a/src/tests/cli/memoryReminder.test.ts +++ b/src/tests/cli/memoryReminder.test.ts @@ -10,6 +10,11 @@ import { reflectionSettingsToLegacyMode, shouldFireStepCountTrigger, } from "../../cli/helpers/memoryReminder"; +import { + type SharedReminderContext, + sharedReminderProviders, +} from "../../reminders/engine"; +import { createSharedReminderState } from "../../reminders/state"; import { settingsManager } from "../../settings-manager"; const originalGetLocalProjectSettings = settingsManager.getLocalProjectSettings; @@ -170,3 +175,121 @@ describe("memoryReminder", () => { ).toBe(false); }); }); + +describe("deep-init trigger", () => { + const deepInitProvider = sharedReminderProviders["deep-init"]; + + function makeContext( + overrides: Partial<{ + shallowInitCompleted: boolean; + deepInitFired: boolean; + turnCount: number; + memfsEnabled: boolean; + callback: (() => Promise) | undefined; + }> = {}, + ): SharedReminderContext { + const state = createSharedReminderState(); + state.shallowInitCompleted = overrides.shallowInitCompleted ?? false; + state.deepInitFired = overrides.deepInitFired ?? false; + state.turnCount = overrides.turnCount ?? 0; + + const memfsEnabled = overrides.memfsEnabled ?? true; + (settingsManager as typeof settingsManager).isMemfsEnabled = (() => + memfsEnabled) as typeof settingsManager.isMemfsEnabled; + + return { + mode: "interactive", + agent: { id: "test-agent", name: "test" }, + state, + sessionContextReminderEnabled: false, + reflectionSettings: { + trigger: "step-count", + behavior: "auto-launch", + stepCount: 25, + }, + skillSources: [], + resolvePlanModeReminder: async () => "", + maybeLaunchDeepInitSubagent: overrides.callback, + }; + } + + test("does not fire before turn 8", async () => { + let launched = false; + const ctx = makeContext({ + shallowInitCompleted: true, + turnCount: 7, + callback: async () => { + launched = true; + return true; + }, + }); + const result = await deepInitProvider(ctx); + expect(result).toBeNull(); + expect(launched).toBe(false); + }); + + // Deep init auto-launch is currently disabled (reflection + deep init + // at similar turn counts is too chaotic). This test documents the + // disabled behavior; re-enable when subagent prompts are tuned. + test("is currently disabled — does not launch even when conditions are met", async () => { + let launched = false; + const ctx = makeContext({ + shallowInitCompleted: true, + turnCount: 8, + callback: async () => { + launched = true; + return true; + }, + }); + const result = await deepInitProvider(ctx); + expect(result).toBeNull(); + expect(launched).toBe(false); + }); + + test("does not re-fire once deepInitFired is true", async () => { + let launched = false; + const ctx = makeContext({ + shallowInitCompleted: true, + deepInitFired: true, + turnCount: 10, + callback: async () => { + launched = true; + return true; + }, + }); + const result = await deepInitProvider(ctx); + expect(result).toBeNull(); + expect(launched).toBe(false); + }); + + test("does not fire when shallowInitCompleted is false", async () => { + let launched = false; + const ctx = makeContext({ + shallowInitCompleted: false, + turnCount: 10, + callback: async () => { + launched = true; + return true; + }, + }); + const result = await deepInitProvider(ctx); + expect(result).toBeNull(); + expect(launched).toBe(false); + }); + + test("does not fire when memfs is disabled", async () => { + let launched = false; + const ctx = makeContext({ + shallowInitCompleted: true, + turnCount: 8, + memfsEnabled: false, + callback: async () => { + launched = true; + return true; + }, + }); + const result = await deepInitProvider(ctx); + expect(result).toBeNull(); + expect(launched).toBe(false); + }); +}); diff --git a/src/tests/cli/task-notification-flush.test.ts b/src/tests/cli/task-notification-flush.test.ts index f462f0f..3b6a403 100644 --- a/src/tests/cli/task-notification-flush.test.ts +++ b/src/tests/cli/task-notification-flush.test.ts @@ -144,12 +144,14 @@ describe("background onComplete → flush wiring in App.tsx", () => { expect(onCompleteIdx).toBeGreaterThan(-1); // The appendTaskNotificationEvents call must be within the onComplete body - // (before the next closing of spawnBackgroundSubagentTask) + // (before the closing of spawnBackgroundSubagentTask). + // Use "});" with leading whitespace to match the spawn call's closing, + // not inner statements like updateInitProgress(...}); const callIdx = source.indexOf( "appendTaskNotificationEvents(", onCompleteIdx, ); - const blockEnd = source.indexOf("});", onCompleteIdx); + const blockEnd = source.indexOf(" });", onCompleteIdx); expect(callIdx).toBeGreaterThan(onCompleteIdx); expect(callIdx).toBeLessThan(blockEnd); });