diff --git a/src/core/bot.ts b/src/core/bot.ts index c08fa88..e932869 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -79,6 +79,13 @@ export class LettaBot { }); return '⏰ Heartbeat triggered (silent mode - check server logs)'; } + case 'reset': { + const oldConversationId = this.store.conversationId; + this.store.conversationId = null; + this.store.resetRecoveryAttempts(); + console.log(`[Command] /reset - conversation cleared (was: ${oldConversationId})`); + return 'Conversation reset. Send a message to start a new conversation. (Agent memory is preserved.)'; + } default: return null; } diff --git a/src/core/commands.ts b/src/core/commands.ts index 739d0c6..3ac5375 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -4,7 +4,7 @@ * Shared command parsing and help text for all channels. */ -export const COMMANDS = ['status', 'heartbeat', 'help', 'start'] as const; +export const COMMANDS = ['status', 'heartbeat', 'reset', 'help', 'start'] as const; export type Command = typeof COMMANDS[number]; export const HELP_TEXT = `LettaBot - AI assistant with persistent memory @@ -12,6 +12,7 @@ export const HELP_TEXT = `LettaBot - AI assistant with persistent memory Commands: /status - Show current status /heartbeat - Trigger heartbeat +/reset - Reset conversation (keeps agent memory) /help - Show this message Just send a message to get started!`; diff --git a/src/main.ts b/src/main.ts index 6697259..0723be7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -122,7 +122,7 @@ import { DiscordAdapter } from './channels/discord.js'; import { CronService } from './cron/service.js'; import { HeartbeatService } from './cron/heartbeat.js'; import { PollingService } from './polling/service.js'; -import { agentExists, findAgentByName } from './tools/letta-api.js'; +import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/letta-api.js'; // Skills are now installed to agent-scoped location after agent creation (see bot.ts) // Check if config exists (skip in Railway/Docker where env vars are used directly) @@ -380,6 +380,14 @@ async function main() { console.log(`[Agent] No agent found - will create "${agentName}" on first message`); } + // Proactively disable tool approvals for headless operation + // Prevents stuck states from server-side requires_approval=true (SDK issue #25) + if (initialStatus.agentId) { + ensureNoToolApprovals(initialStatus.agentId).catch(err => { + console.warn('[Agent] Failed to check tool approvals:', err); + }); + } + // Register enabled channels if (config.telegram.enabled) { const telegram = new TelegramAdapter({ diff --git a/src/tools/letta-api.ts b/src/tools/letta-api.ts index 70923fc..0fa6b99 100644 --- a/src/tools/letta-api.ts +++ b/src/tools/letta-api.ts @@ -235,20 +235,46 @@ export async function getPendingApprovals( 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); + const agentState = await client.agents.retrieve(agentId, { + include: ['agent.pending_approval'], + }); if ('pending_approval' in agentState) { - const pending = (agentState as { pending_approval?: { id: string; run_id?: string | null; tool_calls?: Array<{ tool_call_id: string; name: string }>; tool_call?: { tool_call_id: string; name: string } } | null }).pending_approval; + const pending = agentState.pending_approval; if (!pending) { + console.log('[Letta API] No pending approvals on agent'); return []; } - const toolCalls = pending.tool_calls || (pending.tool_call ? [pending.tool_call] : []); + console.log(`[Letta API] Found pending approval: ${pending.id}, run_id=${pending.run_id}`); + + // Extract tool calls - handle both Array 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(); const approvals: PendingApproval[] = []; - for (const tc of toolCalls) { - if (!tc?.tool_call_id || seen.has(tc.tool_call_id)) { - continue; - } + 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', @@ -257,6 +283,7 @@ export async function getPendingApprovals( messageId: pending.id, }); } + console.log(`[Letta API] Extracted ${approvals.length} pending approval(s): ${approvals.map(a => a.toolName).join(', ')}`); return approvals; } } catch (e) { @@ -460,6 +487,24 @@ export async function getAgentTools(agentId: string): Promise { + try { + const tools = await getAgentTools(agentId); + const approvalTools = tools.filter(t => t.requiresApproval); + if (approvalTools.length > 0) { + console.log(`[Letta API] Found ${approvalTools.length} tool(s) requiring approval: ${approvalTools.map(t => t.name).join(', ')}`); + console.log('[Letta API] Disabling tool approvals for headless operation...'); + await disableAllToolApprovals(agentId); + } + } catch (e) { + console.warn('[Letta API] 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.