Files
lettabot/src/core/errors.ts

129 lines
5.6 KiB
TypeScript

/**
* Error classification and user-facing error formatting.
*
* Extracted from bot.ts to keep error logic isolated and testable.
*/
/**
* Detect if an error is a 409 CONFLICT from an orphaned approval.
*/
export function isApprovalConflictError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (msg.includes('waiting for approval')) return true;
if (msg.includes('conflict') && msg.includes('approval')) return true;
}
const statusError = error as { status?: number };
if (statusError?.status === 409) return true;
return false;
}
/**
* Detect if an error indicates a missing conversation or agent.
* Only these errors should trigger the "create new conversation" fallback.
* Auth, network, and protocol errors should NOT be retried.
*/
export function isConversationMissingError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (msg.includes('not found')) return true;
if (msg.includes('conversation') && (msg.includes('missing') || msg.includes('does not exist'))) return true;
if (msg.includes('agent') && msg.includes('not found')) return true;
}
const statusError = error as { status?: number };
if (statusError?.status === 404) return true;
return false;
}
/**
* Detect if a session initialization error indicates the agent doesn't exist.
* The SDK includes CLI stderr in the error message when the subprocess exits
* before sending an init message. We check for agent-not-found indicators in
* both the SDK-level message and the CLI stderr output it includes.
*
* This intentionally does NOT treat generic init failures (like "no init
* message received") as recoverable. Those can be transient SDK/process
* issues, and clearing persisted agent state in those cases can destroy
* valid mappings.
*/
export function isAgentMissingFromInitError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const msg = error.message.toLowerCase();
const agentMissingPatterns = [
/\bagent\b[^.\n]{0,80}\bnot found\b/,
/\bnot found\b[^.\n]{0,80}\bagent\b/,
/\bagent\b[^.\n]{0,80}\bdoes not exist\b/,
/\bunknown agent\b/,
/\bagent_not_found\b/,
];
return agentMissingPatterns.some((pattern) => pattern.test(msg));
}
/**
* Map a structured API error into a clear, user-facing message.
* The `error` object comes from the SDK's new SDKErrorMessage type.
*/
export function formatApiErrorForUser(error: { message: string; stopReason: string; apiError?: Record<string, unknown> }): string {
const msg = error.message.toLowerCase();
const stopReason = error.stopReason.toLowerCase();
const apiError = error.apiError || {};
const apiMsg = (typeof apiError.message === 'string' ? apiError.message : '').toLowerCase();
const reasons: string[] = Array.isArray(apiError.reasons) ? apiError.reasons : [];
// Billing / credits exhausted
if (msg.includes('out of credits') || apiMsg.includes('out of credits')) {
return '(Out of credits for hosted inference. Add credits or enable auto-recharge at app.letta.com/settings/organization/usage.)';
}
// Rate limiting / usage exceeded (429)
if (msg.includes('rate limit') || msg.includes('429') || msg.includes('usage limit')
|| apiMsg.includes('rate limit') || apiMsg.includes('usage limit')) {
if (reasons.includes('premium-usage-exceeded') || msg.includes('hosted model usage limit')) {
return '(Rate limited -- your Letta Cloud usage limit has been exceeded. Check your plan at app.letta.com.)';
}
const reasonStr = reasons.length > 0 ? `: ${reasons.join(', ')}` : '';
return `(Rate limited${reasonStr}. Try again in a moment.)`;
}
// 409 CONFLICT -- approval-specific (stuck tool approval blocking messages)
const hasApprovalSignal = stopReason === 'requires_approval'
|| msg.includes('waiting for approval')
|| msg.includes('pending_approval')
|| msg.includes('stuck waiting for tool approval')
|| apiMsg.includes('waiting for approval')
|| apiMsg.includes('pending_approval');
const hasConflictSignal = msg.includes('conflict')
|| msg.includes('409')
|| apiMsg.includes('conflict')
|| apiMsg.includes('409')
|| stopReason === 'requires_approval';
if (hasApprovalSignal && hasConflictSignal) {
return '(A stuck tool approval is blocking this conversation. Run `lettabot reset-conversation` to clear it, or approve/deny the pending request at app.letta.com.)';
}
// 409 CONFLICT (concurrent request on same conversation)
if (msg.includes('conflict') || msg.includes('409') || msg.includes('another request is currently being processed')) {
return '(Another request is still processing on this conversation. Wait a moment and try again.)';
}
// Authentication
if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('forbidden')) {
return '(Authentication failed -- check your API key in lettabot.yaml.)';
}
// Agent/conversation not found
if (msg.includes('not found') || msg.includes('404')) {
return '(Agent or conversation not found -- the configured agent may have been deleted. Try re-onboarding.)';
}
// Server errors
if (msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('internal server error')) {
return '(Letta API server error -- try again in a moment.)';
}
// Fallback: use the actual error message (truncated for safety)
const detail = error.message.length > 200 ? error.message.slice(0, 200) + '...' : error.message;
const trimmed = detail.replace(/[.\s]+$/, '');
return `(Agent error: ${trimmed}. Try sending your message again.)`;
}