From 06ec8b9d3c1486f02fb4bfc13f39da7540fdc5d9 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 27 Nov 2025 02:12:41 -0800 Subject: [PATCH] feat: new flag to filter blocks on creation (#132) --- src/agent/create.ts | 44 ++++++++++-- src/headless.ts | 50 ++++++++++++++ src/index.ts | 59 ++++++++++++++++ src/tests/agent/init-blocks.test.ts | 103 ++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 src/tests/agent/init-blocks.test.ts diff --git a/src/agent/create.ts b/src/agent/create.ts index c6e9cc9..a6e747b 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -32,6 +32,8 @@ export async function createAgent( parallelToolCalls = true, enableSleeptime = false, systemPromptId?: string, + initBlocks?: string[], + baseTools?: string[], ) { // Resolve model identifier to handle let modelHandle: string; @@ -59,16 +61,42 @@ export async function createAgent( getServerToolName(name), ); - const toolNames = [ - ...serverToolNames, + const defaultBaseTools = baseTools ?? [ "memory", "web_search", "conversation_search", "fetch_webpage", ]; + const toolNames = [...serverToolNames, ...defaultBaseTools]; + // Load memory blocks from .mdx files - const defaultMemoryBlocks = await getDefaultMemoryBlocks(); + const defaultMemoryBlocks = + initBlocks && initBlocks.length === 0 ? [] : await getDefaultMemoryBlocks(); + + // Optional filter: only initialize a subset of memory blocks on creation + const allowedBlockLabels = initBlocks + ? new Set( + initBlocks.map((name) => name.trim()).filter((name) => name.length > 0), + ) + : undefined; + + if (allowedBlockLabels && allowedBlockLabels.size > 0) { + const knownLabels = new Set(defaultMemoryBlocks.map((b) => b.label)); + for (const label of Array.from(allowedBlockLabels)) { + if (!knownLabels.has(label)) { + console.warn( + `Ignoring unknown init block "${label}". Valid blocks: ${Array.from(knownLabels).join(", ")}`, + ); + allowedBlockLabels.delete(label); + } + } + } + + const filteredMemoryBlocks = + allowedBlockLabels && allowedBlockLabels.size > 0 + ? defaultMemoryBlocks.filter((b) => allowedBlockLabels.has(b.label)) + : defaultMemoryBlocks; // Resolve absolute path for skills directory const resolvedSkillsDirectory = @@ -87,7 +115,7 @@ export async function createAgent( } // Find and update the skills memory block with discovered skills - const skillsBlock = defaultMemoryBlocks.find((b) => b.label === "skills"); + const skillsBlock = filteredMemoryBlocks.find((b) => b.label === "skills"); if (skillsBlock) { skillsBlock.value = formatSkillsForMemory( skills, @@ -116,6 +144,9 @@ export async function createAgent( if (!forceNewBlocks) { // Load global blocks (persona, human) for (const [label, blockId] of Object.entries(globalSharedBlockIds)) { + if (allowedBlockLabels && !allowedBlockLabels.has(label)) { + continue; + } try { const block = await client.blocks.retrieve(blockId); existingBlocks.set(label, block); @@ -129,6 +160,9 @@ export async function createAgent( // Load local blocks (style) for (const [label, blockId] of Object.entries(localSharedBlockIds)) { + if (allowedBlockLabels && !allowedBlockLabels.has(label)) { + continue; + } try { const block = await client.blocks.retrieve(blockId); existingBlocks.set(label, block); @@ -145,7 +179,7 @@ export async function createAgent( const blockIds: string[] = []; const blocksToCreate: Array<{ block: CreateBlock; label: string }> = []; - for (const defaultBlock of defaultMemoryBlocks) { + for (const defaultBlock of filteredMemoryBlocks) { const existingBlock = existingBlocks.get(defaultBlock.label); if (existingBlock?.id) { // Reuse existing global shared block diff --git a/src/headless.ts b/src/headless.ts index 3342b98..3d44756 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -52,6 +52,8 @@ export async function handleHeadlessCommand( link: { type: "boolean" }, unlink: { type: "boolean" }, sleeptime: { type: "boolean" }, + "init-blocks": { type: "string" }, + "base-tools": { type: "string" }, }, strict: false, allowPositionals: true, @@ -84,8 +86,50 @@ export async function handleHeadlessCommand( const specifiedAgentId = values.agent as string | undefined; const shouldContinue = values.continue as boolean | undefined; const forceNew = values.new as boolean | undefined; + const initBlocksRaw = values["init-blocks"] as string | undefined; + const baseToolsRaw = values["base-tools"] as string | undefined; const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined; + if (initBlocksRaw && !forceNew) { + console.error( + "Error: --init-blocks can only be used together with --new to control initial memory blocks.", + ); + process.exit(1); + } + + let initBlocks: string[] | undefined; + if (initBlocksRaw !== undefined) { + const trimmed = initBlocksRaw.trim(); + if (!trimmed || trimmed.toLowerCase() === "none") { + initBlocks = []; + } else { + initBlocks = trimmed + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + } + } + + if (baseToolsRaw && !forceNew) { + console.error( + "Error: --base-tools can only be used together with --new to control initial base tools.", + ); + process.exit(1); + } + + let baseTools: string[] | undefined; + if (baseToolsRaw !== undefined) { + const trimmed = baseToolsRaw.trim(); + if (!trimmed || trimmed.toLowerCase() === "none") { + baseTools = []; + } else { + baseTools = trimmed + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + } + } + // Priority 1: Try to use --agent specified ID if (specifiedAgentId) { try { @@ -107,6 +151,9 @@ export async function handleHeadlessCommand( skillsDirectory, settings.parallelToolCalls, sleeptimeFlag ?? settings.enableSleeptime, + undefined, + initBlocks, + baseTools, ); } @@ -148,6 +195,9 @@ export async function handleHeadlessCommand( skillsDirectory, settings.parallelToolCalls, sleeptimeFlag ?? settings.enableSleeptime, + undefined, + undefined, + undefined, ); } diff --git a/src/index.ts b/src/index.ts index 61e3330..17f466a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ OPTIONS -v, --version Print version and exit --new Create new agent (reuses global blocks like persona/human) --fresh-blocks Force create all new memory blocks (isolate from other agents) + --init-blocks Comma-separated memory blocks to initialize when using --new (e.g., "persona,skills") + --base-tools Comma-separated base tools to attach when using --new (e.g., "memory,web_search,conversation_search") -c, --continue Resume previous session (uses global lastAgent, deprecated) -a, --agent Use a specific agent ID -m, --model Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5") @@ -121,6 +123,8 @@ async function main() { continue: { type: "boolean", short: "c" }, new: { type: "boolean" }, "fresh-blocks": { type: "boolean" }, + "init-blocks": { type: "string" }, + "base-tools": { type: "string" }, agent: { type: "string", short: "a" }, model: { type: "string", short: "m" }, system: { type: "string", short: "s" }, @@ -177,6 +181,8 @@ async function main() { const shouldContinue = (values.continue as boolean | undefined) ?? false; const forceNew = (values.new as boolean | undefined) ?? false; const freshBlocks = (values["fresh-blocks"] as boolean | undefined) ?? false; + const initBlocksRaw = values["init-blocks"] as string | undefined; + const baseToolsRaw = values["base-tools"] as string | undefined; const specifiedAgentId = (values.agent as string | undefined) ?? null; const specifiedModel = (values.model as string | undefined) ?? undefined; const specifiedSystem = (values.system as string | undefined) ?? undefined; @@ -185,6 +191,49 @@ async function main() { const sleeptimeFlag = (values.sleeptime as boolean | undefined) ?? undefined; const isHeadless = values.prompt || values.run || !process.stdin.isTTY; + // --init-blocks only makes sense when creating a brand new agent + if (initBlocksRaw && !forceNew) { + console.error( + "Error: --init-blocks can only be used together with --new to control initial memory blocks.", + ); + process.exit(1); + } + + let initBlocks: string[] | undefined; + if (initBlocksRaw !== undefined) { + const trimmed = initBlocksRaw.trim(); + if (!trimmed || trimmed.toLowerCase() === "none") { + // Explicitly requested zero blocks + initBlocks = []; + } else { + initBlocks = trimmed + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + } + } + + // --base-tools only makes sense when creating a brand new agent + if (baseToolsRaw && !forceNew) { + console.error( + "Error: --base-tools can only be used together with --new to control initial base tools.", + ); + process.exit(1); + } + + let baseTools: string[] | undefined; + if (baseToolsRaw !== undefined) { + const trimmed = baseToolsRaw.trim(); + if (!trimmed || trimmed.toLowerCase() === "none") { + baseTools = []; + } else { + baseTools = trimmed + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + } + } + // Validate toolset if provided if ( specifiedToolset && @@ -350,6 +399,8 @@ async function main() { continueSession, forceNew, freshBlocks, + initBlocks, + baseTools, agentIdArg, model, system, @@ -359,6 +410,8 @@ async function main() { continueSession: boolean; forceNew: boolean; freshBlocks: boolean; + initBlocks?: string[]; + baseTools?: string[]; agentIdArg: string | null; model?: string; system?: string; @@ -514,6 +567,8 @@ async function main() { settings.parallelToolCalls, sleeptimeFlag ?? settings.enableSleeptime, system, + initBlocks, + baseTools, ); } @@ -561,6 +616,8 @@ async function main() { settings.parallelToolCalls, sleeptimeFlag ?? settings.enableSleeptime, system, + undefined, + undefined, ); } @@ -633,6 +690,8 @@ async function main() { continueSession: shouldContinue, forceNew: forceNew, freshBlocks: freshBlocks, + initBlocks: initBlocks, + baseTools: baseTools, agentIdArg: specifiedAgentId, model: specifiedModel, system: specifiedSystem, diff --git a/src/tests/agent/init-blocks.test.ts b/src/tests/agent/init-blocks.test.ts new file mode 100644 index 0000000..f8dfe16 --- /dev/null +++ b/src/tests/agent/init-blocks.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { getClient } from "../../agent/client"; +import { createAgent } from "../../agent/create"; +import { settingsManager } from "../../settings-manager"; + +// Skip these integration tests if LETTA_API_KEY is not set +const shouldSkip = !process.env.LETTA_API_KEY; +const describeOrSkip = shouldSkip ? describe.skip : describe; + +describeOrSkip("createAgent init-blocks filtering", () => { + let originalGlobalSharedBlockIds: Record; + let originalLocalSharedBlockIds: Record; + let createdAgentId: string | null = null; + + beforeAll(async () => { + const apiKey = process.env.LETTA_API_KEY; + if (!apiKey) { + throw new Error("LETTA_API_KEY must be set to run this test"); + } + + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + await settingsManager.loadProjectSettings(); + const projectSettings = settingsManager.getProjectSettings(); + + originalGlobalSharedBlockIds = { ...settings.globalSharedBlockIds }; + originalLocalSharedBlockIds = { ...projectSettings.localSharedBlockIds }; + }); + + afterAll(async () => { + const client = await getClient(); + + if (createdAgentId) { + try { + await client.agents.delete(createdAgentId); + } catch { + // Ignore cleanup errors + } + } + + // Restore original shared block mappings to avoid polluting user settings + settingsManager.updateSettings({ + globalSharedBlockIds: originalGlobalSharedBlockIds, + }); + settingsManager.updateProjectSettings( + { + localSharedBlockIds: originalLocalSharedBlockIds, + }, + process.cwd(), + ); + }); + + test( + "only requested memory blocks are created/registered", + async () => { + const agent = await createAgent( + "init-blocks-test", + undefined, + "openai/text-embedding-3-small", + undefined, + true, // force new blocks instead of reusing shared ones + undefined, + true, + false, + undefined, + ["persona", "skills"], + undefined, + ); + createdAgentId = agent.id; + + const settings = settingsManager.getSettings(); + await settingsManager.loadProjectSettings(); + const projectSettings = settingsManager.getProjectSettings(); + + const globalIds = settings.globalSharedBlockIds; + const localIds = projectSettings.localSharedBlockIds; + + // Requested blocks must be present + expect(globalIds.persona).toBeDefined(); + expect(localIds.skills).toBeDefined(); + + // No new GLOBAL shared blocks outside of the allowed set + const newGlobalLabels = Object.keys(globalIds).filter( + (label) => !(label in originalGlobalSharedBlockIds), + ); + const disallowedGlobalLabels = newGlobalLabels.filter( + (label) => label !== "persona", + ); + expect(disallowedGlobalLabels.length).toBe(0); + + // No new LOCAL shared blocks outside of the allowed set + const newLocalLabels = Object.keys(localIds).filter( + (label) => !(label in originalLocalSharedBlockIds), + ); + const disallowedLocalLabels = newLocalLabels.filter( + (label) => label !== "skills", + ); + expect(disallowedLocalLabels.length).toBe(0); + }, + { timeout: 90000 }, + ); +});