From 35962f7bc79908bd06f06bed1866d774441d767b Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:35:19 -0800 Subject: [PATCH] feat: make /init a background subagent (#1208) --- src/agent/subagents/builtin/init.md | 209 ++++++++++++++++++ src/agent/subagents/index.ts | 2 + src/cli/App.tsx | 197 +++++++---------- src/cli/helpers/initCommand.ts | 133 +++++++++++ .../builtin/initializing-memory/SKILL.md | 9 + .../cli/init-background-subagent.test.ts | 73 ++++++ 6 files changed, 512 insertions(+), 111 deletions(-) create mode 100644 src/agent/subagents/builtin/init.md create mode 100644 src/cli/helpers/initCommand.ts create mode 100644 src/tests/cli/init-background-subagent.test.ts diff --git a/src/agent/subagents/builtin/init.md b/src/agent/subagents/builtin/init.md new file mode 100644 index 0000000..a462b17 --- /dev/null +++ b/src/agent/subagents/builtin/init.md @@ -0,0 +1,209 @@ +--- +name: init +description: Initialize agent memory by researching the project and creating a hierarchical memory file structure +tools: Read, Edit, Write, Glob, Grep, Bash, TaskOutput +model: sonnet +memoryBlocks: none +skills: initializing-memory +permissionMode: bypassPermissions +--- + +You are a memory initialization subagent — a background agent that autonomously researches the project and sets up the agent's memory file structure. + +You run autonomously in the background and return a single final report when done. You CANNOT ask questions (AskUserQuestion is not available). + +## Your Purpose + +Research the current project and create a comprehensive, hierarchical memory file structure so the primary agent can be an effective collaborator from its very first interaction. + +**You are NOT the primary agent.** You are a background worker initializing memory for the primary agent. + +## Autonomous Mode Defaults + +Since you cannot ask questions mid-execution: +- Use **standard research depth** (~5-20 tool calls) +- Detect user identity from git logs: + ```bash + git shortlog -sn --all | head -5 + git log --format="%an <%ae>" | sort -u | head -10 + ``` +- Skip historical session analysis +- Use reasonable defaults for all preferences +- Any specific overrides will be provided in your initial prompt + +## Operating Procedure + +### Phase 1: Set Up + +The memory directory is at: `~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory/` + +```bash +MEMORY_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory +WORKTREE_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory-worktrees +``` + +The memory directory should already be a git repo (initialized when MemFS was enabled). If it's not, or if git is unavailable, report the issue back and exit without making changes. + +**Step 1a: Create worktree** + +```bash +BRANCH="init-$(date +%s)" +mkdir -p "$WORKTREE_DIR" +cd "$MEMORY_DIR" +git worktree add "$WORKTREE_DIR/$BRANCH" -b "$BRANCH" +``` + +All subsequent file operations target the worktree: `$WORKTREE_DIR/$BRANCH/system/` (not the main memory dir). + +### Phase 2: Research the Project + +Follow the `initializing-memory` skill (pre-loaded below) for detailed research instructions. Key steps: + +1. Inspect existing memory files in the worktree +2. Scan README, package.json/config files, AGENTS.md, CLAUDE.md +3. Review git status and recent commits (provided in prompt) +4. Explore key directories and understand project structure +5. Detect user identity from git logs + +### Phase 3: Create Memory File Structure + +Create a deeply hierarchical structure of 15-25 small, focused files in the worktree at `$WORKTREE_DIR/$BRANCH/system/`. + +Follow the `initializing-memory` skill for file organization guidelines, hierarchy requirements, and content standards. + +### Phase 4: Merge, Push, and Clean Up (MANDATORY) + +**Step 4a: Commit in worktree** + +```bash +MEMORY_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory +WORKTREE_DIR=~/.letta/agents/$LETTA_PARENT_AGENT_ID/memory-worktrees +cd $WORKTREE_DIR/$BRANCH +git add -A +``` + +Check `git status` — if there are no changes to commit, skip straight to Step 4d (cleanup). Report "no updates needed" in your output. + +If there are changes, commit using Conventional Commits format with the `(init)` scope: + +```bash +git commit -m "feat(init): initialize memory file structure + +Created hierarchical memory structure for project. + +Updates: +- + +Generated-By: Letta Code +Agent-ID: +Parent-Agent-ID: " +``` + +Before writing the commit, resolve the actual ID values: +```bash +echo "AGENT_ID=$LETTA_AGENT_ID" +echo "PARENT_AGENT_ID=$LETTA_PARENT_AGENT_ID" +``` + +**Step 4b: Pull + merge to main** + +```bash +cd $MEMORY_DIR +``` + +First, check that main is in a clean state (`git status`). If a merge or rebase is in progress (lock file, dirty index), wait and retry up to 3 times with backoff (sleep 2, 5, 10 seconds). Never delete `.git/index.lock` manually. If still busy after retries, go to Error Handling. + +Pull from remote: + +```bash +git pull --ff-only +``` + +If `--ff-only` fails (remote has diverged), fall back: + +```bash +git pull --rebase +``` + +Now merge the init branch: + +```bash +git merge $BRANCH --no-edit +``` + +If the merge has conflicts, resolve by preferring init branch/worktree content for memory files, stage the resolved files, and complete with `git commit --no-edit`. + +**Step 4c: Push to remote** + +```bash +git push +``` + +If push fails, retry once. If it still fails, report that local main is ahead of remote and needs a push. + +**Step 4d: Clean up worktree and branch** + +Only clean up when merge to main completed: + +```bash +git worktree remove $WORKTREE_DIR/$BRANCH +git branch -d $BRANCH +``` + +**Step 4e: Verify** + +```bash +git status +git log --oneline -3 +``` + +## Error Handling + +If anything goes wrong at any phase: + +1. Stabilize main first (abort in-progress operations): + ```bash + cd $MEMORY_DIR + git merge --abort 2>/dev/null + git rebase --abort 2>/dev/null + ``` + +2. Do NOT clean up the worktree or branch on failure — preserve them for debugging and manual recovery. + +3. Report clearly in your output: + - What failed and the error message + - Worktree path: `$WORKTREE_DIR/$BRANCH` + - Branch name: `$BRANCH` + - Whether main has uncommitted/dirty state + +4. Do NOT leave uncommitted changes on main. + +## Output Format + +Return a report with: + +### 1. Summary +- Brief overview (2-3 sentences) +- Research depth used, tool calls made + +### 2. Files Created/Modified +- **Count**: Total files created +- **Structure**: Tree view of the memory hierarchy +- For each file: path, description, what content was added + +### 3. Commit Reference +- **Commit hash**: The merge commit hash +- **Branch**: The init branch name + +### 4. Issues Encountered +- Any problems or limitations found during research +- Information that couldn't be determined without user input + +## Critical Reminders + +1. **Not the primary agent** — Don't respond to user messages +2. **Edit worktree files** — NOT the main memory dir +3. **Cannot ask questions** — Use defaults and git logs +4. **Be thorough but efficient** — Standard depth by default +5. **Always commit, merge, AND push** — Your work is wasted if it isn't merged to main and pushed to remote +6. **Report errors clearly** — If something breaks, say what happened and suggest a fix diff --git a/src/agent/subagents/index.ts b/src/agent/subagents/index.ts index 88b3725..53aca9d 100644 --- a/src/agent/subagents/index.ts +++ b/src/agent/subagents/index.ts @@ -21,6 +21,7 @@ import { MEMORY_BLOCK_LABELS, type MemoryBlockLabel } from "../memory"; import exploreAgentMd from "./builtin/explore.md"; import generalPurposeAgentMd from "./builtin/general-purpose.md"; import historyAnalyzerAgentMd from "./builtin/history-analyzer.md"; +import initAgentMd from "./builtin/init.md"; import memoryAgentMd from "./builtin/memory.md"; import recallAgentMd from "./builtin/recall.md"; @@ -30,6 +31,7 @@ const BUILTIN_SOURCES = [ exploreAgentMd, generalPurposeAgentMd, historyAnalyzerAgentMd, + initAgentMd, memoryAgentMd, recallAgentMd, reflectionAgentMd, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 5720746..572b098 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -216,6 +216,12 @@ import { } from "./helpers/errorFormatter"; import { formatCompact } from "./helpers/format"; import { parsePatchOperations } from "./helpers/formatArgsDisplay"; +import { + buildLegacyInitMessage, + buildMemoryInitRuntimePrompt, + gatherGitContext, + hasActiveInitSubagent, +} from "./helpers/initCommand"; import { getReflectionSettings, parseMemoryPreference, @@ -9052,135 +9058,104 @@ export default function App({ return { submitted: true }; } - // Special handling for /init command - initialize agent memory + // Special handling for /init command if (trimmed === "/init") { const cmd = commandRunner.start(msg, "Gathering project context..."); - // Check for pending approvals before sending + // Check for pending approvals before either path const approvalCheck = await checkPendingApprovalsForSlashCommand(); if (approvalCheck.blocked) { cmd.fail( "Pending approval(s). Resolve approvals before running /init.", ); - return { submitted: false }; // Keep /init in input box, user handles approval first + return { submitted: false }; } - setCommandRunning(true); + const gitContext = gatherGitContext(); - try { - // Gather git context if available - let gitContext = ""; - try { - const { execSync } = await import("node:child_process"); - const cwd = process.cwd(); - - // Check if we're in a git repo - try { - execSync("git rev-parse --git-dir", { - cwd, - stdio: "pipe", - }); - - // Gather git info - const branch = execSync("git branch --show-current", { - cwd, - encoding: "utf-8", - }).trim(); - const mainBranch = execSync( - "git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo 'main'", - { cwd, encoding: "utf-8", shell: "/bin/bash" }, - ).trim(); - const status = execSync("git status --short", { - cwd, - encoding: "utf-8", - }).trim(); - const recentCommits = execSync( - "git log --oneline -10 2>/dev/null || echo 'No commits yet'", - { cwd, encoding: "utf-8" }, - ).trim(); - - gitContext = ` -## Current Project Context - -**Working directory**: ${cwd} - -### Git Status -- **Current branch**: ${branch} -- **Main branch**: ${mainBranch} -- **Status**: -${status || "(clean working tree)"} - -### Recent Commits -${recentCommits} -`; - } catch { - // Not a git repo, just include working directory - gitContext = ` -## Current Project Context - -**Working directory**: ${cwd} -**Git**: Not a git repository -`; - } - } catch { - // execSync import failed, skip git context + if (settingsManager.isMemfsEnabled(agentId)) { + // MemFS path: background subagent + if (hasActiveInitSubagent()) { + cmd.fail( + "Memory initialization is already running in the background.", + ); + return { submitted: true }; } - // Mark command as finished before sending message - cmd.finish( - "Assimilating project context and defragmenting memories...", - true, - ); + try { + const initPrompt = buildMemoryInitRuntimePrompt({ + agentId, + workingDirectory: process.cwd(), + memoryDir: getMemoryFilesystemRoot(agentId), + gitContext, + }); - // Send trigger message instructing agent to load the initializing-memory skill - // Only include memfs path if memfs is enabled for this agent - const memfsSection = settingsManager.isMemfsEnabled(agentId) - ? ` -## Memory Filesystem Location + const { spawnBackgroundSubagentTask } = await import( + "../tools/impl/Task" + ); + spawnBackgroundSubagentTask({ + subagentType: "init", + prompt: initPrompt, + description: "Initializing memory", + silentCompletion: true, + onComplete: ({ success, error }) => { + const msg = success + ? "Built a memory palace of you. Visit it with /palace." + : `Memory initialization failed: ${error}`; + appendTaskNotificationEvents([msg]); + }, + }); -Your memory blocks are synchronized with the filesystem at: -\`${getMemoryFilesystemRoot(agentId)}\` + cmd.finish( + "Learning about you and your codebase in the background. You'll be notified when ready.", + true, + ); -Environment variables available in Letta Code: -- \`AGENT_ID=${agentId}\` -- \`MEMORY_DIR=${getMemoryFilesystemRoot(agentId)}\` + // TODO: Remove this hack once commandRunner supports a + // "silent" finish that skips the reminder callback. + // Currently cmd.finish() always enqueues a command-IO + // reminder, which leaks the /init context into the + // primary agent's next turn and causes it to invoke the + // initializing-memory skill itself. + const reminders = + sharedReminderStateRef.current.pendingCommandIoReminders; + const idx = reminders.findIndex((r) => r.input === "/init"); + if (idx !== -1) { + reminders.splice(idx, 1); + } + } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); + cmd.fail( + `Failed to start memory initialization: ${errorDetails}`, + ); + } + } else { + // Legacy path: primary agent processConversation + setCommandRunning(true); + try { + cmd.finish( + "Assimilating project context and defragmenting memories...", + true, + ); -Use \`$MEMORY_DIR\` when working with memory files during initialization. -` - : ""; + const initMessage = buildLegacyInitMessage({ + gitContext, + memfsSection: "", + }); - const initMessage = `${SYSTEM_REMINDER_OPEN} -The user has requested memory initialization via /init. -${memfsSection} -## 1. Invoke the initializing-memory skill - -Use the \`Skill\` tool with \`skill: "initializing-memory"\` to load the comprehensive instructions for memory initialization. - -If the skill fails to invoke, proceed with your best judgment based on these guidelines: -- Ask upfront questions (research depth, identity, related repos, workflow style) -- Research the project based on chosen depth -- Create/update memory blocks incrementally -- Reflect and verify completeness - -## 2. Follow the skill instructions - -Once invoked, follow the instructions from the \`initializing-memory\` skill to complete the initialization. -${gitContext} -${SYSTEM_REMINDER_CLOSE}`; - - // Process conversation with the init prompt - await processConversation([ - { - type: "message", - role: "user", - content: buildTextParts(initMessage), - }, - ]); - } catch (error) { - const errorDetails = formatErrorDetails(error, agentId); - cmd.fail(`Failed: ${errorDetails}`); - } finally { - setCommandRunning(false); + await processConversation([ + { + type: "message", + role: "user", + content: buildTextParts(initMessage), + }, + ]); + } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); + cmd.fail(`Failed: ${errorDetails}`); + } finally { + setCommandRunning(false); + } } return { submitted: true }; } diff --git a/src/cli/helpers/initCommand.ts b/src/cli/helpers/initCommand.ts new file mode 100644 index 0000000..22b4543 --- /dev/null +++ b/src/cli/helpers/initCommand.ts @@ -0,0 +1,133 @@ +/** + * Helpers for the /init slash command. + * + * Pure functions live here; App.tsx keeps the orchestration + * (commandRunner, processConversation, setCommandRunning, etc.) + */ + +import { execSync } from "node:child_process"; +import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; +import { getSnapshot as getSubagentSnapshot } from "./subagentState"; + +// ── Guard ────────────────────────────────────────────────── + +export function hasActiveInitSubagent(): boolean { + const snapshot = getSubagentSnapshot(); + return snapshot.agents.some( + (agent) => + agent.type.toLowerCase() === "init" && + (agent.status === "pending" || agent.status === "running"), + ); +} + +// ── Git context ──────────────────────────────────────────── + +export function gatherGitContext(): string { + try { + const cwd = process.cwd(); + + try { + execSync("git rev-parse --git-dir", { cwd, stdio: "pipe" }); + + const branch = execSync("git branch --show-current", { + cwd, + encoding: "utf-8", + }).trim(); + const mainBranch = execSync( + "git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo 'main'", + { cwd, encoding: "utf-8", shell: "/bin/bash" }, + ).trim(); + const status = execSync("git status --short", { + cwd, + encoding: "utf-8", + }).trim(); + const recentCommits = execSync( + "git log --oneline -10 2>/dev/null || echo 'No commits yet'", + { cwd, encoding: "utf-8" }, + ).trim(); + + return ` +## Current Project Context + +**Working directory**: ${cwd} + +### Git Status +- **Current branch**: ${branch} +- **Main branch**: ${mainBranch} +- **Status**: +${status || "(clean working tree)"} + +### Recent Commits +${recentCommits} +`; + } catch { + return ` +## Current Project Context + +**Working directory**: ${cwd} +**Git**: Not a git repository +`; + } + } catch { + // execSync import failed (shouldn't happen with static import, but be safe) + return ""; + } +} + +// ── Prompt builders ──────────────────────────────────────── + +/** Prompt for the background init subagent (MemFS path). */ +export function buildMemoryInitRuntimePrompt(args: { + agentId: string; + workingDirectory: string; + memoryDir: string; + gitContext: string; +}): string { + return ` +The user ran /init for the current project. + +Runtime context: +- parent_agent_id: ${args.agentId} +- working_directory: ${args.workingDirectory} +- memory_dir: ${args.memoryDir} + +Git/project context: +${args.gitContext} + +Task: +Initialize or reorganize the parent agent's filesystem-backed memory for this project. + +Instructions: +- Use the pre-loaded initializing-memory skill as your operating guide +- Inspect existing memory before editing +- Base your decisions on the current repository and current memory contents +- Do not ask follow-up questions +- Make reasonable assumptions and report them +- If the memory filesystem is unavailable or unsafe to modify, stop and explain why +`.trim(); +} + +/** Message for the primary agent via processConversation (legacy non-MemFS path). */ +export function buildLegacyInitMessage(args: { + gitContext: string; + memfsSection: string; +}): string { + return `${SYSTEM_REMINDER_OPEN} +The user has requested memory initialization via /init. +${args.memfsSection} +## 1. Invoke the initializing-memory skill + +Use the \`Skill\` tool with \`skill: "initializing-memory"\` to load the comprehensive instructions for memory initialization. + +If the skill fails to invoke, proceed with your best judgment based on these guidelines: +- Ask upfront questions (research depth, identity, related repos, workflow style) +- Research the project based on chosen depth +- Create/update memory blocks incrementally +- Reflect and verify completeness + +## 2. Follow the skill instructions + +Once invoked, follow the instructions from the \`initializing-memory\` skill to complete the initialization. +${args.gitContext} +${SYSTEM_REMINDER_CLOSE}`; +} diff --git a/src/skills/builtin/initializing-memory/SKILL.md b/src/skills/builtin/initializing-memory/SKILL.md index 432e77f..47ffd70 100644 --- a/src/skills/builtin/initializing-memory/SKILL.md +++ b/src/skills/builtin/initializing-memory/SKILL.md @@ -7,6 +7,15 @@ description: Comprehensive guide for initializing or reorganizing agent memory. The user has requested that you initialize or reorganize your memory. Your memory is a filesystem — files under `system/` are rendered in-context every turn, while all file metadata is always visible in the filesystem tree. Files outside `system/` (e.g. `reference/`, `history/`) are accessible via tools when needed. +## Autonomous Mode + +If you are running as a background subagent (you cannot use AskUserQuestion): +- Default to standard research depth (~5-20 tool calls) +- Detect user identity from git logs (`git shortlog -sn`, `git log --format="%an <%ae>"`) +- Skip historical session analysis +- Use reasonable defaults for all preferences +- Any specific overrides will be provided in your initial prompt + ## Your Goal: Explode Into 15-25 Hierarchical Files Your goal is to **explode** memory into a **deeply hierarchical structure of 15-25 small, focused files**. diff --git a/src/tests/cli/init-background-subagent.test.ts b/src/tests/cli/init-background-subagent.test.ts new file mode 100644 index 0000000..66a3df5 --- /dev/null +++ b/src/tests/cli/init-background-subagent.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +describe("init background subagent wiring", () => { + const readSource = (relativePath: string) => + readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + "utf-8", + ); + + test("App.tsx checks pending approvals before either branch", () => { + const appSource = readSource("../../cli/App.tsx"); + + // The approval check must appear before the MemFS branch + const approvalIdx = appSource.indexOf( + "checkPendingApprovalsForSlashCommand", + appSource.indexOf('trimmed === "/init"'), + ); + const memfsBranchIdx = appSource.indexOf( + "isMemfsEnabled", + appSource.indexOf('trimmed === "/init"'), + ); + expect(approvalIdx).toBeGreaterThan(-1); + expect(memfsBranchIdx).toBeGreaterThan(-1); + expect(approvalIdx).toBeLessThan(memfsBranchIdx); + }); + + test("App.tsx branches on MemFS: background subagent vs legacy processConversation", () => { + const appSource = readSource("../../cli/App.tsx"); + + // MemFS path — background subagent + expect(appSource).toContain("hasActiveInitSubagent()"); + expect(appSource).toContain("buildMemoryInitRuntimePrompt({"); + expect(appSource).toContain("spawnBackgroundSubagentTask({"); + expect(appSource).toContain('subagentType: "init"'); + expect(appSource).toContain("silentCompletion: true"); + expect(appSource).toContain("appendTaskNotificationEvents("); + expect(appSource).toContain("Learning about you and your codebase"); + + // Legacy non-MemFS path — primary agent + expect(appSource).toContain("buildLegacyInitMessage({"); + expect(appSource).toContain("processConversation("); + }); + + test("initCommand.ts exports all helpers", () => { + const helperSource = readSource("../../cli/helpers/initCommand.ts"); + + expect(helperSource).toContain("export function hasActiveInitSubagent("); + expect(helperSource).toContain("export function gatherGitContext()"); + expect(helperSource).toContain( + "export function buildMemoryInitRuntimePrompt(", + ); + expect(helperSource).toContain("export function buildLegacyInitMessage("); + }); + + test("init.md exists as a builtin subagent", () => { + const content = readSource("../../agent/subagents/builtin/init.md"); + + expect(content).toContain("name: init"); + expect(content).toContain("skills: initializing-memory"); + expect(content).toContain("permissionMode: bypassPermissions"); + }); + + test("init subagent is registered in BUILTIN_SOURCES", () => { + const indexSource = readSource("../../agent/subagents/index.ts"); + + expect(indexSource).toContain( + 'import initAgentMd from "./builtin/init.md"', + ); + expect(indexSource).toContain("initAgentMd"); + }); +});