@@ -912,6 +912,14 @@ const MAX_WAITING_CODE_MODE_CELLS = 1;
912912const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10 ;
913913const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1 ;
914914const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1 ;
915+ /**
916+ * Desktop interactive turns often omit `maxSteps`, so a model that keeps
917+ * emitting the same tool-call with no visible assistant text can loop forever
918+ * and flood the transcript with empty AI replies (#4083). Only identical,
919+ * textless steps are counted: ordinary multi-step tool workflows and an
920+ * explicit `maxSteps` remain authoritative.
921+ */
922+ const MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS = 3 ;
915923const PROVIDER_RETRY_BASE_DELAY_MS = 1_000 ;
916924const PROVIDER_RETRY_MAX_DELAY_MS = 32_000 ;
917925const PROVIDER_RETRY_JITTER_FACTOR = 0.25 ;
@@ -2175,7 +2183,11 @@ export class AiSdkBackend implements AgentBackend {
21752183 let providerOutcome : ModelStepOutcome ;
21762184 let finishReason : ModelFinishReason = 'stop' ;
21772185 let terminalProviderError : unknown ;
2186+ let consecutiveIdenticalEmptySteps = 0 ;
2187+ let previousEmptyStepSignature : string | undefined ;
21782188 agentLoop: for ( ; ; ) {
2189+ let stepSawVisibleText = false ;
2190+ let stepSawThinking = false ;
21792191 await this . drainSteeringInto ( scope , input , queue ) ;
21802192 if ( this . input . loadTurnRuntimeEvents ) {
21812193 requestMessages = await loadDurableTurnProjection ( ) ;
@@ -2362,7 +2374,10 @@ export class AiSdkBackend implements AgentBackend {
23622374 stepTextPartStartOffset = stepText . length ;
23632375 } else if ( event . kind === 'text' ) {
23642376 stepText += event . text ;
2365- if ( event . text . length > 0 ) attemptSawText = true ;
2377+ if ( event . text . length > 0 ) {
2378+ attemptSawText = true ;
2379+ stepSawVisibleText = true ;
2380+ }
23662381 queue . push ( {
23672382 type : 'text_delta' ,
23682383 id : this . newId ( ) ,
@@ -2381,6 +2396,7 @@ export class AiSdkBackend implements AgentBackend {
23812396 stepTextPartStartOffset ,
23822397 ) ;
23832398 } else if ( event . kind === 'thinking' ) {
2399+ stepSawThinking = true ;
23842400 sawStepThinking = true ;
23852401 stepThinking += event . text ;
23862402 if ( event . text . length > 0 ) attemptSawThinking = true ;
@@ -2426,6 +2442,7 @@ export class AiSdkBackend implements AgentBackend {
24262442 text : event . text ,
24272443 } satisfies ThinkingDeltaEvent ) ;
24282444 } else if ( event . kind === 'thinking-signature' ) {
2445+ stepSawThinking = true ;
24292446 attemptSawContinuationMetadata = true ;
24302447 stepSignature = event . signature ;
24312448 } else if ( event . kind === 'provider-tool-input' ) {
@@ -2799,6 +2816,33 @@ export class AiSdkBackend implements AgentBackend {
27992816 ...( providerStepUsage ? { usage : providerStepUsage } : { } ) ,
28002817 } ) ;
28012818 lastCompletedStepHadToolResult = returnedToolCalls . length > 0 ;
2819+ const emptyStepSignature =
2820+ ! stepSawVisibleText && ! stepSawThinking && returnedToolCalls . length > 0
2821+ ? JSON . stringify (
2822+ returnedToolCalls . map ( ( { toolName, input } ) => ( { toolName, input } ) ) ,
2823+ )
2824+ : undefined ;
2825+ if (
2826+ maxSteps === undefined &&
2827+ emptyStepSignature !== undefined &&
2828+ ! scope . loopStopRequested
2829+ ) {
2830+ consecutiveIdenticalEmptySteps =
2831+ emptyStepSignature === previousEmptyStepSignature
2832+ ? consecutiveIdenticalEmptySteps + 1
2833+ : 1 ;
2834+ previousEmptyStepSignature = emptyStepSignature ;
2835+ if ( consecutiveIdenticalEmptySteps >= MAX_CONSECUTIVE_IDENTICAL_EMPTY_STEPS ) {
2836+ // The model is repeating the same tool-only step with no visible
2837+ // progress. Stop as a failed tool-step cap rather than reporting a
2838+ // successful end_turn with no answer (#4083).
2839+ scope . loopStopReason = 'step_limit' ;
2840+ scope . loopStopRequested = true ;
2841+ }
2842+ } else {
2843+ consecutiveIdenticalEmptySteps = 0 ;
2844+ previousEmptyStepSignature = undefined ;
2845+ }
28022846 const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps ;
28032847 if (
28042848 sandboxBoundaryFinalizationStep ||
@@ -2842,6 +2886,10 @@ export class AiSdkBackend implements AgentBackend {
28422886 ! scope . loopStopRequested &&
28432887 ! scope . aborted
28442888 ) {
2889+ // A redirected prompt deserves a fresh empty-step streak; otherwise
2890+ // a prior empty run would stop the turn before the steer can land.
2891+ consecutiveIdenticalEmptySteps = 0 ;
2892+ previousEmptyStepSignature = undefined ;
28452893 currentStepMessageId = this . newId ( ) ;
28462894 continue agentLoop;
28472895 }
0 commit comments