From ce89e962c72928feed0362eec38dad87d68658dc Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 21 Jan 2026 17:45:41 -0800 Subject: [PATCH] fix: update isolated blocks in conversation context for Skill tool (#622) Co-authored-by: Letta --- src/agent/context.ts | 18 +++++ src/headless.ts | 9 ++- src/index.ts | 8 +- src/tools/impl/Skill.ts | 161 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 179 insertions(+), 17 deletions(-) diff --git a/src/agent/context.ts b/src/agent/context.ts index 3e2d71b..6a1fea0 100644 --- a/src/agent/context.ts +++ b/src/agent/context.ts @@ -7,6 +7,7 @@ interface AgentContext { agentId: string | null; skillsDirectory: string | null; hasLoadedSkills: boolean; + conversationId: string | null; } // Use globalThis to ensure singleton across bundle @@ -24,6 +25,7 @@ function getContext(): AgentContext { agentId: null, skillsDirectory: null, hasLoadedSkills: false, + conversationId: null, }; } return global[CONTEXT_KEY]; @@ -86,6 +88,22 @@ export function setHasLoadedSkills(loaded: boolean): void { context.hasLoadedSkills = loaded; } +/** + * Set the current conversation ID + * @param conversationId - The conversation ID, or null to clear + */ +export function setConversationId(conversationId: string | null): void { + context.conversationId = conversationId; +} + +/** + * Get the current conversation ID + * @returns The conversation ID or null if not set + */ +export function getConversationId(): string | null { + return context.conversationId; +} + /** * Initialize the loaded skills flag by checking the block * Should be called after setAgentContext to sync the cached state diff --git a/src/headless.ts b/src/headless.ts index f71c1e0..6b0f33c 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -16,7 +16,11 @@ import { isConversationBusyError, } from "./agent/approval-recovery"; import { getClient } from "./agent/client"; -import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context"; +import { + initializeLoadedSkillsFlag, + setAgentContext, + setConversationId, +} from "./agent/context"; import { createAgent } from "./agent/create"; import { ensureSkillsBlocks, ISOLATED_BLOCK_LABELS } from "./agent/memory"; import { sendMessageStream } from "./agent/message"; @@ -659,6 +663,9 @@ export async function handleHeadlessCommand( } markMilestone("HEADLESS_CONVERSATION_READY"); + // Set conversation ID in context for tools (e.g., Skill tool) to access + setConversationId(conversationId); + // Save session (agent + conversation) to both project and global settings // Skip for subagents - they shouldn't pollute the LRU settings if (!isSubagent) { diff --git a/src/index.ts b/src/index.ts index 0a9009b..e5a43e3 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,11 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents" import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; import { getResumeData, type ResumeData } from "./agent/check-approval"; import { getClient } from "./agent/client"; -import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context"; +import { + initializeLoadedSkillsFlag, + setAgentContext, + setConversationId as setContextConversationId, +} from "./agent/context"; import type { AgentProvenance } from "./agent/create"; import { INCOGNITO_TAG, MEMO_TAG } from "./agent/defaults"; import { ensureSkillsBlocks, ISOLATED_BLOCK_LABELS } from "./agent/memory"; @@ -1767,6 +1771,8 @@ async function main(): Promise { setAgentId(agent.id); setAgentState(agent); setConversationId(conversationIdToUse); + // Also set in global context for tools (e.g., Skill tool) to access + setContextConversationId(conversationIdToUse); setLoadingState("ready"); } diff --git a/src/tools/impl/Skill.ts b/src/tools/impl/Skill.ts index 4955d4b..9dacc49 100644 --- a/src/tools/impl/Skill.ts +++ b/src/tools/impl/Skill.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { getClient } from "../../agent/client"; import { + getConversationId, getCurrentAgentId, getSkillsDirectory, setHasLoadedSkills, @@ -25,6 +26,147 @@ interface SkillResult { message: string; } +// Cache for isolated block IDs: Map +// This avoids repeated API calls within a session +let isolatedBlockCache: Map | null = null; +let cachedConversationId: string | null = null; + +/** + * Clear the cache (called when conversation changes or on errors) + */ +function clearIsolatedBlockCache(): void { + isolatedBlockCache = null; + cachedConversationId = null; +} + +/** + * Get the block ID for an isolated block label in the current conversation context. + * Uses caching to avoid repeated API calls. + * If in a conversation with isolated blocks, returns the isolated block ID. + * Otherwise returns null (use agent-level block). + * + * SAFETY: Any error returns null (falls back to agent-level block). + * Caching never causes errors - only helps performance. + */ +async function getIsolatedBlockId( + client: Awaited>, + label: string, +): Promise { + const conversationId = getConversationId(); + + // "default" conversation doesn't have isolated blocks + if (!conversationId || conversationId === "default") { + return null; + } + + try { + // Check if conversation changed - invalidate cache + if (cachedConversationId !== conversationId) { + clearIsolatedBlockCache(); + cachedConversationId = conversationId; + } + + // Check cache first + if (isolatedBlockCache?.has(label)) { + return isolatedBlockCache.get(label) ?? null; + } + + // Cache miss - fetch from API + const conversation = await client.conversations.retrieve(conversationId); + const isolatedBlockIds = conversation.isolated_block_ids || []; + + if (isolatedBlockIds.length === 0) { + // No isolated blocks - cache this fact as empty map + isolatedBlockCache = new Map(); + return null; + } + + // Build cache: fetch all isolated blocks and map label -> blockId + if (!isolatedBlockCache) { + isolatedBlockCache = new Map(); + } + + for (const blockId of isolatedBlockIds) { + try { + const block = await client.blocks.retrieve(blockId); + if (block.label) { + isolatedBlockCache.set(block.label, blockId); + } + } catch { + // Individual block fetch failed - skip it, don't fail the whole operation + } + } + + return isolatedBlockCache.get(label) ?? null; + } catch { + // If anything fails, fall back to agent-level block (safe default) + // Don't cache the error - next call will try again + return null; + } +} + +/** + * Update a block by label, using isolated block if in conversation context. + * + * SAFETY: If updating isolated block fails, clears cache and falls back to + * agent-level block. Errors from agent-level update are propagated (that's + * the existing behavior). + */ +async function updateBlock( + client: Awaited>, + agentId: string, + label: string, + value: string, +): Promise { + const isolatedBlockId = await getIsolatedBlockId(client, label); + + if (isolatedBlockId) { + try { + // Update the conversation's isolated block directly + await client.blocks.update(isolatedBlockId, { value }); + return; + } catch { + // If isolated block update fails (e.g., block was deleted), + // clear cache and fall back to agent-level block + clearIsolatedBlockCache(); + // Fall through to agent-level update + } + } + + // Fall back to agent-level block + await client.agents.blocks.update(label, { + agent_id: agentId, + value, + }); +} + +/** + * Retrieve a block by label, using isolated block if in conversation context. + * + * SAFETY: If retrieving isolated block fails, clears cache and falls back to + * agent-level block. + */ +async function retrieveBlock( + client: Awaited>, + agentId: string, + label: string, +): Promise>> { + const isolatedBlockId = await getIsolatedBlockId(client, label); + + if (isolatedBlockId) { + try { + return await client.blocks.retrieve(isolatedBlockId); + } catch { + // If isolated block retrieval fails, clear cache and fall back + clearIsolatedBlockCache(); + // Fall through to agent-level retrieval + } + } + + // Fall back to agent-level block + return await client.agents.blocks.retrieve(label, { agent_id: agentId }); +} + function coreMemoryBlockEditedMessage(label: string): string { return ( `The core memory block with label \`${label}\` has been successfully edited. ` + @@ -248,10 +390,7 @@ export async function skill(args: SkillArgs): Promise { // Format and update the skills block const formattedSkills = formatSkillsForMemory(skills, skillsDir); - await client.agents.blocks.update("skills", { - agent_id: agentId, - value: formattedSkills, - }); + await updateBlock(client, agentId, "skills", formattedSkills); const successMsg = coreMemoryBlockEditedMessage("skills") + @@ -268,9 +407,7 @@ export async function skill(args: SkillArgs): Promise { ReturnType >; try { - loadedSkillsBlock = await client.agents.blocks.retrieve("loaded_skills", { - agent_id: agentId, - }); + loadedSkillsBlock = await retrieveBlock(client, agentId, "loaded_skills"); } catch (error) { throw new Error( `Error: loaded_skills block not found. This block is required for the Skill tool to work.\nAgent ID: ${agentId}\nError: ${error instanceof Error ? error.message : String(error)}`, @@ -334,10 +471,7 @@ export async function skill(args: SkillArgs): Promise { } if (loaded.length > 0) { - await client.agents.blocks.update("loaded_skills", { - agent_id: agentId, - value: currentValue, - }); + await updateBlock(client, agentId, "loaded_skills", currentValue); // Update the cached flag setHasLoadedSkills(true); @@ -436,10 +570,7 @@ export async function skill(args: SkillArgs): Promise { } // Update the block - await client.agents.blocks.update("loaded_skills", { - agent_id: agentId, - value: currentValue, - }); + await updateBlock(client, agentId, "loaded_skills", currentValue); // Update the cached flag const remainingSkills = getLoadedSkillIds(currentValue);