retryEmptyCompletion function

Future<(AssistantMessage, Context)> retryEmptyCompletion(
  1. AssistantMessage message,
  2. Context context,
  3. AgentLoopConfig config,
  4. AgentEventSink emit,
  5. StreamFunction streamFunction,
  6. CancelToken? cancelToken, {
  7. required List<Message> newMessages,
  8. required Future<List<Message>> drainSteering(),
  9. CancelTokenSource? cancelTokenSource,
})

See the library doc above.

Implementation

Future<(AssistantMessage, Context)> retryEmptyCompletion(
  AssistantMessage message,
  Context context,
  AgentLoopConfig config,
  AgentEventSink emit,
  StreamFunction streamFunction,
  CancelToken? cancelToken, {
  required List<Message> newMessages,
  required Future<List<Message>> Function() drainSteering,
  CancelTokenSource? cancelTokenSource,
}) async {
  var generic = 0;
  var hiccup = 0;
  var current = message;
  var out = context;
  var cancelled = _isCancelRequested(cancelToken);
  while (!cancelled) {
    final verdict = classifyEmptyCompletion(out.messages, current);
    // Healthy completions and aborted/errored streams keep their own paths.
    if (verdict == EmptyCompletionVerdict.notEmpty ||
        verdict == EmptyCompletionVerdict.abortedOrError) {
      break;
    }
    if (generic < config.maxEmptyRetries) {
      generic++;
    } else if (verdict == EmptyCompletionVerdict.cleanEmpty) {
      // A genuine empty final: the host's nudge/settle semantics own it
      // (issue #1085 M2b, pinned unchanged by gh-1577 AC2).
      break;
    } else if (hiccup >= config.emptyHiccupRetries) {
      // gh-1577 exhaustion verdict: retries are spent and the completion is
      // still empty — end the run LOUDLY instead of settling a silent
      // success. An abort that raced the classification wins instead (E5):
      // the exhaustion error is never printed over an aborted run.
      current = await _finishWithoutStream(
        out,
        emit,
        _terminalMessage(
          config.model,
          StopReason.error,
          emptyHiccupExhaustedError(
            retryCount: generic + hiccup,
            attempts: 1 + generic + hiccup,
            lastStopReason: current.stopReason,
          ),
        ),
      );
      break;
    } else {
      final delay = emptyHiccupBackoffFor(
        attempt: hiccup,
        baseDelay: config.emptyHiccupBaseDelay,
        maxBackoff: config.emptyHiccupMaxBackoff,
      );
      if (delay > Duration.zero) {
        // E7: race the sleep against the token — a cancel landing during
        // the backoff wakes immediately instead of stalling for up to
        // [AgentLoopConfig.emptyHiccupMaxBackoff]. The next
        // `_streamAssistantResponse` short-circuits into `_abortedTurn`.
        await Future.any<void>([
          Future<void>.delayed(delay),
          if (cancelToken != null) cancelToken.onCancel,
        ]);
      }
      hiccup++;
    }
    // gh-1603: the FIRST re-stream replays the plain request; from the
    // second on, the previous retry already came back empty — inject the
    // continuation nudge so the request carries new signal (a cap-truncated
    // reasoning model re-streamed verbatim repeats the empty outcome). The
    // MessageStart/End pair lands the nudge in the host transcript and
    // session record (same as steering injections — a truthful record, the
    // gh-1577 invariant); the classifier treats the exact nudge message as
    // transparent, so the turn's partial-content window survives it.
    if (generic + hiccup >= 2) {
      final nudge = UserMessage.text(emptyContinuationNudge);
      await emit(MessageStartEvent(nudge));
      await emit(MessageEndEvent(nudge));
      out.messages.add(nudge);
    }
    (current, out) = await _streamAssistantResponse(
      out,
      config,
      emit,
      streamFunction,
      cancelToken,
      newMessages: newMessages,
      drainSteering: drainSteering,
      cancelTokenSource: cancelTokenSource,
    );
    cancelled = _isCancelRequested(cancelToken);
  }
  // E5: the abort landed with (or after) the last empty completion, before
  // the classification — the abort wins over ANY empty shape (a clean
  // `stop`, a `length` cut, or the tolerated silent-truncation flag): a
  // truncation-flagged empty that settles as the run final leaves headless
  // at exit 0 on an aborted run. Settle the explicit aborted terminal
  // instead — never the hiccup error, never a silent empty success. An
  // already-aborted terminal (the `_streamAssistantResponse` short-circuit)
  // is left as-is: converting it again would duplicate the emission.
  if (_needsAbortedTerminal(cancelled, current)) {
    current = await _finishWithoutStream(
      out,
      emit,
      _terminalMessage(config.model, StopReason.aborted, 'Operation aborted'),
    );
  }
  return (current, out);
}