feat: simplify startup flow with auto-select Memo for fresh users (#568)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-16 14:17:35 -08:00
committed by GitHub
parent 414affedd0
commit cbc9f8dce9
4 changed files with 652 additions and 88 deletions

View File

@@ -13,8 +13,8 @@ import { parseMdxFrontmatter } from "./memory";
import { MEMORY_PROMPTS } from "./promptAssets";
// Tags used to identify default agents
const MEMO_TAG = "default:memo";
const INCOGNITO_TAG = "default:incognito";
export const MEMO_TAG = "default:memo";
export const INCOGNITO_TAG = "default:incognito";
// Memo's persona - loaded from persona_memo.mdx
const MEMO_PERSONA = parseMdxFrontmatter(
@@ -125,8 +125,9 @@ export async function ensureDefaultAgents(
settingsManager.pinGlobal(agent.id);
}
} catch (err) {
console.warn(
`Warning: Failed to ensure default agents: ${err instanceof Error ? err.message : String(err)}`,
// Re-throw so caller can handle/exit appropriately
throw new Error(
`Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`,
);
}

View File

@@ -206,6 +206,26 @@ export async function handleHeadlessCommand(
const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined;
const fromAfFile = values["from-af"] as string | undefined;
// Validate --conversation flag (mutually exclusive with agent-selection flags)
if (specifiedConversationId) {
if (specifiedAgentId) {
console.error("Error: --conversation cannot be used with --agent");
process.exit(1);
}
if (forceNew) {
console.error("Error: --conversation cannot be used with --new-agent");
process.exit(1);
}
if (fromAfFile) {
console.error("Error: --conversation cannot be used with --from-af");
process.exit(1);
}
if (shouldContinue) {
console.error("Error: --conversation cannot be used with --continue");
process.exit(1);
}
}
// Validate --from-af flag
if (fromAfFile) {
if (specifiedAgentId) {
@@ -340,8 +360,21 @@ export async function handleHeadlessCommand(
}
}
// Priority 0: --conversation derives agent from conversation ID
if (specifiedConversationId) {
try {
const conversation = await client.conversations.retrieve(
specifiedConversationId,
);
agent = await client.agents.retrieve(conversation.agent_id);
} catch (_error) {
console.error(`Conversation ${specifiedConversationId} not found`);
process.exit(1);
}
}
// Priority 1: Import from AgentFile template
if (fromAfFile) {
if (!agent && fromAfFile) {
const { importAgentFromFile } = await import("./agent/import");
const result = await importAgentFromFile({
filePath: fromAfFile,
@@ -356,7 +389,8 @@ export async function handleHeadlessCommand(
try {
agent = await client.agents.retrieve(specifiedAgentId);
} catch (_error) {
console.error(`Agent ${specifiedAgentId} not found, creating new one...`);
console.error(`Agent ${specifiedAgentId} not found`);
process.exit(1);
}
}
@@ -389,38 +423,36 @@ export async function handleHeadlessCommand(
try {
agent = await client.agents.retrieve(localProjectSettings.lastAgent);
} catch (_error) {
// Local LRU agent doesn't exist - log and continue
console.error(
`Project agent ${localProjectSettings.lastAgent} not found, creating new one...`,
`Unable to locate agent ${localProjectSettings.lastAgent} in .letta/`,
);
}
}
}
// Priority 5: Try to reuse global lastAgent if --continue flag is passed
if (!agent && shouldContinue && settings.lastAgent) {
try {
agent = await client.agents.retrieve(settings.lastAgent);
} catch (_error) {
console.error(
`Previous agent ${settings.lastAgent} not found, creating new one...`,
);
if (!agent && shouldContinue) {
if (settings.lastAgent) {
try {
agent = await client.agents.retrieve(settings.lastAgent);
} catch (_error) {
// Global LRU agent doesn't exist
}
}
// --continue requires an LRU agent to exist
if (!agent) {
console.error("No recent session found in .letta/ or ~/.letta.");
console.error("Run 'letta' to get started.");
process.exit(1);
}
}
// Priority 6: Create a new agent
// All paths should have resolved to an agent by now
// If not, it's an unexpected state - error out instead of auto-creating
if (!agent) {
const updateArgs = getModelUpdateArgs(model);
const createOptions = {
model,
updateArgs,
skillsDirectory,
parallelToolCalls: true,
enableSleeptime: sleeptimeFlag ?? settings.enableSleeptime,
systemPromptPreset,
// Note: systemCustom, systemAppend, and memoryBlocks only apply with --new flag
};
const result = await createAgent(createOptions);
agent = result.agent;
console.error("No agent found. Use --new-agent to create a new agent.");
process.exit(1);
}
// Check if we're resuming an existing agent (not creating a new one)

View File

@@ -7,6 +7,7 @@ import { getResumeData, type ResumeData } from "./agent/check-approval";
import { getClient } from "./agent/client";
import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context";
import type { AgentProvenance } from "./agent/create";
import { INCOGNITO_TAG, MEMO_TAG } from "./agent/defaults";
import { ensureSkillsBlocks, ISOLATED_BLOCK_LABELS } from "./agent/memory";
import { LETTA_CLOUD_API_URL } from "./auth/oauth";
import { ConversationSelector } from "./cli/components/ConversationSelector";
@@ -23,6 +24,37 @@ import { loadTools } from "./tools/manager";
const EMPTY_APPROVAL_ARRAY: ApprovalRequest[] = [];
const EMPTY_MESSAGE_ARRAY: Message[] = [];
/**
* Check if pinned agents consist only of default agents (Memo + Incognito).
* Used to auto-select Memo for fresh users without showing a selector.
*/
async function hasOnlyDefaultAgents(
pinnedIds: string[],
): Promise<{ onlyDefaults: boolean; memoId: string | null }> {
if (pinnedIds.length === 0) return { onlyDefaults: true, memoId: null };
if (pinnedIds.length > 2) return { onlyDefaults: false, memoId: null };
const client = await getClient();
let memoId: string | null = null;
for (const id of pinnedIds) {
try {
const agent = await client.agents.retrieve(id);
const tags = agent.tags || [];
if (tags.includes(MEMO_TAG)) {
memoId = agent.id;
} else if (!tags.includes(INCOGNITO_TAG)) {
// Found a non-default agent
return { onlyDefaults: false, memoId: null };
}
} catch {
// Agent doesn't exist, skip it
}
}
return { onlyDefaults: true, memoId };
}
function printHelp() {
// Keep this plaintext (no colors) so output pipes cleanly
const usage = `
@@ -576,6 +608,34 @@ async function main(): Promise<void> {
}
}
// Validate --conversation flag (mutually exclusive with agent-selection flags)
if (specifiedConversationId) {
if (specifiedAgentId) {
console.error("Error: --conversation cannot be used with --agent");
process.exit(1);
}
if (specifiedAgentName) {
console.error("Error: --conversation cannot be used with --name");
process.exit(1);
}
if (forceNew) {
console.error("Error: --conversation cannot be used with --new-agent");
process.exit(1);
}
if (fromAfFile) {
console.error("Error: --conversation cannot be used with --from-af");
process.exit(1);
}
if (shouldResume) {
console.error("Error: --conversation cannot be used with --resume");
process.exit(1);
}
if (shouldContinue) {
console.error("Error: --conversation cannot be used with --continue");
process.exit(1);
}
}
// Validate --from-af flag
if (fromAfFile) {
if (specifiedAgentId) {
@@ -936,70 +996,206 @@ async function main(): Promise<void> {
await settingsManager.loadLocalProjectSettings();
const localSettings = settingsManager.getLocalProjectSettings();
let globalPinned = settingsManager.getGlobalPinnedAgents();
// Check if user would see selector (fresh dir, no bypass flags)
const wouldShowSelector =
!localSettings.lastAgent &&
!forceNew &&
!agentIdArg &&
!fromAfFile &&
!continueSession;
// Ensure default agents (Memo/Incognito) exist for all users
const client = await getClient();
const { ensureDefaultAgents } = await import("./agent/defaults");
if (wouldShowSelector && globalPinned.length === 0) {
// New user with no agents - block and show loading while creating defaults
setLoadingState("assembling");
// =====================================================================
// TOP-LEVEL PATH: --conversation <id>
// Conversation ID is unique, so we can derive the agent from it
// =====================================================================
if (specifiedConversationId) {
try {
await ensureDefaultAgents(client);
// Refresh pinned list after defaults created
globalPinned = settingsManager.getGlobalPinnedAgents();
} catch (err) {
console.warn(
`Warning: Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`,
const conversation = await client.conversations.retrieve(
specifiedConversationId,
);
// Use the agent that owns this conversation
setSelectedGlobalAgentId(conversation.agent_id);
setSelectedConversationId(specifiedConversationId);
setLoadingState("assembling");
return;
} catch (error) {
if (
error instanceof APIError &&
(error.status === 404 || error.status === 422)
) {
console.error(
`Conversation ${specifiedConversationId} not found`,
);
process.exit(1);
}
throw error;
}
} else {
// Existing user - fire and forget, don't block startup
ensureDefaultAgents(client).catch((err) =>
console.warn(
`Warning: Failed to ensure default agents: ${err instanceof Error ? err.message : String(err)}`,
),
);
}
// Handle --resume flag: show conversation selector directly
// =====================================================================
// TOP-LEVEL PATH: --resume
// Show conversation selector for last-used agent (local → global fallback)
// =====================================================================
if (shouldResume) {
// Find the last-used agent for this project
const lastSession =
settingsManager.getLocalLastSession(process.cwd()) ??
settingsManager.getGlobalLastSession();
const lastAgentId = lastSession?.agentId ?? localSettings.lastAgent;
const localSession = settingsManager.getLocalLastSession(
process.cwd(),
);
const localAgentId = localSession?.agentId ?? localSettings.lastAgent;
if (lastAgentId) {
// Verify agent exists
// Try local LRU first
if (localAgentId) {
try {
const agent = await client.agents.retrieve(lastAgentId);
setResumeAgentId(lastAgentId);
const agent = await client.agents.retrieve(localAgentId);
setResumeAgentId(localAgentId);
setResumeAgentName(agent.name ?? null);
setLoadingState("selecting_conversation");
return;
} catch {
// Agent doesn't exist, fall through to normal flow
// Local agent doesn't exist, try global
console.log(
`Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`,
);
}
} else {
console.log("No recent agent in .letta/, using global (~/.letta)");
}
// Try global LRU
const globalSession = settingsManager.getGlobalLastSession();
const globalAgentId = globalSession?.agentId;
if (globalAgentId) {
try {
const agent = await client.agents.retrieve(globalAgentId);
setResumeAgentId(globalAgentId);
setResumeAgentName(agent.name ?? null);
setLoadingState("selecting_conversation");
return;
} catch {
// Global agent also doesn't exist
}
}
// No valid agent found, fall through to normal startup
// No valid agent found anywhere
console.error("No recent session found in .letta/ or ~/.letta.");
console.error("Run 'letta' to get started.");
process.exit(1);
}
// Show selector if:
// 1. No lastAgent in this project (fresh directory)
// 2. No explicit flags that bypass selection (--new, --agent, --from-af, --continue)
// 3. Has global pinned agents available
const shouldShowSelector = wouldShowSelector && globalPinned.length > 0;
// =====================================================================
// TOP-LEVEL PATH: --continue
// Resume last session directly (local → global fallback)
// =====================================================================
if (continueSession) {
const localSession = settingsManager.getLocalLastSession(
process.cwd(),
);
const localAgentId = localSession?.agentId ?? localSettings.lastAgent;
if (shouldShowSelector) {
// Try local LRU first
if (localAgentId) {
try {
await client.agents.retrieve(localAgentId);
setSelectedGlobalAgentId(localAgentId);
if (localSession?.conversationId) {
setSelectedConversationId(localSession.conversationId);
}
setLoadingState("assembling");
return;
} catch {
// Local agent doesn't exist, try global
console.log(
`Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`,
);
}
} else {
console.log("No recent agent in .letta/, using global (~/.letta)");
}
// Try global LRU
const globalSession = settingsManager.getGlobalLastSession();
const globalAgentId = globalSession?.agentId;
if (globalAgentId) {
try {
await client.agents.retrieve(globalAgentId);
setSelectedGlobalAgentId(globalAgentId);
if (globalSession?.conversationId) {
setSelectedConversationId(globalSession.conversationId);
}
setLoadingState("assembling");
return;
} catch {
// Global agent also doesn't exist
}
}
// No valid agent found anywhere
console.error("No recent session found in .letta/ or ~/.letta.");
console.error("Run 'letta' to get started.");
process.exit(1);
}
// =====================================================================
// DEFAULT PATH: No special flags
// Check local LRU, then selector, then defaults
// =====================================================================
// Check if user would see selector (fresh dir, no bypass flags)
const wouldShowSelector =
!localSettings.lastAgent && !forceNew && !agentIdArg && !fromAfFile;
// Ensure default agents (Memo/Incognito) exist for all users
const { ensureDefaultAgents } = await import("./agent/defaults");
if (wouldShowSelector && globalPinned.length === 0) {
// New user with no agents - create defaults first, then trigger init
// NOTE: Don't set loadingState to "assembling" until we have the agent ID,
// otherwise init will run before we've set selectedGlobalAgentId
try {
const memoAgent = await ensureDefaultAgents(client);
// Refresh pinned list after defaults created
globalPinned = settingsManager.getGlobalPinnedAgents();
// Auto-select Memo for fresh users
if (memoAgent) {
setSelectedGlobalAgentId(memoAgent.id);
setLoadingState("assembling");
return;
}
// If memoAgent is null (createDefaultAgents disabled), fall through
} catch (err) {
console.error(
`Failed to create default agents: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
} else {
// Existing user - fire and forget, don't block startup
ensureDefaultAgents(client).catch(() => {
// Silently ignore - defaults may already exist
});
}
// If there's a local LRU, use it directly
if (localSettings.lastAgent) {
try {
await client.agents.retrieve(localSettings.lastAgent);
setLoadingState("assembling");
return;
} catch {
// LRU agent doesn't exist, show message and fall through to selector
console.log(
`Unable to locate recently used agent ${localSettings.lastAgent}`,
);
}
}
// Check if we should show selector or auto-select Memo
if (wouldShowSelector && globalPinned.length > 0) {
// Check if only default agents are pinned
const { onlyDefaults, memoId } =
await hasOnlyDefaultAgents(globalPinned);
if (onlyDefaults && memoId) {
// Only defaults pinned - auto-select Memo
setSelectedGlobalAgentId(memoId);
setLoadingState("assembling");
return;
}
// Has custom agents - show selector
setLoadingState("selecting_global");
return;
}
@@ -1007,7 +1203,14 @@ async function main(): Promise<void> {
setLoadingState("assembling");
}
checkAndStart();
}, [forceNew, agentIdArg, fromAfFile, continueSession, shouldResume]);
}, [
forceNew,
agentIdArg,
fromAfFile,
continueSession,
shouldResume,
specifiedConversationId,
]);
// Main initialization effect - runs after profile selection
useEffect(() => {
@@ -1196,23 +1399,13 @@ async function main(): Promise<void> {
}
}
// Priority 7: Create a new agent
// All paths should have resolved to an agent by now
// If not, it's an unexpected state - error out instead of auto-creating
if (!agent) {
const updateArgs = getModelUpdateArgs(model);
const result = await createAgent(
undefined,
model,
undefined,
updateArgs,
skillsDirectory,
true, // parallelToolCalls always enabled
sleeptimeFlag ?? settings.enableSleeptime,
systemPromptPreset,
undefined,
undefined,
console.error(
"No agent found. Use --new-agent to create a new agent.",
);
agent = result.agent;
setAgentProvenance(result.provenance);
process.exit(1);
}
// Ensure local project settings are loaded before updating

View File

@@ -0,0 +1,338 @@
import { describe, expect, test } from "bun:test";
import { spawn } from "node:child_process";
/**
* Integration tests for CLI startup flows.
*
* These tests verify the boot flow decision tree:
* - Flag conflict detection
* - --conversation: derives agent from conversation
* - --agent: uses specified agent
* - --new-agent: creates new agent
* - Error messages for invalid inputs
*
* Note: Tests that depend on settings files (.letta/) are harder to isolate
* because the CLI uses process.cwd(). For now, we focus on flag-based tests.
*/
const projectRoot = process.cwd();
// Helper to run CLI and capture output
async function runCli(
args: string[],
options: {
timeoutMs?: number;
expectExit?: number;
} = {},
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
const { timeoutMs = 30000, expectExit } = options;
return new Promise((resolve, reject) => {
const proc = spawn("bun", ["run", "dev", ...args], {
cwd: projectRoot,
env: { ...process.env },
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
const timeout = setTimeout(() => {
proc.kill();
reject(
new Error(
`Timeout after ${timeoutMs}ms. stdout: ${stdout}, stderr: ${stderr}`,
),
);
}, timeoutMs);
proc.on("close", (code) => {
clearTimeout(timeout);
if (expectExit !== undefined && code !== expectExit) {
reject(
new Error(
`Expected exit code ${expectExit}, got ${code}. stdout: ${stdout}, stderr: ${stderr}`,
),
);
} else {
resolve({ stdout, stderr, exitCode: code });
}
});
proc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
// ============================================================================
// Flag Conflict Tests (fast, no API calls needed)
// ============================================================================
describe("Startup Flow - Flag Conflicts", () => {
test("--conversation conflicts with --agent", async () => {
const result = await runCli(
["--conversation", "conv-123", "--agent", "agent-123"],
{ expectExit: 1 },
);
expect(result.stderr).toContain(
"--conversation cannot be used with --agent",
);
});
test("--conversation conflicts with --new-agent", async () => {
const result = await runCli(["--conversation", "conv-123", "--new-agent"], {
expectExit: 1,
});
expect(result.stderr).toContain(
"--conversation cannot be used with --new-agent",
);
});
test("--conversation conflicts with --resume", async () => {
const result = await runCli(["--conversation", "conv-123", "--resume"], {
expectExit: 1,
});
expect(result.stderr).toContain(
"--conversation cannot be used with --resume",
);
});
test("--conversation conflicts with --continue", async () => {
const result = await runCli(["--conversation", "conv-123", "--continue"], {
expectExit: 1,
});
expect(result.stderr).toContain(
"--conversation cannot be used with --continue",
);
});
test("--conversation conflicts with --from-af", async () => {
const result = await runCli(
["--conversation", "conv-123", "--from-af", "test.af"],
{ expectExit: 1 },
);
expect(result.stderr).toContain(
"--conversation cannot be used with --from-af",
);
});
test("--conversation conflicts with --name", async () => {
const result = await runCli(
["--conversation", "conv-123", "--name", "MyAgent"],
{ expectExit: 1 },
);
expect(result.stderr).toContain(
"--conversation cannot be used with --name",
);
});
});
// ============================================================================
// Invalid Input Tests (require API calls but fail fast)
// ============================================================================
describe("Startup Flow - Invalid Inputs", () => {
test(
"--agent with nonexistent ID shows error",
async () => {
const result = await runCli(
["--agent", "agent-definitely-does-not-exist-12345", "-p", "test"],
{ expectExit: 1, timeoutMs: 60000 },
);
expect(result.stderr).toContain("not found");
},
{ timeout: 70000 },
);
test(
"--conversation with nonexistent ID shows error",
async () => {
const result = await runCli(
[
"--conversation",
"conversation-definitely-does-not-exist-12345",
"-p",
"test",
],
{ expectExit: 1, timeoutMs: 60000 },
);
expect(result.stderr).toContain("not found");
},
{ timeout: 70000 },
);
test("--from-af with nonexistent file shows error", async () => {
const result = await runCli(
["--from-af", "/nonexistent/path/agent.af", "-p", "test"],
{ expectExit: 1 },
);
expect(result.stderr).toContain("not found");
});
});
// ============================================================================
// Integration Tests (require API access, create real agents)
// ============================================================================
describe("Startup Flow - Integration", () => {
// Store created agent/conversation IDs for cleanup and reuse
let testAgentId: string | null = null;
let testConversationId: string | null = null;
test(
"--new-agent creates agent and responds",
async () => {
const result = await runCli(
[
"--new-agent",
"-m",
"haiku",
"-p",
"Say OK and nothing else",
"--output-format",
"json",
],
{ timeoutMs: 120000 },
);
expect(result.exitCode).toBe(0);
// stdout includes the bun invocation line, extract just the JSON
const jsonStart = result.stdout.indexOf("{");
const output = JSON.parse(result.stdout.slice(jsonStart));
expect(output.agent_id).toBeDefined();
expect(output.result).toBeDefined();
// Save for later tests
testAgentId = output.agent_id;
testConversationId = output.conversation_id;
},
{ timeout: 130000 },
);
test(
"--agent with valid ID uses that agent",
async () => {
// Skip if previous test didn't create an agent
if (!testAgentId) {
console.log("Skipping: no test agent available");
return;
}
const result = await runCli(
[
"--agent",
testAgentId,
"-m",
"haiku",
"-p",
"Say OK",
"--output-format",
"json",
],
{ timeoutMs: 120000 },
);
expect(result.exitCode).toBe(0);
const jsonStart = result.stdout.indexOf("{");
const output = JSON.parse(result.stdout.slice(jsonStart));
expect(output.agent_id).toBe(testAgentId);
},
{ timeout: 130000 },
);
test(
"--conversation with valid ID derives agent and uses conversation",
async () => {
// Skip if previous test didn't create an agent/conversation
if (!testAgentId || !testConversationId) {
console.log("Skipping: no test conversation available");
return;
}
const result = await runCli(
[
"--conversation",
testConversationId,
"-m",
"haiku",
"-p",
"Say OK",
"--output-format",
"json",
],
{ timeoutMs: 120000 },
);
expect(result.exitCode).toBe(0);
const jsonStart = result.stdout.indexOf("{");
const output = JSON.parse(result.stdout.slice(jsonStart));
// Should use the same agent that owns the conversation
expect(output.agent_id).toBe(testAgentId);
// Should use the specified conversation
expect(output.conversation_id).toBe(testConversationId);
},
{ timeout: 130000 },
);
test(
"--new-agent with --init-blocks none creates minimal agent",
async () => {
const result = await runCli(
[
"--new-agent",
"--init-blocks",
"none",
"-m",
"haiku",
"-p",
"Say OK",
"--output-format",
"json",
],
{ timeoutMs: 120000 },
);
expect(result.exitCode).toBe(0);
// stdout includes the bun invocation line, extract just the JSON
const jsonStart = result.stdout.indexOf("{");
const output = JSON.parse(result.stdout.slice(jsonStart));
expect(output.agent_id).toBeDefined();
},
{ timeout: 130000 },
);
});
// ============================================================================
// --continue Tests (depend on LRU state, harder to isolate)
// ============================================================================
describe("Startup Flow - Continue Flag", () => {
test(
"--continue with no LRU shows error",
async () => {
// This test relies on running in a directory with no .letta/ settings
// In practice, this might use the project's .letta/ which has an LRU
// So we check for either success (if LRU exists) or error (if not)
const result = await runCli(
["--continue", "-p", "Say OK", "--output-format", "json"],
{ timeoutMs: 60000 },
);
// Either succeeds (LRU exists) or fails with specific error
if (result.exitCode !== 0) {
expect(result.stderr).toContain("No recent session found");
}
// If it succeeds, that's also valid (test env has LRU)
},
{ timeout: 70000 },
);
});