fix: handle mid-stream errors properly using sdk typing (#54)

This commit is contained in:
Charles Packer
2025-11-02 20:36:05 -08:00
committed by GitHub
parent 998f24aaf5
commit f7cea4a830
4 changed files with 36 additions and 26 deletions

View File

@@ -1,5 +1,6 @@
// src/cli/App.tsx // src/cli/App.tsx
import { APIError } from "@letta-ai/letta-client/core/error";
import type { import type {
AgentState, AgentState,
MessageCreate, MessageCreate,
@@ -535,7 +536,17 @@ export default function App({
return; return;
} }
} catch (e) { } 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); setStreaming(false);
} finally { } finally {
abortControllerRef.current = null; abortControllerRef.current = null;
@@ -560,13 +571,14 @@ export default function App({
const client = await getClient(); const client = await getClient();
// Send cancel request to backend // 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 // 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 // TODO: Once backend is fixed, comment out the immediate abort below and uncomment the timeout version
if (abortControllerRef.current) { // if (abortControllerRef.current) {
abortControllerRef.current.abort(); // abortControllerRef.current.abort();
} // }
// FUTURE: Use this timeout-based abort once backend properly sends "cancelled" stop reason // 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 // This gives the backend 5 seconds to gracefully close the stream before forcing abort

View File

@@ -197,26 +197,8 @@ function extractTextPart(v: unknown): string {
export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { export function onChunk(b: Buffers, chunk: LettaStreamingResponse) {
// TODO remove once SDK v1 has proper typing for in-stream errors // 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) // 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.) // Note: Error handling moved to catch blocks in App.tsx and headless.ts
const chunkWithError = chunk as typeof chunk & { // The SDK now throws APIError when it sees event: error, so chunks never have error property
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;
}
switch (chunk.message_type) { switch (chunk.message_type) {
case "reasoning_message": { case "reasoning_message": {

View File

@@ -117,6 +117,11 @@ export async function drainStream(
stopReason = "error"; 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 // Mark the final line as finished now that stream has ended
markCurrentLineAsFinished(buffers); markCurrentLineAsFinished(buffers);
queueMicrotask(refresh); queueMicrotask(refresh);

View File

@@ -1,4 +1,5 @@
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { APIError } from "@letta-ai/letta-client/core/error";
import type { import type {
AgentState, AgentState,
MessageCreate, MessageCreate,
@@ -575,7 +576,17 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
process.exit(1); process.exit(1);
} }
} catch (error) { } 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); process.exit(1);
} }