From f7cea4a830a5e09dbad370834f5f9467e9c25bdc Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 2 Nov 2025 20:36:05 -0800 Subject: [PATCH] fix: handle mid-stream errors properly using sdk typing (#54) --- src/cli/App.tsx | 22 +++++++++++++++++----- src/cli/helpers/accumulator.ts | 22 ++-------------------- src/cli/helpers/stream.ts | 5 +++++ src/headless.ts | 13 ++++++++++++- 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3f2d3d2..55c677c 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1,5 +1,6 @@ // src/cli/App.tsx +import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState, MessageCreate, @@ -535,7 +536,17 @@ export default function App({ return; } } catch (e) { - appendError(String(e)); + // 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}`); + } else { + // Fallback for non-API errors + appendError(e instanceof Error ? e.message : String(e)); + } setStreaming(false); } finally { abortControllerRef.current = null; @@ -560,13 +571,14 @@ export default function App({ const client = await getClient(); // Send cancel request to backend - await client.agents.messages.cancel(agentId); + const cancelResult = await client.agents.messages.cancel(agentId); + // console.error("cancelResult", JSON.stringify(cancelResult, null, 2)); // WORKAROUND: Also abort the stream immediately since backend cancellation is buggy // TODO: Once backend is fixed, comment out the immediate abort below and uncomment the timeout version - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } + // if (abortControllerRef.current) { + // abortControllerRef.current.abort(); + // } // FUTURE: Use this timeout-based abort once backend properly sends "cancelled" stop reason // This gives the backend 5 seconds to gracefully close the stream before forcing abort diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index ffc22f6..0425efe 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -197,26 +197,8 @@ function extractTextPart(v: unknown): string { export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { // TODO remove once SDK v1 has proper typing for in-stream errors // Check for streaming error objects (not typed in SDK but emitted by backend) - // These are emitted when LLM errors occur during streaming (rate limits, timeouts, etc.) - const chunkWithError = chunk as typeof chunk & { - error?: { message?: string; detail?: string }; - }; - if (chunkWithError.error && !chunk.message_type) { - const errorId = `err-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - const errorMsg = chunkWithError.error.message || "An error occurred"; - const errorDetail = chunkWithError.error.detail || ""; - const fullErrorText = errorDetail - ? `${errorMsg}: ${errorDetail}` - : errorMsg; - - b.byId.set(errorId, { - kind: "error", - id: errorId, - text: `⚠ ${fullErrorText}`, - }); - b.order.push(errorId); - return; - } + // Note: Error handling moved to catch blocks in App.tsx and headless.ts + // The SDK now throws APIError when it sees event: error, so chunks never have error property switch (chunk.message_type) { case "reasoning_message": { diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts index b2040a4..856afa6 100644 --- a/src/cli/helpers/stream.ts +++ b/src/cli/helpers/stream.ts @@ -117,6 +117,11 @@ export async function drainStream( stopReason = "error"; } + // Mark incomplete tool calls as cancelled if stream was cancelled + if (stopReason === "cancelled") { + markIncompleteToolsAsCancelled(buffers); + } + // Mark the final line as finished now that stream has ended markCurrentLineAsFinished(buffers); queueMicrotask(refresh); diff --git a/src/headless.ts b/src/headless.ts index 91c0325..458d9a1 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,4 +1,5 @@ import { parseArgs } from "node:util"; +import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState, MessageCreate, @@ -575,7 +576,17 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { process.exit(1); } } catch (error) { - console.error(`Error: ${error}`); + // Handle APIError from streaming (event: error) + if (error instanceof APIError && error.error?.error) { + const { type, message, detail } = error.error.error; + const errorType = type ? `[${type}] ` : ""; + const errorMessage = message || "An error occurred"; + const errorDetail = detail ? `: ${detail}` : ""; + console.error(`Error: ${errorType}${errorMessage}${errorDetail}`); + } else { + // Fallback for non-API errors + console.error(`Error: ${error}`); + } process.exit(1); }