fix: update isolated blocks in conversation context for Skill tool (#622)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user