Files
lettabot/src/tools/letta-api.ts

802 lines
26 KiB
TypeScript

/**
* Letta API Client
*
* Uses the official @letta-ai/letta-client SDK for all API interactions.
*/
import { Letta } from '@letta-ai/letta-client';
import { createLogger } from '../logger.js';
const log = createLogger('Letta-api');
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com';
function getClient(): Letta {
const apiKey = process.env.LETTA_API_KEY;
// Local servers may not require an API key
return new Letta({
apiKey: apiKey || '',
baseURL: LETTA_BASE_URL,
defaultHeaders: { "X-Letta-Source": "lettabot" },
});
}
/**
* Test connection to Letta server (silent, no error logging)
*/
export async function testConnection(): Promise<boolean> {
try {
const client = getClient();
// Use a simple endpoint that doesn't have pagination issues
await client.agents.list({ limit: 1 });
return true;
} catch {
return false;
}
}
// Re-export types that callers use
export type LettaTool = Awaited<ReturnType<Letta['tools']['upsert']>>;
/**
* Upsert a tool to the Letta API
*/
export async function upsertTool(params: {
source_code: string;
description?: string;
tags?: string[];
}): Promise<LettaTool> {
const client = getClient();
return client.tools.upsert({
source_code: params.source_code,
description: params.description,
tags: params.tags,
});
}
/**
* List all tools
*/
export async function listTools(): Promise<LettaTool[]> {
const client = getClient();
const page = await client.tools.list();
const tools: LettaTool[] = [];
for await (const tool of page) {
tools.push(tool);
}
return tools;
}
/**
* Get a tool by name
*/
export async function getToolByName(name: string): Promise<LettaTool | null> {
try {
const client = getClient();
const page = await client.tools.list({ name });
for await (const tool of page) {
if (tool.name === name) return tool;
}
return null;
} catch {
return null;
}
}
/**
* Add a tool to an agent
*/
export async function addToolToAgent(agentId: string, toolId: string): Promise<void> {
const client = getClient();
await client.agents.tools.attach(toolId, { agent_id: agentId });
}
/**
* Check if an agent exists
*/
export async function agentExists(agentId: string): Promise<boolean> {
try {
const client = getClient();
await client.agents.retrieve(agentId);
return true;
} catch {
return false;
}
}
/**
* Get an agent's current model handle
*/
export async function getAgentModel(agentId: string): Promise<string | null> {
try {
const client = getClient();
const agent = await client.agents.retrieve(agentId);
return agent.model ?? null;
} catch (e) {
log.error('Failed to get agent model:', e);
return null;
}
}
/**
* Update an agent's model
*/
export async function updateAgentModel(agentId: string, model: string): Promise<boolean> {
try {
const client = getClient();
await client.agents.update(agentId, { model });
return true;
} catch (e) {
log.error('Failed to update agent model:', e);
return false;
}
}
/**
* Update an agent's name
*/
export async function updateAgentName(agentId: string, name: string): Promise<boolean> {
try {
const client = getClient();
await client.agents.update(agentId, { name });
return true;
} catch (e) {
log.error('Failed to update agent name:', e);
return false;
}
}
/**
* List available models
*/
export async function listModels(options?: { providerName?: string; providerCategory?: 'base' | 'byok' }): Promise<Array<{ handle: string; name: string; display_name?: string; tier?: string }>> {
try {
const client = getClient();
const params: Record<string, unknown> = {};
if (options?.providerName) params.provider_name = options.providerName;
if (options?.providerCategory) params.provider_category = [options.providerCategory];
const page = await client.models.list(Object.keys(params).length > 0 ? params : undefined);
const models: Array<{ handle: string; name: string; display_name?: string; tier?: string }> = [];
for await (const model of page) {
if (model.handle && model.name) {
models.push({
handle: model.handle,
name: model.name,
display_name: model.display_name ?? undefined,
tier: (model as { tier?: string }).tier ?? undefined,
});
}
}
return models;
} catch (e) {
log.error('Failed to list models:', e);
return [];
}
}
/**
* Get the most recent run time for an agent
*/
export async function getLastRunTime(agentId: string): Promise<Date | null> {
try {
const client = getClient();
const page = await client.runs.list({ agent_id: agentId, limit: 1 });
for await (const run of page) {
if (run.created_at) {
return new Date(run.created_at);
}
}
return null;
} catch (e) {
log.error('Failed to get last run time:', e);
return null;
}
}
/**
* List agents, optionally filtered by name search
*/
export async function listAgents(query?: string): Promise<Array<{ id: string; name: string; description?: string | null; created_at?: string | null }>> {
try {
const client = getClient();
const page = await client.agents.list({ query_text: query, limit: 50 });
const agents: Array<{ id: string; name: string; description?: string | null; created_at?: string | null }> = [];
for await (const agent of page) {
agents.push({
id: agent.id,
name: agent.name,
description: agent.description,
created_at: agent.created_at,
});
}
return agents;
} catch (e) {
log.error('Failed to list agents:', e);
return [];
}
}
/**
* Find an agent by exact name match
* Returns the most recently created agent if multiple match
*/
export async function findAgentByName(name: string): Promise<{ id: string; name: string } | null> {
try {
const client = getClient();
const page = await client.agents.list({ query_text: name, limit: 50 });
let bestMatch: { id: string; name: string; created_at?: string | null } | null = null;
for await (const agent of page) {
// Exact name match only
if (agent.name === name) {
// Keep the most recently created if multiple match
if (!bestMatch || (agent.created_at && bestMatch.created_at && agent.created_at > bestMatch.created_at)) {
bestMatch = { id: agent.id, name: agent.name, created_at: agent.created_at };
}
}
}
return bestMatch ? { id: bestMatch.id, name: bestMatch.name } : null;
} catch (e) {
log.error('Failed to find agent by name:', e);
return null;
}
}
// ============================================================================
// Tool Approval Management
// ============================================================================
export interface PendingApproval {
runId: string;
toolCallId: string;
toolName: string;
messageId: string;
}
/**
* Check for pending approval requests on an agent's conversation.
* Returns details of any tool calls waiting for approval.
*/
export async function getPendingApprovals(
agentId: string,
conversationId?: string
): Promise<PendingApproval[]> {
try {
const client = getClient();
// Prefer agent-level pending approval to avoid scanning stale history.
// IMPORTANT: Must include 'agent.pending_approval' or the field won't be returned.
try {
const agentState = await client.agents.retrieve(agentId, {
include: ['agent.pending_approval'],
});
if ('pending_approval' in agentState) {
const pending = agentState.pending_approval;
if (!pending) {
log.info('No pending approvals on agent');
return [];
}
log.info(`Found pending approval: ${pending.id}, run_id=${pending.run_id}`);
// Extract tool calls - handle both Array<ToolCall> and ToolCallDelta formats
const rawToolCalls = pending.tool_calls;
const toolCallsList: Array<{ tool_call_id: string; name: string }> = [];
if (Array.isArray(rawToolCalls)) {
for (const tc of rawToolCalls) {
if (tc && 'tool_call_id' in tc && tc.tool_call_id) {
toolCallsList.push({ tool_call_id: tc.tool_call_id, name: tc.name || 'unknown' });
}
}
} else if (rawToolCalls && typeof rawToolCalls === 'object' && 'tool_call_id' in rawToolCalls && rawToolCalls.tool_call_id) {
// ToolCallDelta case
toolCallsList.push({ tool_call_id: rawToolCalls.tool_call_id, name: rawToolCalls.name || 'unknown' });
}
// Fallback to deprecated singular tool_call field
if (toolCallsList.length === 0 && pending.tool_call) {
const tc = pending.tool_call;
if ('tool_call_id' in tc && tc.tool_call_id) {
toolCallsList.push({ tool_call_id: tc.tool_call_id, name: tc.name || 'unknown' });
}
}
const seen = new Set<string>();
const approvals: PendingApproval[] = [];
for (const tc of toolCallsList) {
if (seen.has(tc.tool_call_id)) continue;
seen.add(tc.tool_call_id);
approvals.push({
runId: pending.run_id || 'unknown',
toolCallId: tc.tool_call_id,
toolName: tc.name || 'unknown',
messageId: pending.id,
});
}
log.info(`Extracted ${approvals.length} pending approval(s): ${approvals.map(a => a.toolName).join(', ')}`);
return approvals;
}
} catch (e) {
log.warn('Failed to retrieve agent pending_approval, falling back to run scan:', e);
}
// First, check for runs with 'requires_approval' stop reason
const runsPage = await client.runs.list({
agent_id: agentId,
conversation_id: conversationId,
stop_reason: 'requires_approval',
limit: 10,
});
const pendingApprovals: PendingApproval[] = [];
for await (const run of runsPage) {
if (run.status === 'running' || run.stop_reason === 'requires_approval') {
// Get recent messages to find approval_request_message
const messagesPage = await client.agents.messages.list(agentId, {
conversation_id: conversationId,
limit: 100,
});
const messages: Array<{ message_type?: string }> = [];
for await (const msg of messagesPage) {
messages.push(msg as { message_type?: string });
}
const resolvedToolCalls = new Set<string>();
for (const msg of messages) {
if ('message_type' in msg && msg.message_type === 'approval_response_message') {
const approvalMsg = msg as {
approvals?: Array<{ tool_call_id?: string | null }>;
};
const approvals = approvalMsg.approvals || [];
for (const approval of approvals) {
if (approval.tool_call_id) {
resolvedToolCalls.add(approval.tool_call_id);
}
}
}
}
const seenToolCalls = new Set<string>();
for (const msg of messages) {
// Check for approval_request_message type
if ('message_type' in msg && msg.message_type === 'approval_request_message') {
const approvalMsg = msg as {
id: string;
tool_calls?: Array<{ tool_call_id: string; name: string }>;
tool_call?: { tool_call_id: string; name: string };
run_id?: string;
};
// Extract tool call info
const toolCalls = approvalMsg.tool_calls || (approvalMsg.tool_call ? [approvalMsg.tool_call] : []);
for (const tc of toolCalls) {
if (resolvedToolCalls.has(tc.tool_call_id)) {
continue;
}
if (seenToolCalls.has(tc.tool_call_id)) {
continue;
}
seenToolCalls.add(tc.tool_call_id);
pendingApprovals.push({
runId: approvalMsg.run_id || run.id,
toolCallId: tc.tool_call_id,
toolName: tc.name,
messageId: approvalMsg.id,
});
}
}
}
}
}
return pendingApprovals;
} catch (e) {
log.error('Failed to get pending approvals:', e);
return [];
}
}
/**
* Reject a pending tool approval request.
* Sends an approval response with approve: false.
*/
export async function rejectApproval(
agentId: string,
approval: {
toolCallId: string;
reason?: string;
},
conversationId?: string
): Promise<boolean> {
try {
const client = getClient();
// Send approval response via messages.create
await client.agents.messages.create(agentId, {
messages: [{
type: 'approval',
approvals: [{
approve: false,
tool_call_id: approval.toolCallId,
type: 'approval',
reason: approval.reason || 'Session was interrupted - please retry your request',
}],
}],
streaming: false,
});
log.info(`Rejected approval for tool call ${approval.toolCallId}`);
return true;
} catch (e) {
const err = e as { status?: number; error?: { detail?: string } };
const detail = err?.error?.detail || '';
if (err?.status === 400 && detail.includes('No tool call is currently awaiting approval')) {
log.warn(`Approval already resolved for tool call ${approval.toolCallId}`);
return true;
}
log.error('Failed to reject approval:', e);
return false;
}
}
/**
* Cancel active runs for an agent.
* Optionally specify specific run IDs to cancel.
* Note: Requires Redis on the server for canceling active runs.
*/
export async function cancelRuns(
agentId: string,
runIds?: string[]
): Promise<boolean> {
try {
const client = getClient();
await client.agents.messages.cancel(agentId, {
run_ids: runIds,
});
log.info(`Cancelled runs for agent ${agentId}${runIds ? ` (${runIds.join(', ')})` : ''}`);
return true;
} catch (e) {
log.error('Failed to cancel runs:', e);
return false;
}
}
/**
* Fetch the error detail from the latest failed run on an agent.
* Returns the actual error detail from run metadata (which is more
* descriptive than the opaque `stop_reason=error` wire message).
* Single API call -- fast enough to use on every error.
*/
export async function getLatestRunError(
agentId: string,
conversationId?: string
): Promise<{ message: string; stopReason: string; isApprovalError: boolean } | null> {
try {
const client = getClient();
const runs = await client.runs.list({
agent_id: agentId,
conversation_id: conversationId,
limit: 1,
});
const runsArray: Array<Record<string, unknown>> = [];
for await (const run of runs) {
runsArray.push(run as unknown as Record<string, unknown>);
break; // Only need the first one
}
const run = runsArray[0];
if (!run) return null;
if (conversationId
&& typeof run.conversation_id === 'string'
&& run.conversation_id !== conversationId) {
log.warn('Latest run lookup returned a different conversation, skipping enrichment');
return null;
}
const meta = run.metadata as Record<string, unknown> | undefined;
const err = meta?.error as Record<string, unknown> | undefined;
const detail = typeof err?.detail === 'string' ? err.detail : '';
const stopReason = typeof run.stop_reason === 'string' ? run.stop_reason : 'error';
if (!detail) return null;
const isApprovalError = detail.toLowerCase().includes('waiting for approval')
|| detail.toLowerCase().includes('approve or deny');
log.info(`Latest run error: ${detail.slice(0, 150)}${isApprovalError ? ' [approval]' : ''}`);
return { message: detail, stopReason, isApprovalError };
} catch (e) {
log.warn('Failed to fetch latest run error:', e instanceof Error ? e.message : e);
return null;
}
}
async function listActiveConversationRunIds(
agentId: string,
conversationId: string,
limit = 25
): Promise<string[]> {
try {
const client = getClient();
const runs = await client.runs.list({
agent_id: agentId,
conversation_id: conversationId,
active: true,
limit,
});
const runIds: string[] = [];
for await (const run of runs) {
const id = (run as { id?: unknown }).id;
if (typeof id === 'string' && id.length > 0) {
runIds.push(id);
}
if (runIds.length >= limit) break;
}
return runIds;
} catch (e) {
log.warn('Failed to list active conversation runs:', e instanceof Error ? e.message : e);
return [];
}
}
/**
* Disable tool approval requirement for a specific tool on an agent.
* This sets requires_approval: false at the server level.
*/
export async function disableToolApproval(
agentId: string,
toolName: string
): Promise<boolean> {
try {
const client = getClient();
// Note: API expects 'requires_approval' but client types say 'body_requires_approval'
// This is a bug in @letta-ai/letta-client - filed issue, using workaround
await client.agents.tools.updateApproval(toolName, {
agent_id: agentId,
requires_approval: false,
} as unknown as Parameters<typeof client.agents.tools.updateApproval>[1]);
log.info(`Disabled approval requirement for tool ${toolName} on agent ${agentId}`);
return true;
} catch (e) {
log.error(`Failed to disable tool approval for ${toolName}:`, e);
return false;
}
}
/**
* Get tools attached to an agent with their approval settings.
*/
export async function getAgentTools(agentId: string): Promise<Array<{
name: string;
id: string;
requiresApproval?: boolean;
}>> {
try {
const client = getClient();
const toolsPage = await client.agents.tools.list(agentId);
const tools: Array<{ name: string; id: string; requiresApproval?: boolean }> = [];
for await (const tool of toolsPage) {
tools.push({
name: tool.name ?? 'unknown',
id: tool.id,
// Note: The API might not return this field directly on list
// We may need to check each tool individually
requiresApproval: (tool as { requires_approval?: boolean }).requires_approval,
});
}
return tools;
} catch (e) {
log.error('Failed to get agent tools:', e);
return [];
}
}
/**
* Ensure no tools on the agent require approval.
* Call on startup to proactively prevent stuck approval states.
*/
export async function ensureNoToolApprovals(agentId: string): Promise<void> {
try {
const tools = await getAgentTools(agentId);
const approvalTools = tools.filter(t => t.requiresApproval);
if (approvalTools.length > 0) {
log.info(`Found ${approvalTools.length} tool(s) requiring approval: ${approvalTools.map(t => t.name).join(', ')}`);
log.info('Disabling tool approvals for headless operation...');
await disableAllToolApprovals(agentId);
}
} catch (e) {
log.warn('Failed to check/disable tool approvals:', e);
}
}
/**
* Disable approval requirement for ALL tools on an agent.
* Useful for ensuring a headless deployment doesn't get stuck.
*/
/**
* Recover from orphaned approval_request_messages by directly inspecting the conversation.
*
* Unlike getPendingApprovals() which relies on agent.pending_approval or run stop_reason,
* this function looks at the actual conversation messages to find unresolved approval requests
* from terminated (failed/cancelled) runs.
*
* Returns { recovered: true } if orphaned approvals were found and resolved.
*/
export async function recoverOrphanedConversationApproval(
agentId: string,
conversationId: string,
deepScan = false
): Promise<{ recovered: boolean; details: string }> {
try {
const client = getClient();
// List recent messages from the conversation to find orphaned approvals.
// Default: 50 (fast path). Deep scan: 500 (for conversations with many approvals).
const scanLimit = deepScan ? 500 : 50;
log.info(`Scanning ${scanLimit} messages for orphaned approvals...`);
const messagesPage = await client.conversations.messages.list(conversationId, { limit: scanLimit });
const messages: Array<Record<string, unknown>> = [];
for await (const msg of messagesPage) {
messages.push(msg as unknown as Record<string, unknown>);
}
if (messages.length === 0) {
return { recovered: false, details: 'No messages in conversation' };
}
// Build set of tool_call_ids that already have approval responses
const resolvedToolCalls = new Set<string>();
for (const msg of messages) {
if (msg.message_type === 'approval_response_message') {
const approvals = (msg.approvals as Array<{ tool_call_id?: string }>) || [];
for (const a of approvals) {
if (a.tool_call_id) resolvedToolCalls.add(a.tool_call_id);
}
}
}
// Find unresolved approval_request_messages
interface UnresolvedApproval {
toolCallId: string;
toolName: string;
runId: string;
}
const unresolvedByRun = new Map<string, UnresolvedApproval[]>();
for (const msg of messages) {
if (msg.message_type !== 'approval_request_message') continue;
const toolCalls = (msg.tool_calls as Array<{ tool_call_id: string; name: string }>)
|| (msg.tool_call ? [msg.tool_call as { tool_call_id: string; name: string }] : []);
const runId = msg.run_id as string | undefined;
for (const tc of toolCalls) {
if (!tc.tool_call_id || resolvedToolCalls.has(tc.tool_call_id)) continue;
const key = runId || 'unknown';
if (!unresolvedByRun.has(key)) unresolvedByRun.set(key, []);
unresolvedByRun.get(key)!.push({
toolCallId: tc.tool_call_id,
toolName: tc.name || 'unknown',
runId: key,
});
}
}
if (unresolvedByRun.size === 0) {
return { recovered: false, details: 'No unresolved approval requests found' };
}
// Check each run's status - only resolve orphaned approvals from terminated runs
let recoveredCount = 0;
const details: string[] = [];
for (const [runId, approvals] of unresolvedByRun) {
if (runId === 'unknown') {
// No run_id on the approval message - can't verify, skip
details.push(`Skipped ${approvals.length} approval(s) with no run_id`);
continue;
}
try {
const run = await client.runs.retrieve(runId);
const status = run.status;
const stopReason = run.stop_reason;
const isTerminated = status === 'failed' || status === 'cancelled';
const isAbandonedApproval = status === 'completed' && stopReason === 'requires_approval';
// Active runs stuck on approval block the entire conversation.
// No client is going to approve them -- reject and cancel so
// lettabot can proceed.
const isStuckApproval = status === 'running' && stopReason === 'requires_approval';
// Letta Cloud uses status "created" with no stop_reason for runs
// that paused on requires_approval but haven't been resumed yet.
// If we found unresolved approval_request_messages for this run,
// it's stuck -- treat it the same as a running/requires_approval.
const isCreatedWithApproval = status === 'created';
if (isTerminated || isAbandonedApproval || isStuckApproval || isCreatedWithApproval) {
log.info(`Found ${approvals.length} blocking approval(s) from ${status}/${stopReason} run ${runId}`);
// Send denial for each unresolved tool call
const approvalResponses = approvals.map(a => ({
approve: false as const,
tool_call_id: a.toolCallId,
type: 'approval' as const,
reason: `Auto-denied: originating run was ${status}/${stopReason}`,
}));
await client.conversations.messages.create(conversationId, {
messages: [{
type: 'approval',
approvals: approvalResponses,
}],
streaming: false,
});
// The denial triggers a new agent run server-side. Wait for it to
// settle before returning, otherwise the caller retries immediately
// and hits a 409 because the denial's run is still processing.
await new Promise(resolve => setTimeout(resolve, 3000));
// Cancel only active runs for this conversation to avoid interrupting
// unrelated in-flight requests on other conversations.
const activeRunIds = await listActiveConversationRunIds(agentId, conversationId);
let cancelled = false;
if (activeRunIds.length > 0) {
cancelled = await cancelRuns(agentId, activeRunIds);
if (cancelled) {
log.info(`Cancelled ${activeRunIds.length} active conversation run(s) after approval denial`);
}
} else {
log.info(`No active runs to cancel for conversation ${conversationId}`);
}
recoveredCount += approvals.length;
const suffix = cancelled ? ' (runs cancelled)' : '';
details.push(`Denied ${approvals.length} approval(s) from ${status} run ${runId}${suffix}`);
} else {
details.push(`Run ${runId} is ${status}/${stopReason} - not orphaned`);
}
} catch (runError) {
log.warn(`Failed to check run ${runId}:`, runError);
details.push(`Failed to check run ${runId}`);
}
}
const detailStr = details.join('; ');
if (recoveredCount > 0) {
log.info(`Recovered ${recoveredCount} orphaned approval(s): ${detailStr}`);
return { recovered: true, details: detailStr };
}
return { recovered: false, details: detailStr };
} catch (e) {
log.error('Failed to recover orphaned conversation approval:', e);
return { recovered: false, details: `Error: ${e instanceof Error ? e.message : String(e)}` };
}
}
export async function disableAllToolApprovals(agentId: string): Promise<number> {
try {
const tools = await getAgentTools(agentId);
let disabled = 0;
for (const tool of tools) {
const success = await disableToolApproval(agentId, tool.name);
if (success) disabled++;
}
log.info(`Disabled approval for ${disabled}/${tools.length} tools on agent ${agentId}`);
return disabled;
} catch (e) {
log.error('Failed to disable all tool approvals:', e);
return 0;
}
}