feat: retry on empty LLM response (LET-7679) (#1130)

Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
cthomas
2026-02-25 11:17:55 -08:00
committed by GitHub
parent 0023b9c7e5
commit be5fbfca74
5 changed files with 232 additions and 0 deletions

View File

@@ -21,6 +21,8 @@ export {
getPreStreamErrorAction,
isApprovalPendingError,
isConversationBusyError,
isEmptyResponseError,
isEmptyResponseRetryable,
isInvalidToolCallIdsError,
isNonRetryableProviderErrorDetail,
isRetryableProviderErrorDetail,

View File

@@ -16,6 +16,7 @@ const INVALID_TOOL_CALL_IDS_FRAGMENT = "invalid tool call ids";
const APPROVAL_PENDING_DETAIL_FRAGMENT = "waiting for approval";
const CONVERSATION_BUSY_DETAIL_FRAGMENT =
"another request is currently being processed";
const EMPTY_RESPONSE_DETAIL_FRAGMENT = "empty content in";
const RETRYABLE_PROVIDER_DETAIL_PATTERNS = [
"Anthropic API error",
"OpenAI API error",
@@ -94,6 +95,16 @@ export function isConversationBusyError(detail: unknown): boolean {
return detail.toLowerCase().includes(CONVERSATION_BUSY_DETAIL_FRAGMENT);
}
/**
* LLM returned an empty response (no content and no tool calls).
* This can happen with models like Opus 4.6 that occasionally return empty content.
* These are retryable with a cache-busting system message modification.
*/
export function isEmptyResponseError(detail: unknown): boolean {
if (typeof detail !== "string") return false;
return detail.toLowerCase().includes(EMPTY_RESPONSE_DETAIL_FRAGMENT);
}
/** Transient provider/network detail that is usually safe to retry. */
export function isRetryableProviderErrorDetail(detail: unknown): boolean {
if (typeof detail !== "string") return false;
@@ -131,6 +142,24 @@ export function shouldRetryRunMetadataError(
return retryable429Detail || retryableDetail;
}
/**
* Check if this is an empty response error that should be retried.
*
* Empty responses from models like Opus 4.6 are retryable. The caller
* decides whether to retry with the same input or append a system
* reminder nudge (typically on the last attempt).
*/
export function isEmptyResponseRetryable(
errorType: unknown,
detail: unknown,
emptyResponseRetries: number,
maxEmptyResponseRetries: number,
): boolean {
if (emptyResponseRetries >= maxEmptyResponseRetries) return false;
if (errorType !== "llm_error") return false;
return isEmptyResponseError(detail);
}
/** Retry decision for pre-stream send failures before any chunks are yielded. */
export function shouldRetryPreStreamTransientError(opts: {
status: number | undefined;