diff --git a/src/cli/App.tsx b/src/cli/App.tsx index d705b55..2582153 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -216,11 +216,23 @@ async function isRetriableError( // Primary check: backend sets stop_reason=llm_api_error for LLMError exceptions if (stopReason === "llm_api_error") return true; - // Fallback check: in case stop_reason is "error" but metadata indicates LLM error - // This could happen if there's a backend edge case where LLMError is raised but - // stop_reason isn't set correctly. The metadata.error is a LettaErrorMessage with - // error_type="llm_error" for LLM errors (see streaming_service.py:402-411) - if (stopReason === "error" && lastRunId) { + // Early exit for stop reasons that should never be retried + const nonRetriableReasons: StopReasonType[] = [ + "cancelled", + "requires_approval", + "max_steps", + "max_tokens_exceeded", + "context_window_overflow_in_system_prompt", + "end_turn", + "tool_rule", + "no_tool_call", + ]; + if (nonRetriableReasons.includes(stopReason)) return false; + + // Fallback check: for error-like stop_reasons, check metadata for retriable patterns + // This handles cases where the backend sends a generic error stop_reason but the + // underlying cause is a transient LLM/network issue that should be retried + if (lastRunId) { try { const client = await getClient(); const run = await client.runs.retrieve(lastRunId); @@ -237,7 +249,7 @@ async function isRetriableError( const errorType = metaError?.error_type ?? metaError?.error?.error_type; if (errorType === "llm_error") return true; - // Fallback: detect LLM provider errors from detail even if misclassified as internal_error + // Fallback: detect LLM provider errors from detail even if misclassified // This handles edge cases where streaming errors weren't properly converted to LLMError // Patterns are derived from handle_llm_error() message formats in the backend const detail = metaError?.detail ?? metaError?.error?.detail ?? ""; @@ -249,10 +261,7 @@ async function isRetriableError( "api_error", // Anthropic SDK error type field "Network error", // Transient network failures during streaming ]; - if ( - errorType === "internal_error" && - llmProviderPatterns.some((pattern) => detail.includes(pattern)) - ) { + if (llmProviderPatterns.some((pattern) => detail.includes(pattern))) { return true; } @@ -6407,18 +6416,28 @@ DO NOT respond to these messages or otherwise consider them in your response unl [pendingApprovals, approvalResults, sendAllResults], ); - // Auto-reject ExitPlanMode if plan file doesn't exist + // Auto-reject ExitPlanMode if plan mode is not enabled or plan file doesn't exist useEffect(() => { const currentIndex = approvalResults.length; const approval = pendingApprovals[currentIndex]; - if (approval?.toolName === "ExitPlanMode" && !planFileExists()) { - const planFilePath = permissionMode.getPlanFilePath(); - const plansDir = join(homedir(), ".letta", "plans"); - handlePlanKeepPlanning( - `You must write your plan to a plan file before exiting plan mode.\n` + - (planFilePath ? `Plan file path: ${planFilePath}\n` : "") + - `Use a write tool to create your plan in ${plansDir}, then use ExitPlanMode to present the plan to the user.`, - ); + if (approval?.toolName === "ExitPlanMode") { + // First check if plan mode is enabled + if (permissionMode.getMode() !== "plan") { + handlePlanKeepPlanning( + `Plan mode is not currently enabled. Use EnterPlanMode to enter plan mode first, then write your plan and use ExitPlanMode to present it.`, + ); + return; + } + // Then check if plan file exists + if (!planFileExists()) { + const planFilePath = permissionMode.getPlanFilePath(); + const plansDir = join(homedir(), ".letta", "plans"); + handlePlanKeepPlanning( + `You must write your plan to a plan file before exiting plan mode.\n` + + (planFilePath ? `Plan file path: ${planFilePath}\n` : "") + + `Use a write tool to create your plan in ${plansDir}, then use ExitPlanMode to present the plan to the user.`, + ); + } } }, [pendingApprovals, approvalResults.length, handlePlanKeepPlanning]); @@ -6463,6 +6482,12 @@ DO NOT respond to these messages or otherwise consider them in your response unl setThinkingMessage(getRandomThinkingVerb()); refreshDerived(); + // Mark as eagerly committed to prevent duplicate rendering + // (sendAllResults will call setToolCallsRunning which resets phase to "running") + if (approval.toolCallId) { + eagerCommittedPreviewsRef.current.add(approval.toolCallId); + } + const decision = { type: "approve" as const, approval, diff --git a/src/cli/components/CollapsedOutputDisplay.tsx b/src/cli/components/CollapsedOutputDisplay.tsx index aad13c9..fc80aa9 100644 --- a/src/cli/components/CollapsedOutputDisplay.tsx +++ b/src/cli/components/CollapsedOutputDisplay.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import { memo } from "react"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { MarkdownDisplay } from "./MarkdownDisplay"; const COLLAPSED_LINES = 3; const PREFIX_WIDTH = 5; // " ⎿ " or " " @@ -42,7 +43,7 @@ export const CollapsedOutputDisplay = memo( {" ⎿ "} - {visibleLines[0]} + {/* Remaining visible lines with indent (5 spaces to align with content after bracket) */} @@ -53,7 +54,7 @@ export const CollapsedOutputDisplay = memo( {" "} - {line} + ))} diff --git a/src/headless.ts b/src/headless.ts index b3a5cbb..9263623 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1233,16 +1233,24 @@ export async function handleHeadlessCommand( } // Unexpected stop reason (error, llm_api_error, etc.) - // Before failing, check run metadata to see if this is a retriable llm_api_error - // Fallback check: in case stop_reason is "error" but metadata indicates LLM error - // This could happen if there's a backend edge case where LLMError is raised but - // stop_reason isn't set correctly. The metadata.error is a LettaErrorMessage with - // error_type="llm_error" for LLM errors (see streaming_service.py:402-411) - if ( - stopReason === "error" && - lastRunId && - llmApiErrorRetries < LLM_API_ERROR_MAX_RETRIES - ) { + // Before failing, check run metadata to see if this is a retriable error + // This handles cases where the backend sends a generic error stop_reason but the + // underlying cause is a transient LLM/network issue that should be retried + + // Early exit for stop reasons that should never be retried + const nonRetriableReasons: StopReasonType[] = [ + "cancelled", + "requires_approval", + "max_steps", + "max_tokens_exceeded", + "context_window_overflow_in_system_prompt", + "end_turn", + "tool_rule", + "no_tool_call", + ]; + if (nonRetriableReasons.includes(stopReason)) { + // Fall through to error display + } else if (lastRunId && llmApiErrorRetries < LLM_API_ERROR_MAX_RETRIES) { try { const run = await client.runs.retrieve(lastRunId); const metaError = run.metadata?.error as @@ -1259,7 +1267,7 @@ export async function handleHeadlessCommand( const errorType = metaError?.error_type ?? metaError?.error?.error_type; - // Fallback: detect LLM provider errors from detail even if misclassified as internal_error + // Fallback: detect LLM provider errors from detail even if misclassified // Patterns are derived from handle_llm_error() message formats in the backend const detail = metaError?.detail ?? metaError?.error?.detail ?? ""; const llmProviderPatterns = [ @@ -1270,9 +1278,9 @@ export async function handleHeadlessCommand( "api_error", // Anthropic SDK error type field "Network error", // Transient network failures during streaming ]; - const isLlmErrorFromDetail = - errorType === "internal_error" && - llmProviderPatterns.some((pattern) => detail.includes(pattern)); + const isLlmErrorFromDetail = llmProviderPatterns.some((pattern) => + detail.includes(pattern), + ); if (errorType === "llm_error" || isLlmErrorFromDetail) { const attempt = llmApiErrorRetries + 1; diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 16817bb..6d92f51 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -571,6 +571,12 @@ export function clipToolReturn( ): string { if (!text) return text; + // Don't clip user rejection reasons - they contain important feedback + // All denials use format: "Error: request to call tool denied. User reason: ..." + if (text.includes("request to call tool denied")) { + return text; + } + // First apply character limit to avoid extremely long text let clipped = text; if (text.length > maxChars) {