feat(memfs): add owner tags for block ownership tracking (#744)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-29 14:52:58 -08:00
committed by GitHub
parent 66eeac5b59
commit ad95f79133
4 changed files with 439 additions and 30 deletions

View File

@@ -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);
});
});

View File

@@ -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");
});
});