feat: add migrating-memory skill for copying/sharing blocks between agents
- Add migrating-memory bundled skill with 4 scripts: - list-agents.ts: List all accessible agents - get-agent-blocks.ts: Get memory blocks from an agent - copy-block.ts: Copy a block to create independent copy - attach-block.ts: Attach existing block (shared) - Scripts auto-target current agent for safety (no accidental modifications) - Skill.ts now includes "# Skill Directory:" when loading skills with additional files - Update initializing-memory to suggest migration option - Add unit tests for all migration scripts 🐙 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com>
This commit is contained in:
@@ -18,6 +18,18 @@ This command may be run in different scenarios:
|
||||
|
||||
Before making changes, use the `memory` tool to inspect your current memory blocks and understand what already exists.
|
||||
|
||||
## Memory Migration Option
|
||||
|
||||
If you're setting up a new agent that should inherit memory from an existing agent, consider using the `migrating-memory` skill:
|
||||
|
||||
1. Load the skill: `Skill({ command: "load", skills: ["migrating-memory"] })`
|
||||
2. Follow its workflow to copy or share blocks from another agent
|
||||
|
||||
**When to suggest migration**:
|
||||
- User mentions they have an existing agent with useful memory
|
||||
- User is replacing an old agent with a new one
|
||||
- User wants to share memory blocks across multiple agents
|
||||
|
||||
## What Coding Agents Should Remember
|
||||
|
||||
### 1. Procedures (Rules & Workflows)
|
||||
|
||||
118
src/skills/builtin/migrating-memory/SKILL.md
Normal file
118
src/skills/builtin/migrating-memory/SKILL.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: migrating-memory
|
||||
description: Migrate memory blocks from an existing agent to the current agent. Use when the user wants to copy or share memory from another agent, or during /init when setting up a new agent that should inherit memory from an existing one.
|
||||
---
|
||||
|
||||
# Migrating Memory
|
||||
|
||||
This skill helps migrate memory blocks from an existing agent to a new agent, similar to macOS Migration Assistant for AI agents.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- User is setting up a new agent that should inherit memory from an existing one
|
||||
- User wants to share memory blocks across multiple agents
|
||||
- User is replacing an old agent with a new one
|
||||
- User mentions they have an existing agent with useful memory
|
||||
|
||||
## Migration Methods
|
||||
|
||||
### 1. Copy (Independent Blocks)
|
||||
|
||||
Creates new blocks with the same content. After copying:
|
||||
- The new agent owns its copy
|
||||
- Changes to one agent's block don't affect the other
|
||||
- Best for: One-time migration, forking an agent
|
||||
|
||||
### 2. Share (Linked Blocks)
|
||||
|
||||
Attaches the same block to multiple agents. After sharing:
|
||||
- All agents see the same block content
|
||||
- Changes by any agent are visible to all others
|
||||
- Can be read-only (target can read but not modify)
|
||||
- Best for: Shared knowledge bases, synchronized state
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: List Available Agents
|
||||
|
||||
Find the source agent you want to migrate from:
|
||||
|
||||
```bash
|
||||
npx ts-node scripts/list-agents.ts
|
||||
```
|
||||
|
||||
This outputs all agents you have access to with their IDs and names.
|
||||
|
||||
### Step 2: View Source Agent's Blocks
|
||||
|
||||
Inspect what memory blocks the source agent has:
|
||||
|
||||
```bash
|
||||
npx ts-node scripts/get-agent-blocks.ts --agent-id <source-agent-id>
|
||||
```
|
||||
|
||||
This shows each block's ID, label, description, and value.
|
||||
|
||||
### Step 3: Migrate Blocks
|
||||
|
||||
For each block you want to migrate, choose copy or share:
|
||||
|
||||
**To Copy (create independent block):**
|
||||
```bash
|
||||
npx ts-node scripts/copy-block.ts --block-id <block-id>
|
||||
```
|
||||
|
||||
**To Share (attach existing block):**
|
||||
```bash
|
||||
npx ts-node scripts/attach-block.ts --block-id <block-id>
|
||||
```
|
||||
|
||||
Add `--read-only` flag to share to make this agent unable to modify the block.
|
||||
|
||||
Note: These scripts automatically target the current agent (you) for safety.
|
||||
|
||||
## Script Reference
|
||||
|
||||
All scripts are located in the `scripts/` directory and output raw API responses (JSON).
|
||||
|
||||
| Script | Purpose | Required Args |
|
||||
|--------|---------|---------------|
|
||||
| `list-agents.ts` | List all accessible agents | (none) |
|
||||
| `get-agent-blocks.ts` | Get blocks from an agent | `--agent-id` |
|
||||
| `copy-block.ts` | Copy block to current agent | `--block-id` |
|
||||
| `attach-block.ts` | Attach existing block to current agent | `--block-id`, optional `--read-only` |
|
||||
|
||||
## Authentication
|
||||
|
||||
The bundled scripts automatically use the same authentication as Letta Code:
|
||||
- Keychain/secrets storage
|
||||
- `~/.config/letta/settings.json` fallback
|
||||
- `LETTA_API_KEY` environment variable
|
||||
|
||||
You can also make direct API calls using the Letta SDK if you have the API key available.
|
||||
|
||||
## Example: Migrating Project Memory
|
||||
|
||||
Scenario: You're a new agent and want to inherit memory from an existing agent "ProjectX-v1".
|
||||
|
||||
1. **Find source agent:**
|
||||
```bash
|
||||
npx ts-node scripts/list-agents.ts
|
||||
# Find "ProjectX-v1" ID: agent-abc123
|
||||
```
|
||||
|
||||
2. **List its blocks:**
|
||||
```bash
|
||||
npx ts-node scripts/get-agent-blocks.ts --agent-id agent-abc123
|
||||
# Shows: project (block-def456), human (block-ghi789), persona (block-jkl012)
|
||||
```
|
||||
|
||||
3. **Copy project knowledge to yourself:**
|
||||
```bash
|
||||
npx ts-node scripts/copy-block.ts --block-id block-def456
|
||||
```
|
||||
|
||||
4. **Optionally share human preferences (read-only):**
|
||||
```bash
|
||||
npx ts-node scripts/attach-block.ts --block-id block-ghi789 --read-only
|
||||
```
|
||||
100
src/skills/builtin/migrating-memory/scripts/attach-block.ts
Normal file
100
src/skills/builtin/migrating-memory/scripts/attach-block.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* Attach Block - Attaches an existing memory block to an agent (sharing)
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node attach-block.ts --block-id <block-id> --target-agent-id <agent-id> [--read-only]
|
||||
*
|
||||
* This attaches an existing block to another agent, making it shared.
|
||||
* Changes to the block will be visible to all agents that have it attached.
|
||||
*
|
||||
* Options:
|
||||
* --read-only Target agent can read but not modify the block
|
||||
*
|
||||
* Output:
|
||||
* Raw API response from the attach operation
|
||||
*/
|
||||
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import { getClient } from "../../../../agent/client";
|
||||
import { getCurrentAgentId } from "../../../../agent/context";
|
||||
import { settingsManager } from "../../../../settings-manager";
|
||||
|
||||
/**
|
||||
* Attach an existing block to the current agent (sharing it)
|
||||
* @param client - Letta client instance
|
||||
* @param blockId - The block ID to attach
|
||||
* @param readOnly - Whether this agent should have read-only access
|
||||
* @param targetAgentId - Optional target agent ID (defaults to current agent)
|
||||
* @returns API response from the attach operation
|
||||
*/
|
||||
export async function attachBlock(
|
||||
client: Letta,
|
||||
blockId: string,
|
||||
readOnly = false,
|
||||
targetAgentId?: string,
|
||||
): Promise<Awaited<ReturnType<typeof client.agents.blocks.attach>>> {
|
||||
// Get current agent ID (the agent calling this script) or use provided ID
|
||||
const currentAgentId = targetAgentId ?? getCurrentAgentId();
|
||||
|
||||
const result = await client.agents.blocks.attach(blockId, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
|
||||
// If read-only is requested, update the block's read_only flag for this agent
|
||||
// Note: This may require a separate API call depending on how read_only works
|
||||
if (readOnly) {
|
||||
// The read_only flag is per-block, not per-agent attachment
|
||||
// For now, we'll note this in the output
|
||||
console.warn(
|
||||
"Note: read_only flag is set on the block itself, not per-agent. " +
|
||||
"Use the block update API to set read_only if needed.",
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): {
|
||||
blockId: string;
|
||||
readOnly: boolean;
|
||||
} {
|
||||
const blockIdIndex = args.indexOf("--block-id");
|
||||
const readOnly = args.includes("--read-only");
|
||||
|
||||
if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) {
|
||||
throw new Error("Missing required argument: --block-id <block-id>");
|
||||
}
|
||||
|
||||
return {
|
||||
blockId: args[blockIdIndex + 1] as string,
|
||||
readOnly,
|
||||
};
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
try {
|
||||
const { blockId, readOnly } = parseArgs(process.argv.slice(2));
|
||||
await settingsManager.initialize();
|
||||
const client = await getClient();
|
||||
const result = await attachBlock(client, blockId, readOnly);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Missing required argument")
|
||||
) {
|
||||
console.error(
|
||||
"\nUsage: npx ts-node attach-block.ts --block-id <block-id> [--read-only]",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
100
src/skills/builtin/migrating-memory/scripts/copy-block.ts
Normal file
100
src/skills/builtin/migrating-memory/scripts/copy-block.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* Copy Block - Copies a memory block to create a new independent block for another agent
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node copy-block.ts --block-id <block-id> --target-agent-id <agent-id>
|
||||
*
|
||||
* This creates a new block with the same content as the source block,
|
||||
* then attaches it to the target agent. Changes to the new block
|
||||
* won't affect the original.
|
||||
*
|
||||
* Output:
|
||||
* Raw API response from each step (retrieve, create, attach)
|
||||
*/
|
||||
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import { getClient } from "../../../../agent/client";
|
||||
import { getCurrentAgentId } from "../../../../agent/context";
|
||||
import { settingsManager } from "../../../../settings-manager";
|
||||
|
||||
interface CopyBlockResult {
|
||||
sourceBlock: Awaited<ReturnType<typeof Letta.prototype.blocks.retrieve>>;
|
||||
newBlock: Awaited<ReturnType<typeof Letta.prototype.blocks.create>>;
|
||||
attachResult: Awaited<
|
||||
ReturnType<typeof Letta.prototype.agents.blocks.attach>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a block's content to a new block and attach to the current agent
|
||||
* @param client - Letta client instance
|
||||
* @param blockId - The source block ID to copy from
|
||||
* @param targetAgentId - Optional target agent ID (defaults to current agent)
|
||||
* @returns Object containing source block, new block, and attach result
|
||||
*/
|
||||
export async function copyBlock(
|
||||
client: Letta,
|
||||
blockId: string,
|
||||
targetAgentId?: string,
|
||||
): Promise<CopyBlockResult> {
|
||||
// Get current agent ID (the agent calling this script) or use provided ID
|
||||
const currentAgentId = targetAgentId ?? getCurrentAgentId();
|
||||
|
||||
// 1. Get source block details
|
||||
const sourceBlock = await client.blocks.retrieve(blockId);
|
||||
|
||||
// 2. Create new block with same content
|
||||
const newBlock = await client.blocks.create({
|
||||
label: sourceBlock.label || "migrated-block",
|
||||
value: sourceBlock.value,
|
||||
description: sourceBlock.description || undefined,
|
||||
limit: sourceBlock.limit,
|
||||
});
|
||||
|
||||
// 3. Attach new block to current agent
|
||||
const attachResult = await client.agents.blocks.attach(newBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
|
||||
return { sourceBlock, newBlock, attachResult };
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { blockId: string } {
|
||||
const blockIdIndex = args.indexOf("--block-id");
|
||||
|
||||
if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) {
|
||||
throw new Error("Missing required argument: --block-id <block-id>");
|
||||
}
|
||||
|
||||
return {
|
||||
blockId: args[blockIdIndex + 1] as string,
|
||||
};
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
try {
|
||||
const { blockId } = parseArgs(process.argv.slice(2));
|
||||
await settingsManager.initialize();
|
||||
const client = await getClient();
|
||||
const result = await copyBlock(client, blockId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Missing required argument")
|
||||
) {
|
||||
console.error(
|
||||
"\nUsage: npx ts-node copy-block.ts --block-id <block-id>",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* Get Agent Blocks - Retrieves memory blocks from a specific agent
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node get-agent-blocks.ts --agent-id <agent-id>
|
||||
*
|
||||
* Output:
|
||||
* Raw API response from GET /v1/agents/{id}/core-memory/blocks
|
||||
*/
|
||||
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import { getClient } from "../../../../agent/client";
|
||||
import { settingsManager } from "../../../../settings-manager";
|
||||
|
||||
/**
|
||||
* Get memory blocks for a specific agent
|
||||
* @param client - Letta client instance
|
||||
* @param agentId - The agent ID to get blocks from
|
||||
* @returns Array of block objects from the API
|
||||
*/
|
||||
export async function getAgentBlocks(
|
||||
client: Letta,
|
||||
agentId: string,
|
||||
): Promise<Awaited<ReturnType<typeof client.agents.blocks.list>>> {
|
||||
return await client.agents.blocks.list(agentId);
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { agentId: string } {
|
||||
const agentIdIndex = args.indexOf("--agent-id");
|
||||
if (agentIdIndex === -1 || agentIdIndex + 1 >= args.length) {
|
||||
throw new Error("Missing required argument: --agent-id <agent-id>");
|
||||
}
|
||||
return { agentId: args[agentIdIndex + 1] as string };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
try {
|
||||
const { agentId } = parseArgs(process.argv.slice(2));
|
||||
await settingsManager.initialize();
|
||||
const client = await getClient();
|
||||
const result = await getAgentBlocks(client, agentId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Missing required argument")
|
||||
) {
|
||||
console.error(
|
||||
"\nUsage: npx ts-node get-agent-blocks.ts --agent-id <agent-id>",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
43
src/skills/builtin/migrating-memory/scripts/list-agents.ts
Normal file
43
src/skills/builtin/migrating-memory/scripts/list-agents.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
/**
|
||||
* List Agents - Lists all agents accessible to the user
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node list-agents.ts
|
||||
*
|
||||
* Output:
|
||||
* Raw API response from GET /v1/agents
|
||||
*/
|
||||
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import { getClient } from "../../../../agent/client";
|
||||
import { settingsManager } from "../../../../settings-manager";
|
||||
|
||||
/**
|
||||
* List all agents accessible to the user
|
||||
* @param client - Letta client instance
|
||||
* @returns Array of agent objects from the API
|
||||
*/
|
||||
export async function listAgents(
|
||||
client: Letta,
|
||||
): Promise<Awaited<ReturnType<typeof client.agents.list>>> {
|
||||
return await client.agents.list();
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
try {
|
||||
await settingsManager.initialize();
|
||||
const client = await getClient();
|
||||
const result = await listAgents(client);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
273
src/tests/skills/memory-migration-scripts.test.ts
Normal file
273
src/tests/skills/memory-migration-scripts.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Tests for the bundled memory-migration scripts
|
||||
*/
|
||||
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import type Letta from "@letta-ai/letta-client";
|
||||
import { attachBlock } from "../../skills/builtin/migrating-memory/scripts/attach-block";
|
||||
import { copyBlock } from "../../skills/builtin/migrating-memory/scripts/copy-block";
|
||||
import { getAgentBlocks } from "../../skills/builtin/migrating-memory/scripts/get-agent-blocks";
|
||||
import { listAgents } from "../../skills/builtin/migrating-memory/scripts/list-agents";
|
||||
|
||||
// Mock data
|
||||
const mockAgentsResponse = [
|
||||
{ id: "agent-123", name: "Test Agent 1" },
|
||||
{ id: "agent-456", name: "Test Agent 2" },
|
||||
];
|
||||
|
||||
const mockBlocksResponse = [
|
||||
{
|
||||
id: "block-abc",
|
||||
label: "project",
|
||||
description: "Project info",
|
||||
value: "Test project content",
|
||||
},
|
||||
{
|
||||
id: "block-def",
|
||||
label: "human",
|
||||
description: "Human preferences",
|
||||
value: "Test human content",
|
||||
},
|
||||
];
|
||||
|
||||
const mockBlock = {
|
||||
id: "block-abc",
|
||||
label: "project",
|
||||
description: "Project info",
|
||||
value: "Test project content",
|
||||
limit: 5000,
|
||||
};
|
||||
|
||||
const mockNewBlock = {
|
||||
id: "block-new-123",
|
||||
label: "project",
|
||||
description: "Project info",
|
||||
value: "Test project content",
|
||||
limit: 5000,
|
||||
};
|
||||
|
||||
const mockAgentState = {
|
||||
id: "agent-789",
|
||||
name: "Target Agent",
|
||||
agent_type: "memgpt_agent",
|
||||
blocks: [],
|
||||
llm_config: {},
|
||||
memory: {},
|
||||
embedding_config: {},
|
||||
project_id: "project-123",
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
};
|
||||
|
||||
describe("list-agents", () => {
|
||||
test("calls client.agents.list and returns result", async () => {
|
||||
const mockList = mock(() => Promise.resolve(mockAgentsResponse));
|
||||
const mockClient = {
|
||||
agents: {
|
||||
list: mockList,
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
const result = await listAgents(mockClient);
|
||||
expect(mockList).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles empty agent list", async () => {
|
||||
const mockClient = {
|
||||
agents: {
|
||||
list: mock(() => Promise.resolve([])),
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
const result = await listAgents(mockClient);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("propagates API errors", async () => {
|
||||
const mockClient = {
|
||||
agents: {
|
||||
list: mock(() => Promise.reject(new Error("API Error"))),
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
await expect(listAgents(mockClient)).rejects.toThrow("API Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get-agent-blocks", () => {
|
||||
test("calls client.agents.blocks.list with agent ID", async () => {
|
||||
const mockList = mock(() => Promise.resolve(mockBlocksResponse));
|
||||
const mockClient = {
|
||||
agents: {
|
||||
blocks: {
|
||||
list: mockList,
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
const result = await getAgentBlocks(mockClient, "agent-123");
|
||||
expect(mockList).toHaveBeenCalledWith("agent-123");
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles agent with no blocks", async () => {
|
||||
const mockClient = {
|
||||
agents: {
|
||||
blocks: {
|
||||
list: mock(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
const result = await getAgentBlocks(mockClient, "agent-empty");
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("propagates API errors", async () => {
|
||||
const mockClient = {
|
||||
agents: {
|
||||
blocks: {
|
||||
list: mock(() => Promise.reject(new Error("Agent not found"))),
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
await expect(getAgentBlocks(mockClient, "nonexistent")).rejects.toThrow(
|
||||
"Agent not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy-block", () => {
|
||||
test("retrieves source block, creates new block, and attaches to target agent", async () => {
|
||||
const mockRetrieve = mock(() => Promise.resolve(mockBlock));
|
||||
const mockCreate = mock(() => Promise.resolve(mockNewBlock));
|
||||
const mockAttach = mock(() => Promise.resolve(mockAgentState));
|
||||
|
||||
const mockClient = {
|
||||
blocks: {
|
||||
retrieve: mockRetrieve,
|
||||
create: mockCreate,
|
||||
},
|
||||
agents: {
|
||||
blocks: {
|
||||
attach: mockAttach,
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
// Pass explicit agent ID for testing (in production, defaults to current agent)
|
||||
const result = await copyBlock(mockClient, "block-abc", "agent-789");
|
||||
|
||||
expect(mockRetrieve).toHaveBeenCalledWith("block-abc");
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
label: "project",
|
||||
value: "Test project content",
|
||||
description: "Project info",
|
||||
limit: 5000,
|
||||
});
|
||||
expect(mockAttach).toHaveBeenCalledWith("block-new-123", {
|
||||
agent_id: "agent-789",
|
||||
});
|
||||
|
||||
expect(result.sourceBlock).toEqual(mockBlock);
|
||||
expect(result.newBlock).toEqual(mockNewBlock);
|
||||
expect(result.attachResult).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles block without description", async () => {
|
||||
const blockWithoutDesc = { ...mockBlock, description: null };
|
||||
const mockClient = {
|
||||
blocks: {
|
||||
retrieve: mock(() => Promise.resolve(blockWithoutDesc)),
|
||||
create: mock(() => Promise.resolve(mockNewBlock)),
|
||||
},
|
||||
agents: {
|
||||
blocks: {
|
||||
attach: mock(() => Promise.resolve(mockAgentState)),
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
const result = await copyBlock(mockClient, "block-abc", "agent-789");
|
||||
expect(result.newBlock).toBeDefined();
|
||||
});
|
||||
|
||||
test("propagates errors from retrieve", async () => {
|
||||
const mockClient = {
|
||||
blocks: {
|
||||
retrieve: mock(() => Promise.reject(new Error("Block not found"))),
|
||||
create: mock(() => Promise.resolve(mockNewBlock)),
|
||||
},
|
||||
agents: {
|
||||
blocks: {
|
||||
attach: mock(() => Promise.resolve(mockAgentState)),
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
await expect(
|
||||
copyBlock(mockClient, "nonexistent", "agent-789"),
|
||||
).rejects.toThrow("Block not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attach-block", () => {
|
||||
test("attaches existing block to target agent", async () => {
|
||||
const mockAttach = mock(() => Promise.resolve(mockAgentState));
|
||||
const mockClient = {
|
||||
agents: {
|
||||
blocks: {
|
||||
attach: mockAttach,
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
// Pass explicit agent ID for testing (in production, defaults to current agent)
|
||||
const result = await attachBlock(
|
||||
mockClient,
|
||||
"block-abc",
|
||||
false,
|
||||
"agent-789",
|
||||
);
|
||||
|
||||
expect(mockAttach).toHaveBeenCalledWith("block-abc", {
|
||||
agent_id: "agent-789",
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles read-only flag without error", async () => {
|
||||
const mockClient = {
|
||||
agents: {
|
||||
blocks: {
|
||||
attach: mock(() => Promise.resolve(mockAgentState)),
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
// The function should work with read-only flag (currently just warns)
|
||||
const result = await attachBlock(
|
||||
mockClient,
|
||||
"block-abc",
|
||||
true,
|
||||
"agent-789",
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("propagates API errors", async () => {
|
||||
const mockClient = {
|
||||
agents: {
|
||||
blocks: {
|
||||
attach: mock(() => Promise.reject(new Error("Cannot attach block"))),
|
||||
},
|
||||
},
|
||||
} as unknown as Letta;
|
||||
|
||||
await expect(
|
||||
attachBlock(mockClient, "block-abc", false, "agent-789"),
|
||||
).rejects.toThrow("Cannot attach block");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getClient } from "../../agent/client";
|
||||
import {
|
||||
getCurrentAgentId,
|
||||
@@ -101,19 +102,34 @@ function extractSkillsDir(skillsBlockValue: string): string | null {
|
||||
return match ? match[1]?.trim() || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill directory has additional files beyond SKILL.md
|
||||
*/
|
||||
function hasAdditionalFiles(skillMdPath: string): boolean {
|
||||
try {
|
||||
const skillDir = dirname(skillMdPath);
|
||||
const entries = readdirSync(skillDir);
|
||||
return entries.some((e) => e.toUpperCase() !== "SKILL.MD");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read skill content from file or bundled source
|
||||
* Returns both content and the path to the SKILL.md file
|
||||
*/
|
||||
async function readSkillContent(
|
||||
skillId: string,
|
||||
skillsDir: string,
|
||||
): Promise<string> {
|
||||
): Promise<{ content: string; path: string }> {
|
||||
// 1. Check bundled skills first (they have a path now)
|
||||
const bundledSkills = await getBundledSkills();
|
||||
const bundledSkill = bundledSkills.find((s) => s.id === skillId);
|
||||
if (bundledSkill?.path) {
|
||||
try {
|
||||
return await readFile(bundledSkill.path, "utf-8");
|
||||
const content = await readFile(bundledSkill.path, "utf-8");
|
||||
return { content, path: bundledSkill.path };
|
||||
} catch {
|
||||
// Bundled skill path not found, continue to other sources
|
||||
}
|
||||
@@ -122,21 +138,24 @@ async function readSkillContent(
|
||||
// 2. Try global skills directory
|
||||
const globalSkillPath = join(GLOBAL_SKILLS_DIR, skillId, "SKILL.md");
|
||||
try {
|
||||
return await readFile(globalSkillPath, "utf-8");
|
||||
const content = await readFile(globalSkillPath, "utf-8");
|
||||
return { content, path: globalSkillPath };
|
||||
} catch {
|
||||
// Not in global, continue
|
||||
}
|
||||
|
||||
// 3. Try project skills directory
|
||||
const skillPath = join(skillsDir, skillId, "SKILL.md");
|
||||
const projectSkillPath = join(skillsDir, skillId, "SKILL.md");
|
||||
try {
|
||||
return await readFile(skillPath, "utf-8");
|
||||
const content = await readFile(projectSkillPath, "utf-8");
|
||||
return { content, path: projectSkillPath };
|
||||
} catch (primaryError) {
|
||||
// Fallback: check for bundled skills in a repo-level skills directory (legacy)
|
||||
try {
|
||||
const bundledSkillsDir = join(process.cwd(), "skills", "skills");
|
||||
const bundledSkillPath = join(bundledSkillsDir, skillId, "SKILL.md");
|
||||
return await readFile(bundledSkillPath, "utf-8");
|
||||
const content = await readFile(bundledSkillPath, "utf-8");
|
||||
return { content, path: bundledSkillPath };
|
||||
} catch {
|
||||
// If all fallbacks fail, rethrow the original error
|
||||
throw primaryError;
|
||||
@@ -259,16 +278,23 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
}
|
||||
|
||||
try {
|
||||
const skillContent = await readSkillContent(skillId, skillsDir);
|
||||
const { content: skillContent, path: skillPath } =
|
||||
await readSkillContent(skillId, skillsDir);
|
||||
|
||||
// Replace placeholder if this is the first skill
|
||||
if (currentValue === "[CURRENTLY EMPTY]") {
|
||||
currentValue = "";
|
||||
}
|
||||
|
||||
// Build skill header with optional path info
|
||||
const skillDir = dirname(skillPath);
|
||||
const pathLine = hasAdditionalFiles(skillPath)
|
||||
? `# Skill Directory: ${skillDir}\n\n`
|
||||
: "";
|
||||
|
||||
// Append new skill
|
||||
const separator = currentValue ? "\n\n---\n\n" : "";
|
||||
currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${skillContent}`;
|
||||
currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${pathLine}${skillContent}`;
|
||||
loadedSkillIds.push(skillId);
|
||||
results.push(`"${skillId}" loaded`);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user