| 1 | //! One private phase of the existing Engine turn loop. |
| 2 | |
| 3 | use super::*; |
| 4 | |
| 5 | impl Engine { |
| 6 | pub(super) async fn continue_model_step( |
| 7 | &mut self, |
| 8 | turn: &mut TurnContext, |
| 9 | tool_policy: &ToolSurfacePolicy, |
| 10 | progress: &mut TurnLoopProgress, |
| 11 | client: &SharedModelClient, |
| 12 | response: AcceptedModelStep, |
| 13 | ) -> PhaseResult<AcceptedModelStep> { |
| 14 | if self.rlm_host.is_some() { |
| 15 | return self |
| 16 | .continue_rlm_model_step(turn, tool_policy, progress, client, response) |
| 17 | .await; |
| 18 | } |
| 19 | let tool_registry = Some(&tool_policy.registry); |
| 20 | let AcceptedModelStep { |
| 21 | current_text_visible, |
| 22 | tool_uses, |
| 23 | mut pending_steers, |
| 24 | mut output_limit_truncated, |
| 25 | zero_tool_turn, |
| 26 | zero_tool_text_call, |
| 27 | fleet_report_response, |
| 28 | fleet_no_progress_report, |
| 29 | has_sendable_assistant_content, |
| 30 | has_provider_reasoning, |
| 31 | no_sendable_assistant_content, |
| 32 | stop_reason, |
| 33 | stream_errors, |
| 34 | prepared_output_tokens, |
| 35 | } = response; |
| 36 | if self.child_report() { |
| 37 | // Reporting has one logical model step and never authorizes tool, |
| 38 | // code-fence, steer, child completion or goal continuation. |
| 39 | return if tool_uses.is_empty() |
| 40 | && output_limit_truncated.is_none() |
| 41 | && !current_text_visible.trim().is_empty() |
| 42 | && has_sendable_assistant_content |
| 43 | { |
| 44 | PhaseResult::Break |
| 45 | } else { |
| 46 | PhaseResult::Return(( |
| 47 | TurnOutcomeStatus::Failed, |
| 48 | Some("bounded Core report did not finish as text".into()), |
| 49 | )) |
| 50 | }; |
| 51 | } |
| 52 | // A truncated response with no tool call cannot continue through |
| 53 | // tool execution: surface the truncation as a bounded observation |
| 54 | // and resume the loop so the model can act on it instead of the |
| 55 | // turn silently ending on a cut-off answer. Resume only when the |
| 56 | // truncated response actually delivered partial content — a |
| 57 | // reasoning-only length stop delivered nothing to continue from, |
| 58 | // and re-issuing it would only reproduce the same stop instead of |
| 59 | // failing the turn honestly. |
| 60 | if output_limit_truncated.is_some() |
| 61 | && !fleet_no_progress_report |
| 62 | && tool_uses.is_empty() |
| 63 | && has_sendable_assistant_content |
| 64 | { |
| 65 | let reason = output_limit_truncated |
| 66 | .take() |
| 67 | .expect("output_limit_truncated checked above"); |
| 68 | self.add_session_message( |
| 69 | self.runtime_text_message_with_turn_metadata( |
| 70 | format!( |
| 71 | "[runtime] The provider stopped generation at its output limit (`{reason}`) before completing. Your last response was cut off. Continue from where you left off; do not repeat content already delivered." |
| 72 | ), |
| 73 | UserInputProvenance::Runtime, |
| 74 | ), |
| 75 | ) |
| 76 | .await; |
| 77 | let _ = self |
| 78 | .send_event(Event::status( |
| 79 | "Continuing — provider output limit reached; asking the model to continue" |
| 80 | .to_string(), |
| 81 | )) |
| 82 | .await; |
| 83 | turn.next_step(); |
| 84 | return PhaseResult::Retry; |
| 85 | } |
| 86 | |
| 87 | // If no tool uses, check for inline REPL blocks (paper §2) or |
| 88 | // finish the turn. Honest ladder (NOTE-turn-loop-wrongness §3): |
| 89 | // 1) pending steers → resume, 2) queued subagent completions → |
| 90 | // resume, 3) REPL fences → run (empty cap may end), 4) goal |
| 91 | // continuation if under cap → resume, 5) else end. Healthy |
| 92 | // children continue in the background; their existence alone |
| 93 | // does not authorize another parent model request. |
| 94 | if tool_uses.is_empty() && !fleet_no_progress_report { |
| 95 | if !pending_steers.is_empty() { |
| 96 | if let Some(guard) = progress.fleet_denial_guard.as_mut() { |
| 97 | guard.reset(); |
| 98 | turn.stop_diagnostics |
| 99 | .permission_denial_rounds_without_progress = 0; |
| 100 | } |
| 101 | for pending in pending_steers.drain(..) { |
| 102 | let steer = pending.commit().trim().to_string(); |
| 103 | self.session |
| 104 | .working_set |
| 105 | .observe_user_message(&steer, &self.session.workspace); |
| 106 | self.add_session_message(self.user_text_message_with_turn_metadata(steer)) |
| 107 | .await; |
| 108 | } |
| 109 | let _ = self |
| 110 | .send_event(Event::status("Continuing — queued steer input".to_string())) |
| 111 | .await; |
| 112 | turn.next_step(); |
| 113 | return PhaseResult::Retry; |
| 114 | } |
| 115 | |
| 116 | let shell_completions = if self.is_acp_turn() { |
| 117 | Vec::new() |
| 118 | } else { |
| 119 | self.drain_shell_completion_events() |
| 120 | }; |
| 121 | if !shell_completions.is_empty() { |
| 122 | self.add_session_message(shell_completion_runtime_message(&shell_completions)) |
| 123 | .await; |
| 124 | if let Some(status) = shell_completion_status_text(&shell_completions, "") { |
| 125 | let _ = self.send_event(Event::status(status)).await; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // Sub-agent completion handoff (issue #756). Resuming when |
| 130 | // queued completions exist is correct; #3216 says do not wait |
| 131 | // indefinitely for every running child here. Healthy work |
| 132 | // keeps running and reports by sentinel on a later turn. |
| 133 | let subagent_completions = if self.is_acp_turn() { |
| 134 | 0 |
| 135 | } else { |
| 136 | self.drain_subagent_completion_events("").await |
| 137 | }; |
| 138 | if subagent_completions > 0 { |
| 139 | let _ = self |
| 140 | .send_event(Event::status(format!( |
| 141 | "Continuing — {subagent_completions} sub-agent(s) completed" |
| 142 | ))) |
| 143 | .await; |
| 144 | turn.next_step(); |
| 145 | return PhaseResult::Retry; |
| 146 | } |
| 147 | |
| 148 | match self |
| 149 | .run_inline_repl_phase( |
| 150 | turn, |
| 151 | tool_policy, |
| 152 | progress, |
| 153 | client, |
| 154 | ¤t_text_visible, |
| 155 | has_sendable_assistant_content, |
| 156 | ) |
| 157 | .await |
| 158 | { |
| 159 | PhaseResult::Ready(()) => {} |
| 160 | PhaseResult::Retry => return PhaseResult::Retry, |
| 161 | PhaseResult::Break => return PhaseResult::Break, |
| 162 | PhaseResult::Return(outcome) => return PhaseResult::Return(outcome), |
| 163 | } |
| 164 | |
| 165 | // Issue #1727: the turn is now genuinely finishing with no |
| 166 | // sendable content. Control only reaches here when there were |
| 167 | // no pending steers (`continue`d above) and no sub-agent |
| 168 | // completions to resume with. Healthy running children do |
| 169 | // not force another model request. |
| 170 | // If the assistant produced ONLY a reasoning block, the prior |
| 171 | // code fell straight through to this `break`, emitting nothing |
| 172 | // and leaving the UI spinner hung. Surface a status now — |
| 173 | // safe because the turn can no longer resume. |
| 174 | // #1961: Before breaking, drain any sub-agent completions that |
| 175 | // arrived between the last hold check and now. If a child finished |
| 176 | // while we were running the thinking-only check, surface its |
| 177 | // sentinel rather than delaying it to the next turn. |
| 178 | let late_shell_completions = if self.is_acp_turn() { |
| 179 | Vec::new() |
| 180 | } else { |
| 181 | self.drain_shell_completion_events() |
| 182 | }; |
| 183 | if !late_shell_completions.is_empty() { |
| 184 | self.add_session_message(shell_completion_runtime_message(&late_shell_completions)) |
| 185 | .await; |
| 186 | if let Some(status) = shell_completion_status_text(&late_shell_completions, "late") |
| 187 | { |
| 188 | let _ = self.send_event(Event::status(status)).await; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | if !self.is_acp_turn() && self.drain_subagent_completion_events("late").await > 0 { |
| 193 | let _ = self |
| 194 | .send_event(Event::status( |
| 195 | "Continuing — late sub-agent completion".to_string(), |
| 196 | )) |
| 197 | .await; |
| 198 | turn.next_step(); |
| 199 | return PhaseResult::Retry; |
| 200 | } |
| 201 | |
| 202 | // A goal continuation is optional work on top of a productive |
| 203 | // step. A response that produced nothing sendable and ran no |
| 204 | // tools is a failed step (incomplete/length-stopped provider |
| 205 | // response): continuing would re-issue the exact request that |
| 206 | // just failed — for an output-length stop it can only |
| 207 | // reproduce — instead of failing the turn honestly. |
| 208 | let step_produced_nothing = no_sendable_assistant_content && tool_uses.is_empty(); |
| 209 | if !self.is_acp_turn() |
| 210 | && !step_produced_nothing |
| 211 | && let Some(continuation) = self |
| 212 | .goal_continuation_message_if_needed( |
| 213 | tool_registry, |
| 214 | &mut progress.goal_continuations_this_turn, |
| 215 | &turn.usage, |
| 216 | ) |
| 217 | .await |
| 218 | { |
| 219 | // The model already delivered a complete answer this step; |
| 220 | // the continuation is optional runtime work on top of it. |
| 221 | // If the step budget then runs out, the turn is finished, |
| 222 | // not failed. |
| 223 | progress.step_budget_exhaustion_is_terminal = false; |
| 224 | self.add_session_message(self.runtime_text_message_with_turn_metadata( |
| 225 | continuation, |
| 226 | UserInputProvenance::Runtime, |
| 227 | )) |
| 228 | .await; |
| 229 | let _ = self |
| 230 | .send_event(Event::status(format!( |
| 231 | "Continuing — goal still active (pass {goal_continuations_this_turn})", |
| 232 | goal_continuations_this_turn = progress.goal_continuations_this_turn |
| 233 | ))) |
| 234 | .await; |
| 235 | turn.next_step(); |
| 236 | return PhaseResult::Retry; |
| 237 | } |
| 238 | |
| 239 | if no_sendable_assistant_content |
| 240 | && !zero_tool_text_call |
| 241 | && has_provider_reasoning |
| 242 | && should_fail_no_sendable_content( |
| 243 | tool_uses.is_empty(), |
| 244 | progress.turn_error.is_none(), |
| 245 | self.cancel_token.is_cancelled(), |
| 246 | !pending_steers.is_empty(), |
| 247 | false, |
| 248 | ) |
| 249 | && !stop_reason_is_output_limit(stop_reason.as_deref()) |
| 250 | && progress.reasoning_only_reprompts < self.config.reasoning_only_max_reprompts |
| 251 | { |
| 252 | // Reasoning-only, clean stop: recover instead of dead-ending |
| 253 | // the turn. Nothing was persisted for this response (a bare |
| 254 | // Thinking block is not sendable), so re-issuing the request |
| 255 | // is an exact cached-prefix retry — no synthetic message, |
| 256 | // no prefix churn. An output-length stop is excluded above |
| 257 | // because retrying would only reproduce it. |
| 258 | progress.reasoning_only_reprompts += 1; |
| 259 | turn.stop_diagnostics.reasoning_only_reprompts = progress.reasoning_only_reprompts; |
| 260 | let attempt = progress.reasoning_only_reprompts; |
| 261 | let max_reprompts = self.config.reasoning_only_max_reprompts; |
| 262 | // Attempt 1 preserves the prefix; a cache hit or lower |
| 263 | // cost is not guaranteed. From attempt 2 on, |
| 264 | // an identical request has already failed once, so carry |
| 265 | // the nudge rather than reproduce the same answerless reply. |
| 266 | let nudged = attempt > 1; |
| 267 | if nudged { |
| 268 | let text = self |
| 269 | .config |
| 270 | .reasoning_only_reprompt_message |
| 271 | .clone() |
| 272 | .unwrap_or_else(|| { |
| 273 | crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE.to_string() |
| 274 | }); |
| 275 | if !text.trim().is_empty() { |
| 276 | progress.reasoning_only_nudge = Some(text); |
| 277 | } |
| 278 | } |
| 279 | let how = if nudged { |
| 280 | "re-requesting the answer with a nudge" |
| 281 | } else { |
| 282 | "re-requesting the answer" |
| 283 | }; |
| 284 | crate::logging::warn(format!( |
| 285 | "Model returned only reasoning with no answer or tool call (attempt {attempt}/{max_reprompts}); {how}" |
| 286 | )); |
| 287 | let _ = self.send_retry_status(format!( |
| 288 | "Retry attempt: reasoning-only {attempt}/{max_reprompts}; no answer or tool call; {how}" |
| 289 | )) |
| 290 | .await; |
| 291 | progress.turn_error = None; |
| 292 | return PhaseResult::Retry; |
| 293 | } |
| 294 | |
| 295 | // #6310: a clean terminal stop with no text, no reasoning and |
| 296 | // no tool call. The stream finished without a transport error, |
| 297 | // so the NoContentStreamDeath resume above never sees it; it |
| 298 | // is the same transient failure all the same. Nothing was |
| 299 | // persisted for this response, so the first retry re-issues |
| 300 | // the identical request, the second carries the request-scoped |
| 301 | // nudge, and after that the turn fails visibly below. |
| 302 | let empty_clean_stop = no_sendable_assistant_content |
| 303 | && !zero_tool_text_call |
| 304 | && !has_provider_reasoning |
| 305 | && stream_errors == 0 |
| 306 | && stop_reason.is_some() |
| 307 | && !stop_reason_is_output_limit(stop_reason.as_deref()) |
| 308 | && should_fail_no_sendable_content( |
| 309 | tool_uses.is_empty(), |
| 310 | progress.turn_error.is_none(), |
| 311 | self.cancel_token.is_cancelled(), |
| 312 | !pending_steers.is_empty(), |
| 313 | false, |
| 314 | ); |
| 315 | if empty_clean_stop |
| 316 | && let Some(retry) = plan_empty_stop_retry(progress.empty_stop_retries) |
| 317 | { |
| 318 | progress.empty_stop_retries += 1; |
| 319 | turn.stop_diagnostics.empty_stop_retries = progress.empty_stop_retries; |
| 320 | let attempt = progress.empty_stop_retries; |
| 321 | let reason = stop_reason_detail(stop_reason.as_deref()); |
| 322 | let how = match retry { |
| 323 | EmptyStopRetry::ExactPrefix => "re-requesting the answer", |
| 324 | EmptyStopRetry::Nudged => { |
| 325 | let text = self |
| 326 | .config |
| 327 | .reasoning_only_reprompt_message |
| 328 | .clone() |
| 329 | .unwrap_or_else(|| { |
| 330 | crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE.to_string() |
| 331 | }); |
| 332 | if !text.trim().is_empty() { |
| 333 | progress.reasoning_only_nudge = Some(text); |
| 334 | } |
| 335 | "re-requesting the answer with a nudge" |
| 336 | } |
| 337 | }; |
| 338 | crate::logging::warn(format!( |
| 339 | "Model returned terminal stop reason `{reason}` with no answer or tool call (attempt {attempt}/{EMPTY_STOP_MAX_RETRIES}); {how}" |
| 340 | )); |
| 341 | let _ = self.send_retry_status(format!( |
| 342 | "Retry attempt: empty-stop {attempt}/{EMPTY_STOP_MAX_RETRIES}; no answer, reasoning or tool call; {how}" |
| 343 | )) |
| 344 | .await; |
| 345 | return PhaseResult::Retry; |
| 346 | } |
| 347 | |
| 348 | if no_sendable_assistant_content |
| 349 | && should_fail_no_sendable_content( |
| 350 | tool_uses.is_empty(), |
| 351 | progress.turn_error.is_none(), |
| 352 | self.cancel_token.is_cancelled(), |
| 353 | !pending_steers.is_empty(), |
| 354 | false, |
| 355 | ) |
| 356 | { |
| 357 | let message = if zero_tool_text_call { |
| 358 | "Model answered only with a tool call, and this turn offers no tools." |
| 359 | .to_string() |
| 360 | } else if has_provider_reasoning |
| 361 | && stop_reason_is_output_limit(stop_reason.as_deref()) |
| 362 | { |
| 363 | format!( |
| 364 | "Model reached the response output limit with no answer or tool call (requested allowance: {} tokens, including reasoning).", |
| 365 | prepared_output_tokens |
| 366 | ) |
| 367 | } else if has_provider_reasoning { |
| 368 | let reason = codewhale_models::stop_reason_detail(stop_reason.as_deref()); |
| 369 | format!( |
| 370 | "Model returned reasoning but no answer or tool call; the provider response was incomplete (stop reason: {}).", |
| 371 | reason |
| 372 | .chars() |
| 373 | .flat_map(char::escape_default) |
| 374 | .take(120) |
| 375 | .collect::<String>() |
| 376 | ) |
| 377 | } else if let Some(reason) = stop_reason.as_deref() { |
| 378 | if progress.empty_stop_retries > 0 { |
| 379 | format!( |
| 380 | "Model returned terminal stop reason `{reason}` with no answer or tool call (after {empty_stop_retries} retries).", |
| 381 | empty_stop_retries = progress.empty_stop_retries |
| 382 | ) |
| 383 | } else { |
| 384 | format!( |
| 385 | "Model returned terminal stop reason `{reason}` with no answer or tool call." |
| 386 | ) |
| 387 | } |
| 388 | } else { |
| 389 | "Model stream ended with no answer or tool call.".to_string() |
| 390 | }; |
| 391 | crate::logging::warn(&message); |
| 392 | progress.turn_error = Some(message.clone()); |
| 393 | let _ = self |
| 394 | .send_event(Event::error(ErrorEnvelope::classify(message, true))) |
| 395 | .await; |
| 396 | } |
| 397 | |
| 398 | if progress.turn_error.is_none() { |
| 399 | if !turn.budget_exhausted_final_report { |
| 400 | turn.stop_diagnostics.reason = Some(TurnStopReason::ProviderNoToolCall); |
| 401 | } |
| 402 | // This branch received no calls and dispatches no tools. |
| 403 | turn.stop_diagnostics.last_response_tool_calls_suppressed = Some(0); |
| 404 | } |
| 405 | return PhaseResult::Break; |
| 406 | } |
| 407 | |
| 408 | PhaseResult::Ready(AcceptedModelStep { |
| 409 | current_text_visible, |
| 410 | tool_uses, |
| 411 | pending_steers, |
| 412 | output_limit_truncated, |
| 413 | zero_tool_turn, |
| 414 | zero_tool_text_call, |
| 415 | fleet_report_response, |
| 416 | fleet_no_progress_report, |
| 417 | has_sendable_assistant_content, |
| 418 | has_provider_reasoning, |
| 419 | no_sendable_assistant_content, |
| 420 | stop_reason, |
| 421 | stream_errors, |
| 422 | prepared_output_tokens, |
| 423 | }) |
| 424 | } |
| 425 | } |
| 426 |