From ead42034483b410fefceb481bf2dec5d329b4760 Mon Sep 17 00:00:00 2001 From: Christina Tong Date: Thu, 11 Dec 2025 12:49:29 -0800 Subject: [PATCH] fix: handle multiple error types (#178) --- src/cli/App.tsx | 171 ++++++++++++++++++------------ src/cli/helpers/errorFormatter.ts | 70 ++++++++++++ 2 files changed, 174 insertions(+), 67 deletions(-) create mode 100644 src/cli/helpers/errorFormatter.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 354b12f..90f7329 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1,7 +1,7 @@ // src/cli/App.tsx import { existsSync, readFileSync } from "node:fs"; -import { APIError, APIUserAbortError } from "@letta-ai/letta-client/core/error"; +import { APIUserAbortError } from "@letta-ai/letta-client/core/error"; import type { AgentState, MessageCreate, @@ -57,6 +57,7 @@ import { toLines, } from "./helpers/accumulator"; import { backfillBuffers } from "./helpers/backfill"; +import { formatErrorDetails } from "./helpers/errorFormatter"; import { buildMessageContentFromDisplay, clearPlaceholdersInText, @@ -600,8 +601,6 @@ export default function App({ initialInput: Array, ): Promise => { const currentInput = initialInput; - // Track lastRunId outside the while loop so it's available in catch block - let lastKnownRunId: string | null = null; try { // Check if user hit escape before we started @@ -634,11 +633,6 @@ export default function App({ abortControllerRef.current?.signal, ); - // Update lastKnownRunId for error handling in catch block - if (lastRunId) { - lastKnownRunId = lastRunId; - } - // Track API duration sessionStatsRef.current.endTurn(apiDurationMs); sessionStatsRef.current.updateUsageFromBuffers(buffersRef.current); @@ -800,6 +794,17 @@ export default function App({ // If all are auto-handled, continue immediately without showing dialog if (needsUserInput.length === 0) { + // Check if user cancelled before continuing + if ( + userCancelledRef.current || + abortControllerRef.current?.signal.aborted + ) { + setStreaming(false); + markIncompleteToolsAsCancelled(buffersRef.current); + refreshDerived(); + return; + } + // Rotate to a new thinking message setThinkingMessage(getRandomThinkingMessage()); refreshDerived(); @@ -848,13 +853,7 @@ export default function App({ // Mark incomplete tool calls as finished to prevent stuck blinking UI markIncompleteToolsAsCancelled(buffersRef.current); - // Build run info suffix for debugging - const runInfoSuffix = lastRunId - ? `\n(run_id: ${lastRunId}, stop_reason: ${stopReason})` - : `\n(stop_reason: ${stopReason})`; - // Fetch error details from the run if available - let errorDetails = `An error occurred during agent execution`; if (lastRunId) { try { const client = await getClient(); @@ -862,27 +861,41 @@ export default function App({ // Check if run has error information in metadata if (run.metadata?.error) { - const error = run.metadata.error as { + const errorData = run.metadata.error as { type?: string; message?: string; detail?: string; }; - const errorType = error.type ? `[${error.type}] ` : ""; - const errorMessage = error.message || "An error occurred"; - const errorDetail = error.detail ? `\n${error.detail}` : ""; - errorDetails = `${errorType}${errorMessage}${errorDetail}`; + + // Pass structured error data to our formatter + const errorObject = { + error: { + error: errorData, + run_id: lastRunId, + }, + }; + const errorDetails = formatErrorDetails(errorObject, agentId); + appendError(errorDetails); + } else { + // No error metadata, show generic error with run info + appendError( + `An error occurred during agent execution\n(run_id: ${lastRunId}, stop_reason: ${stopReason})`, + ); } } catch (_e) { - // If we can't fetch error details, let user know + // If we can't fetch error details, show generic error appendError( - `${errorDetails}${runInfoSuffix}\n(Unable to fetch additional error details from server)`, + `An error occurred during agent execution\n(run_id: ${lastRunId}, stop_reason: ${stopReason})\n(Unable to fetch additional error details from server)`, ); return; } + } else { + // No run_id available - but this is unusual since errors should have run_ids + appendError( + `An error occurred during agent execution\n(stop_reason: ${stopReason})`, + ); } - appendError(`${errorDetails}${runInfoSuffix}`); - setStreaming(false); refreshDerived(); return; @@ -899,25 +912,9 @@ export default function App({ return; } - // Build error message with run_id for debugging - const runIdSuffix = lastKnownRunId - ? `\n(run_id: ${lastKnownRunId}, stop_reason: error)` - : ""; - - // Handle APIError from streaming (event: error) - if (e instanceof APIError && e.error?.error) { - const { type, message, detail } = e.error.error; - const errorType = type ? `[${type}] ` : ""; - const errorMessage = message || "An error occurred"; - const errorDetail = detail ? `:\n${detail}` : ""; - appendError( - `${errorType}${errorMessage}${errorDetail}${runIdSuffix}`, - ); - } else { - // Fallback for non-API errors - const errorMessage = e instanceof Error ? e.message : String(e); - appendError(`${errorMessage}${runIdSuffix}`); - } + // Use comprehensive error formatting + const errorDetails = formatErrorDetails(e, agentId); + appendError(errorDetails); setStreaming(false); refreshDerived(); } finally { @@ -988,7 +985,8 @@ export default function App({ abortControllerRef.current.abort(); } } catch (e) { - appendError(`Failed to interrupt stream: ${String(e)}`); + const errorDetails = formatErrorDetails(e, agentId); + appendError(`Failed to interrupt stream: ${errorDetails}`); setInterruptRequested(false); } } @@ -1134,11 +1132,12 @@ export default function App({ // Exit after a brief delay to show the message setTimeout(() => process.exit(0), 500); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1187,11 +1186,12 @@ export default function App({ refreshDerived(); } catch (error) { // Mark command as failed + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1243,11 +1243,12 @@ export default function App({ buffersRef.current.order.push(cmdId); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1286,11 +1287,12 @@ export default function App({ }); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1329,11 +1331,12 @@ export default function App({ }); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1392,11 +1395,12 @@ export default function App({ }); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1494,11 +1498,12 @@ export default function App({ buffersRef.current.order.push(successCmdId); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1577,11 +1582,12 @@ export default function App({ }); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1650,13 +1656,12 @@ export default function App({ }, ]); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${ - error instanceof Error ? error.message : String(error) - }`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1771,11 +1776,12 @@ ${recentCommits} }, ]); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1817,11 +1823,12 @@ ${recentCommits} refreshDerived(); } catch (error) { // Mark command as failed if executeCommand throws + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: msg, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -1986,6 +1993,21 @@ ${recentCommits} | { type: "deny"; approval: ApprovalRequest; reason: string }, ) => { try { + // Don't send results if user has already cancelled + if ( + userCancelledRef.current || + abortControllerRef.current?.signal.aborted + ) { + setStreaming(false); + setIsExecutingTool(false); + setPendingApprovals([]); + setApprovalContexts([]); + setApprovalResults([]); + setAutoHandledResults([]); + setAutoDeniedApprovals([]); + return; + } + // Snapshot current state before clearing dialog const approvalResultsSnapshot = [...approvalResults]; const autoHandledSnapshot = [...autoHandledResults]; @@ -2086,10 +2108,15 @@ ${recentCommits} refreshDerived(); const wasAborted = approvalAbortController.signal.aborted; + const userCancelled = + userCancelledRef.current || + abortControllerRef.current?.signal.aborted; - if (wasAborted) { - // Queue results to send alongside the next user message - setQueuedApprovalResults(allResults as ApprovalResult[]); + if (wasAborted || userCancelled) { + // Queue results to send alongside the next user message (if not cancelled entirely) + if (!userCancelled) { + setQueuedApprovalResults(allResults as ApprovalResult[]); + } setStreaming(false); } else { // Continue conversation with all results @@ -2146,11 +2173,13 @@ ${recentCommits} setIsExecutingTool(false); } } catch (e) { - appendError(String(e)); + const errorDetails = formatErrorDetails(e, agentId); + appendError(errorDetails); setStreaming(false); setIsExecutingTool(false); } }, [ + agentId, pendingApprovals, approvalResults, sendAllResults, @@ -2234,12 +2263,14 @@ ${recentCommits} setIsExecutingTool(false); } } catch (e) { - appendError(String(e)); + const errorDetails = formatErrorDetails(e, agentId); + appendError(errorDetails); setStreaming(false); setIsExecutingTool(false); } }, [ + agentId, pendingApprovals, approvalResults, sendAllResults, @@ -2352,12 +2383,13 @@ ${recentCommits} refreshDerived(); } catch (error) { // Mark command as failed (only if cmdId was created) + const errorDetails = formatErrorDetails(error, agentId); if (cmdId) { buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/model ${modelId}`, - output: `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed to switch model: ${errorDetails}`, phase: "finished", success: false, }); @@ -2439,11 +2471,12 @@ ${recentCommits} } refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/system ${promptId}`, - output: `Failed to switch system prompt: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed to switch system prompt: ${errorDetails}`, phase: "finished", success: false, }); @@ -2499,11 +2532,12 @@ ${recentCommits} }); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/toolset ${toolsetId}`, - output: `Failed to switch toolset: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed to switch toolset: ${errorDetails}`, phase: "finished", success: false, }); @@ -2594,11 +2628,12 @@ ${recentCommits} buffersRef.current.order.push(successCmdId); refreshDerived(); } catch (error) { + const errorDetails = formatErrorDetails(error, agentId); buffersRef.current.byId.set(cmdId, { kind: "command", id: cmdId, input: `/swap ${targetAgentId}`, - output: `Failed: ${error instanceof Error ? error.message : String(error)}`, + output: `Failed: ${errorDetails}`, phase: "finished", success: false, }); @@ -2607,7 +2642,7 @@ ${recentCommits} setCommandRunning(false); } }, - [refreshDerived, commitEligibleLines, columns], + [refreshDerived, commitEligibleLines, columns, agentId], ); // Track permission mode changes for UI updates @@ -2664,11 +2699,13 @@ ${recentCommits} setApprovalResults((prev) => [...prev, decision]); } } catch (e) { - appendError(String(e)); + const errorDetails = formatErrorDetails(e, agentId); + appendError(errorDetails); setStreaming(false); } }, [ + agentId, pendingApprovals, approvalResults, sendAllResults, diff --git a/src/cli/helpers/errorFormatter.ts b/src/cli/helpers/errorFormatter.ts new file mode 100644 index 0000000..7656005 --- /dev/null +++ b/src/cli/helpers/errorFormatter.ts @@ -0,0 +1,70 @@ +import { APIError } from "@letta-ai/letta-client/core/error"; + +/** + * Extract comprehensive error details from any error object + * Handles APIError, Error, and other error types consistently + * @param e The error object to format + * @param agentId Optional agent ID to create hyperlinks to the Letta dashboard + */ +export function formatErrorDetails(e: unknown, agentId?: string): string { + let runId: string | undefined; + + // Handle APIError from streaming (event: error) + if (e instanceof APIError) { + // Check for nested error structure: e.error.error + if (e.error && typeof e.error === "object" && "error" in e.error) { + const errorData = e.error.error; + if (errorData && typeof errorData === "object") { + const type = "type" in errorData ? errorData.type : undefined; + const message = + "message" in errorData ? errorData.message : "An error occurred"; + const detail = "detail" in errorData ? errorData.detail : undefined; + + const errorType = type ? `[${type}] ` : ""; + const errorDetail = detail ? `\nDetail: ${detail}` : ""; + + // Extract run_id from e.error + if ("run_id" in e.error && typeof e.error.run_id === "string") { + runId = e.error.run_id; + } + + const baseError = `${errorType}${message}${errorDetail}`; + return runId && agentId + ? `${baseError}\n${createAgentLink(runId, agentId)}` + : baseError; + } + } + + // Handle APIError with direct error structure: e.error.detail + if (e.error && typeof e.error === "object") { + const detail = "detail" in e.error ? e.error.detail : undefined; + if ("run_id" in e.error && typeof e.error.run_id === "string") { + runId = e.error.run_id; + } + + const baseError = detail ? `${e.message}\nDetail: ${detail}` : e.message; + return runId && agentId + ? `${baseError}\n${createAgentLink(runId, agentId)}` + : baseError; + } + + // Fallback for APIError with just message + return e.message; + } + + // Handle regular Error objects + if (e instanceof Error) { + return e.message; + } + + // Fallback for any other type + return String(e); +} + +/** + * Create a terminal hyperlink to the agent with run ID displayed + */ +function createAgentLink(runId: string, agentId: string): string { + const url = `https://app.letta.com/agents/${agentId}`; + return `View agent: \x1b]8;;${url}\x1b\\${agentId}\x1b]8;;\x1b\\ (run: ${runId})`; +}