返回 CodeWhale
continuation.rs
根目录 / crates / tui / src / core / engine / turn_loop / continuation.rs
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 &current_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
426 lines RUST