fix: reconcile memfs/standard prompt sections safely (#985)
This commit is contained in:
@@ -10,6 +10,7 @@ import { DEFAULT_AGENT_NAME } from "../constants";
|
||||
import { getModelContextWindow } from "./available-models";
|
||||
import { getClient } from "./client";
|
||||
import { getDefaultMemoryBlocks } from "./memory";
|
||||
import { type MemoryPromptMode, reconcileMemoryPrompt } from "./memoryPrompt";
|
||||
import {
|
||||
formatAvailableModels,
|
||||
getDefaultModel,
|
||||
@@ -17,10 +18,7 @@ import {
|
||||
resolveModel,
|
||||
} from "./model";
|
||||
import { updateAgentLLMConfig } from "./modify";
|
||||
import {
|
||||
resolveSystemPrompt,
|
||||
SYSTEM_PROMPT_MEMORY_ADDON,
|
||||
} from "./promptAssets";
|
||||
import { resolveSystemPrompt } from "./promptAssets";
|
||||
import { SLEEPTIME_MEMORY_PERSONA } from "./prompts/sleeptime";
|
||||
|
||||
/**
|
||||
@@ -63,6 +61,8 @@ export interface CreateAgentOptions {
|
||||
systemPromptCustom?: string;
|
||||
/** Additional text to append to the resolved system prompt */
|
||||
systemPromptAppend?: string;
|
||||
/** Which managed memory prompt mode to apply */
|
||||
memoryPromptMode?: MemoryPromptMode;
|
||||
/** Block labels to initialize (from default blocks) */
|
||||
initBlocks?: string[];
|
||||
/** Base tools to include */
|
||||
@@ -272,7 +272,8 @@ export async function createAgent(
|
||||
// Resolve system prompt content:
|
||||
// 1. If systemPromptCustom is provided, use it as-is
|
||||
// 2. Otherwise, resolve systemPromptPreset to content
|
||||
// 3. If systemPromptAppend is provided, append it to the result
|
||||
// 3. Reconcile to the selected managed memory mode
|
||||
// 4. If systemPromptAppend is provided, append it to the result
|
||||
let systemPromptContent: string;
|
||||
if (options.systemPromptCustom) {
|
||||
systemPromptContent = options.systemPromptCustom;
|
||||
@@ -280,9 +281,10 @@ export async function createAgent(
|
||||
systemPromptContent = await resolveSystemPrompt(options.systemPromptPreset);
|
||||
}
|
||||
|
||||
// Append the non-memfs memory section by default.
|
||||
// If memfs is enabled, updateAgentSystemPromptMemfs() will swap it for the memfs version.
|
||||
systemPromptContent = `${systemPromptContent}\n${SYSTEM_PROMPT_MEMORY_ADDON}`;
|
||||
systemPromptContent = reconcileMemoryPrompt(
|
||||
systemPromptContent,
|
||||
options.memoryPromptMode ?? "standard",
|
||||
);
|
||||
|
||||
// Append additional instructions if provided
|
||||
if (options.systemPromptAppend) {
|
||||
|
||||
@@ -149,11 +149,11 @@ export interface ApplyMemfsFlagsResult {
|
||||
* Shared between interactive (index.ts), headless (headless.ts), and
|
||||
* the /memfs enable command (App.tsx) to avoid duplicating the setup logic.
|
||||
*
|
||||
* Steps when enabling:
|
||||
* 1. Validate Letta Cloud requirement
|
||||
* 2. Persist memfs setting
|
||||
* 3. Detach old API-based memory tools
|
||||
* 4. Update system prompt to include memfs section
|
||||
* Steps when toggling:
|
||||
* 1. Validate Letta Cloud requirement (for explicit enable)
|
||||
* 2. Reconcile system prompt to the target memory mode
|
||||
* 3. Persist memfs setting locally
|
||||
* 4. Detach old API-based memory tools (when enabling)
|
||||
* 5. Add git-memory-enabled tag + clone/pull repo
|
||||
*
|
||||
* @throws {Error} if Letta Cloud validation fails or git setup fails
|
||||
@@ -167,7 +167,7 @@ export async function applyMemfsFlags(
|
||||
const { getServerUrl } = await import("./client");
|
||||
const { settingsManager } = await import("../settings-manager");
|
||||
|
||||
// 1. Validate + persist setting
|
||||
// 1. Validate explicit enable on supported backend.
|
||||
if (memfsFlag) {
|
||||
const serverUrl = getServerUrl();
|
||||
if (!serverUrl.includes("api.letta.com")) {
|
||||
@@ -175,26 +175,39 @@ export async function applyMemfsFlags(
|
||||
"--memfs is only available on Letta Cloud (api.letta.com).",
|
||||
);
|
||||
}
|
||||
settingsManager.setMemfsEnabled(agentId, true);
|
||||
} else if (noMemfsFlag) {
|
||||
settingsManager.setMemfsEnabled(agentId, false);
|
||||
}
|
||||
|
||||
const isEnabled = settingsManager.isMemfsEnabled(agentId);
|
||||
const hasExplicitToggle = Boolean(memfsFlag || noMemfsFlag);
|
||||
const targetEnabled = memfsFlag
|
||||
? true
|
||||
: noMemfsFlag
|
||||
? false
|
||||
: settingsManager.isMemfsEnabled(agentId);
|
||||
|
||||
// 2. Detach old API-based memory tools when enabling
|
||||
// 2. Reconcile system prompt first, then persist local memfs setting.
|
||||
if (hasExplicitToggle) {
|
||||
const { updateAgentSystemPromptMemfs } = await import("./modify");
|
||||
const promptUpdate = await updateAgentSystemPromptMemfs(
|
||||
agentId,
|
||||
targetEnabled,
|
||||
);
|
||||
if (!promptUpdate.success) {
|
||||
throw new Error(promptUpdate.message);
|
||||
}
|
||||
settingsManager.setMemfsEnabled(agentId, targetEnabled);
|
||||
}
|
||||
|
||||
const isEnabled = hasExplicitToggle
|
||||
? targetEnabled
|
||||
: settingsManager.isMemfsEnabled(agentId);
|
||||
|
||||
// 3. Detach old API-based memory tools when explicitly enabling.
|
||||
if (isEnabled && memfsFlag) {
|
||||
const { detachMemoryTools } = await import("../tools/toolset");
|
||||
await detachMemoryTools(agentId);
|
||||
}
|
||||
|
||||
// 3. Update system prompt to include/exclude memfs section
|
||||
if (memfsFlag || noMemfsFlag) {
|
||||
const { updateAgentSystemPromptMemfs } = await import("./modify");
|
||||
await updateAgentSystemPromptMemfs(agentId, isEnabled);
|
||||
}
|
||||
|
||||
// 4. Add git tag + clone/pull repo
|
||||
// 4. Add git tag + clone/pull repo.
|
||||
let pullSummary: string | undefined;
|
||||
if (isEnabled) {
|
||||
const { addGitMemoryTag, isGitRepo, cloneMemoryRepo, pullMemory } =
|
||||
|
||||
201
src/agent/memoryPrompt.ts
Normal file
201
src/agent/memoryPrompt.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
}
|
||||
@@ -245,13 +245,25 @@ export async function updateAgentSystemPrompt(
|
||||
systemPromptId: string,
|
||||
): Promise<UpdateSystemPromptResult> {
|
||||
try {
|
||||
const { resolveSystemPrompt, SYSTEM_PROMPT_MEMORY_ADDON } = await import(
|
||||
"./promptAssets"
|
||||
const { resolveSystemPrompt } = await import("./promptAssets");
|
||||
const { detectMemoryPromptDrift, reconcileMemoryPrompt } = await import(
|
||||
"./memoryPrompt"
|
||||
);
|
||||
const { settingsManager } = await import("../settings-manager");
|
||||
|
||||
const client = await getClient();
|
||||
const currentAgent = await client.agents.retrieve(agentId);
|
||||
const baseContent = await resolveSystemPrompt(systemPromptId);
|
||||
// Append the non-memfs memory section by default.
|
||||
// If memfs is enabled, the caller should follow up with updateAgentSystemPromptMemfs().
|
||||
const systemPromptContent = `${baseContent}\n${SYSTEM_PROMPT_MEMORY_ADDON}`;
|
||||
|
||||
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);
|
||||
|
||||
const updateResult = await updateAgentSystemPromptRaw(
|
||||
agentId,
|
||||
@@ -266,7 +278,6 @@ export async function updateAgentSystemPrompt(
|
||||
}
|
||||
|
||||
// Re-fetch agent to get updated state
|
||||
const client = await getClient();
|
||||
const agent = await client.agents.retrieve(agentId);
|
||||
|
||||
return {
|
||||
@@ -284,10 +295,10 @@ export async function updateAgentSystemPrompt(
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an agent's system prompt to swap between the memfs and non-memfs memory sections.
|
||||
* Updates an agent's system prompt to swap between managed memory modes.
|
||||
*
|
||||
* When enabling memfs: strips any existing # Memory section, appends the memfs memory addon.
|
||||
* When disabling memfs: strips any existing # Memory section, appends the non-memfs memory addon.
|
||||
* Uses the shared memory prompt reconciler so we safely replace managed memory
|
||||
* sections without corrupting fenced code blocks or leaving orphan fragments.
|
||||
*
|
||||
* @param agentId - The agent ID to update
|
||||
* @param enableMemfs - Whether to enable (add) or disable (remove) the memfs addon
|
||||
@@ -300,26 +311,15 @@ export async function updateAgentSystemPromptMemfs(
|
||||
try {
|
||||
const client = await getClient();
|
||||
const agent = await client.agents.retrieve(agentId);
|
||||
let currentSystemPrompt = agent.system || "";
|
||||
const { reconcileMemoryPrompt } = await import("./memoryPrompt");
|
||||
|
||||
const { SYSTEM_PROMPT_MEMFS_ADDON, SYSTEM_PROMPT_MEMORY_ADDON } =
|
||||
await import("./promptAssets");
|
||||
|
||||
// Strip any existing memory section (covers both old inline "# Memory" / "## Memory"
|
||||
// sections and the new addon format including "## Memory Filesystem" subsections).
|
||||
// Matches from "# Memory" or "## Memory" to the next top-level heading or end of string.
|
||||
const memoryHeaderRegex =
|
||||
/\n#{1,2} Memory\b[\s\S]*?(?=\n#{1,2} (?!Memory|Filesystem|Structure|How It Works|Syncing|History)[^\n]|$)/;
|
||||
currentSystemPrompt = currentSystemPrompt.replace(memoryHeaderRegex, "");
|
||||
|
||||
// Append the appropriate memory section
|
||||
const addon = enableMemfs
|
||||
? SYSTEM_PROMPT_MEMFS_ADDON
|
||||
: SYSTEM_PROMPT_MEMORY_ADDON;
|
||||
currentSystemPrompt = `${currentSystemPrompt}\n${addon}`;
|
||||
const nextSystemPrompt = reconcileMemoryPrompt(
|
||||
agent.system || "",
|
||||
enableMemfs ? "memfs" : "standard",
|
||||
);
|
||||
|
||||
await client.agents.update(agentId, {
|
||||
system: currentSystemPrompt,
|
||||
system: nextSystemPrompt,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -9732,13 +9732,8 @@ ${SYSTEM_REMINDER_CLOSE}
|
||||
phase: "running",
|
||||
});
|
||||
|
||||
const { updateAgentSystemPromptRaw } = await import(
|
||||
"../agent/modify"
|
||||
);
|
||||
const result = await updateAgentSystemPromptRaw(
|
||||
agentId,
|
||||
prompt.content,
|
||||
);
|
||||
const { updateAgentSystemPrompt } = await import("../agent/modify");
|
||||
const result = await updateAgentSystemPrompt(agentId, promptId);
|
||||
|
||||
if (result.success) {
|
||||
setCurrentSystemPromptId(promptId);
|
||||
|
||||
@@ -266,6 +266,12 @@ export async function handleHeadlessCommand(
|
||||
const baseToolsRaw = values["base-tools"] as string | undefined;
|
||||
const memfsFlag = values.memfs as boolean | undefined;
|
||||
const noMemfsFlag = values["no-memfs"] as boolean | undefined;
|
||||
const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag
|
||||
? "memfs"
|
||||
: noMemfsFlag
|
||||
? "standard"
|
||||
: undefined;
|
||||
const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag;
|
||||
const fromAfFile = values["from-af"] as string | undefined;
|
||||
const preLoadSkillsRaw = values["pre-load-skills"] as string | undefined;
|
||||
const maxTurnsRaw = values["max-turns"] as string | undefined;
|
||||
@@ -593,6 +599,7 @@ export async function handleHeadlessCommand(
|
||||
systemPromptPreset,
|
||||
systemPromptCustom: systemCustom,
|
||||
systemPromptAppend: systemAppend,
|
||||
memoryPromptMode: requestedMemoryPromptMode,
|
||||
initBlocks,
|
||||
baseTools,
|
||||
memoryBlocks,
|
||||
@@ -602,9 +609,11 @@ export async function handleHeadlessCommand(
|
||||
const result = await createAgent(createOptions);
|
||||
agent = result.agent;
|
||||
|
||||
// Enable memfs by default on Letta Cloud for new agents
|
||||
const { enableMemfsIfCloud } = await import("./agent/memoryFilesystem");
|
||||
await enableMemfsIfCloud(agent.id);
|
||||
// Enable memfs by default on Letta Cloud for new agents when no explicit memfs flags are provided.
|
||||
if (shouldAutoEnableMemfsForNewAgent) {
|
||||
const { enableMemfsIfCloud } = await import("./agent/memoryFilesystem");
|
||||
await enableMemfsIfCloud(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Try to resume from project settings (.letta/settings.local.json)
|
||||
|
||||
19
src/index.ts
19
src/index.ts
@@ -552,6 +552,12 @@ async function main(): Promise<void> {
|
||||
const skillsDirectory = (values.skills as string | undefined) ?? undefined;
|
||||
const memfsFlag = values.memfs as boolean | undefined;
|
||||
const noMemfsFlag = values["no-memfs"] as boolean | undefined;
|
||||
const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag
|
||||
? "memfs"
|
||||
: noMemfsFlag
|
||||
? "standard"
|
||||
: undefined;
|
||||
const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag;
|
||||
const noSkillsFlag = values["no-skills"] as boolean | undefined;
|
||||
const fromAfFile =
|
||||
(values.import as string | undefined) ??
|
||||
@@ -1630,17 +1636,20 @@ async function main(): Promise<void> {
|
||||
skillsDirectory,
|
||||
parallelToolCalls: true,
|
||||
systemPromptPreset,
|
||||
memoryPromptMode: requestedMemoryPromptMode,
|
||||
initBlocks,
|
||||
baseTools,
|
||||
});
|
||||
agent = result.agent;
|
||||
setAgentProvenance(result.provenance);
|
||||
|
||||
// Enable memfs by default on Letta Cloud for new agents
|
||||
const { enableMemfsIfCloud } = await import(
|
||||
"./agent/memoryFilesystem"
|
||||
);
|
||||
await enableMemfsIfCloud(agent.id);
|
||||
// Enable memfs by default on Letta Cloud for new agents when no explicit memfs flags are provided.
|
||||
if (shouldAutoEnableMemfsForNewAgent) {
|
||||
const { enableMemfsIfCloud } = await import(
|
||||
"./agent/memoryFilesystem"
|
||||
);
|
||||
await enableMemfsIfCloud(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Try to resume from project settings LRU (.letta/settings.local.json)
|
||||
|
||||
98
src/tests/agent/memoryPrompt.integration.test.ts
Normal file
98
src/tests/agent/memoryPrompt.integration.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { getClient } from "../../agent/client";
|
||||
import { createAgent } from "../../agent/create";
|
||||
import { updateAgentSystemPromptMemfs } from "../../agent/modify";
|
||||
import {
|
||||
SYSTEM_PROMPT_MEMFS_ADDON,
|
||||
SYSTEM_PROMPT_MEMORY_ADDON,
|
||||
} from "../../agent/promptAssets";
|
||||
|
||||
const describeIntegration = process.env.LETTA_API_KEY
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
function expectedPrompt(base: string, addon: string): string {
|
||||
return `${base.trimEnd()}\n\n${addon.trimStart()}`.trim();
|
||||
}
|
||||
|
||||
describeIntegration("memory prompt integration", () => {
|
||||
const createdAgentIds: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
// Avoid polluting user's normal local LRU state in integration runs.
|
||||
process.env.LETTA_CODE_AGENT_ROLE = "subagent";
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const client = await getClient();
|
||||
for (const agentId of createdAgentIds) {
|
||||
try {
|
||||
await client.agents.delete(agentId);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"new agent prompt is exact for memfs enabled and disabled modes",
|
||||
async () => {
|
||||
const base = [
|
||||
"You are a test agent.",
|
||||
"Follow user instructions precisely.",
|
||||
].join("\n");
|
||||
|
||||
const created = await createAgent({
|
||||
name: `prompt-memfs-${Date.now()}`,
|
||||
systemPromptCustom: base,
|
||||
memoryPromptMode: "memfs",
|
||||
});
|
||||
createdAgentIds.push(created.agent.id);
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
const expectedMemfs = expectedPrompt(base, SYSTEM_PROMPT_MEMFS_ADDON);
|
||||
let fetched = await client.agents.retrieve(created.agent.id);
|
||||
expect(fetched.system).toBe(expectedMemfs);
|
||||
expect((fetched.system.match(/## Memory Filesystem/g) || []).length).toBe(
|
||||
1,
|
||||
);
|
||||
expect((fetched.system.match(/# See what changed/g) || []).length).toBe(
|
||||
1,
|
||||
);
|
||||
|
||||
const enableAgain = await updateAgentSystemPromptMemfs(
|
||||
created.agent.id,
|
||||
true,
|
||||
);
|
||||
expect(enableAgain.success).toBe(true);
|
||||
fetched = await client.agents.retrieve(created.agent.id);
|
||||
expect(fetched.system).toBe(expectedMemfs);
|
||||
|
||||
const disable = await updateAgentSystemPromptMemfs(
|
||||
created.agent.id,
|
||||
false,
|
||||
);
|
||||
expect(disable.success).toBe(true);
|
||||
const expectedStandard = expectedPrompt(base, SYSTEM_PROMPT_MEMORY_ADDON);
|
||||
fetched = await client.agents.retrieve(created.agent.id);
|
||||
expect(fetched.system).toBe(expectedStandard);
|
||||
expect(fetched.system).not.toContain("## Memory Filesystem");
|
||||
expect(fetched.system).toContain(
|
||||
"Your memory consists of core memory (composed of memory blocks)",
|
||||
);
|
||||
|
||||
const reEnable = await updateAgentSystemPromptMemfs(
|
||||
created.agent.id,
|
||||
true,
|
||||
);
|
||||
expect(reEnable.success).toBe(true);
|
||||
fetched = await client.agents.retrieve(created.agent.id);
|
||||
expect(fetched.system).toBe(expectedMemfs);
|
||||
expect((fetched.system.match(/# See what changed/g) || []).length).toBe(
|
||||
1,
|
||||
);
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
});
|
||||
70
src/tests/agent/memoryPrompt.test.ts
Normal file
70
src/tests/agent/memoryPrompt.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
detectMemoryPromptDrift,
|
||||
reconcileMemoryPrompt,
|
||||
} from "../../agent/memoryPrompt";
|
||||
import {
|
||||
SYSTEM_PROMPT_MEMFS_ADDON,
|
||||
SYSTEM_PROMPT_MEMORY_ADDON,
|
||||
} from "../../agent/promptAssets";
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
if (!needle) return 0;
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
describe("memoryPrompt reconciler", () => {
|
||||
test("replaces existing standard memory section with memfs section", () => {
|
||||
const base = "You are a test agent.";
|
||||
const standard = `${base}\n\n${SYSTEM_PROMPT_MEMORY_ADDON.trimStart()}`;
|
||||
|
||||
const reconciled = reconcileMemoryPrompt(standard, "memfs");
|
||||
|
||||
expect(reconciled).toContain("## Memory Filesystem");
|
||||
expect(reconciled).not.toContain(
|
||||
"Your memory consists of core memory (composed of memory blocks)",
|
||||
);
|
||||
expect(countOccurrences(reconciled, "## Memory Filesystem")).toBe(1);
|
||||
});
|
||||
|
||||
test("does not leave orphan memfs sync fragment when switching from memfs to standard", () => {
|
||||
const base = "You are a test agent.";
|
||||
const memfs = `${base}\n\n${SYSTEM_PROMPT_MEMFS_ADDON.trimStart()}`;
|
||||
|
||||
const reconciled = reconcileMemoryPrompt(memfs, "standard");
|
||||
|
||||
expect(reconciled).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>"');
|
||||
});
|
||||
|
||||
test("cleans orphan memfs tail fragment before rebuilding target mode", () => {
|
||||
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 reconciled = reconcileMemoryPrompt(drifted, "standard");
|
||||
expect(reconciled).toContain(
|
||||
"Your memory consists of core memory (composed of memory blocks)",
|
||||
);
|
||||
expect(reconciled).not.toContain("# See what changed");
|
||||
});
|
||||
|
||||
test("memfs reconciliation is idempotent and keeps single syncing section", () => {
|
||||
const base = "You are a test agent.";
|
||||
const once = reconcileMemoryPrompt(base, "memfs");
|
||||
const twice = reconcileMemoryPrompt(once, "memfs");
|
||||
|
||||
expect(twice).toBe(once);
|
||||
expect(countOccurrences(twice, "## Syncing")).toBe(1);
|
||||
expect(countOccurrences(twice, "# See what changed")).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user