diff --git a/src/agent/memory.ts b/src/agent/memory.ts index 1b05edb..496430b 100644 --- a/src/agent/memory.ts +++ b/src/agent/memory.ts @@ -200,7 +200,10 @@ export async function ensureSkillsBlocks(agentId: string): Promise { } // Create the block and attach to agent - const createdBlock = await client.blocks.create(blockData); + const createdBlock = await client.blocks.create({ + ...blockData, + tags: [`owner:${agentId}`], + }); await client.agents.blocks.attach(createdBlock.id, { agent_id: agentId }); createdLabels.push(label); } diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index 8cb7946..e77c2bb 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -306,6 +306,49 @@ async function fetchAgentBlocks(agentId: string): Promise { return items; } +/** + * Fetch all blocks owned by this agent (via owner tag). + * This includes both attached and detached blocks. + */ +async function fetchOwnedBlocks(agentId: string): Promise { + const client = await getClient(); + const ownerTag = `owner:${agentId}`; + const page = await client.blocks.list({ tags: [ownerTag], limit: 1000 }); + + // Handle both array response and paginated response + if (Array.isArray(page)) { + return page; + } + + const items = + (page as { items?: Block[] }).items || + (page as { blocks?: Block[] }).blocks || + []; + return items; +} + +/** + * Backfill owner tags on blocks that don't have them. + * This ensures backwards compatibility with blocks created before tagging. + */ +async function backfillOwnerTags( + agentId: string, + blocks: Block[], +): Promise { + const client = await getClient(); + const ownerTag = `owner:${agentId}`; + + for (const block of blocks) { + if (!block.id) continue; + const tags = block.tags || []; + if (!tags.includes(ownerTag)) { + await client.blocks.update(block.id, { + tags: [...tags, ownerTag], + }); + } + } +} + export function renderMemoryFilesystemTree( systemLabels: string[], detachedLabels: string[], @@ -453,14 +496,25 @@ export async function syncMemoryFilesystem( const client = await getClient(); - const detachedBlockIds = { ...lastState.detachedBlockIds }; + // Backfill owner tags on attached blocks (for backwards compat) + await backfillOwnerTags(agentId, attachedBlocks); + + // Discover detached blocks via owner tag (replaces detachedBlockIds tracking) + const allOwnedBlocks = await fetchOwnedBlocks(agentId); + const attachedIds = new Set(attachedBlocks.map((b) => b.id)); + const detachedBlocks = allOwnedBlocks.filter((b) => !attachedIds.has(b.id)); + + // Build detached block map and IDs (for sync state compatibility) + const detachedBlockIds: Record = {}; const detachedBlockMap = new Map(); - for (const [label, blockId] of Object.entries(detachedBlockIds)) { - try { - const block = await client.blocks.retrieve(blockId); - detachedBlockMap.set(label, block as Block); - } catch { - delete detachedBlockIds[label]; + for (const block of detachedBlocks) { + if (block.label && block.id) { + // Skip managed blocks (skills, loaded_skills, memory_filesystem) + if (MANAGED_BLOCK_LABELS.has(block.label)) { + continue; + } + detachedBlockIds[block.label] = block.id; + detachedBlockMap.set(block.label, block); } } @@ -500,7 +554,10 @@ export async function syncMemoryFilesystem( // Create block from file (parsing frontmatter for description/limit) const blockData = parseBlockFromFileContent(fileEntry.content, label); - const createdBlock = await client.blocks.create(blockData); + const createdBlock = await client.blocks.create({ + ...blockData, + tags: [`owner:${agentId}`], + }); if (createdBlock.id) { await client.agents.blocks.attach(createdBlock.id, { agent_id: agentId, @@ -512,14 +569,13 @@ export async function syncMemoryFilesystem( if (!fileEntry && blockEntry) { if (lastFileHash && !blockChanged) { - // File deleted, block unchanged -> delete block + // File deleted, block unchanged -> detach only (block stays with owner tag) if (blockEntry.id) { try { await client.agents.blocks.detach(blockEntry.id, { agent_id: agentId, }); - // Also delete the block to avoid orphaned blocks on the server - await client.blocks.delete(blockEntry.id); + // Note: Don't delete the block - it keeps its owner tag for potential recovery deletedBlocks.push(label); } catch (err) { // Block may have been manually deleted already - ignore @@ -585,7 +641,10 @@ export async function syncMemoryFilesystem( fileEntry.content, label, ); - const createdBlock = await client.blocks.create(blockData); + const createdBlock = await client.blocks.create({ + ...blockData, + tags: [`owner:${agentId}`], + }); if (createdBlock.id) { await client.agents.blocks.attach(createdBlock.id, { agent_id: agentId, @@ -638,7 +697,10 @@ export async function syncMemoryFilesystem( } const blockData = parseBlockFromFileContent(fileEntry.content, label); - const createdBlock = await client.blocks.create(blockData); + const createdBlock = await client.blocks.create({ + ...blockData, + tags: [`owner:${agentId}`], + }); if (createdBlock.id) { detachedBlockIds[blockData.label] = createdBlock.id; detachedBlockMap.set(blockData.label, createdBlock as Block); @@ -649,10 +711,8 @@ export async function syncMemoryFilesystem( if (!fileEntry && blockEntry) { if (lastFileHash && !blockChanged) { - // File deleted, block unchanged -> delete block - if (blockEntry.id) { - await client.blocks.delete(blockEntry.id); - } + // File deleted, block unchanged -> just remove from tracking (block keeps owner tag) + // Note: Don't delete the block - it stays discoverable via owner tag deletedBlocks.push(label); delete detachedBlockIds[label]; continue; @@ -808,6 +868,7 @@ export async function ensureMemoryFilesystemBlock(agentId: string) { description: "Filesystem view of memory blocks", limit: 20000, read_only: true, + tags: [`owner:${agentId}`], }); if (createdBlock.id) { @@ -891,16 +952,19 @@ export async function checkMemoryFilesystemStatus( const newFiles: string[] = []; const newBlocks: string[] = []; - // Fetch user blocks for status check - const detachedBlockIds = { ...lastState.detachedBlockIds }; + // Discover detached blocks via owner tag + const allOwnedBlocks = await fetchOwnedBlocks(agentId); + const attachedIds = new Set(attachedBlocks.map((b) => b.id)); + const detachedBlocks = allOwnedBlocks.filter((b) => !attachedIds.has(b.id)); + const detachedBlockMap = new Map(); - const client = await getClient(); - for (const [label, blockId] of Object.entries(detachedBlockIds)) { - try { - const block = await client.blocks.retrieve(blockId); - detachedBlockMap.set(label, block as Block); - } catch { - delete detachedBlockIds[label]; + for (const block of detachedBlocks) { + if (block.label) { + // Skip managed blocks + if (MANAGED_BLOCK_LABELS.has(block.label)) { + continue; + } + detachedBlockMap.set(block.label, block); } } diff --git a/src/tests/agent/memoryFilesystem.integration.test.ts b/src/tests/agent/memoryFilesystem.integration.test.ts new file mode 100644 index 0000000..25d1577 --- /dev/null +++ b/src/tests/agent/memoryFilesystem.integration.test.ts @@ -0,0 +1,173 @@ +/** + * Integration tests for memory filesystem block tagging. + * These tests hit the real Letta API and require LETTA_API_KEY to be set. + * + * Run with: bun test src/tests/agent/memoryFilesystem.integration.test.ts + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import Letta from "@letta-ai/letta-client"; + +// Skip all tests if no API key is available +const LETTA_API_KEY = process.env.LETTA_API_KEY; +const LETTA_BASE_URL = process.env.LETTA_BASE_URL || "https://api.letta.com"; + +const describeIntegration = LETTA_API_KEY ? describe : describe.skip; + +describeIntegration("block tagging integration", () => { + let client: Letta; + let testAgentId: string; + const createdBlockIds: string[] = []; + + beforeAll(async () => { + client = new Letta({ + baseURL: LETTA_BASE_URL, + apiKey: LETTA_API_KEY!, + }); + + // Create a test agent + const agent = await client.agents.create({ + name: `memfs-test-${Date.now()}`, + model: "letta/letta-free", + embedding: "letta/letta-free", + }); + testAgentId = agent.id; + }); + + afterAll(async () => { + // Clean up: delete created blocks + for (const blockId of createdBlockIds) { + try { + await client.blocks.delete(blockId); + } catch { + // Ignore errors during cleanup + } + } + + // Delete test agent + if (testAgentId) { + try { + await client.agents.delete(testAgentId); + } catch { + // Ignore errors during cleanup + } + } + }); + + test("block created with owner tag is discoverable via tag query", async () => { + const ownerTag = `owner:${testAgentId}`; + + // Create a block with owner tag + const block = await client.blocks.create({ + label: `test-tagged-${Date.now()}`, + value: "Test content", + tags: [ownerTag], + }); + createdBlockIds.push(block.id); + + // Query blocks by owner tag + const ownedBlocks = await client.blocks.list({ tags: [ownerTag] }); + const ownedBlocksArray = Array.isArray(ownedBlocks) + ? ownedBlocks + : (ownedBlocks as { items?: Array<{ id: string }> }).items || []; + + // Verify our block is in the results + const found = ownedBlocksArray.some((b) => b.id === block.id); + expect(found).toBe(true); + }); + + test("detached block remains discoverable via owner tag after detach", async () => { + const ownerTag = `owner:${testAgentId}`; + + // Create and attach a block + const block = await client.blocks.create({ + label: `test-detach-${Date.now()}`, + value: "Test content for detach", + tags: [ownerTag], + }); + createdBlockIds.push(block.id); + + await client.agents.blocks.attach(block.id, { agent_id: testAgentId }); + + // Verify it's attached + const attachedBlocks = await client.agents.blocks.list(testAgentId); + const attachedArray = Array.isArray(attachedBlocks) + ? attachedBlocks + : (attachedBlocks as { items?: Array<{ id: string }> }).items || []; + expect(attachedArray.some((b) => b.id === block.id)).toBe(true); + + // Detach the block + await client.agents.blocks.detach(block.id, { agent_id: testAgentId }); + + // Verify it's no longer attached + const afterDetach = await client.agents.blocks.list(testAgentId); + const afterDetachArray = Array.isArray(afterDetach) + ? afterDetach + : (afterDetach as { items?: Array<{ id: string }> }).items || []; + expect(afterDetachArray.some((b) => b.id === block.id)).toBe(false); + + // But it should still be discoverable via owner tag + const ownedBlocks = await client.blocks.list({ tags: [ownerTag] }); + const ownedBlocksArray = Array.isArray(ownedBlocks) + ? ownedBlocks + : (ownedBlocks as { items?: Array<{ id: string }> }).items || []; + expect(ownedBlocksArray.some((b) => b.id === block.id)).toBe(true); + }); + + test("backfill adds owner tag to existing block", async () => { + const ownerTag = `owner:${testAgentId}`; + + // Create a block WITHOUT owner tag + const block = await client.blocks.create({ + label: `test-backfill-${Date.now()}`, + value: "Test content for backfill", + // No tags + }); + createdBlockIds.push(block.id); + + // Verify it's NOT discoverable via owner tag initially + const beforeBackfill = await client.blocks.list({ tags: [ownerTag] }); + const beforeArray = Array.isArray(beforeBackfill) + ? beforeBackfill + : (beforeBackfill as { items?: Array<{ id: string }> }).items || []; + expect(beforeArray.some((b) => b.id === block.id)).toBe(false); + + // Backfill: add owner tag + await client.blocks.update(block.id, { + tags: [ownerTag], + }); + + // Now it should be discoverable via owner tag + const afterBackfill = await client.blocks.list({ tags: [ownerTag] }); + const afterArray = Array.isArray(afterBackfill) + ? afterBackfill + : (afterBackfill as { items?: Array<{ id: string }> }).items || []; + expect(afterArray.some((b) => b.id === block.id)).toBe(true); + }); + + test("multiple agents can own the same block", async () => { + const ownerTag1 = `owner:${testAgentId}`; + const ownerTag2 = `owner:other-agent-${Date.now()}`; + + // Create a block with both owner tags (shared block) + const block = await client.blocks.create({ + label: `test-shared-${Date.now()}`, + value: "Shared content", + tags: [ownerTag1, ownerTag2], + }); + createdBlockIds.push(block.id); + + // Verify it's discoverable via both tags + const owned1 = await client.blocks.list({ tags: [ownerTag1] }); + const owned1Array = Array.isArray(owned1) + ? owned1 + : (owned1 as { items?: Array<{ id: string }> }).items || []; + expect(owned1Array.some((b) => b.id === block.id)).toBe(true); + + const owned2 = await client.blocks.list({ tags: [ownerTag2] }); + const owned2Array = Array.isArray(owned2) + ? owned2 + : (owned2 as { items?: Array<{ id: string }> }).items || []; + expect(owned2Array.some((b) => b.id === block.id)).toBe(true); + }); +}); diff --git a/src/tests/agent/memoryFilesystem.test.ts b/src/tests/agent/memoryFilesystem.test.ts index c17bc2b..3eed55b 100644 --- a/src/tests/agent/memoryFilesystem.test.ts +++ b/src/tests/agent/memoryFilesystem.test.ts @@ -17,14 +17,27 @@ import { // Helper to create a mock client function createMockClient(options: { - blocks?: Array<{ id: string; label: string; value: string }>; + blocks?: Array<{ + id: string; + label: string; + value: string; + tags?: string[]; + }>; + ownedBlocks?: Array<{ + id: string; + label: string; + value: string; + tags?: string[]; + }>; onBlockCreate?: (data: unknown) => { id: string }; - onBlockUpdate?: (label: string, data: unknown) => void; + onBlockUpdate?: (blockId: string, data: unknown) => void; + onAgentBlockUpdate?: (label: string, data: unknown) => void; onBlockAttach?: (blockId: string, data: unknown) => void; onBlockDetach?: (blockId: string, data: unknown) => void; throwOnUpdate?: string; // label to throw "Not Found" on }) { const blocks = options.blocks ?? []; + const ownedBlocks = options.ownedBlocks ?? []; return { agents: { @@ -34,7 +47,7 @@ function createMockClient(options: { if (options.throwOnUpdate === label) { return Promise.reject(new Error("Not Found")); } - options.onBlockUpdate?.(label, data); + options.onAgentBlockUpdate?.(label, data); return Promise.resolve({}); }), attach: mock((blockId: string, data: unknown) => { @@ -60,6 +73,20 @@ function createMockClient(options: { return Promise.resolve(block); }), delete: mock(() => Promise.resolve({})), + list: mock((params?: { tags?: string[] }) => { + // Filter by tags if provided + if (params?.tags?.length) { + const filtered = ownedBlocks.filter((b) => + params.tags!.some((tag) => b.tags?.includes(tag)), + ); + return Promise.resolve(filtered); + } + return Promise.resolve(ownedBlocks); + }), + update: mock((blockId: string, data: unknown) => { + options.onBlockUpdate?.(blockId, data); + return Promise.resolve({}); + }), }, }; } @@ -343,3 +370,145 @@ describe("memory filesystem paths", () => { ); }); }); + +describe("block tagging", () => { + test("block creation includes owner tag", async () => { + const createdBlockData: unknown[] = []; + const mockClient = createMockClient({ + blocks: [], + ownedBlocks: [], + onBlockCreate: (data) => { + createdBlockData.push(data); + return { id: "new-block-id" }; + }, + }); + + // Verify mock client tracks tags in block creation + await mockClient.blocks.create({ + label: "test-block", + value: "test content", + tags: ["owner:agent-123"], + }); + + expect(createdBlockData.length).toBe(1); + expect((createdBlockData[0] as { tags: string[] }).tags).toContain( + "owner:agent-123", + ); + }); + + test("blocks.list filters by owner tag", async () => { + const mockClient = createMockClient({ + blocks: [], + ownedBlocks: [ + { id: "block-1", label: "owned", value: "v1", tags: ["owner:agent-1"] }, + { id: "block-2", label: "other", value: "v2", tags: ["owner:agent-2"] }, + { + id: "block-3", + label: "also-owned", + value: "v3", + tags: ["owner:agent-1"], + }, + ], + }); + + const result = await mockClient.blocks.list({ tags: ["owner:agent-1"] }); + + expect(result.length).toBe(2); + expect(result.map((b) => b.label)).toContain("owned"); + expect(result.map((b) => b.label)).toContain("also-owned"); + expect(result.map((b) => b.label)).not.toContain("other"); + }); + + test("detached blocks are discovered via owner tag", async () => { + const agentId = "agent-123"; + + // Attached blocks (returned by agents.blocks.list) + const attachedBlocks = [ + { + id: "attached-1", + label: "persona", + value: "v1", + tags: [`owner:${agentId}`], + }, + ]; + + // All owned blocks (returned by blocks.list with tag filter) + const ownedBlocks = [ + ...attachedBlocks, + { + id: "detached-1", + label: "notes", + value: "v2", + tags: [`owner:${agentId}`], + }, + { + id: "detached-2", + label: "archive", + value: "v3", + tags: [`owner:${agentId}`], + }, + ]; + + const mockClient = createMockClient({ + blocks: attachedBlocks, + ownedBlocks: ownedBlocks, + }); + + // Get attached blocks + const attached = await mockClient.agents.blocks.list(); + const attachedIds = new Set(attached.map((b) => b.id)); + + // Get all owned blocks via tag + const allOwned = await mockClient.blocks.list({ + tags: [`owner:${agentId}`], + }); + + // Calculate detached = owned - attached + const detached = allOwned.filter((b) => !attachedIds.has(b.id)); + + expect(detached.length).toBe(2); + expect(detached.map((b) => b.label)).toContain("notes"); + expect(detached.map((b) => b.label)).toContain("archive"); + expect(detached.map((b) => b.label)).not.toContain("persona"); + }); + + test("backfill adds owner tag to blocks missing it", async () => { + const updatedBlocks: Array<{ blockId: string; data: unknown }> = []; + const agentId = "agent-123"; + + const mockClient = createMockClient({ + blocks: [ + { id: "block-1", label: "persona", value: "v1", tags: [] }, // No owner tag + { + id: "block-2", + label: "human", + value: "v2", + tags: [`owner:${agentId}`], + }, // Has owner tag + { id: "block-3", label: "project", value: "v3" }, // No tags at all + ], + onBlockUpdate: (blockId, data) => { + updatedBlocks.push({ blockId, data }); + }, + }); + + // Simulate backfill logic + const blocks = await mockClient.agents.blocks.list(); + const ownerTag = `owner:${agentId}`; + + for (const block of blocks) { + const tags = block.tags || []; + if (!tags.includes(ownerTag)) { + await mockClient.blocks.update(block.id, { + tags: [...tags, ownerTag], + }); + } + } + + // Should have updated block-1 and block-3, but not block-2 + expect(updatedBlocks.length).toBe(2); + expect(updatedBlocks.map((u) => u.blockId)).toContain("block-1"); + expect(updatedBlocks.map((u) => u.blockId)).toContain("block-3"); + expect(updatedBlocks.map((u) => u.blockId)).not.toContain("block-2"); + }); +});