retryEmptyCompletion function
Future<(AssistantMessage, Context)>
retryEmptyCompletion(
- AssistantMessage message,
- Context context,
- AgentLoopConfig config,
- AgentEventSink emit,
- StreamFunction streamFunction,
- CancelToken? cancelToken, {
- required List<
Message> newMessages, - required Future<
List< drainSteering(),Message> > - 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);
}