feat: reduce time-to-boot, remove default eager approval checks on inputs, auto-cancel stale approvals (#579)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -2,9 +2,14 @@ import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agen
|
||||
import { getClient } from "./client";
|
||||
import { APPROVAL_RECOVERY_PROMPT } from "./promptAssets";
|
||||
|
||||
// Error when trying to SEND approval but server has no pending approval
|
||||
const APPROVAL_RECOVERY_DETAIL_FRAGMENT =
|
||||
"no tool call is currently awaiting approval";
|
||||
|
||||
// Error when trying to SEND message but server has pending approval waiting
|
||||
// This is the CONFLICT error - opposite of desync
|
||||
const APPROVAL_PENDING_DETAIL_FRAGMENT = "cannot send a new message";
|
||||
|
||||
type RunErrorMetadata =
|
||||
| {
|
||||
error_type?: string;
|
||||
@@ -20,6 +25,19 @@ export function isApprovalStateDesyncError(detail: unknown): boolean {
|
||||
return detail.toLowerCase().includes(APPROVAL_RECOVERY_DETAIL_FRAGMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error indicates there's a pending approval blocking new messages.
|
||||
* This is the CONFLICT error from the backend when trying to send a user message
|
||||
* while the agent is waiting for approval on a tool call.
|
||||
*
|
||||
* Error format:
|
||||
* { detail: "CONFLICT: Cannot send a new message: The agent is waiting for approval..." }
|
||||
*/
|
||||
export function isApprovalPendingError(detail: unknown): boolean {
|
||||
if (typeof detail !== "string") return false;
|
||||
return detail.toLowerCase().includes(APPROVAL_PENDING_DETAIL_FRAGMENT);
|
||||
}
|
||||
|
||||
export async function fetchRunErrorDetail(
|
||||
runId: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
|
||||
@@ -29,8 +29,9 @@ export interface ResumeData {
|
||||
|
||||
/**
|
||||
* Extract approval requests from an approval_request_message.
|
||||
* Exported for testing parallel tool call handling.
|
||||
*/
|
||||
function extractApprovals(messageToCheck: Message): {
|
||||
export function extractApprovals(messageToCheck: Message): {
|
||||
pendingApproval: ApprovalRequest | null;
|
||||
pendingApprovals: ApprovalRequest[];
|
||||
} {
|
||||
|
||||
@@ -271,26 +271,14 @@ export async function createAgent(
|
||||
// Track provenance: which blocks were created
|
||||
// Note: We no longer reuse shared blocks - each agent gets fresh blocks
|
||||
const blockProvenance: BlockProvenance[] = [];
|
||||
const blockIds: string[] = [];
|
||||
|
||||
// Create all blocks fresh for the new agent
|
||||
// Mark new blocks for provenance tracking (actual creation happens in agents.create)
|
||||
for (const block of filteredMemoryBlocks) {
|
||||
try {
|
||||
const createdBlock = await client.blocks.create(block);
|
||||
if (!createdBlock.id) {
|
||||
throw new Error(`Created block ${block.label} has no ID`);
|
||||
}
|
||||
blockIds.push(createdBlock.id);
|
||||
blockProvenance.push({ label: block.label, source: "new" });
|
||||
} catch (error) {
|
||||
console.error(`Failed to create block ${block.label}:`, error);
|
||||
throw error;
|
||||
}
|
||||
blockProvenance.push({ label: block.label, source: "new" });
|
||||
}
|
||||
|
||||
// Add any referenced block IDs (existing blocks to attach)
|
||||
// Mark referenced blocks for provenance tracking
|
||||
for (const blockId of referencedBlockIds) {
|
||||
blockIds.push(blockId);
|
||||
blockProvenance.push({ label: blockId, source: "shared" });
|
||||
}
|
||||
|
||||
@@ -314,7 +302,9 @@ export async function createAgent(
|
||||
systemPromptContent = `${systemPromptContent}\n\n${options.systemPromptAppend}`;
|
||||
}
|
||||
|
||||
// Create agent with all block IDs (existing + newly created)
|
||||
// Create agent with inline memory blocks (LET-7101: single API call instead of N+1)
|
||||
// - memory_blocks: new blocks to create inline
|
||||
// - block_ids: references to existing blocks (for shared memory)
|
||||
const tags = ["origin:letta-code"];
|
||||
if (process.env.LETTA_CODE_AGENT_ROLE === "subagent") {
|
||||
tags.push("role:subagent");
|
||||
@@ -332,7 +322,11 @@ export async function createAgent(
|
||||
model: modelHandle,
|
||||
context_window_limit: contextWindow,
|
||||
tools: toolNames,
|
||||
block_ids: blockIds,
|
||||
// New blocks created inline with agent (saves ~2s of sequential API calls)
|
||||
memory_blocks:
|
||||
filteredMemoryBlocks.length > 0 ? filteredMemoryBlocks : undefined,
|
||||
// Referenced block IDs (existing blocks to attach)
|
||||
block_ids: referencedBlockIds.length > 0 ? referencedBlockIds : undefined,
|
||||
tags,
|
||||
// should be default off, but just in case
|
||||
include_base_tools: false,
|
||||
|
||||
@@ -486,3 +486,125 @@ export function formatSkillsForMemory(
|
||||
// Otherwise fall back to compact tree format
|
||||
return formatSkillsAsTree(skills, skillsDirectory);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skills Sync with Hash-Based Caching (Phase 2.5 - LET-7101)
|
||||
// ============================================================================
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
/**
|
||||
* Get the project-local skills hash file path.
|
||||
* Uses .letta/skills-hash.json in the current working directory
|
||||
* because the skills block content depends on the project's .skills/ folder.
|
||||
*/
|
||||
function getSkillsHashFilePath(): string {
|
||||
return join(process.cwd(), ".letta", "skills-hash.json");
|
||||
}
|
||||
|
||||
interface SkillsHashCache {
|
||||
hash: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a hash of the formatted skills content
|
||||
*/
|
||||
function computeSkillsHash(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached skills hash (if any)
|
||||
*/
|
||||
async function getCachedSkillsHash(): Promise<string | null> {
|
||||
try {
|
||||
const hashFile = getSkillsHashFilePath();
|
||||
const data = await readFile(hashFile, "utf-8");
|
||||
const cache: SkillsHashCache = JSON.parse(data);
|
||||
return cache.hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the cached skills hash
|
||||
*/
|
||||
async function setCachedSkillsHash(hash: string): Promise<void> {
|
||||
try {
|
||||
const hashFile = getSkillsHashFilePath();
|
||||
// Ensure project .letta directory exists
|
||||
const lettaDir = join(process.cwd(), ".letta");
|
||||
await mkdir(lettaDir, { recursive: true });
|
||||
|
||||
const cache: SkillsHashCache = {
|
||||
hash,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
await writeFile(hashFile, JSON.stringify(cache, null, 2));
|
||||
} catch {
|
||||
// Ignore cache write failures - not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync skills to an agent's memory block.
|
||||
* Discovers skills from filesystem and updates the skills block.
|
||||
*
|
||||
* @param client - Letta client
|
||||
* @param agentId - Agent ID to update
|
||||
* @param skillsDirectory - Path to project skills directory
|
||||
* @param options - Optional settings
|
||||
* @returns Object indicating if sync occurred and discovered skills
|
||||
*/
|
||||
export async function syncSkillsToAgent(
|
||||
client: import("@letta-ai/letta-client").default,
|
||||
agentId: string,
|
||||
skillsDirectory: string,
|
||||
options?: { skipIfUnchanged?: boolean },
|
||||
): Promise<{ synced: boolean; skills: Skill[] }> {
|
||||
// Discover skills from filesystem
|
||||
const { skills, errors } = await discoverSkills(skillsDirectory);
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
console.warn(`[skills] Discovery error: ${error.path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Format skills for memory block
|
||||
const formattedSkills = formatSkillsForMemory(skills, skillsDirectory);
|
||||
|
||||
// Check if we can skip the update
|
||||
if (options?.skipIfUnchanged) {
|
||||
const newHash = computeSkillsHash(formattedSkills);
|
||||
const cachedHash = await getCachedSkillsHash();
|
||||
|
||||
if (newHash === cachedHash) {
|
||||
return { synced: false, skills };
|
||||
}
|
||||
|
||||
// Update the block and cache the new hash
|
||||
await client.agents.blocks.update("skills", {
|
||||
agent_id: agentId,
|
||||
value: formattedSkills,
|
||||
});
|
||||
await setCachedSkillsHash(newHash);
|
||||
|
||||
return { synced: true, skills };
|
||||
}
|
||||
|
||||
// No skip option - always update
|
||||
await client.agents.blocks.update("skills", {
|
||||
agent_id: agentId,
|
||||
value: formattedSkills,
|
||||
});
|
||||
|
||||
// Update hash cache for future runs
|
||||
const newHash = computeSkillsHash(formattedSkills);
|
||||
await setCachedSkillsHash(newHash);
|
||||
|
||||
return { synced: true, skills };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user