basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { Config } from '@qwen-code/qwen-code-core';8import {9 OutputFormat,10 JsonFormatter,11 parseAndFormatApiError,12 FatalTurnLimitedError,13 FatalCancellationError,14 FatalBudgetExceededError,15 ToolErrorType,16 createDebugLogger,17} from '@qwen-code/qwen-code-core';18import type { BudgetExceeded } from './runBudget.js';19import { runExitCleanup } from './cleanup.js';20import { writeStderrLine } from './stdioHelpers.js';21 22const debugLogger = createDebugLogger('CLI_ERRORS');23 24/**25 * Marker thrown when a producer has already formatted the error message and26 * written it to stderr — the downstream `handleError` should propagate the27 * exit code without printing or reformatting again.28 *29 * The non-interactive runner uses this when an upstream API error event30 * arrives mid-stream: it formats with parseAndFormatApiError, writes once,31 * and then throws. Without this marker, handleError would call32 * parseAndFormatApiError a second time on the (now formatted) Error.message,33 * yielding "[API Error: [API Error: ...]]" plus a duplicate stderr line.34 */35export class AlreadyReportedError extends Error {36 /** Exit code to surface — defaults to 1 for generic upstream failures. */37 exitCode: number;38 39 constructor(message: string, exitCode = 1) {40 super(message);41 this.name = 'AlreadyReportedError';42 this.exitCode = exitCode;43 }44}45 46export function getErrorMessage(error: unknown): string {47 if (error instanceof Error) {48 return error.message;49 }50 51 // Handle objects with message property (error-like objects)52 if (53 error !== null &&54 typeof error === 'object' &&55 'message' in error &&56 typeof (error as { message: unknown }).message === 'string'57 ) {58 return (error as { message: string }).message;59 }60 61 // Handle plain objects by stringifying them62 if (error !== null && typeof error === 'object') {63 try {64 const stringified = JSON.stringify(error);65 // JSON.stringify can return undefined for objects with toJSON() returning undefined66 return stringified ?? String(error);67 } catch {68 // If JSON.stringify fails (circular reference, etc.), fall back to String69 return String(error);70 }71 }72 73 return String(error);74}75 76interface ErrorWithCode extends Error {77 exitCode?: number;78 code?: string | number;79 status?: string | number;80}81 82/**83 * Extracts the appropriate error code from an error object.84 */85function extractErrorCode(error: unknown): string | number {86 const errorWithCode = error as ErrorWithCode;87 88 // Prioritize exitCode for FatalError types, fall back to other codes89 if (typeof errorWithCode.exitCode === 'number') {90 return errorWithCode.exitCode;91 }92 if (errorWithCode.code !== undefined) {93 return errorWithCode.code;94 }95 if (errorWithCode.status !== undefined) {96 return errorWithCode.status;97 }98 99 return 1; // Default exit code100}101 102/**103 * Converts an error code to a numeric exit code.104 */105function getNumericExitCode(errorCode: string | number): number {106 return typeof errorCode === 'number' ? errorCode : 1;107}108 109/**110 * Drains pending cleanup before terminating. Routing every "we're about111 * to die" path through here keeps async exit-side I/O (chat-recording112 * flush, telemetry shutdown, MCP disconnect) from being skipped — the113 * earlier sync writes were inherently bounded so a bare `process.exit`114 * was safe; with the async-jsonl change it is not.115 */116// Guards against double-entry when two terminating paths race (e.g. SIGINT117// fires `handleCancellationError` while a stream rejection routes through118// `handleError`): only the first caller drains cleanup + exits; the second119// suspends forever in the unresolved promise and gets killed when the first120// caller's process.exit fires.121let exiting = false;122 123async function exitAfterCleanup(code: number): Promise<never> {124 if (exiting) return new Promise<never>(() => {});125 exiting = true;126 await runExitCleanup();127 // `return` so process.exit's `never` narrows the function's terminating128 // statement — without it TS reports "function returning 'never' cannot129 // have a reachable end point" because await doesn't propagate `never`.130 return process.exit(code);131}132 133/** Test-only — reset the exit-once latch between cases. */134export function _resetExitLatchForTest(): void {135 exiting = false;136}137 138/**139 * Handles errors consistently for both JSON and text output formats.140 * In JSON mode, outputs formatted JSON error and exits.141 * In text mode, outputs error message and re-throws.142 */143export async function handleError(144 error: unknown,145 config: Config,146 customErrorCode?: string | number,147): Promise<never> {148 // Producers that already wrote a formatted message to stderr (see149 // AlreadyReportedError above) should not be reprinted or reformatted here.150 // In TEXT mode this short-circuits straight to a clean re-throw; in JSON151 // mode we still emit the structured payload exactly once so machine152 // consumers don't lose the error.153 if (error instanceof AlreadyReportedError) {154 if (config.getOutputFormat() === OutputFormat.JSON) {155 const formatter = new JsonFormatter();156 const errorCode = customErrorCode ?? error.exitCode;157 const formattedError = formatter.formatError(error, errorCode);158 writeStderrLine(formattedError);159 return exitAfterCleanup(getNumericExitCode(errorCode));160 }161 await runExitCleanup();162 throw error;163 }164 165 const errorMessage = parseAndFormatApiError(166 error,167 config.getContentGeneratorConfig()?.authType,168 );169 170 if (config.getOutputFormat() === OutputFormat.JSON) {171 const formatter = new JsonFormatter();172 const errorCode = customErrorCode ?? extractErrorCode(error);173 174 const formattedError = formatter.formatError(175 error instanceof Error ? error : new Error(getErrorMessage(error)),176 errorCode,177 );178 179 writeStderrLine(formattedError);180 return exitAfterCleanup(getNumericExitCode(errorCode));181 } else {182 writeStderrLine(errorMessage);183 // Drain queued writes before re-throwing so the unhandled rejection184 // path doesn't lose chat-recording records that are still in the queue.185 await runExitCleanup();186 throw error;187 }188}189 190/**191 * Handles tool execution errors specifically.192 * In JSON/STREAM_JSON mode, outputs error message to stderr only and does not exit.193 * The error will be properly formatted in the tool_result block by the adapter,194 * allowing the session to continue so the LLM can decide what to do next.195 * In text mode, outputs error message to stderr only.196 *197 * @param toolName - Name of the tool that failed198 * @param toolError - The error that occurred during tool execution199 * @param config - Configuration object200 * @param errorCode - Optional error code201 * @param resultDisplay - Optional display message for the error202 */203export function handleToolError(204 toolName: string,205 toolError: Error,206 config: Config,207 errorCode?: string | number,208 resultDisplay?: string,209): void {210 // Check if this is a permission denied error in non-interactive mode211 const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;212 const isNonInteractive = !config.isInteractive();213 const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;214 215 // Show warning for permission denied errors in non-interactive text mode216 if (isExecutionDenied && isNonInteractive && isTextMode) {217 const warningMessage =218 `Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +219 `To enable automatic tool execution, use the -y flag (YOLO mode):\n` +220 `Example: qwen -p 'your prompt' -y\n\n`;221 process.stderr.write(warningMessage);222 }223 224 debugLogger.error(225 `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,226 );227}228 229/**230 * Handles cancellation/abort signals consistently.231 */232export async function handleCancellationError(config: Config): Promise<never> {233 const cancellationError = new FatalCancellationError('Operation cancelled.');234 235 if (config.getOutputFormat() === OutputFormat.JSON) {236 const formatter = new JsonFormatter();237 const formattedError = formatter.formatError(238 cancellationError,239 cancellationError.exitCode,240 );241 242 writeStderrLine(formattedError);243 } else {244 writeStderrLine(cancellationError.message);245 }246 return exitAfterCleanup(cancellationError.exitCode);247}248 249/**250 * Handles max session turns exceeded consistently.251 *252 * When `--json-schema` is active the error gets an extra hint pointing at the253 * common reasons a structured-output run never terminated: the model never254 * called `structured_output`, the tool was denied by `permissions.deny` /255 * `--exclude-tools`, or the schema is unsatisfiable. Without this, all three256 * failure modes surface as the same generic "increase maxSessionTurns" line257 * even though the fix is a permissions / schema change, not a turns bump.258 */259export async function handleMaxTurnsExceededError(260 config: Config,261): Promise<never> {262 const baseMessage =263 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.';264 const jsonSchemaActive = config.getJsonSchema?.() !== undefined;265 const message = jsonSchemaActive266 ? `${baseMessage}\nNote: --json-schema is active. If the model never called structured_output, verify it isn't denied by permissions.deny / --exclude-tools and that the schema is satisfiable.`267 : baseMessage;268 const maxTurnsError = new FatalTurnLimitedError(message);269 270 if (config.getOutputFormat() === OutputFormat.JSON) {271 const formatter = new JsonFormatter();272 const formattedError = formatter.formatError(273 maxTurnsError,274 maxTurnsError.exitCode,275 );276 277 writeStderrLine(formattedError);278 } else {279 writeStderrLine(maxTurnsError.message);280 }281 return exitAfterCleanup(maxTurnsError.exitCode);282}283 284/**285 * Emits the structured "run aborted by budget" error and exits. Used by286 * the non-interactive run loop when `--max-wall-time` or `--max-tool-calls`287 * fires (see `RunBudgetEnforcer`). Exit code is 55, distinct from the288 * turn-cap exit code 53 and SIGINT's 130 so CI scripts can branch on the289 * reason.290 *291 * The output shape intentionally mirrors `handleMaxTurnsExceededError` /292 * `handleCancellationError`: structured JSON only on `OutputFormat.JSON`293 * and plain stderr for everything else (incl. STREAM_JSON). Emitting a294 * structured envelope on STREAM_JSON too is a real gap, but it's a295 * codebase-wide convention question that affects cancel / max-turns296 * equally, not a budget-specific decision.297 */298export async function handleBudgetExceededError(299 config: Config,300 exceeded: BudgetExceeded,301): Promise<never> {302 const fatal = new FatalBudgetExceededError(exceeded.message);303 if (config.getOutputFormat() === OutputFormat.JSON) {304 const formatter = new JsonFormatter();305 writeStderrLine(formatter.formatError(fatal, fatal.exitCode));306 } else {307 writeStderrLine(fatal.message);308 }309 return exitAfterCleanup(fatal.exitCode);310}311 