feat: agent-driven memory filesystem sync conflict resolution (#724)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: Kevin Lin <kl2806@columbia.edu>
This commit is contained in:
Charles Packer
2026-01-28 19:30:26 -08:00
committed by GitHub
parent af1f2df260
commit 654e492479
10 changed files with 1599 additions and 34 deletions

View File

@@ -45,6 +45,21 @@ export type MemorySyncConflict = {
fileValue: string | null;
};
export type MemfsSyncStatus = {
/** Blocks where both file and block changed since last sync */
conflicts: MemorySyncConflict[];
/** Labels where only the file changed (would auto-resolve to block) */
pendingFromFile: string[];
/** Labels where only the block changed (would auto-resolve to file) */
pendingFromBlock: string[];
/** Labels where a file exists but no block */
newFiles: string[];
/** Labels where a block exists but no file */
newBlocks: string[];
/** True when there are no conflicts or pending changes */
isClean: boolean;
};
export type MemorySyncResult = {
updatedBlocks: string[];
createdBlocks: string[];
@@ -838,6 +853,181 @@ export function formatMemorySyncSummary(result: MemorySyncResult): string {
return `Memory filesystem sync complete:\n${lines.join("\n")}`;
}
/**
* Read-only check of the current memFS sync status.
* Does NOT modify any blocks, files, or sync state.
* Safe to call frequently (e.g., after every turn).
*/
export async function checkMemoryFilesystemStatus(
agentId: string,
options?: { homeDir?: string },
): Promise<MemfsSyncStatus> {
const homeDir = options?.homeDir ?? homedir();
ensureMemoryFilesystemDirs(agentId, homeDir);
const systemDir = getMemorySystemDir(agentId, homeDir);
const userDir = getMemoryUserDir(agentId, homeDir);
const systemFiles = await readMemoryFiles(systemDir);
const userFiles = await readMemoryFiles(userDir);
systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
const attachedBlocks = await fetchAgentBlocks(agentId);
const systemBlockMap = new Map(
attachedBlocks
.filter((block) => block.label)
.map((block) => [block.label as string, block]),
);
systemBlockMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
const lastState = loadSyncState(agentId, homeDir);
const conflicts: MemorySyncConflict[] = [];
const pendingFromFile: string[] = [];
const pendingFromBlock: string[] = [];
const newFiles: string[] = [];
const newBlocks: string[] = [];
// Fetch user blocks for status check
const userBlockIds = { ...lastState.userBlockIds };
const userBlockMap = new Map<string, Block>();
const client = await getClient();
for (const [label, blockId] of Object.entries(userBlockIds)) {
try {
const block = await client.blocks.retrieve(blockId);
userBlockMap.set(label, block as Block);
} catch {
delete userBlockIds[label];
}
}
// Check system labels
const systemLabels = new Set<string>([
...Array.from(systemFiles.keys()),
...Array.from(systemBlockMap.keys()),
...Object.keys(lastState.systemBlocks),
...Object.keys(lastState.systemFiles),
]);
for (const label of Array.from(systemLabels).sort()) {
if (MANAGED_BLOCK_LABELS.has(label)) continue;
classifyLabel(
label,
systemFiles.get(label)?.content ?? null,
systemBlockMap.get(label)?.value ?? null,
lastState.systemFiles[label] ?? null,
lastState.systemBlocks[label] ?? null,
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
);
}
// Check user labels
const userLabels = new Set<string>([
...Array.from(userFiles.keys()),
...Array.from(userBlockMap.keys()),
...Object.keys(lastState.userBlocks),
...Object.keys(lastState.userFiles),
]);
for (const label of Array.from(userLabels).sort()) {
classifyLabel(
label,
userFiles.get(label)?.content ?? null,
userBlockMap.get(label)?.value ?? null,
lastState.userFiles[label] ?? null,
lastState.userBlocks[label] ?? null,
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
);
}
const isClean =
conflicts.length === 0 &&
pendingFromFile.length === 0 &&
pendingFromBlock.length === 0 &&
newFiles.length === 0 &&
newBlocks.length === 0;
return {
conflicts,
pendingFromFile,
pendingFromBlock,
newFiles,
newBlocks,
isClean,
};
}
/**
* Classify a single label's sync status (read-only).
* Pushes into the appropriate output array based on file/block state comparison.
*/
function classifyLabel(
label: string,
fileContent: string | null,
blockValue: string | null,
lastFileHash: string | null,
lastBlockHash: string | null,
conflicts: MemorySyncConflict[],
pendingFromFile: string[],
pendingFromBlock: string[],
newFiles: string[],
newBlocks: string[],
): void {
const fileHash = fileContent !== null ? hashContent(fileContent) : null;
const blockHash = blockValue !== null ? hashContent(blockValue) : null;
const fileChanged = fileHash !== lastFileHash;
const blockChanged = blockHash !== lastBlockHash;
if (fileContent !== null && blockValue === null) {
if (lastBlockHash && !fileChanged) {
// Block was deleted, file unchanged — would delete file
return;
}
newFiles.push(label);
return;
}
if (fileContent === null && blockValue !== null) {
if (lastFileHash && !blockChanged) {
// File was deleted, block unchanged — would delete block
return;
}
newBlocks.push(label);
return;
}
if (fileContent === null || blockValue === null) {
return;
}
// Both exist — check for differences
if (fileHash === blockHash) {
return; // In sync
}
if (fileChanged && blockChanged) {
conflicts.push({ label, blockValue, fileValue: fileContent });
return;
}
if (fileChanged && !blockChanged) {
pendingFromFile.push(label);
return;
}
if (!fileChanged && blockChanged) {
pendingFromBlock.push(label);
}
}
/**
* Detach the memory_filesystem block from an agent.
* Used when disabling memfs.