feat: recipe-based system prompt management (#1293)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 "<type>: <what changed>"');
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -343,25 +343,21 @@ export async function updateAgentSystemPrompt(
|
||||
systemPromptId: string,
|
||||
): Promise<UpdateSystemPromptResult> {
|
||||
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<SystemPromptUpdateResult> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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<unknown> | 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,
|
||||
|
||||
70
src/index.ts
70
src/index.ts
@@ -1538,6 +1538,12 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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 "<type>: <what changed>"');
|
||||
expect(result).not.toContain("## Memory Filesystem");
|
||||
expect(result).not.toContain("# See what changed");
|
||||
expect(result).not.toContain('git commit -m "<type>: <what changed>"');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user