feat: add --override flag and improve docs (#462)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -29,7 +29,6 @@ Best for: Extracting sections, cleaning up messy content, selective migration.
|
||||
|
||||
Creates new blocks with the same content using `copy-block.ts`. After copying:
|
||||
- You own the copy - changes don't sync
|
||||
- Use `--label` flag if you already have a block with that label
|
||||
- Best for: One-time migration, forking an agent
|
||||
|
||||
### 3. Share (Linked Blocks)
|
||||
@@ -40,7 +39,31 @@ Attaches the same block to multiple agents using `attach-block.ts`. After sharin
|
||||
- Can be read-only (target can read but not modify)
|
||||
- Best for: Shared knowledge bases, synchronized state
|
||||
|
||||
**Note:** You cannot have two blocks with the same label. When copying, use `--label` to rename if needed.
|
||||
## Handling Duplicate Label Errors
|
||||
|
||||
**You cannot have two blocks with the same label.** If you try to copy/attach a block and you already have one with that label, you'll get a `duplicate key value violates unique constraint` error.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use `--label` (copy only):** Rename the block when copying:
|
||||
```bash
|
||||
npx tsx <SKILL_DIR>/scripts/copy-block.ts --block-id <id> --label project-imported
|
||||
```
|
||||
|
||||
2. **Use `--override` (copy or attach):** Automatically detach your existing block first:
|
||||
```bash
|
||||
npx tsx <SKILL_DIR>/scripts/copy-block.ts --block-id <id> --override
|
||||
npx tsx <SKILL_DIR>/scripts/attach-block.ts --block-id <id> --override
|
||||
```
|
||||
If the operation fails, the original block is automatically reattached.
|
||||
|
||||
3. **Manual detach first:** Use the `memory` tool to detach your existing block:
|
||||
```
|
||||
memory(agent_state, "delete", path="/memories/<label>")
|
||||
```
|
||||
Then run the copy/attach script.
|
||||
|
||||
**Note:** `attach-block.ts` does NOT support `--label` because attached blocks keep their original label (they're shared, not copied).
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -92,8 +115,8 @@ All scripts are located in the `scripts/` directory and output raw API responses
|
||||
| Script | Purpose | Args |
|
||||
|--------|---------|------|
|
||||
| `get-agent-blocks.ts` | Get blocks from an agent | `--agent-id` |
|
||||
| `copy-block.ts` | Copy block to current agent | `--block-id`, optional `--label` |
|
||||
| `attach-block.ts` | Attach existing block to current agent | `--block-id`, optional `--read-only` |
|
||||
| `copy-block.ts` | Copy block to current agent | `--block-id`, optional `--label`, `--override` |
|
||||
| `attach-block.ts` | Attach existing block to current agent | `--block-id`, optional `--read-only`, `--override` |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* It reads agent ID from LETTA_AGENT_ID env var or --agent-id arg.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx attach-block.ts --block-id <block-id> [--agent-id <agent-id>] [--read-only]
|
||||
* npx tsx attach-block.ts --block-id <block-id> [--agent-id <agent-id>] [--read-only] [--override]
|
||||
*
|
||||
* 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.
|
||||
@@ -15,6 +15,8 @@
|
||||
* Options:
|
||||
* --agent-id Target agent ID (overrides LETTA_AGENT_ID env var)
|
||||
* --read-only Target agent can read but not modify the block
|
||||
* --override If you already have a block with the same label, detach it first
|
||||
* (on error, the original block is reattached)
|
||||
*
|
||||
* Output:
|
||||
* Raw API response from the attach operation
|
||||
@@ -75,49 +77,109 @@ function createClient(): LettaClient {
|
||||
return new Letta({ apiKey: getApiKey() });
|
||||
}
|
||||
|
||||
interface AttachBlockResult {
|
||||
attachResult: Awaited<ReturnType<LettaClient["agents"]["blocks"]["attach"]>>;
|
||||
detachedBlock?: Awaited<ReturnType<LettaClient["blocks"]["retrieve"]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @param options - readOnly, targetAgentId, override (detach existing block with same label)
|
||||
* @returns API response from the attach operation
|
||||
*/
|
||||
export async function attachBlock(
|
||||
client: LettaClient,
|
||||
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 = getAgentId(targetAgentId);
|
||||
options?: { readOnly?: boolean; targetAgentId?: string; override?: boolean },
|
||||
): Promise<AttachBlockResult> {
|
||||
const currentAgentId = getAgentId(options?.targetAgentId);
|
||||
let detachedBlock:
|
||||
| Awaited<ReturnType<LettaClient["blocks"]["retrieve"]>>
|
||||
| undefined;
|
||||
|
||||
const result = await client.agents.blocks.attach(blockId, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
// If override is requested, check for existing block with same label and detach it
|
||||
if (options?.override) {
|
||||
// Get the block we're trying to attach to find its label
|
||||
const sourceBlock = await client.blocks.retrieve(blockId);
|
||||
const sourceLabel = sourceBlock.label;
|
||||
|
||||
// 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
|
||||
// Get current agent's blocks to check for label conflict
|
||||
const currentBlocksResponse =
|
||||
await client.agents.blocks.list(currentAgentId);
|
||||
// The response may be paginated or an array depending on SDK version
|
||||
const currentBlocks = Array.isArray(currentBlocksResponse)
|
||||
? currentBlocksResponse
|
||||
: (currentBlocksResponse as { items?: unknown[] }).items || [];
|
||||
const conflictingBlock = currentBlocks.find(
|
||||
(b: { label?: string }) => b.label === sourceLabel,
|
||||
);
|
||||
|
||||
if (conflictingBlock) {
|
||||
console.error(
|
||||
`Detaching existing block with label "${sourceLabel}" (${conflictingBlock.id})...`,
|
||||
);
|
||||
detachedBlock = conflictingBlock;
|
||||
try {
|
||||
await client.agents.blocks.detach(conflictingBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
} catch (detachError) {
|
||||
throw new Error(
|
||||
`Failed to detach existing block "${sourceLabel}": ${detachError instanceof Error ? detachError.message : String(detachError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to attach the new block
|
||||
let attachResult: Awaited<ReturnType<typeof client.agents.blocks.attach>>;
|
||||
try {
|
||||
attachResult = await client.agents.blocks.attach(blockId, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
} catch (attachError) {
|
||||
// If attach failed and we detached a block, try to reattach it
|
||||
if (detachedBlock) {
|
||||
console.error(
|
||||
`Attach failed, reattaching original block "${detachedBlock.label}"...`,
|
||||
);
|
||||
try {
|
||||
await client.agents.blocks.attach(detachedBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
console.error("Original block reattached successfully.");
|
||||
} catch {
|
||||
console.error(
|
||||
`WARNING: Failed to reattach original block! Block ID: ${detachedBlock.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw attachError;
|
||||
}
|
||||
|
||||
// If read-only is requested, note the limitation
|
||||
if (options?.readOnly) {
|
||||
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;
|
||||
return { attachResult, detachedBlock };
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): {
|
||||
blockId: string;
|
||||
readOnly: boolean;
|
||||
override: boolean;
|
||||
agentId?: string;
|
||||
} {
|
||||
const blockIdIndex = args.indexOf("--block-id");
|
||||
const agentIdIndex = args.indexOf("--agent-id");
|
||||
const readOnly = args.includes("--read-only");
|
||||
const override = args.includes("--override");
|
||||
|
||||
if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) {
|
||||
throw new Error("Missing required argument: --block-id <block-id>");
|
||||
@@ -126,6 +188,7 @@ function parseArgs(args: string[]): {
|
||||
return {
|
||||
blockId: args[blockIdIndex + 1] as string,
|
||||
readOnly,
|
||||
override,
|
||||
agentId:
|
||||
agentIdIndex !== -1 && agentIdIndex + 1 < args.length
|
||||
? (args[agentIdIndex + 1] as string)
|
||||
@@ -138,9 +201,15 @@ const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
(async () => {
|
||||
try {
|
||||
const { blockId, readOnly, agentId } = parseArgs(process.argv.slice(2));
|
||||
const { blockId, readOnly, override, agentId } = parseArgs(
|
||||
process.argv.slice(2),
|
||||
);
|
||||
const client = createClient();
|
||||
const result = await attachBlock(client, blockId, readOnly, agentId);
|
||||
const result = await attachBlock(client, blockId, {
|
||||
readOnly,
|
||||
override,
|
||||
targetAgentId: agentId,
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -152,7 +221,7 @@ if (isMainModule) {
|
||||
error.message.includes("Missing required argument")
|
||||
) {
|
||||
console.error(
|
||||
"\nUsage: npx tsx attach-block.ts --block-id <block-id> [--agent-id <agent-id>] [--read-only]",
|
||||
"\nUsage: npx tsx attach-block.ts --block-id <block-id> [--agent-id <agent-id>] [--read-only] [--override]",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
* It reads agent ID from LETTA_AGENT_ID env var or --agent-id arg.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx copy-block.ts --block-id <block-id> [--label <new-label>] [--agent-id <agent-id>]
|
||||
* npx tsx copy-block.ts --block-id <block-id> [--label <new-label>] [--agent-id <agent-id>] [--override]
|
||||
*
|
||||
* Options:
|
||||
* --label Override the block label (required if you already have a block with that label)
|
||||
* --label Override the block label (useful to avoid duplicate label errors)
|
||||
* --agent-id Target agent ID (overrides LETTA_AGENT_ID env var)
|
||||
* --override If you already have a block with the same label, detach it first
|
||||
* (on error, the original block is reattached)
|
||||
*
|
||||
* This creates a new block with the same content as the source block,
|
||||
* then attaches it to the current agent. Changes to the new block
|
||||
@@ -37,6 +39,7 @@ interface CopyBlockResult {
|
||||
sourceBlock: Awaited<ReturnType<LettaClient["blocks"]["retrieve"]>>;
|
||||
newBlock: Awaited<ReturnType<LettaClient["blocks"]["create"]>>;
|
||||
attachResult: Awaited<ReturnType<LettaClient["agents"]["blocks"]["attach"]>>;
|
||||
detachedBlock?: Awaited<ReturnType<LettaClient["blocks"]["retrieve"]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,44 +89,125 @@ function createClient(): LettaClient {
|
||||
* 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 options - Optional settings: labelOverride, targetAgentId
|
||||
* @param options - Optional settings: labelOverride, targetAgentId, override
|
||||
* @returns Object containing source block, new block, and attach result
|
||||
*/
|
||||
export async function copyBlock(
|
||||
client: LettaClient,
|
||||
blockId: string,
|
||||
options?: { labelOverride?: string; targetAgentId?: string },
|
||||
options?: {
|
||||
labelOverride?: string;
|
||||
targetAgentId?: string;
|
||||
override?: boolean;
|
||||
},
|
||||
): Promise<CopyBlockResult> {
|
||||
// Get current agent ID (the agent calling this script) or use provided ID
|
||||
const currentAgentId = getAgentId(options?.targetAgentId);
|
||||
let detachedBlock:
|
||||
| Awaited<ReturnType<LettaClient["blocks"]["retrieve"]>>
|
||||
| undefined;
|
||||
|
||||
// 1. Get source block details
|
||||
const sourceBlock = await client.blocks.retrieve(blockId);
|
||||
const targetLabel =
|
||||
options?.labelOverride || sourceBlock.label || "migrated-block";
|
||||
|
||||
// 2. Create new block with same content (optionally override label)
|
||||
const newBlock = await client.blocks.create({
|
||||
label: options?.labelOverride || sourceBlock.label || "migrated-block",
|
||||
value: sourceBlock.value,
|
||||
description: sourceBlock.description || undefined,
|
||||
limit: sourceBlock.limit,
|
||||
});
|
||||
// 2. If override is requested, check for existing block with same label and detach it
|
||||
if (options?.override) {
|
||||
const currentBlocksResponse =
|
||||
await client.agents.blocks.list(currentAgentId);
|
||||
// The response may be paginated or an array depending on SDK version
|
||||
const currentBlocks = Array.isArray(currentBlocksResponse)
|
||||
? currentBlocksResponse
|
||||
: (currentBlocksResponse as { items?: unknown[] }).items || [];
|
||||
const conflictingBlock = currentBlocks.find(
|
||||
(b: { label?: string }) => b.label === targetLabel,
|
||||
);
|
||||
|
||||
// 3. Attach new block to current agent
|
||||
const attachResult = await client.agents.blocks.attach(newBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
if (conflictingBlock) {
|
||||
console.error(
|
||||
`Detaching existing block with label "${targetLabel}" (${conflictingBlock.id})...`,
|
||||
);
|
||||
detachedBlock = conflictingBlock;
|
||||
try {
|
||||
await client.agents.blocks.detach(conflictingBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
} catch (detachError) {
|
||||
throw new Error(
|
||||
`Failed to detach existing block "${targetLabel}": ${detachError instanceof Error ? detachError.message : String(detachError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sourceBlock, newBlock, attachResult };
|
||||
// 3. Create new block with same content
|
||||
let newBlock: Awaited<ReturnType<LettaClient["blocks"]["create"]>>;
|
||||
try {
|
||||
newBlock = await client.blocks.create({
|
||||
label: targetLabel,
|
||||
value: sourceBlock.value,
|
||||
description: sourceBlock.description || undefined,
|
||||
limit: sourceBlock.limit,
|
||||
});
|
||||
} catch (createError) {
|
||||
// If create failed and we detached a block, try to reattach it
|
||||
if (detachedBlock) {
|
||||
console.error(
|
||||
`Create failed, reattaching original block "${detachedBlock.label}"...`,
|
||||
);
|
||||
try {
|
||||
await client.agents.blocks.attach(detachedBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
console.error("Original block reattached successfully.");
|
||||
} catch {
|
||||
console.error(
|
||||
`WARNING: Failed to reattach original block! Block ID: ${detachedBlock.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw createError;
|
||||
}
|
||||
|
||||
// 4. Attach new block to current agent
|
||||
let attachResult: Awaited<ReturnType<typeof client.agents.blocks.attach>>;
|
||||
try {
|
||||
attachResult = await client.agents.blocks.attach(newBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
} catch (attachError) {
|
||||
// If attach failed and we detached a block, try to reattach it
|
||||
if (detachedBlock) {
|
||||
console.error(
|
||||
`Attach failed, reattaching original block "${detachedBlock.label}"...`,
|
||||
);
|
||||
try {
|
||||
await client.agents.blocks.attach(detachedBlock.id, {
|
||||
agent_id: currentAgentId,
|
||||
});
|
||||
console.error("Original block reattached successfully.");
|
||||
} catch {
|
||||
console.error(
|
||||
`WARNING: Failed to reattach original block! Block ID: ${detachedBlock.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw attachError;
|
||||
}
|
||||
|
||||
return { sourceBlock, newBlock, attachResult, detachedBlock };
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): {
|
||||
blockId: string;
|
||||
label?: string;
|
||||
agentId?: string;
|
||||
override: boolean;
|
||||
} {
|
||||
const blockIdIndex = args.indexOf("--block-id");
|
||||
const labelIndex = args.indexOf("--label");
|
||||
const agentIdIndex = args.indexOf("--agent-id");
|
||||
const override = args.includes("--override");
|
||||
|
||||
if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) {
|
||||
throw new Error("Missing required argument: --block-id <block-id>");
|
||||
@@ -139,6 +223,7 @@ function parseArgs(args: string[]): {
|
||||
agentIdIndex !== -1 && agentIdIndex + 1 < args.length
|
||||
? (args[agentIdIndex + 1] as string)
|
||||
: undefined,
|
||||
override,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,11 +232,14 @@ const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
(async () => {
|
||||
try {
|
||||
const { blockId, label, agentId } = parseArgs(process.argv.slice(2));
|
||||
const { blockId, label, agentId, override } = parseArgs(
|
||||
process.argv.slice(2),
|
||||
);
|
||||
const client = createClient();
|
||||
const result = await copyBlock(client, blockId, {
|
||||
labelOverride: label,
|
||||
targetAgentId: agentId,
|
||||
override,
|
||||
});
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
@@ -164,7 +252,7 @@ if (isMainModule) {
|
||||
error.message.includes("Missing required argument")
|
||||
) {
|
||||
console.error(
|
||||
"\nUsage: npx tsx copy-block.ts --block-id <block-id> [--label <new-label>] [--agent-id <agent-id>]",
|
||||
"\nUsage: npx tsx copy-block.ts --block-id <block-id> [--label <new-label>] [--agent-id <agent-id>] [--override]",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
|
||||
@@ -214,12 +214,9 @@ describe("attach-block", () => {
|
||||
} 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",
|
||||
);
|
||||
const result = await attachBlock(mockClient, "block-abc", {
|
||||
targetAgentId: "agent-789",
|
||||
});
|
||||
|
||||
expect(mockAttach).toHaveBeenCalledWith("block-abc", {
|
||||
agent_id: "agent-789",
|
||||
@@ -237,12 +234,10 @@ describe("attach-block", () => {
|
||||
} 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",
|
||||
);
|
||||
const result = await attachBlock(mockClient, "block-abc", {
|
||||
readOnly: true,
|
||||
targetAgentId: "agent-789",
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -256,7 +251,7 @@ describe("attach-block", () => {
|
||||
} as unknown as Letta;
|
||||
|
||||
await expect(
|
||||
attachBlock(mockClient, "block-abc", false, "agent-789"),
|
||||
attachBlock(mockClient, "block-abc", { targetAgentId: "agent-789" }),
|
||||
).rejects.toThrow("Cannot attach block");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user