feat: add --override flag and improve docs (#462)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-04 19:12:15 -08:00
committed by GitHub
parent c7ed2e37ed
commit 1894a290d4
4 changed files with 231 additions and 56 deletions

View File

@@ -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

View File

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

View File

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

View File

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