fix: approval detection missing include param + /reset command + startup check (#175)

getPendingApprovals() was calling agents.retrieve() without the
include: ['agent.pending_approval'] parameter, so the Letta API never
returned the pending_approval field. This caused stuck server-side tool
approvals (requires_approval=true) to go undetected, leaving the agent
permanently stuck with empty responses.

Also adds:
- Proactive ensureNoToolApprovals() on startup to disable requires_approval
- /reset slash command for deployed instances (clears conversation, keeps memory)
- Robust parsing for ToolCallDelta and deprecated tool_call field formats

Written by Cameron ◯ Letta Code

"The only way to do great work is to love what you do." - Steve Jobs
This commit is contained in:
Cameron
2026-02-05 17:38:48 -08:00
committed by GitHub
parent 0bed2cc166
commit 3b7150013c
4 changed files with 70 additions and 9 deletions

View File

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

View File

@@ -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!`;

View File

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

View File

@@ -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<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 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<Array<{
}
}
/**
* 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) {
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.