fix: update isolated blocks in conversation context for Skill tool (#622)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-21 17:45:41 -08:00
committed by GitHub
parent 2c82bd880a
commit ce89e962c7
4 changed files with 179 additions and 17 deletions

View File

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

View File

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

View File

@@ -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<void> {
setAgentId(agent.id);
setAgentState(agent);
setConversationId(conversationIdToUse);
// Also set in global context for tools (e.g., Skill tool) to access
setContextConversationId(conversationIdToUse);
setLoadingState("ready");
}

View File

@@ -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<label, blockId>
// This avoids repeated API calls within a session
let isolatedBlockCache: Map<string, string> | 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<ReturnType<typeof getClient>>,
label: string,
): Promise<string | null> {
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<ReturnType<typeof getClient>>,
agentId: string,
label: string,
value: string,
): Promise<void> {
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<ReturnType<typeof getClient>>,
agentId: string,
label: string,
): Promise<Awaited<ReturnType<typeof client.blocks.retrieve>>> {
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<SkillResult> {
// 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<SkillResult> {
ReturnType<typeof client.agents.blocks.retrieve>
>;
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<SkillResult> {
}
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<SkillResult> {
}
// 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);