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:
Charles Packer
2026-01-17 16:19:30 -08:00
committed by GitHub
parent f4eb921af7
commit 5f5c0df18e
13 changed files with 1376 additions and 93 deletions

View File

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

View File

@@ -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[];
} {

View File

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

View File

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