CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
nonInteractiveCli.ts2079 linesDownload Raw Back to src
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import type {8  BackgroundTaskStatus,9  Config,10  CronJob,11  CronScheduler,12  ToolCallRequestInfo,13} from '@qwen-code/qwen-code-core';14import { isSlashCommand } from './ui/utils/commandUtils.js';15import { isInlineModelOverrideAllowed } from './utils/acpModelUtils.js';16import type { LoadedSettings } from './config/settings.js';17import {18  executeToolCall,19  shutdownTelemetry,20  isTelemetrySdkInitialized,21  GeminiEventType,22  FatalInputError,23  promptIdContext,24  OutputFormat,25  InputFormat,26  LoopType,27  ToolNames,28  uiTelemetryService,29  parseAndFormatApiError,30  createDebugLogger,31  detectAutonomousSentinel,32  detectLoopSentinel,33  SendMessageType,34  buildSyntheticToolResponseParts,35  detectTurnInterruption,36  ORPHAN_TOOL_USE_REPAIR_REASON,37  restoreWorktreeContext,38  TeamEventType,39  ApprovalMode,40  ToolConfirmationOutcome,41  createDuplicateProviderToolCallResponse,42  isSystemReminderContent,43  markDuplicateProviderToolCallResponseSent,44  findRepeatedDuplicateProviderToolCall,45} from '@qwen-code/qwen-code-core';46import type { Content, Part, PartListUnion } from '@google/genai';47import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';48import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';49import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';50import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';51import type { ControlService } from './nonInteractive/control/ControlService.js';52 53import { handleSlashCommand } from './nonInteractiveCliCommands.js';54import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';55import {56  AlreadyReportedError,57  handleError,58  handleToolError,59  handleCancellationError,60  handleMaxTurnsExceededError,61  handleBudgetExceededError,62} from './utils/errors.js';63import { RunBudgetEnforcer } from './utils/runBudget.js';64 65const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI');66 67/**68 * Maximum wait, in milliseconds, for in-flight background tasks to emit69 * their terminal `task_notification` after `abortAll()` on the70 * structured-output success path. Tasks are marked cancelled71 * synchronously by `abortAll`, but the natural task handler emits the72 * notification on a later microtask — without a brief holdback the73 * structured-output run would silently drop those events. Capped so a74 * slow agent can't block exit indefinitely.75 */76const STRUCTURED_SHUTDOWN_HOLDBACK_MS = 500;77 78function isHeadlessLoopSentinel(prompt: string): boolean {79  return (80    detectLoopSentinel(prompt) !== null ||81    detectAutonomousSentinel(prompt) !== null82  );83}84 85/**86 * Body of the synthesised `tool_result` for a `tool_use` block that was87 * suppressed because a sibling `structured_output` call took precedence88 * as the terminal output for the same turn.89 *90 * Two variants — the success-path body drops the trailing "Re-issue this91 * call in a separate turn if needed." sentence because the session92 * terminates immediately after synthesis (no model or SDK consumer can93 * act on the advice). The retry-path body keeps it: when the structured94 * call failed validation, the model is about to receive these parts in95 * the next turn and may legitimately re-issue the suppressed call.96 *97 * Shared between the main-turn and drain-turn synthesis sites so a98 * future wording change can't desync them.99 */100const SUPPRESSED_OUTPUT_SUCCESS =101  "Skipped: this turn's structured_output contract took precedence as the terminal output.";102const SUPPRESSED_OUTPUT_RETRY = `${SUPPRESSED_OUTPUT_SUCCESS} Re-issue this call in a separate turn if needed.`;103function suppressedOutputBody(structuredCaptured: boolean): string {104  return structuredCaptured105    ? SUPPRESSED_OUTPUT_SUCCESS106    : SUPPRESSED_OUTPUT_RETRY;107}108 109import {110  normalizePartList,111  extractPartsFromUserMessage,112  buildSystemMessage,113  createToolProgressHandler,114  createAgentToolProgressHandler,115  computeUsageFromMetrics,116  buildInitialSystemReminders,117  insertAfterFunctionResponses,118} from './utils/nonInteractiveHelpers.js';119 120// Human-readable labels for the detectors that can fire mid-stream.121// Surfaced to stderr in TEXT mode so a headless run that halts on a loop122// doesn't exit with empty stdout and no explanation — see PR #3236 review.123const LOOP_TYPE_LABELS: Record<LoopType, string> = {124  [LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS]:125    'the model repeated the same tool call with identical arguments',126  [LoopType.CHANTING_IDENTICAL_SENTENCES]:127    'the model repeated the same sentence in its output',128  [LoopType.REPETITIVE_THOUGHTS]:129    'the model repeated the same reasoning thought',130  [LoopType.READ_FILE_LOOP]:131    'the model spent too many consecutive calls reading files without making progress',132  [LoopType.ACTION_STAGNATION]:133    'the model kept calling the same tool without making progress',134  [LoopType.SHELL_COMMAND_STAGNATION]:135    'the model repeated similar shell inspection commands without making progress',136  [LoopType.GLOBAL_TOOL_CALL_DUPLICATE]:137    'the model repeated the same tool call across the turn, even when not back-to-back',138  [LoopType.ALTERNATING_TOOL_CALL_PATTERN]:139    'the model alternated between the same two tool calls in a repeating pattern',140  [LoopType.TURN_TOOL_CALL_CAP]:141    'the model exceeded the maximum number of tool calls allowed in a single turn',142  [LoopType.INVALID_TOOL_PARAMS_STAGNATION]:143    'the model repeatedly sent invalid tool parameters without correcting them',144};145 146function formatLoopDetectedMessage(loopType: LoopType | undefined): string {147  const reason = loopType ? LOOP_TYPE_LABELS[loopType] : undefined;148  const detail = reason ? ` (${loopType}: ${reason})` : '';149  // The always-on guards run before the skipLoopDetection gate, so that150  // setting can't disable them — don't suggest it for those loop types. The151  // per-turn cap is also always-on but has its own knob, so it gets a152  // dedicated hint instead of membership in this list.153  const isAlwaysOn =154    loopType === LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS ||155    loopType === LoopType.SHELL_COMMAND_STAGNATION ||156    loopType === LoopType.GLOBAL_TOOL_CALL_DUPLICATE ||157    loopType === LoopType.INVALID_TOOL_PARAMS_STAGNATION;158  const hint =159    loopType === LoopType.TURN_TOOL_CALL_CAP160      ? ' Raise the `model.maxToolCallsPerTurn` setting to allow longer turns, or set it to 0 to disable the cap.'161      : isAlwaysOn162        ? ' This is an always-on guard and cannot be disabled via `model.skipLoopDetection`.'163        : ' Set the `model.skipLoopDetection` setting to true to disable.';164  return `Loop detection halted the run${detail}.${hint}`;165}166 167/**168 * Headless handling for fired loop sentinels. loop.md and autonomous sentinel169 * expansion is interactive-only for now, so a bare sentinel can't be turned into170 * a real prompt here — the tick is skipped (no-op) rather than sent to the model171 * as empty content. Returns true when `job` was a sentinel so the caller skips172 * enqueuing it.173 *174 * A recurring SESSION (non-durable) loop.md job would otherwise stay in175 * `scheduler.sessionSize` and re-fire every interval, pinning the headless run176 * open forever (the hold-open resolves only when sessionSize hits zero); delete177 * it so the run can terminate. Durable jobs are left untouched here — they178 * persist for a future owning session and never count toward sessionSize — and179 * a one-shot job is already removed before it fires.180 *181 * Note: a DURABLE loop.md sentinel never even reaches this callback in headless,182 * because `setSkipDurableFire` filters it at the scheduler before any fire or183 * lastFiredAt persist (otherwise the tick would be marked fired while the work184 * is skipped — silent loss). This guard's durable branch is kept defensive.185 */186export function skipHeadlessLoopSentinel(187  scheduler: CronScheduler,188  job: CronJob,189): boolean {190  if (!isHeadlessLoopSentinel(job.prompt)) {191    return false;192  }193  if (job.recurring && !job.durable) {194    // A user created this recurring loop.md cron via /loop in interactive mode;195    // deleting it here is otherwise silent, so leave a trace of why it vanished196    // from `cron list` when the same workspace is later run headless.197    debugLogger.debug(198      'skipHeadlessLoopSentinel: cleaning up recurring session loop.md cron in headless mode',199      { jobId: job.id },200    );201    // delete() removes the in-memory job synchronously before any await, so the202    // sessionSize check that follows this call sees it gone; the returned promise203    // has no on-disk work for a session job. Fire-and-forget, but swallow a204    // rejection so a future async delete() can't surface as an unhandled205    // rejection (fatal under Node's --unhandled-rejections=throw).206    void scheduler.delete(job.id).catch(() => {207      /* session job: nothing to clean up on a delete failure */208    });209  }210  return true;211}212 213function emitLoopDetectedMessage(214  config: Config,215  loopType: LoopType | undefined,216): string {217  const message = formatLoopDetectedMessage(loopType);218  // In TEXT mode the adapter swallows LoopDetected, so we print here. In219  // JSON modes the adapter emits a structured result, which is enough.220  if (config.getOutputFormat() !== OutputFormat.TEXT) {221    return message;222  }223  process.stderr.write(`${message}\n`);224  return message;225}226 227/**228 * Emits a final message for slash command results.229 * Note: systemMessage should already be emitted before calling this function.230 */231async function emitNonInteractiveFinalMessage(params: {232  message: string;233  isError: boolean;234  adapter: JsonOutputAdapterInterface;235  config: Config;236  startTimeMs: number;237}): Promise<void> {238  const { message, isError, adapter, config } = params;239 240  // JSON output mode: emit assistant message and result241  // (systemMessage should already be emitted by caller)242  adapter.startAssistantMessage();243  adapter.processEvent({244    type: GeminiEventType.Content,245    value: message,246  } as unknown as Parameters<JsonOutputAdapterInterface['processEvent']>[0]);247  adapter.finalizeAssistantMessage();248 249  const metrics = uiTelemetryService.getMetrics();250  const usage = computeUsageFromMetrics(metrics);251  const outputFormat = config.getOutputFormat();252  const stats =253    outputFormat === OutputFormat.JSON254      ? uiTelemetryService.getMetrics()255      : undefined;256 257  adapter.emitResult({258    isError,259    durationMs: Date.now() - params.startTimeMs,260    apiDurationMs: 0,261    numTurns: 0,262    errorMessage: isError ? message : undefined,263    usage,264    stats,265    summary: message,266  });267}268 269/**270 * Provides optional overrides for `runNonInteractive` execution.271 *272 * @param abortController - Optional abort controller for cancellation.273 * @param adapter - Optional JSON output adapter for structured output formats.274 * @param userMessage - Optional CLI user message payload for preformatted input.275 * @param controlService - Optional control service for future permission handling.276 */277export interface RunNonInteractiveOptions {278  abortController?: AbortController;279  adapter?: JsonOutputAdapterInterface;280  userMessage?: CLIUserMessage;281  controlService?: ControlService;282  sendMessageType?: SendMessageType;283  notificationDisplayText?: string;284  captureMonitorNotifications?: boolean;285  captureMonitorRegistrations?: boolean;286  onResultEmitted?: () => void;287  /**288   * Continue the most recent unfinished turn from chat history instead of289   * submitting `input` (which is ignored). No new user message enters the290   * transcript: an orphaned trailing user entry is re-submitted with Retry291   * semantics, and dangling tool calls are closed with synthesized error292   * functionResponses sent as a ToolResult. When the last turn ended293   * cleanly the run emits a no-op result and exits 0.294   */295  continueInterrupted?: boolean;296}297 298/**299 * Executes the non-interactive CLI flow for a single request.300 */301export async function runNonInteractive(302  config: Config,303  settings: LoadedSettings,304  input: string,305  prompt_id: string,306  options: RunNonInteractiveOptions = {},307): Promise<number> {308  return promptIdContext.run(prompt_id, async (): Promise<number> => {309    // Create output adapter based on format310    let adapter: JsonOutputAdapterInterface;311    const outputFormat = config.getOutputFormat();312 313    if (options.adapter) {314      adapter = options.adapter;315    } else if (outputFormat === OutputFormat.STREAM_JSON) {316      adapter = new StreamJsonOutputAdapter(317        config,318        config.getIncludePartialMessages(),319      );320    } else {321      adapter = new JsonOutputAdapter(config);322    }323    const emitResult = (324      result: Parameters<JsonOutputAdapterInterface['emitResult']>[0],325    ) => {326      // Fire the callback only after a successful emit. The continue caller327      // (session.ts) uses it to mark the result as delivered and swallow any328      // later error; if emitResult itself throws, the flag must stay unset so329      // the error still surfaces instead of losing both result and error.330      adapter.emitResult(result);331      options.onResultEmitted?.();332    };333 334    // Get readonly values once at the start335    const sessionId = config.getSessionId();336    const permissionMode = config.getApprovalMode() as PermissionMode;337 338    let turnCount = 0;339    let totalApiDurationMs = 0;340    const startTime = Date.now();341 342    const geminiClient = config.getGeminiClient();343    const abortController = options.abortController ?? new AbortController();344 345    // Run-level budget enforcement for headless / unattended runs346    // (issue #4103). Tied to the same abortController as user-initiated347    // SIGINT so the existing cancellation plumbing carries the abort;348    // `routeAbort` below interprets the reason so the user sees349    // "budget exceeded" instead of a generic "cancelled" envelope.350    const budgetEnforcer = new RunBudgetEnforcer(351      {352        maxWallTimeSeconds: config.getMaxWallTimeSeconds(),353        maxToolCalls: config.getMaxToolCalls(),354      },355      abortController,356    );357    budgetEnforcer.start();358 359    /**360     * Called at every abort-detection site in place of361     * `handleCancellationError` directly. If a budget tripped, surface the362     * structured budget error (exit 55); otherwise fall through to the363     * SIGINT / user-cancel path (exit 130) so existing behavior is364     * preserved. Both branches call into `process.exit(...)` so the365     * `unreachable` throw is only present to keep the type-checker honest.366     */367    const routeAbort = async (): Promise<never> => {368      const exceeded = budgetEnforcer.getExceeded();369      if (exceeded) {370        await handleBudgetExceededError(config, exceeded);371        // Explicit unreachable — `handleBudgetExceededError` is `never`372        // in production (it calls `process.exit`). If a test stubs373        // `process.exit` or a future refactor makes the handler374        // resumable, this throw carries the original budget message375        // so the outer catch's `errorMessage` field stays actionable376        // (vs. a useless literal "unreachable").377        throw new Error(exceeded.message);378      }379      await handleCancellationError(config);380      throw new Error('Operation cancelled.');381    };382 383    interface LocalQueueItem {384      displayText: string;385      modelText: string;386      sendMessageType: SendMessageType;387      sdkNotification?: {388        task_id: string;389        tool_use_id?: string;390        status: BackgroundTaskStatus;391        usage?: {392          total_tokens: number;393          tool_uses: number;394          duration_ms: number;395        };396      };397    }398    const localQueue: LocalQueueItem[] = [];399    const sdkOnlyMonitorQueue: LocalQueueItem[] = [];400    const emitNotificationToSdk = (item: LocalQueueItem) => {401      if (item.sendMessageType !== SendMessageType.Notification) return;402      adapter.emitUserMessage([{ text: item.displayText }]);403      if (item.sdkNotification) {404        adapter.emitSystemMessage('task_notification', item.sdkNotification);405      }406    };407    const flushQueuedNotificationsToSdk = (queue: LocalQueueItem[]) => {408      while (queue.length > 0) {409        emitNotificationToSdk(queue.shift()!);410      }411    };412    let captureMonitorTurnsInLocalQueue = true;413    let oneShotMonitorsFinalized = false;414    const finalizeOneShotMonitors = () => {415      if (416        options.captureMonitorNotifications === false ||417        oneShotMonitorsFinalized418      )419        return;420      oneShotMonitorsFinalized = true;421      captureMonitorTurnsInLocalQueue = false;422      config.getMonitorRegistry().abortAll();423      flushQueuedNotificationsToSdk(sdkOnlyMonitorQueue);424    };425 426    // EPIPE: don't process.exit here — that bypasses the caller's427    // runExitCleanup → flush() and drops queued JSONL writes. Destroy428    // stdout instead and let the natural return drive cleanup. (Aborting429    // is also wrong: the abort path runs handleCancellationError → exit430    // 130 and re-introduces the same bypass.)431    let pipeBroken = false;432    const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {433      if (err.code === 'EPIPE' && !pipeBroken) {434        pipeBroken = true;435        process.stdout.destroy();436      }437    };438 439    // Setup signal handlers for graceful shutdown440    const shutdownHandler = () => {441      debugLogger.debug('[runNonInteractive] Shutdown signal received');442      abortController.abort();443    };444 445    // ─── Teammate message queue ─────────────────────────446    // When teammates send messages to the leader, they447    // accumulate here and are drained into the LLM448    // conversation between turns.449    const pendingTeammateMessages: string[] = [];450    // Track the manager we're currently bound to so we can451    // detach the leader callback and approval listener before452    // a new manager is installed (or in `finally`). Without453    // this, a reused stream-json session could leave callbacks454    // attached to a stale TeamManager.455    let boundManager: import('@qwen-code/qwen-code-core').TeamManager | null =456      null;457    let approvalListener:458      | ((459          event: import('@qwen-code/qwen-code-core').TeammateApprovalRequestEvent,460        ) => void)461      | null = null;462    const detachFromManager = (463      m: import('@qwen-code/qwen-code-core').TeamManager,464    ) => {465      m.setLeaderMessageCallback(null);466      if (approvalListener) {467        m.getEventEmitter().off(468          TeamEventType.TEAMMATE_APPROVAL_REQUEST,469          approvalListener,470        );471        approvalListener = null;472      }473    };474    const onTeamManagerChangeHandler = (475      manager: import('@qwen-code/qwen-code-core').TeamManager | null,476    ) => {477      // Detach from the previous manager before rebinding.478      if (boundManager && boundManager !== manager) {479        detachFromManager(boundManager);480      }481      boundManager = manager;482      if (manager) {483        manager.setLeaderMessageCallback((formatted) => {484          pendingTeammateMessages.push(formatted);485        });486 487        // Route teammate tool approvals through the session's488        // permission channel.489        if (options.controlService) {490          // Stream-json mode: SDK handles approvals. Catch instead of491          // void: the handler's own error path re-issues a respond()492          // that can reject (teammate terminated mid-request), and a493          // voided rejection here is an unhandledRejection in an SDK494          // session — mirror the headless listeners below.495          approvalListener = (event) => {496            options497              .controlService!.permission.handleTeammateApproval(event)498              .catch((err) => {499                debugLogger.warn('Teammate approval handling failed:', err);500              });501          };502        } else {503          // Headless / non-stream-json mode: there is no UI to504          // surface a prompt, so the only safe options are505          // YOLO (auto-approve) or Cancel. Without this fallback506          // listener, the event has no subscriber and the teammate507          // hangs until its 600s stall timeout fires.508          approvalListener = (event) => {509            const mode = config.getApprovalMode();510            if (mode === ApprovalMode.YOLO) {511              // `respond` may reject if the teammate terminates between the512              // approval request and our response — catch it so it doesn't513              // become an unhandledRejection that can crash the process.514              event515                .respond(ToolConfirmationOutcome.ProceedOnce)516                .catch((err) => {517                  debugLogger.warn(518                    'Teammate approval ProceedOnce failed:',519                    err,520                  );521                });522              return;523            }524            // Surface a clear reason on stderr — otherwise the525            // failure looks like the teammate gave up for no reason.526            const reason =527              `Auto-cancelling tool ${event.toolName} requested by ` +528              `teammate "${event.teammateName}": current approval mode ` +529              `(${mode}) cannot prompt in non-stream-json mode. ` +530              `Use --yolo or stream-json to allow teammate tool calls.`;531            process.stderr.write(`[team] ${reason}\n`);532            // Also surface to the leader's LLM, otherwise it just533            // sees the teammate fail without any signal that an534            // approval was needed and the host couldn't prompt.535            pendingTeammateMessages.push(536              `<team_notice>\n${reason}\n</team_notice>`,537            );538            event.respond(ToolConfirmationOutcome.Cancel).catch((err) => {539              debugLogger.warn('Teammate approval Cancel failed:', err);540            });541          };542        }543        manager544          .getEventEmitter()545          .on(TeamEventType.TEAMMATE_APPROVAL_REQUEST, approvalListener);546      }547    };548 549    // First-turn SendMessageType override for continuation turns; null means550    // the regular options.sendMessageType / UserQuery selection applies.551    let continueSendType: SendMessageType | null = null;552 553    try {554      process.stdout.on('error', stdoutErrorHandler);555 556      process.on('SIGINT', shutdownHandler);557      process.on('SIGTERM', shutdownHandler);558 559      config.onTeamManagerChange(onTeamManagerChangeHandler);560 561      // Handle the case where a manager already exists (e.g.,562      // a follow-up turn in a stream-json session that created563      // a team on a previous turn).564      const existingManager = config.getTeamManager();565      if (existingManager) {566        onTeamManagerChangeHandler(existingManager);567      }568 569      // Emit systemMessage first (always the first message in JSON mode)570      const systemMessage = await buildSystemMessage(571        config,572        sessionId,573        permissionMode,574      );575      adapter.emitMessage(systemMessage);576 577      let initialPartList: PartListUnion | null = extractPartsFromUserMessage(578        options.userMessage,579      );580      // Per-turn model override captured from an inline `/model <id> <prompt>`581      // slash command; seeds the loop-scoped `modelOverride` below so the582      // submitted prompt runs on the chosen model without a session switch.583      let inlineModelOverride: string | undefined;584 585      if (options.continueInterrupted) {586        // Read the full history, not a bounded tail: the Retry send path in587        // client.ts strips the ENTIRE trailing user run, so detection must588        // re-submit exactly that run or the oldest orphans get dropped. This589        // runs once per (rare) continue request, so the full clone is fine.590        const detection = detectTurnInterruption(591          geminiClient.getChat().getHistory(),592        );593        debugLogger.info('[runNonInteractive] continueInterrupted detection', {594          kind: detection.kind,595          partsCount:596            detection.kind === 'interrupted_prompt'597              ? detection.parts.length598              : 0,599          danglingCallCount:600            detection.kind === 'interrupted_turn'601              ? detection.danglingCalls.length602              : 0,603        });604        if (detection.kind === 'none') {605          await emitNonInteractiveFinalMessage({606            message: 'No interrupted turn to continue.',607            isError: false,608            adapter,609            config,610            startTimeMs: startTime,611          });612          return 0;613        }614        if (detection.kind === 'interrupted_prompt') {615          // Re-submit the orphaned user content under Retry semantics. The616          // Retry send path strips the orphaned original from history and617          // restores it if the send never starts, so history is neither618          // duplicated nor lost — no separate strip/restore needed here.619          initialPartList = detection.parts;620          continueSendType = SendMessageType.Retry;621        } else {622          initialPartList = buildSyntheticToolResponseParts(623            detection.danglingCalls,624            ORPHAN_TOOL_USE_REPAIR_REASON,625          );626          continueSendType = SendMessageType.ToolResult;627        }628 629        const reminderParts = buildInitialSystemReminders(config);630        if (reminderParts.length > 0 && initialPartList) {631          const continuationParts = normalizePartList(initialPartList);632          const hasSystemReminderPart = continuationParts.some((part) =>633            isSystemReminderContent({ role: 'user', parts: [part] }),634          );635          if (!hasSystemReminderPart) {636            initialPartList = insertAfterFunctionResponses(637              continuationParts,638              reminderParts,639            );640          }641        }642      }643 644      if (!initialPartList) {645        let slashHandled = false;646        if (isSlashCommand(input)) {647          const slashCommandResult = await handleSlashCommand(648            input,649            abortController,650            config,651            settings,652          );653          switch (slashCommandResult.type) {654            case 'submit_prompt':655              // A slash command can replace the prompt entirely; fall back to @-command processing otherwise.656              initialPartList = slashCommandResult.content;657              // Re-validate provider identity rather than trust the producer:658              // any slash command can set `modelOverride`, so the consumer659              // enforces that it names a model on the active provider before660              // redirecting API calls to it.661              if (662                slashCommandResult.modelOverride !== undefined &&663                isInlineModelOverrideAllowed(664                  config,665                  slashCommandResult.modelOverride,666                )667              ) {668                inlineModelOverride = slashCommandResult.modelOverride;669                debugLogger.debug(670                  `[runNonInteractive] inline model override captured: ${inlineModelOverride}`,671                );672              } else if (slashCommandResult.modelOverride !== undefined) {673                debugLogger.warn(674                  `[runNonInteractive] ignoring model override '${slashCommandResult.modelOverride}': not a model on the active provider`,675                );676              }677              slashHandled = true;678              break;679            case 'message': {680              // systemMessage already emitted above681              await emitNonInteractiveFinalMessage({682                message: slashCommandResult.content,683                isError: slashCommandResult.messageType === 'error',684                adapter,685                config,686                startTimeMs: startTime,687              });688              return slashCommandResult.messageType === 'error' ? 1 : 0;689            }690            case 'stream_messages':691              throw new FatalInputError(692                'Stream messages mode is not supported in non-interactive CLI',693              );694            case 'unsupported': {695              await emitNonInteractiveFinalMessage({696                message: slashCommandResult.reason,697                isError: true,698                adapter,699                config,700                startTimeMs: startTime,701              });702              return 1;703            }704            case 'no_command':705              break;706            default: {707              const _exhaustive: never = slashCommandResult;708              throw new FatalInputError(709                `Unhandled slash command result type: ${(_exhaustive as { type: string }).type}`,710              );711            }712          }713        }714 715        if (!slashHandled) {716          const { processedQuery, shouldProceed } = await handleAtCommand({717            query: input,718            config,719            onDebugMessage: () => {},720            messageId: Date.now(),721            signal: abortController.signal,722          });723 724          if (!shouldProceed || !processedQuery) {725            // An error occurred during @include processing (e.g., file not found).726            // The error message is already logged by handleAtCommand.727            throw new FatalInputError(728              'Exiting due to an error processing the @ command.',729            );730          }731          initialPartList = processedQuery as PartListUnion;732        }733      }734 735      if (!initialPartList) {736        initialPartList = [{ text: input }];737      }738 739      // Inject a worktree context notice into the model's first prompt.740      // Two sources: the `--worktree` startup flag (set by gemini.tsx741      // before loadCliConfig) takes precedence over the Phase C resume742      // restore. TUI does this via historyManager.addItem(INFO); here in743      // headless we prepend a `<system-reminder>` block since there is744      // no UI history to write into.745      const withReminder = (746        existing: PartListUnion,747        text: string,748      ): PartListUnion => {749        const reminderPart: Part = {750          text: `<system-reminder>\n${text}\n</system-reminder>\n\n`,751        };752        return Array.isArray(existing)753          ? [reminderPart, ...existing]754          : [reminderPart, existing];755      };756 757      // Continuation turns must not prepend reminder text: a ToolResult758      // payload's functionResponse parts have to stay at the HEAD of the759      // user message or Anthropic-compatible backends reject the pairing.760      const startupNotice = options.continueInterrupted761        ? null762        : config.consumePendingStartupWorktreeNotice();763      if (startupNotice) {764        initialPartList = withReminder(initialPartList, startupNotice);765        adapter.emitSystemMessage('worktree_started', {766          notice: startupNotice,767        });768      } else if (769        !options.continueInterrupted &&770        config.getResumedSessionData()771      ) {772        try {773          const sessionPath = config774            .getSessionService()775            .getWorktreeSessionPath(sessionId);776          const restored = await restoreWorktreeContext(sessionPath);777          if (restored.contextMessage) {778            initialPartList = withReminder(779              initialPartList,780              restored.contextMessage,781            );782            // Surface the notice in the JSON stream so SDK consumers783            // can react to it (logging, UI hints, etc.).784            adapter.emitSystemMessage('worktree_restored', {785              slug: restored.session?.slug,786              path: restored.session?.worktreePath,787              branch: restored.session?.worktreeBranch,788            });789          }790        } catch (error) {791          debugLogger.warn(`worktree restore failed (non-fatal):`, error);792        }793      }794 795      const initialParts = normalizePartList(initialPartList);796      let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];797 798      // Register the callback early so background agents launched during the main799      // tool-call chain can push completions onto the queue.800      const registry = config.getBackgroundTaskRegistry();801      registry.setNotificationCallback((displayText, modelText, meta) => {802        localQueue.push({803          displayText,804          modelText,805          sendMessageType: SendMessageType.Notification,806          sdkNotification: {807            task_id: meta.agentId,808            tool_use_id: meta.toolUseId,809            status: meta.status,810            usage: meta.stats811              ? {812                  total_tokens: meta.stats.totalTokens,813                  tool_uses: meta.stats.toolUses,814                  duration_ms: meta.stats.durationMs,815                }816              : undefined,817          },818        });819      });820 821      registry.setRegisterCallback((entry) => {822        adapter.emitSystemMessage('task_started', {823          task_id: entry.agentId,824          tool_use_id: entry.toolUseId,825          description: entry.description,826          subagent_type: entry.subagentType,827        });828      });829 830      const monitorRegistry = config.getMonitorRegistry();831      if (options.captureMonitorNotifications !== false) {832        // One-shot headless runs capture monitor notifications locally so any833        // events already emitted before exit can be surfaced to the SDK/model.834        // Persistent stream-json sessions own this callback at the Session835        // layer instead, so future monitor events can continue after the836        // originating turn has already completed.837        monitorRegistry.setNotificationCallback(838          (displayText, modelText, meta) => {839            if (840              meta.status === 'running' &&841              typeof monitorRegistry.get === 'function'842            ) {843              const entry = monitorRegistry.get(meta.monitorId);844              if (!entry || entry.status !== 'running') return;845            }846 847            const queueItem = {848              displayText,849              modelText,850              sendMessageType: SendMessageType.Notification,851              sdkNotification: {852                task_id: meta.monitorId,853                tool_use_id: meta.toolUseId,854                status: meta.status,855              },856            };857 858            if (captureMonitorTurnsInLocalQueue) {859              localQueue.push(queueItem);860            } else {861              sdkOnlyMonitorQueue.push(queueItem);862              flushQueuedNotificationsToSdk(sdkOnlyMonitorQueue);863            }864          },865        );866      }867 868      if (options.captureMonitorRegistrations !== false) {869        monitorRegistry.setRegisterCallback((entry) => {870          adapter.emitSystemMessage('task_started', {871            task_id: entry.monitorId,872            tool_use_id: entry.toolUseId,873            description: entry.description,874          });875        });876      }877 878      let isFirstTurn = true;879      let hasUnsentToolResponse = false;880      let modelOverride: string | undefined = inlineModelOverride;881      // An explicit inline `/model <id> <prompt>` override wins for the whole882      // turn: while active, skill-tool `modelOverride` writes (including the883      // undefined-clears case) are skipped so they cannot silently revert the884      // submitted prompt to the session model mid-turn. Unlike useGeminiStream's885      // ref-based `applyModelOverride`/`clearModelOverride` helpers, this is a886      // run-scoped const — non-interactive mode is single-turn, so there is no887      // retry-clearing or skill-tool takeover to guard against, just the888      // within-turn precedence above.889      const inlineModelOverrideActive = inlineModelOverride !== undefined;890      if (inlineModelOverrideActive) {891        debugLogger.debug(892          `[runNonInteractive] inline model override active for turn: ${inlineModelOverride}`,893        );894      }895      // Session-scoped because the synthetic `structured_output` tool can896      // be invoked from EITHER the main assistant-turn loop or from a897      // drain-turn (queued notification / cron prompt); whichever fires898      // first wins, and both paths need to surface the same structured899      // result envelope.900      let structuredSubmission: unknown = undefined;901      // Captures the first ~200 chars of model-emitted plain text across902      // turns. Used only to enrich the --json-schema "produced plain903      // text" error: the user/operator gets a hint of what the model904      // actually said instead of a static, context-free message.905      let plainTextPreview = '';906      const PLAIN_TEXT_PREVIEW_LIMIT = 200;907      let loopDetected = false;908      let loopDetectedMessage = formatLoopDetectedMessage(undefined);909 910      // Shared terminal block for the structured-output success911      // contract. Both the main-turn loop and the drain-turn post-loop912      // previously reproduced this block verbatim913      // (`registry.abortAll()` → bounded holdback for in-flight914      // background-task `task_notification` events → flush localQueue →915      // finalize one-shot monitors → `adapter.emitResult` → return 0).916      // `finalizeOneShotMonitors` is idempotent (the917      // `oneShotMonitorsFinalized` guard makes the second call a918      // no-op), so unconditional invocation is safe even when the drain919      // path already finalized monitors before reaching here.920      const emitStructuredSuccess = async (): Promise<0> => {921        registry.abortAll();922        // `abortAll()` marks each task `cancelled` synchronously, but923        // the matching `task_notification` is emitted later by the924        // task's natural handler. Hold back briefly (capped at925        // STRUCTURED_SHUTDOWN_HOLDBACK_MS) so consumers see every926        // `task_started` paired with its terminal notification, without927        // blocking exit on a slow agent that the user has already928        // declared done.929        const holdbackDeadline = Date.now() + STRUCTURED_SHUTDOWN_HOLDBACK_MS;930        while (931          Date.now() < holdbackDeadline &&932          registry.hasUnfinalizedTasks()933        ) {934          await new Promise((r) => setTimeout(r, 50));935        }936        flushQueuedNotificationsToSdk(localQueue);937        finalizeOneShotMonitors();938        const metrics = uiTelemetryService.getMetrics();939        const usage = computeUsageFromMetrics(metrics);940        const stats =941          outputFormat === OutputFormat.JSON942            ? uiTelemetryService.getMetrics()943            : undefined;944        emitResult({945          isError: false,946          durationMs: Date.now() - startTime,947          apiDurationMs: totalApiDurationMs,948          numTurns: turnCount,949          usage,950          stats,951          structuredResult: structuredSubmission,952        });953        return 0;954      };955 956      const emitLoopDetectedResult = (): 1 => {957        registry.abortAll();958        flushQueuedNotificationsToSdk(localQueue);959        finalizeOneShotMonitors();960 961        if (outputFormat === OutputFormat.TEXT) {962          return 1;963        }964 965        const metrics = uiTelemetryService.getMetrics();966        const usage = computeUsageFromMetrics(metrics);967        const stats =968          outputFormat === OutputFormat.JSON969            ? uiTelemetryService.getMetrics()970            : undefined;971        adapter.emitResult({972          isError: true,973          durationMs: Date.now() - startTime,974          apiDurationMs: totalApiDurationMs,975          numTurns: turnCount,976          errorMessage: loopDetectedMessage,977          usage,978          stats,979        });980        return 1;981      };982 983      /**984       * Shared per-turn tool-call dispatch for the main-turn loop and985       * `drainBatch`. Both call sites used to reproduce ~120 lines of986       * near-identical logic that filtered `structured_output` to its987       * own pre-scan when `--json-schema` is active, executed each988       * request through `executeToolCall`, captured the `structured_output`989       * args into the session-scoped `structuredSubmission`, and990       * synthesised `tool_result` events for every suppressed sibling991       * `tool_use`. The two blocks differed only by variable name992       * prefixes (`requestsToExecute` vs `itemRequestsToExecute`, etc.)993       * and which scope's `modelOverride` to update — passed in as994       * `setModelOverride` so the caller controls binding.995       *996       * The helper mutates the closure-captured `structuredSubmission`997       * directly (it's session-scoped on purpose: whichever turn998       * captures it terminates the run). The caller is responsible for999       * acting on a non-undefined `structuredSubmission` after the1000       * helper returns (main-turn → emitStructuredSuccess(); drain-turn1001       * → return so the post-drain code emits success).1002       */1003      const handledProviderToolCallIds =1004        geminiClient.getHistoryFunctionResponseIds();1005      // Tracks duplicate-error responses emitted during this headless run.1006      // Once a provider id reaches this set, seeing it again is terminal for1007      // the current tool batch so we do not send partial tool responses.1008      const duplicateProviderToolCallResponseIds = new Set<string>();1009 1010      type ToolCallBatchResult = {1011        responseParts: Part[];1012        repeatedDuplicateProviderToolCall: boolean;1013      };1014 1015      const processToolCallBatch = async (1016        batchRequests: ToolCallRequestInfo[],1017        setModelOverride: (override: string | undefined) => void,1018      ): Promise<ToolCallBatchResult> => {1019        const toolResponseParts: Part[] = [];1020        const structuredOutputActive =1021          config.getJsonSchema() &&1022          batchRequests.some((r) => r.name === ToolNames.STRUCTURED_OUTPUT);1023        const getProviderResponseId = (1024          request: ToolCallRequestInfo,1025        ): string | undefined =>1026          request.providerCallId ??1027          (structuredOutputActive ? undefined : request.callId || undefined);1028        const seenBatchCallIds = new Set<string>();1029        const duplicateBatchRequests: ToolCallRequestInfo[] = [];1030        const uniqueBatchRequests = batchRequests.filter((request) => {1031          if (request.callId) {1032            if (seenBatchCallIds.has(request.callId)) {1033              if (1034                structuredOutputActive &&1035                request.name === ToolNames.STRUCTURED_OUTPUT1036              ) {1037                return true;1038              }1039              debugLogger.debug(1040                `Dropping duplicate non-interactive tool callId=${request.callId} name=${request.name}`,1041              );1042              duplicateBatchRequests.push(request);1043              return false;1044            }1045            seenBatchCallIds.add(request.callId);1046          }1047          return true;1048        });1049        const repeatedDuplicateRequest = findRepeatedDuplicateProviderToolCall(1050          [...uniqueBatchRequests, ...duplicateBatchRequests],1051          getProviderResponseId,1052          handledProviderToolCallIds,1053          duplicateProviderToolCallResponseIds,1054        );1055        if (repeatedDuplicateRequest) {1056          const providerCallId =1057            repeatedDuplicateRequest.providerCallId ??1058            repeatedDuplicateRequest.callId;1059          debugLogger.debug(1060            `[runNonInteractive] Dropping batch after repeated duplicate provider tool-call id: ${providerCallId} (tool: ${repeatedDuplicateRequest.name})`,1061          );1062          return {1063            responseParts: [],1064            repeatedDuplicateProviderToolCall: true,1065          };1066        }1067 1068        const respondedRequests = new Set<ToolCallRequestInfo>();1069        const executableBatchRequests: ToolCallRequestInfo[] = [];1070        const duplicatePendingResponses: Part[] = [];1071 1072        for (const requestInfo of uniqueBatchRequests) {1073          const providerCallId = getProviderResponseId(requestInfo);1074          if (!providerCallId) {1075            executableBatchRequests.push(requestInfo);1076            continue;1077          }1078 1079          if (!handledProviderToolCallIds.has(providerCallId)) {1080            handledProviderToolCallIds.add(providerCallId);1081            executableBatchRequests.push(requestInfo);1082            continue;1083          }1084 1085          markDuplicateProviderToolCallResponseSent(1086            providerCallId,1087            duplicateProviderToolCallResponseIds,1088          );1089 1090          const toolResponse =1091            createDuplicateProviderToolCallResponse(requestInfo);1092          debugLogger.debug(1093            `[runNonInteractive] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${requestInfo.name})`,1094          );1095          respondedRequests.add(requestInfo);1096          adapter.emitToolResult(requestInfo, toolResponse);1097          duplicatePendingResponses.push(...toolResponse.responseParts);1098        }1099 1100        // Duplicate responses must always reach the model. They pair with a1101        // tool call the provider already emitted, even when structured_output1102        // is the only executable sibling in this batch.1103        toolResponseParts.push(...duplicatePendingResponses);1104 1105        // Pre-scan: when --json-schema is active and the model emitted1106        // a `structured_output` call alongside other tools in the same1107        // turn, the structured call is the terminal contract. Execute1108        // every structured_output in original order until one succeeds,1109        // suppress every non-structured sibling. See the multi-shape1110        // examples in the main loop's prior comment for the1111        // [bad/good/side-effect] permutations.1112        let requestsToExecute = executableBatchRequests;1113        if (structuredOutputActive) {1114          requestsToExecute = executableBatchRequests.filter(1115            (r) => r.name === ToolNames.STRUCTURED_OUTPUT,1116          );1117        }1118        const executedRequests = new Set<ToolCallRequestInfo>(1119          respondedRequests,1120        );1121 1122        for (const requestInfo of requestsToExecute) {1123          executedRequests.add(requestInfo);1124 1125          const inputFormat =1126            typeof config.getInputFormat === 'function'1127              ? config.getInputFormat()1128              : InputFormat.TEXT;1129          const toolCallUpdateCallback =1130            inputFormat === InputFormat.STREAM_JSON && options.controlService1131              ? options.controlService.permission.getToolCallUpdateCallback()1132              : undefined;1133 1134          // Build outputUpdateHandler for this tool call. Agent tool1135          // has its own complex handler (subagent messages). All other1136          // tools with canUpdateOutput=true (e.g., MCP tools) get a1137          // generic handler that emits progress via the adapter.1138          const isAgentTool = requestInfo.name === 'agent';1139          const { handler: outputUpdateHandler } = isAgentTool1140            ? createAgentToolProgressHandler(1141                config,1142                requestInfo.callId,1143                adapter,1144              )1145            : createToolProgressHandler(requestInfo, adapter);1146 1147          // Tick BEFORE the call so that --max-tool-calls=N caps the run1148          // at exactly N executions: the (N+1)th tick aborts before the1149          // tool runs. Ticking after would let the (N+1)th tool execute1150          // and only then abort. See issue #4103.1151          //1152          // Exempt `structured_output` ONLY when `--json-schema` is1153          // active: under --json-schema this is the terminal "I'm done"1154          // contract tool, not real work, and counting it would abort1155          // an otherwise-valid completion at the budget edge (budget=3,1156          // model used 3 tools then emits structured_output as call #41157          // → exit 55 instead of success). Guarding on1158          // `getJsonSchema()` keeps the exemption tied to the feature1159          // that owns the tool name — an MCP server that registers an1160          // unrelated tool literally named `structured_output` would1161          // otherwise inherit a free pass.1162          //1163          // Caveat: failed structured_output calls (Ajv validation1164          // failure) also skip the tick, so a model stuck in a1165          // validation-retry loop is not bounded by --max-tool-calls.1166          // Documented in docs/users/features/headless.md → "Scope".1167          // Combine with --max-session-turns or --max-wall-time.1168          const isStructuredOutputExempt =1169            requestInfo.name === ToolNames.STRUCTURED_OUTPUT &&1170            config.getJsonSchema?.() !== undefined;1171          if (!isStructuredOutputExempt) {1172            budgetEnforcer.tickToolCall();1173          }1174          if (abortController.signal.aborted) await routeAbort();1175          const toolResponse = await executeToolCall(1176            config,1177            requestInfo,1178            abortController.signal,1179            {1180              outputUpdateHandler,1181              ...(toolCallUpdateCallback && {1182                onToolCallsUpdate: toolCallUpdateCallback,1183              }),1184            },1185          );1186 1187          if (toolResponse.error) {1188            // In JSON/STREAM_JSON mode, tool errors are tolerated and1189            // formatted as tool_result blocks. handleToolError detects1190            // mode from config and allows the session to continue so1191            // the LLM can decide what to do next. In text mode, we1192            // still log the error.1193            handleToolError(1194              requestInfo.name,1195              toolResponse.error,1196              config,1197              toolResponse.errorType || 'TOOL_EXECUTION_ERROR',1198              typeof toolResponse.resultDisplay === 'string'1199                ? toolResponse.resultDisplay1200                : undefined,

Showing the first 1,200 of 2079 lines. Download the file for the rest.

basant307/AI_Governance_Project · CoolFace