basant307/AI_Governance_Project
045
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,