diff --git a/src/agent/create.ts b/src/agent/create.ts index e8241c1..0c2ee9e 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -12,7 +12,6 @@ import { getModelContextWindow } from "./available-models"; import { getClient, getServerUrl } from "./client"; import { getLettaCodeHeaders } from "./http-headers"; import { getDefaultMemoryBlocks } from "./memory"; -import { type MemoryPromptMode, reconcileMemoryPrompt } from "./memoryPrompt"; import { formatAvailableModels, getDefaultModel, @@ -20,7 +19,12 @@ import { resolveModel, } from "./model"; import { updateAgentLLMConfig } from "./modify"; -import { resolveSystemPrompt } from "./promptAssets"; +import { + isKnownPreset, + type MemoryPromptMode, + resolveAndBuildSystemPrompt, + swapMemoryAddon, +} from "./promptAssets"; import { SLEEPTIME_MEMORY_PERSONA } from "./prompts/sleeptime"; /** @@ -351,21 +355,11 @@ export async function createAgent( (modelUpdateArgs?.context_window as number | undefined) ?? (await getModelContextWindow(modelHandle)); - // Resolve system prompt content: - // 1. If systemPromptCustom is provided, use it as-is - // 2. Otherwise, resolve systemPromptPreset to content - // 3. Reconcile to the selected managed memory mode - let systemPromptContent: string; - if (options.systemPromptCustom) { - systemPromptContent = options.systemPromptCustom; - } else { - systemPromptContent = await resolveSystemPrompt(options.systemPromptPreset); - } - - systemPromptContent = reconcileMemoryPrompt( - systemPromptContent, - options.memoryPromptMode ?? "standard", - ); + // Resolve system prompt content + const memMode: MemoryPromptMode = options.memoryPromptMode ?? "standard"; + const systemPromptContent = options.systemPromptCustom + ? swapMemoryAddon(options.systemPromptCustom, memMode) + : await resolveAndBuildSystemPrompt(options.systemPromptPreset, memMode); // Create agent with inline memory blocks (LET-7101: single API call instead of N+1) // - memory_blocks: new blocks to create inline @@ -462,6 +456,20 @@ export async function createAgent( } } + // Persist system prompt preset — only for non-subagents and known presets or custom. + // Guarded by isReady since settings may not be initialized in direct/test callers. + if (!isSubagent && settingsManager.isReady) { + if (options.systemPromptCustom) { + settingsManager.setSystemPromptPreset(fullAgent.id, "custom"); + } else if (isKnownPreset(options.systemPromptPreset ?? "default")) { + settingsManager.setSystemPromptPreset( + fullAgent.id, + options.systemPromptPreset ?? "default", + ); + } + // Subagent names: don't persist (no reproducible recipe) + } + // Build provenance info const provenance: AgentProvenance = { isNew: true, diff --git a/src/agent/memoryPrompt.ts b/src/agent/memoryPrompt.ts deleted file mode 100644 index 566a55a..0000000 --- a/src/agent/memoryPrompt.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - SYSTEM_PROMPT_MEMFS_ADDON, - SYSTEM_PROMPT_MEMORY_ADDON, -} from "./promptAssets"; - -export type MemoryPromptMode = "standard" | "memfs"; - -export interface MemoryPromptDrift { - code: - | "legacy_memory_language_with_memfs" - | "memfs_language_with_standard_mode" - | "orphan_memfs_fragment"; - message: string; -} - -interface Heading { - level: number; - title: string; - startOffset: number; -} - -function normalizeNewlines(text: string): string { - return text.replace(/\r\n/g, "\n"); -} - -function scanHeadingsOutsideFences(text: string): Heading[] { - const lines = text.split("\n"); - const headings: Heading[] = []; - let inFence = false; - let fenceToken = ""; - let offset = 0; - - for (const line of lines) { - const trimmed = line.trimStart(); - const fenceMatch = trimmed.match(/^(```+|~~~+)/); - if (fenceMatch) { - const token = fenceMatch[1] ?? fenceMatch[0] ?? ""; - const tokenChar = token.startsWith("`") ? "`" : "~"; - if (!inFence) { - inFence = true; - fenceToken = tokenChar; - } else if (fenceToken === tokenChar) { - inFence = false; - fenceToken = ""; - } - } - - if (!inFence) { - const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/); - if (headingMatch) { - const hashes = headingMatch[1] ?? ""; - const rawTitle = headingMatch[2] ?? ""; - if (hashes && rawTitle) { - const level = hashes.length; - const title = rawTitle.replace(/\s+#*$/, "").trim(); - headings.push({ - level, - title, - startOffset: offset, - }); - } - } - } - - offset += line.length + 1; - } - - return headings; -} - -function stripHeadingSections( - text: string, - shouldStrip: (heading: Heading) => boolean, -): string { - let current = text; - while (true) { - const headings = scanHeadingsOutsideFences(current); - const target = headings.find(shouldStrip); - if (!target) { - return current; - } - - const nextHeading = headings.find( - (heading) => - heading.startOffset > target.startOffset && - heading.level <= target.level, - ); - const end = nextHeading ? nextHeading.startOffset : current.length; - current = `${current.slice(0, target.startOffset)}${current.slice(end)}`; - } -} - -function getMemfsTailFragment(): string { - const tailAnchor = "# See what changed"; - const start = SYSTEM_PROMPT_MEMFS_ADDON.indexOf(tailAnchor); - if (start === -1) return ""; - return SYSTEM_PROMPT_MEMFS_ADDON.slice(start).trim(); -} - -function stripExactAddon(text: string, addon: string): string { - const trimmedAddon = addon.trim(); - if (!trimmedAddon) return text; - let current = text; - while (current.includes(trimmedAddon)) { - current = current.replace(trimmedAddon, ""); - } - return current; -} - -function stripOrphanMemfsTail(text: string): string { - const tail = getMemfsTailFragment(); - if (!tail) return text; - let current = text; - while (current.includes(tail)) { - current = current.replace(tail, ""); - } - return current; -} - -function compactBlankLines(text: string): string { - return text.replace(/\n{3,}/g, "\n\n").trimEnd(); -} - -export function stripManagedMemorySections(systemPrompt: string): string { - let current = normalizeNewlines(systemPrompt); - - // Strip exact current addons first (fast path). - current = stripExactAddon(current, SYSTEM_PROMPT_MEMORY_ADDON); - current = stripExactAddon(current, SYSTEM_PROMPT_MEMFS_ADDON); - - // Strip known orphan fragment produced by the old regex bug. - current = stripOrphanMemfsTail(current); - - // Strip legacy/variant memory sections by markdown heading parsing. - current = stripHeadingSections( - current, - (heading) => heading.title === "Memory", - ); - current = stripHeadingSections(current, (heading) => - heading.title.startsWith("Memory Filesystem"), - ); - - return compactBlankLines(current); -} - -export function reconcileMemoryPrompt( - systemPrompt: string, - mode: MemoryPromptMode, -): string { - const base = stripManagedMemorySections(systemPrompt).trimEnd(); - const addon = - mode === "memfs" - ? SYSTEM_PROMPT_MEMFS_ADDON.trimStart() - : SYSTEM_PROMPT_MEMORY_ADDON.trimStart(); - return `${base}\n\n${addon}`.trim(); -} - -export function detectMemoryPromptDrift( - systemPrompt: string, - expectedMode: MemoryPromptMode, -): MemoryPromptDrift[] { - const prompt = normalizeNewlines(systemPrompt); - const drifts: MemoryPromptDrift[] = []; - - const hasLegacyMemoryLanguage = prompt.includes( - "Your memory consists of core memory (composed of memory blocks)", - ); - const hasMemfsLanguage = - prompt.includes("## Memory Filesystem") || - prompt.includes("Your memory is stored in a git repository at"); - const hasOrphanFragment = - prompt.includes("# See what changed") && - prompt.includes("git add system/") && - prompt.includes('git commit -m ": "'); - - if (expectedMode === "memfs" && hasLegacyMemoryLanguage) { - drifts.push({ - code: "legacy_memory_language_with_memfs", - message: - "System prompt contains legacy memory-block language while memfs is enabled.", - }); - } - - if (expectedMode === "standard" && hasMemfsLanguage) { - drifts.push({ - code: "memfs_language_with_standard_mode", - message: - "System prompt contains Memory Filesystem language while memfs is disabled.", - }); - } - - if (hasOrphanFragment && !hasMemfsLanguage) { - drifts.push({ - code: "orphan_memfs_fragment", - message: - "System prompt contains orphaned memfs sync fragment without a full memfs section.", - }); - } - - return drifts; -} diff --git a/src/agent/modify.ts b/src/agent/modify.ts index f6b302b..987e885 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -343,25 +343,21 @@ export async function updateAgentSystemPrompt( systemPromptId: string, ): Promise { try { - const { resolveSystemPrompt } = await import("./promptAssets"); - const { detectMemoryPromptDrift, reconcileMemoryPrompt } = await import( - "./memoryPrompt" + const { isKnownPreset, resolveAndBuildSystemPrompt } = await import( + "./promptAssets" ); const { settingsManager } = await import("../settings-manager"); const client = await getClient(); - const currentAgent = await client.agents.retrieve(agentId); - const baseContent = await resolveSystemPrompt(systemPromptId); - - const settingIndicatesMemfs = settingsManager.isMemfsEnabled(agentId); - const promptIndicatesMemfs = detectMemoryPromptDrift( - currentAgent.system || "", - "standard", - ).some((drift) => drift.code === "memfs_language_with_standard_mode"); - const memoryMode = - settingIndicatesMemfs || promptIndicatesMemfs ? "memfs" : "standard"; - const systemPromptContent = reconcileMemoryPrompt(baseContent, memoryMode); + settingsManager.isReady && settingsManager.isMemfsEnabled(agentId) + ? "memfs" + : "standard"; + + const systemPromptContent = await resolveAndBuildSystemPrompt( + systemPromptId, + memoryMode, + ); const updateResult = await updateAgentSystemPromptRaw( agentId, @@ -375,6 +371,15 @@ export async function updateAgentSystemPrompt( }; } + // Persist preset for known presets; clear stale preset for subagent/unknown + if (settingsManager.isReady) { + if (isKnownPreset(systemPromptId)) { + settingsManager.setSystemPromptPreset(agentId, systemPromptId); + } else { + settingsManager.clearSystemPromptPreset(agentId); + } + } + // Re-fetch agent to get updated state const agent = await client.agents.retrieve(agentId); @@ -407,15 +412,26 @@ export async function updateAgentSystemPromptMemfs( enableMemfs: boolean, ): Promise { try { - const client = await getClient(); - const agent = await client.agents.retrieve(agentId); - const { reconcileMemoryPrompt } = await import("./memoryPrompt"); - - const nextSystemPrompt = reconcileMemoryPrompt( - agent.system || "", - enableMemfs ? "memfs" : "standard", + const { settingsManager } = await import("../settings-manager"); + const { isKnownPreset, buildSystemPrompt, swapMemoryAddon } = await import( + "./promptAssets" ); + const newMode = enableMemfs ? "memfs" : "standard"; + const storedPreset = settingsManager.isReady + ? settingsManager.getSystemPromptPreset(agentId) + : undefined; + + let nextSystemPrompt: string; + if (storedPreset && isKnownPreset(storedPreset)) { + nextSystemPrompt = buildSystemPrompt(storedPreset, newMode); + } else { + const client = await getClient(); + const agent = await client.agents.retrieve(agentId); + nextSystemPrompt = swapMemoryAddon(agent.system || "", newMode); + } + + const client = await getClient(); await client.agents.update(agentId, { system: nextSystemPrompt, }); diff --git a/src/agent/promptAssets.ts b/src/agent/promptAssets.ts index aa9d9d0..a531653 100644 --- a/src/agent/promptAssets.ts +++ b/src/agent/promptAssets.ts @@ -114,6 +114,140 @@ export const SYSTEM_PROMPTS: SystemPromptOption[] = [ }, ]; +export type MemoryPromptMode = "standard" | "memfs"; + +// --- Heading-aware section stripping (for legacy/custom prompts) --- + +interface Heading { + level: number; + title: string; + startOffset: number; +} + +function scanHeadingsOutsideFences(text: string): Heading[] { + const lines = text.split("\n"); + const headings: Heading[] = []; + let inFence = false; + let fenceToken = ""; + let offset = 0; + + for (const line of lines) { + const trimmed = line.trimStart(); + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch) { + const token = fenceMatch[1] ?? fenceMatch[0] ?? ""; + const tokenChar = token.startsWith("`") ? "`" : "~"; + if (!inFence) { + inFence = true; + fenceToken = tokenChar; + } else if (fenceToken === tokenChar) { + inFence = false; + fenceToken = ""; + } + } + + if (!inFence) { + const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/); + if (headingMatch) { + const hashes = headingMatch[1] ?? ""; + const rawTitle = headingMatch[2] ?? ""; + if (hashes && rawTitle) { + const level = hashes.length; + const title = rawTitle.replace(/\s+#*$/, "").trim(); + headings.push({ level, title, startOffset: offset }); + } + } + } + + offset += line.length + 1; + } + + return headings; +} + +function stripHeadingSections( + text: string, + shouldStrip: (heading: Heading) => boolean, +): string { + let current = text; + while (true) { + const headings = scanHeadingsOutsideFences(current); + const target = headings.find(shouldStrip); + if (!target) return current; + + const nextHeading = headings.find( + (h) => h.startOffset > target.startOffset && h.level <= target.level, + ); + const end = nextHeading ? nextHeading.startOffset : current.length; + current = `${current.slice(0, target.startOffset)}${current.slice(end)}`; + } +} + +/** + * Check if a preset ID exists in SYSTEM_PROMPTS. + */ +export function isKnownPreset(id: string): boolean { + return SYSTEM_PROMPTS.some((p) => p.id === id); +} + +/** + * Deterministic rebuild of a system prompt from a known preset + memory mode. + * Throws on unknown preset (prevents stale/renamed presets from silently rewriting prompts). + */ +export function buildSystemPrompt( + presetId: string, + memoryMode: MemoryPromptMode, +): string { + const preset = SYSTEM_PROMPTS.find((p) => p.id === presetId); + if (!preset) { + throw new Error( + `Unknown preset "${presetId}" — cannot rebuild system prompt`, + ); + } + const addon = + memoryMode === "memfs" + ? SYSTEM_PROMPT_MEMFS_ADDON + : SYSTEM_PROMPT_MEMORY_ADDON; + return `${preset.content.trimEnd()}\n\n${addon.trimStart()}`.trim(); +} + +/** + * Swap the memory addon on a custom/subagent/legacy prompt. + * Strips all existing addons (handles duplicates) and orphan memfs tail fragments, + * then appends the target addon. + */ +export function swapMemoryAddon( + systemPrompt: string, + mode: MemoryPromptMode, +): string { + let result = systemPrompt; + // Strip all existing addons (replaceAll handles duplicates) + for (const addon of [ + SYSTEM_PROMPT_MEMORY_ADDON.trim(), + SYSTEM_PROMPT_MEMFS_ADDON.trim(), + ]) { + result = result.replaceAll(addon, ""); + } + // Strip orphan memfs tail fragment (from old drift bugs) + const tailAnchor = "# See what changed"; + const tailStart = SYSTEM_PROMPT_MEMFS_ADDON.indexOf(tailAnchor); + if (tailStart !== -1) { + const orphanTail = SYSTEM_PROMPT_MEMFS_ADDON.slice(tailStart).trim(); + result = result.replaceAll(orphanTail, ""); + } + // Strip legacy/variant memory sections by markdown heading parsing + // (handles edited or older ## Memory / ## Memory Filesystem sections) + result = stripHeadingSections(result, (h) => h.title === "Memory"); + result = stripHeadingSections(result, (h) => + h.title.startsWith("Memory Filesystem"), + ); + // Compact blank lines and append target addon + result = result.replace(/\n{3,}/g, "\n\n").trimEnd(); + const target = + mode === "memfs" ? SYSTEM_PROMPT_MEMFS_ADDON : SYSTEM_PROMPT_MEMORY_ADDON; + return `${result}\n\n${target.trimStart()}`.trim(); +} + /** * Validate a system prompt preset ID. * @@ -145,6 +279,23 @@ export async function validateSystemPromptPreset( ); } +/** + * Resolve a prompt ID and build the full system prompt with memory addon. + * Known presets are rebuilt deterministically; unknown IDs (subagent names) + * are resolved async and have the addon swapped in. + */ +export async function resolveAndBuildSystemPrompt( + promptId: string | undefined, + memoryMode: MemoryPromptMode, +): Promise { + const id = promptId ?? "default"; + if (isKnownPreset(id)) { + return buildSystemPrompt(id, memoryMode); + } + const resolved = await resolveSystemPrompt(id); + return swapMemoryAddon(resolved, memoryMode); +} + /** * Resolve a system prompt ID to its content. * diff --git a/src/headless.ts b/src/headless.ts index 43b6df6..ad9d588 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -772,6 +772,12 @@ export async function handleHeadlessCommand( agent = result.agent; + // Mark imported agents as "custom" to prevent legacy auto-migration + // from overwriting their system prompt on resume. + if (settingsManager.isReady) { + settingsManager.setSystemPromptPreset(agent.id, "custom"); + } + // Display extracted skills summary if (result.skills && result.skills.length > 0) { const { getAgentSkillsDir } = await import("./agent/skills"); @@ -907,18 +913,6 @@ export async function handleHeadlessCommand( } } } - - if (systemPromptPreset) { - const result = await updateAgentSystemPrompt( - agent.id, - systemPromptPreset, - ); - if (!result.success || !result.agent) { - console.error(`Failed to update system prompt: ${result.message}`); - process.exit(1); - } - agent = result.agent; - } } // Determine which conversation to use @@ -927,6 +921,9 @@ export async function handleHeadlessCommand( const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent"; + // Captured so prompt logic below can await it when needed. + let memfsBgPromise: Promise | undefined; + // Apply memfs flags and auto-enable from server tag when local settings are missing. // Respects memfsStartupPolicy: // "blocking" (default) – await the pull; exit on conflict. @@ -949,7 +946,7 @@ export async function handleHeadlessCommand( } else if (memfsStartupPolicy === "background") { // Fire pull async; don't block session initialisation. const { applyMemfsFlags } = await import("./agent/memoryFilesystem"); - applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, { + memfsBgPromise = applyMemfsFlags(agent.id, memfsFlag, noMemfsFlag, { pullOnExistingRepo: true, agentTags: agent.tags, }).catch((error) => { @@ -982,6 +979,56 @@ export async function handleHeadlessCommand( } } + // Ensure background memfs sync settles before prompt logic reads isMemfsEnabled(). + if (memfsBgPromise && isResumingAgent) { + await memfsBgPromise; + } + + // Apply --system flag after memfs sync so isMemfsEnabled() is up to date. + if (isResumingAgent && systemPromptPreset) { + const result = await updateAgentSystemPrompt(agent.id, systemPromptPreset); + if (!result.success || !result.agent) { + console.error(`Failed to update system prompt: ${result.message}`); + process.exit(1); + } + agent = result.agent; + } + + // Auto-heal system prompt drift (rebuild from stored recipe). + // Runs after memfs sync so isMemfsEnabled() reflects the final state. + if (isResumingAgent && !systemPromptPreset) { + let storedPreset = settingsManager.getSystemPromptPreset(agent.id); + + // Adopt legacy agents (created before recipe tracking) as "custom" + // so their prompts are left untouched by auto-heal. + if ( + !storedPreset && + agent.tags?.includes("origin:letta-code") && + !agent.tags?.includes("role:subagent") + ) { + storedPreset = "custom"; + settingsManager.setSystemPromptPreset(agent.id, storedPreset); + } + + if (storedPreset && storedPreset !== "custom") { + const { buildSystemPrompt: rebuildPrompt, isKnownPreset: isKnown } = + await import("./agent/promptAssets"); + if (isKnown(storedPreset)) { + const memoryMode = settingsManager.isMemfsEnabled(agent.id) + ? "memfs" + : "standard"; + const expected = rebuildPrompt(storedPreset, memoryMode); + if (agent.system !== expected) { + const client = await getClient(); + await client.agents.update(agent.id, { system: expected }); + agent = await client.agents.retrieve(agent.id); + } + } else { + settingsManager.clearSystemPromptPreset(agent.id); + } + } + } + try { effectiveReflectionSettings = await applyReflectionOverrides( agent.id, diff --git a/src/index.ts b/src/index.ts index 6f03e56..7ca17c2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1538,6 +1538,12 @@ async function main(): Promise { blocks: [], }); + // Mark imported agents as "custom" to prevent legacy auto-migration + // from overwriting their system prompt on resume. + if (settingsManager.isReady) { + settingsManager.setSystemPromptPreset(agent.id, "custom"); + } + // Display extracted skills summary if (result.skills && result.skills.length > 0) { const { getAgentSkillsDir } = await import("./agent/skills"); @@ -1552,24 +1558,6 @@ async function main(): Promise { if (!agent && agentIdArg) { try { agent = await client.agents.retrieve(agentIdArg); - - // Apply --system flag to existing agent if provided - if (systemPromptPreset) { - const { updateAgentSystemPrompt } = await import( - "./agent/modify" - ); - const result = await updateAgentSystemPrompt( - agent.id, - systemPromptPreset, - ); - if (!result.success || !result.agent) { - console.error( - `Failed to update system prompt: ${result.message}`, - ); - process.exit(1); - } - agent = result.agent; - } } catch (error) { console.error( `Agent ${agentIdArg} not found (error: ${JSON.stringify(error)})`, @@ -1767,6 +1755,17 @@ async function main(): Promise { } if (systemPromptPreset) { + // Await memfs sync first so isMemfsEnabled() reflects the final state + // before updateAgentSystemPrompt reads it to pick the memory addon. + try { + await memfsSyncPromise; + } catch (error) { + console.error( + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } + const result = await updateAgentSystemPrompt( agent.id, systemPromptPreset, @@ -1929,6 +1928,41 @@ async function main(): Promise { process.exit(1); } + // Auto-heal system prompt drift (rebuild from stored recipe). + // Runs after memfs sync so isMemfsEnabled() reflects the final state. + if (resuming && !systemPromptPreset) { + let storedPreset = settingsManager.getSystemPromptPreset(agent.id); + + // Adopt legacy agents (created before recipe tracking) as "custom" + // so their prompts are left untouched by auto-heal. + if ( + !storedPreset && + agent.tags?.includes("origin:letta-code") && + !agent.tags?.includes("role:subagent") + ) { + storedPreset = "custom"; + settingsManager.setSystemPromptPreset(agent.id, storedPreset); + } + + if (storedPreset && storedPreset !== "custom") { + const { buildSystemPrompt: rebuildPrompt, isKnownPreset: isKnown } = + await import("./agent/promptAssets"); + if (isKnown(storedPreset)) { + const memoryMode = settingsManager.isMemfsEnabled(agent.id) + ? "memfs" + : "standard"; + const expected = rebuildPrompt(storedPreset, memoryMode); + if (agent.system !== expected) { + const client = await getClient(); + await client.agents.update(agent.id, { system: expected }); + agent = await client.agents.retrieve(agent.id); + } + } else { + settingsManager.clearSystemPromptPreset(agent.id); + } + } + } + // Save the session (agent + conversation) to settings // Skip for subagents - they shouldn't pollute the LRU settings if (!isSubagent) { diff --git a/src/settings-manager.ts b/src/settings-manager.ts index a2338b6..daac620 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -56,6 +56,7 @@ export interface AgentSettings { | "gemini" | "gemini_snake" | "none"; // toolset mode for this agent (manual override or auto) + systemPromptPreset?: string; // known preset ID, "custom", or undefined (legacy/subagent) } export interface Settings { @@ -207,6 +208,13 @@ class SettingsManager { } } + /** + * Whether the settings manager has been initialized. + */ + get isReady(): boolean { + return this.initialized; + } + /** * Initialize the settings manager (loads from disk) * Should be called once at app startup @@ -1469,12 +1477,18 @@ class SettingsManager { // Use nullish coalescing for toolset (undefined = keep existing) toolset: updates.toolset !== undefined ? updates.toolset : existing.toolset, + // Use nullish coalescing for systemPromptPreset (undefined = keep existing) + systemPromptPreset: + updates.systemPromptPreset !== undefined + ? updates.systemPromptPreset + : existing.systemPromptPreset, }; // Clean up undefined/false values if (!updated.pinned) delete updated.pinned; if (!updated.memfs) delete updated.memfs; if (!updated.toolset || updated.toolset === "auto") delete updated.toolset; + if (!updated.systemPromptPreset) delete updated.systemPromptPreset; if (!updated.baseUrl) delete updated.baseUrl; agents[idx] = updated; } else { @@ -1489,6 +1503,7 @@ class SettingsManager { if (!newAgent.memfs) delete newAgent.memfs; if (!newAgent.toolset || newAgent.toolset === "auto") delete newAgent.toolset; + if (!newAgent.systemPromptPreset) delete newAgent.systemPromptPreset; if (!newAgent.baseUrl) delete newAgent.baseUrl; agents.push(newAgent); } @@ -1544,6 +1559,28 @@ class SettingsManager { this.upsertAgentSettings(agentId, { toolset: preference }); } + /** + * Get the stored system prompt preset for an agent on the current server. + */ + getSystemPromptPreset(agentId: string): string | undefined { + return this.getAgentSettings(agentId)?.systemPromptPreset; + } + + /** + * Set the system prompt preset for an agent on the current server. + */ + setSystemPromptPreset(agentId: string, preset: string): void { + this.upsertAgentSettings(agentId, { systemPromptPreset: preset }); + } + + /** + * Clear the stored system prompt preset for an agent (e.g., after switching to a subagent prompt). + */ + clearSystemPromptPreset(agentId: string): void { + // Setting to empty string triggers the cleanup `if (!updated.systemPromptPreset) delete ...` + this.upsertAgentSettings(agentId, { systemPromptPreset: "" }); + } + /** * Check if local .letta directory exists (indicates existing project) */ diff --git a/src/tests/agent/memoryPrompt.test.ts b/src/tests/agent/memoryPrompt.test.ts index 955d31c..606106d 100644 --- a/src/tests/agent/memoryPrompt.test.ts +++ b/src/tests/agent/memoryPrompt.test.ts @@ -1,12 +1,11 @@ import { describe, expect, test } from "bun:test"; import { - detectMemoryPromptDrift, - reconcileMemoryPrompt, -} from "../../agent/memoryPrompt"; -import { + buildSystemPrompt, + isKnownPreset, SYSTEM_PROMPT_MEMFS_ADDON, SYSTEM_PROMPT_MEMORY_ADDON, + swapMemoryAddon, } from "../../agent/promptAssets"; function countOccurrences(haystack: string, needle: string): number { @@ -14,54 +13,138 @@ function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } -describe("memoryPrompt reconciler", () => { - test("replaces existing standard memory section with memfs section", () => { +describe("isKnownPreset", () => { + test("returns true for known preset IDs", () => { + expect(isKnownPreset("default")).toBe(true); + expect(isKnownPreset("letta-claude")).toBe(true); + expect(isKnownPreset("letta-codex")).toBe(true); + }); + + test("returns false for unknown IDs", () => { + expect(isKnownPreset("explore")).toBe(false); + expect(isKnownPreset("nonexistent")).toBe(false); + }); +}); + +describe("buildSystemPrompt", () => { + test("builds standard prompt with memory addon", () => { + const result = buildSystemPrompt("letta-claude", "standard"); + expect(result).toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + expect(result).not.toContain("## Memory Filesystem"); + }); + + test("builds memfs prompt with memfs addon", () => { + const result = buildSystemPrompt("letta-claude", "memfs"); + expect(result).toContain("## Memory Filesystem"); + expect(result).not.toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + }); + + test("throws on unknown preset", () => { + expect(() => buildSystemPrompt("unknown-id", "standard")).toThrow( + 'Unknown preset "unknown-id"', + ); + }); + + test("is idempotent — same inputs always produce same output", () => { + const first = buildSystemPrompt("default", "memfs"); + const second = buildSystemPrompt("default", "memfs"); + expect(first).toBe(second); + }); + + test("default preset uses SYSTEM_PROMPT content", () => { + const result = buildSystemPrompt("default", "standard"); + expect(result).toContain("You are a self-improving AI agent"); + // default is NOT letta-claude — it uses the Letta-tuned system prompt + const lettaClaudeResult = buildSystemPrompt("letta-claude", "standard"); + expect(result).not.toBe(lettaClaudeResult); + }); +}); + +describe("swapMemoryAddon", () => { + test("swaps standard to memfs", () => { const base = "You are a test agent."; const standard = `${base}\n\n${SYSTEM_PROMPT_MEMORY_ADDON.trimStart()}`; - const reconciled = reconcileMemoryPrompt(standard, "memfs"); + const result = swapMemoryAddon(standard, "memfs"); - expect(reconciled).toContain("## Memory Filesystem"); - expect(reconciled).not.toContain( + expect(result).toContain("## Memory Filesystem"); + expect(result).not.toContain( "Your memory consists of core memory (composed of memory blocks)", ); - expect(countOccurrences(reconciled, "## Memory Filesystem")).toBe(1); + expect(countOccurrences(result, "## Memory Filesystem")).toBe(1); }); - test("does not leave orphan memfs sync fragment when switching from memfs to standard", () => { + test("swaps memfs to standard without orphan fragments", () => { const base = "You are a test agent."; const memfs = `${base}\n\n${SYSTEM_PROMPT_MEMFS_ADDON.trimStart()}`; - const reconciled = reconcileMemoryPrompt(memfs, "standard"); + const result = swapMemoryAddon(memfs, "standard"); - expect(reconciled).toContain( + expect(result).toContain( "Your memory consists of core memory (composed of memory blocks)", ); - expect(reconciled).not.toContain("## Memory Filesystem"); - expect(reconciled).not.toContain("# See what changed"); - expect(reconciled).not.toContain('git commit -m ": "'); + expect(result).not.toContain("## Memory Filesystem"); + expect(result).not.toContain("# See what changed"); + expect(result).not.toContain('git commit -m ": "'); }); - test("cleans orphan memfs tail fragment before rebuilding target mode", () => { + test("handles duplicate addons", () => { + const base = "You are a test agent."; + const doubled = `${base}\n\n${SYSTEM_PROMPT_MEMORY_ADDON}\n\n${SYSTEM_PROMPT_MEMORY_ADDON}`; + + const result = swapMemoryAddon(doubled, "memfs"); + + expect(countOccurrences(result, "## Memory Filesystem")).toBe(1); + expect(result).not.toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + }); + + test("strips orphan memfs tail fragment", () => { const tailStart = SYSTEM_PROMPT_MEMFS_ADDON.indexOf("# See what changed"); expect(tailStart).toBeGreaterThanOrEqual(0); const orphanTail = SYSTEM_PROMPT_MEMFS_ADDON.slice(tailStart).trim(); const drifted = `Header text\n\n${orphanTail}`; - const drifts = detectMemoryPromptDrift(drifted, "standard"); - expect(drifts.some((d) => d.code === "orphan_memfs_fragment")).toBe(true); + const result = swapMemoryAddon(drifted, "standard"); - const reconciled = reconcileMemoryPrompt(drifted, "standard"); - expect(reconciled).toContain( + expect(result).toContain( "Your memory consists of core memory (composed of memory blocks)", ); - expect(reconciled).not.toContain("# See what changed"); + expect(result).not.toContain("# See what changed"); }); - test("memfs reconciliation is idempotent and keeps single syncing section", () => { + test("strips legacy heading-based ## Memory section", () => { + const legacy = + "You are a test agent.\n\n## Memory\nLegacy memory instructions here.\n\nSome other details."; + + const result = swapMemoryAddon(legacy, "memfs"); + + expect(result).toContain("## Memory Filesystem"); + expect(result).not.toContain("Legacy memory instructions"); + expect(countOccurrences(result, "## Memory Filesystem")).toBe(1); + }); + + test("strips legacy heading-based ## Memory Filesystem section", () => { + const legacy = + "You are a test agent.\n\n## Memory Filesystem\nOld memfs instructions."; + + const result = swapMemoryAddon(legacy, "standard"); + + expect(result).toContain( + "Your memory consists of core memory (composed of memory blocks)", + ); + expect(result).not.toContain("Old memfs instructions"); + }); + + test("is idempotent", () => { const base = "You are a test agent."; - const once = reconcileMemoryPrompt(base, "memfs"); - const twice = reconcileMemoryPrompt(once, "memfs"); + const once = swapMemoryAddon(base, "memfs"); + const twice = swapMemoryAddon(once, "memfs"); expect(twice).toBe(once); expect(countOccurrences(twice, "## Syncing")).toBe(1);