返回 DeepSeek-TUI-2026
turn_loop.rs
根目录 / crates / tui / src / core / engine / turn_loop.rs
1 //! Main streaming turn loop for the engine.
2 //!
3 //! Extracted from `core/engine.rs` for issue #74. This module keeps the
4 //! existing per-turn orchestration intact: request construction, streaming
5 //! event handling, tool planning/execution, LSP post-edit hooks, capacity
6 //! checkpoints, and loop termination.
7
8 use super::*;
9
10 impl Engine {
11 pub(super) async fn handle_deepseek_turn(
12 &mut self,
13 turn: &mut TurnContext,
14 tool_registry: Option<&crate::tools::ToolRegistry>,
15 tools: Option<Vec<Tool>>,
16 mode: AppMode,
17 force_update_plan_first: bool,
18 ) -> (TurnOutcomeStatus, Option<String>) {
19 let client = self
20 .deepseek_client
21 .clone()
22 .expect("DeepSeek client should be configured");
23
24 let mut consecutive_tool_error_steps = 0u32;
25 let mut turn_error: Option<String> = None;
26 let mut context_recovery_attempts = 0u8;
27 let mut tool_catalog = tools.unwrap_or_default();
28 if !tool_catalog.is_empty() {
29 ensure_advanced_tooling(&mut tool_catalog);
30 }
31 let mut active_tool_names = initial_active_tools(&tool_catalog);
32 let mut loop_guard = LoopGuard::default();
33
34 // Transparent stream-retry counter: when the chunked-transfer
35 // connection dies mid-stream and we got nothing useful out of it
36 // (no tool calls, no completed text), we silently re-issue the
37 // SAME request up to MAX_STREAM_RETRIES times before surfacing
38 // the failure to the user. This is the #103 Phase 3 retry that
39 // keeps long V4 thinking turns from being killed by transient
40 // proxy disconnects.
41 const MAX_STREAM_RETRIES: u32 = 3;
42 let mut stream_retry_attempts: u32 = 0;
43
44 loop {
45 if self.cancel_token.is_cancelled() {
46 let _ = self.tx_event.send(Event::status("Request cancelled")).await;
47 return (TurnOutcomeStatus::Interrupted, None);
48 }
49
50 while let Ok(steer) = self.rx_steer.try_recv() {
51 let steer = steer.trim().to_string();
52 if steer.is_empty() {
53 continue;
54 }
55 self.session
56 .working_set
57 .observe_user_message(&steer, &self.session.workspace);
58 self.add_session_message(Message {
59 role: "user".to_string(),
60 content: vec![ContentBlock::Text {
61 text: steer.clone(),
62 cache_control: None,
63 }],
64 })
65 .await;
66 let _ = self
67 .tx_event
68 .send(Event::status(format!(
69 "Steer input accepted: {}",
70 summarize_text(&steer, 120)
71 )))
72 .await;
73 }
74
75 // Ensure system prompt is up to date with latest session states
76 self.refresh_system_prompt(mode);
77
78 if turn.at_max_steps() {
79 let _ = self
80 .tx_event
81 .send(Event::status("Reached maximum steps"))
82 .await;
83 break;
84 }
85
86 let compaction_pins = self
87 .session
88 .working_set
89 .pinned_message_indices(&self.session.messages, &self.session.workspace);
90 let compaction_paths = self.session.working_set.top_paths(24);
91
92 if self.config.compaction.enabled
93 && should_compact(
94 &self.session.messages,
95 &self.config.compaction,
96 Some(&self.session.workspace),
97 Some(&compaction_pins),
98 Some(&compaction_paths),
99 )
100 {
101 let compaction_id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
102 self.emit_compaction_started(
103 compaction_id.clone(),
104 true,
105 "Auto context compaction started".to_string(),
106 )
107 .await;
108 let _ = self
109 .tx_event
110 .send(Event::status("Auto-compacting context...".to_string()))
111 .await;
112 let auto_messages_before = self.session.messages.len();
113 match compact_messages_safe(
114 &client,
115 &self.session.messages,
116 &self.config.compaction,
117 Some(&self.session.workspace),
118 Some(&compaction_pins),
119 Some(&compaction_paths),
120 )
121 .await
122 {
123 Ok(result) => {
124 // Only update if we got valid messages (never corrupt state)
125 if !result.messages.is_empty() || self.session.messages.is_empty() {
126 let auto_messages_after = result.messages.len();
127 self.session.messages = result.messages;
128 self.merge_compaction_summary(result.summary_prompt);
129 self.emit_session_updated().await;
130 let removed = auto_messages_before.saturating_sub(auto_messages_after);
131 let status = if result.retries_used > 0 {
132 format!(
133 "Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed, {} retries)",
134 result.retries_used
135 )
136 } else {
137 format!(
138 "Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed)"
139 )
140 };
141 self.emit_compaction_completed(
142 compaction_id.clone(),
143 true,
144 status.clone(),
145 Some(auto_messages_before),
146 Some(auto_messages_after),
147 )
148 .await;
149 let _ = self.tx_event.send(Event::status(status)).await;
150 } else {
151 let message = "Auto-compaction skipped: empty result".to_string();
152 self.emit_compaction_failed(
153 compaction_id.clone(),
154 true,
155 message.clone(),
156 )
157 .await;
158 let _ = self.tx_event.send(Event::status(message)).await;
159 }
160 }
161 Err(err) => {
162 // Log error but continue with original messages (never corrupt)
163 let message = format!("Auto-compaction failed: {err}");
164 self.emit_compaction_failed(compaction_id, true, message.clone())
165 .await;
166 let _ = self.tx_event.send(Event::status(message)).await;
167 }
168 }
169 }
170
171 if self
172 .run_capacity_pre_request_checkpoint(turn, Some(&client), mode)
173 .await
174 {
175 continue;
176 }
177
178 if let Some(input_budget) =
179 context_input_budget(&self.session.model, TURN_MAX_OUTPUT_TOKENS)
180 {
181 let estimated_input = self.estimated_input_tokens();
182 if estimated_input > input_budget {
183 if context_recovery_attempts >= MAX_CONTEXT_RECOVERY_ATTEMPTS {
184 let message = format!(
185 "Context remains above model limit after {} recovery attempts \
186 (~{} token estimate, ~{} budget). Please run /compact or /clear.",
187 MAX_CONTEXT_RECOVERY_ATTEMPTS, estimated_input, input_budget
188 );
189 turn_error = Some(message.clone());
190 let _ = self
191 .tx_event
192 .send(Event::error(ErrorEnvelope::context_overflow(message)))
193 .await;
194 return (TurnOutcomeStatus::Failed, turn_error);
195 }
196
197 if self
198 .recover_context_overflow(
199 &client,
200 "preflight token budget",
201 TURN_MAX_OUTPUT_TOKENS,
202 )
203 .await
204 {
205 context_recovery_attempts = context_recovery_attempts.saturating_add(1);
206 continue;
207 }
208 }
209 }
210
211 // #136: drain any LSP diagnostics collected since the last
212 // request and inject them as a synthetic user message so the
213 // model sees compile errors before its next reasoning step.
214 self.flush_pending_lsp_diagnostics().await;
215
216 // #159: layered context seam checkpoint. This is opt-in for
217 // v0.7.5 while #200 audits cache-hit behavior; when enabled it
218 // appends <archived_context> blocks rather than replacing history.
219 self.layered_context_checkpoint().await;
220
221 // Build the request
222 let force_update_plan_this_step = force_update_plan_first && turn.tool_calls.is_empty();
223 let active_tools = if tool_catalog.is_empty() {
224 None
225 } else {
226 Some(active_tools_for_step(
227 &tool_catalog,
228 &active_tool_names,
229 force_update_plan_this_step,
230 ))
231 };
232
233 // Resolve `auto` reasoning_effort to a concrete tier (#663).
234 let effective_reasoning_effort = resolve_auto_effort(
235 self.session.reasoning_effort.as_deref(),
236 &self.session.messages,
237 );
238
239 let request = MessageRequest {
240 model: self.session.model.clone(),
241 messages: self.messages_with_turn_metadata(),
242 max_tokens: effective_max_output_tokens(&self.session.model),
243 system: self.session.system_prompt.clone(),
244 tools: active_tools.clone(),
245 tool_choice: if active_tools.is_some() {
246 if self.config.strict_tool_mode {
247 Some(json!("required"))
248 } else {
249 Some(json!({ "type": "auto" }))
250 }
251 } else {
252 None
253 },
254 metadata: None,
255 thinking: None,
256 reasoning_effort: effective_reasoning_effort,
257 stream: Some(true),
258 temperature: None,
259 top_p: None,
260 };
261
262 // Stream the response. Keep the request around (cloned into the
263 // first call) so we can resend it on a transparent retry below
264 // when the wire dies before any content was streamed (#103).
265 let stream_request = request;
266 let stream_result = client.create_message_stream(stream_request.clone()).await;
267 let stream = match stream_result {
268 Ok(s) => {
269 context_recovery_attempts = 0;
270 s
271 }
272 Err(e) => {
273 let message = self.decorate_auth_error_message(e.to_string());
274 if is_context_length_error_message(&message)
275 && context_recovery_attempts < MAX_CONTEXT_RECOVERY_ATTEMPTS
276 && self
277 .recover_context_overflow(
278 &client,
279 "provider context-length rejection",
280 TURN_MAX_OUTPUT_TOKENS,
281 )
282 .await
283 {
284 context_recovery_attempts = context_recovery_attempts.saturating_add(1);
285 continue;
286 }
287 turn_error = Some(message.clone());
288 let _ = self
289 .tx_event
290 .send(Event::error(ErrorEnvelope::classify(message, true)))
291 .await;
292 return (TurnOutcomeStatus::Failed, turn_error);
293 }
294 };
295 // The stream value is itself `Pin<Box<dyn Stream + Send>>`, which
296 // is `Unpin`, so we can rebind it on a transparent retry without
297 // breaking the existing pin invariants.
298 let mut stream = stream;
299
300 // Track content blocks
301 let mut content_blocks: Vec<ContentBlock> = Vec::new();
302 let mut current_text_raw = String::new();
303 let mut current_text_visible = String::new();
304 let mut current_thinking = String::new();
305 let mut tool_uses: Vec<ToolUseState> = Vec::new();
306 let mut usage = Usage {
307 input_tokens: 0,
308 output_tokens: 0,
309 ..Usage::default()
310 };
311 let mut current_block_kind: Option<ContentBlockKind> = None;
312 let mut current_tool_index: Option<usize> = None;
313 let mut in_tool_call_block = false;
314 let mut fake_wrapper_notice_emitted = false;
315 let mut pending_message_complete = false;
316 let mut last_text_index: Option<usize> = None;
317 let mut stream_errors = 0u32;
318 // #103 transparent retry bookkeeping. `any_content_received` flips
319 // on the first non-MessageStart event so we know whether DeepSeek
320 // billed us / the user has seen any output for this turn yet.
321 // This is distinct from the outer `stream_retry_attempts` (which
322 // restarts the whole turn-step when a stream died with no
323 // content-block delta delivered to the consumer).
324 let mut any_content_received = false;
325 let mut transparent_stream_retries = 0u32;
326 let mut pending_steers: Vec<String> = Vec::new();
327 // `stream_start` is reset on a transparent retry so the wall-clock
328 // budget restarts with the fresh stream.
329 let mut stream_start = Instant::now();
330 let mut stream_content_bytes: usize = 0;
331 let chunk_timeout = Duration::from_secs(STREAM_CHUNK_TIMEOUT_SECS);
332 let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS);
333
334 // Process stream events
335 loop {
336 let poll_outcome = tokio::select! {
337 _ = self.cancel_token.cancelled() => None,
338 result = tokio::time::timeout(chunk_timeout, stream.next()) => {
339 match result {
340 Ok(Some(event_result)) => Some(event_result),
341 Ok(None) => None, // stream ended normally
342 Err(_) => {
343 let envelope = StreamError::Stall {
344 timeout_secs: STREAM_CHUNK_TIMEOUT_SECS,
345 }
346 .into_envelope();
347 crate::logging::warn(&envelope.message);
348 let _ = self.tx_event.send(Event::error(envelope)).await;
349 None
350 }
351 }
352 }
353 };
354 let Some(event_result) = poll_outcome else {
355 break;
356 };
357 while let Ok(steer) = self.rx_steer.try_recv() {
358 let steer = steer.trim().to_string();
359 if steer.is_empty() {
360 continue;
361 }
362 pending_steers.push(steer.clone());
363 let _ = self
364 .tx_event
365 .send(Event::status(format!(
366 "Steer input queued: {}",
367 summarize_text(&steer, 120)
368 )))
369 .await;
370 }
371
372 if self.cancel_token.is_cancelled() {
373 break;
374 }
375
376 // Guard: max wall-clock duration
377 if stream_start.elapsed() > max_duration {
378 let envelope = StreamError::DurationLimit {
379 limit_secs: STREAM_MAX_DURATION_SECS,
380 }
381 .into_envelope();
382 crate::logging::warn(&envelope.message);
383 turn_error.get_or_insert(envelope.message.clone());
384 let _ = self.tx_event.send(Event::error(envelope)).await;
385 break;
386 }
387
388 // Guard: max accumulated content bytes
389 if stream_content_bytes > STREAM_MAX_CONTENT_BYTES {
390 let envelope = StreamError::Overflow {
391 limit_bytes: STREAM_MAX_CONTENT_BYTES,
392 }
393 .into_envelope();
394 crate::logging::warn(&envelope.message);
395 turn_error.get_or_insert(envelope.message.clone());
396 let _ = self.tx_event.send(Event::error(envelope)).await;
397 break;
398 }
399
400 let event = match event_result {
401 Ok(e) => {
402 // Flip on the first non-MessageStart event — that's
403 // the moment we cross from "stream not yet productive"
404 // (eligible for transparent retry) into "DeepSeek has
405 // billed us / user has seen output" (must surface).
406 if !any_content_received && !matches!(e, StreamEvent::MessageStart { .. }) {
407 any_content_received = true;
408 }
409 e
410 }
411 Err(e) => {
412 stream_errors = stream_errors.saturating_add(1);
413 let message = self.decorate_auth_error_message(e.to_string());
414 // #103: when the stream errors before any content was
415 // streamed AND we still have retry budget, transparently
416 // resend the request. DeepSeek has not billed for any
417 // output and the user has seen nothing — re-trying is
418 // the right user-visible behavior.
419 if should_transparently_retry_stream(
420 any_content_received,
421 transparent_stream_retries,
422 self.cancel_token.is_cancelled(),
423 ) {
424 transparent_stream_retries =
425 transparent_stream_retries.saturating_add(1);
426 crate::logging::info(format!(
427 "Transparent stream retry {}/{} (no content received yet): {}",
428 transparent_stream_retries, MAX_TRANSPARENT_STREAM_RETRIES, message,
429 ));
430 // Drop the failed stream before issuing the new
431 // request to release the underlying connection.
432 drop(stream);
433 match client.create_message_stream(stream_request.clone()).await {
434 Ok(fresh) => {
435 stream = fresh;
436 stream_start = Instant::now();
437 // Roll back the error counter — this one
438 // didn't surface to the user.
439 stream_errors = stream_errors.saturating_sub(1);
440 continue;
441 }
442 Err(retry_err) => {
443 let retry_msg = self.decorate_auth_error_message(format!(
444 "Stream retry failed: {retry_err}"
445 ));
446 turn_error.get_or_insert(retry_msg.clone());
447 let _ = self
448 .tx_event
449 .send(Event::error(ErrorEnvelope::classify(
450 retry_msg, true,
451 )))
452 .await;
453 break;
454 }
455 }
456 }
457 turn_error.get_or_insert(message.clone());
458 let _ = self
459 .tx_event
460 .send(Event::error(ErrorEnvelope::classify(message, true)))
461 .await;
462 if stream_errors >= MAX_STREAM_ERRORS_BEFORE_FAIL {
463 break;
464 }
465 continue;
466 }
467 };
468
469 match event {
470 StreamEvent::MessageStart { message } => {
471 usage = message.usage;
472 }
473 StreamEvent::ContentBlockStart {
474 index,
475 content_block,
476 } => match content_block {
477 ContentBlockStart::Text { text } => {
478 current_text_raw = text;
479 current_text_visible.clear();
480 in_tool_call_block = false;
481 let filtered =
482 filter_tool_call_delta(&current_text_raw, &mut in_tool_call_block);
483 if !fake_wrapper_notice_emitted
484 && filtered.len() < current_text_raw.len()
485 && contains_fake_tool_wrapper(&current_text_raw)
486 {
487 let _ =
488 self.tx_event.send(Event::status(FAKE_WRAPPER_NOTICE)).await;
489 fake_wrapper_notice_emitted = true;
490 }
491 current_text_visible.push_str(&filtered);
492 current_block_kind = Some(ContentBlockKind::Text);
493 last_text_index = Some(index as usize);
494 let _ = self
495 .tx_event
496 .send(Event::MessageStarted {
497 index: index as usize,
498 })
499 .await;
500 }
501 ContentBlockStart::Thinking { thinking } => {
502 current_thinking = thinking;
503 current_block_kind = Some(ContentBlockKind::Thinking);
504 let _ = self
505 .tx_event
506 .send(Event::ThinkingStarted {
507 index: index as usize,
508 })
509 .await;
510 }
511 ContentBlockStart::ToolUse {
512 id,
513 name,
514 input,
515 caller,
516 } => {
517 crate::logging::info(format!(
518 "Tool '{}' block start. Initial input: {:?}",
519 name, input
520 ));
521 current_block_kind = Some(ContentBlockKind::ToolUse);
522 current_tool_index = Some(tool_uses.len());
523 // ToolCallStarted is deferred to ContentBlockStop —
524 // see `final_tool_input`. Emitting here would ship
525 // the placeholder `{}` and the cell would render
526 // `<command>` / `<file>` literals to the user.
527 tool_uses.push(ToolUseState {
528 id,
529 name,
530 input,
531 caller,
532 input_buffer: String::new(),
533 });
534 }
535 ContentBlockStart::ServerToolUse { id, name, input } => {
536 crate::logging::info(format!(
537 "Server tool '{}' block start. Initial input: {:?}",
538 name, input
539 ));
540 current_block_kind = Some(ContentBlockKind::ToolUse);
541 current_tool_index = Some(tool_uses.len());
542 tool_uses.push(ToolUseState {
543 id,
544 name,
545 input,
546 caller: None,
547 input_buffer: String::new(),
548 });
549 }
550 },
551 StreamEvent::ContentBlockDelta { index, delta } => match delta {
552 Delta::TextDelta { text } => {
553 stream_content_bytes = stream_content_bytes.saturating_add(text.len());
554 current_text_raw.push_str(&text);
555 let filtered = filter_tool_call_delta(&text, &mut in_tool_call_block);
556 if !fake_wrapper_notice_emitted
557 && filtered.len() < text.len()
558 && contains_fake_tool_wrapper(&text)
559 {
560 let _ =
561 self.tx_event.send(Event::status(FAKE_WRAPPER_NOTICE)).await;
562 fake_wrapper_notice_emitted = true;
563 }
564 if !filtered.is_empty() {
565 current_text_visible.push_str(&filtered);
566 let _ = self
567 .tx_event
568 .send(Event::MessageDelta {
569 index: index as usize,
570 content: filtered,
571 })
572 .await;
573 }
574 }
575 Delta::ThinkingDelta { thinking } => {
576 stream_content_bytes =
577 stream_content_bytes.saturating_add(thinking.len());
578 current_thinking.push_str(&thinking);
579 if !thinking.is_empty() {
580 let _ = self
581 .tx_event
582 .send(Event::ThinkingDelta {
583 index: index as usize,
584 content: thinking,
585 })
586 .await;
587 }
588 }
589 Delta::InputJsonDelta { partial_json } => {
590 if let Some(index) = current_tool_index
591 && let Some(tool_state) = tool_uses.get_mut(index)
592 {
593 tool_state.input_buffer.push_str(&partial_json);
594 crate::logging::info(format!(
595 "Tool '{}' input delta: {} (buffer now: {})",
596 tool_state.name, partial_json, tool_state.input_buffer
597 ));
598 if let Some(value) = parse_tool_input(&tool_state.input_buffer) {
599 tool_state.input = value.clone();
600 crate::logging::info(format!(
601 "Tool '{}' input parsed: {:?}",
602 tool_state.name, value
603 ));
604 }
605 }
606 }
607 },
608 StreamEvent::ContentBlockStop { index } => {
609 let stopped_kind = current_block_kind.take();
610 match stopped_kind {
611 Some(ContentBlockKind::Text) => {
612 pending_message_complete = true;
613 last_text_index = Some(index as usize);
614 }
615 Some(ContentBlockKind::Thinking) => {
616 let _ = self
617 .tx_event
618 .send(Event::ThinkingComplete {
619 index: index as usize,
620 })
621 .await;
622 }
623 Some(ContentBlockKind::ToolUse) | None => {}
624 }
625 if matches!(stopped_kind, Some(ContentBlockKind::ToolUse))
626 && let Some(index) = current_tool_index.take()
627 && let Some(tool_state) = tool_uses.get_mut(index)
628 {
629 crate::logging::info(format!(
630 "Tool '{}' block stop. Buffer: '{}', Current input: {:?}",
631 tool_state.name, tool_state.input_buffer, tool_state.input
632 ));
633 if !tool_state.input_buffer.trim().is_empty() {
634 if let Some(value) = parse_tool_input(&tool_state.input_buffer) {
635 tool_state.input = value;
636 crate::logging::info(format!(
637 "Tool '{}' final input: {:?}",
638 tool_state.name, tool_state.input
639 ));
640 } else {
641 crate::logging::warn(format!(
642 "Tool '{}' failed to parse final input buffer: '{}'",
643 tool_state.name, tool_state.input_buffer
644 ));
645 let _ = self
646 .tx_event
647 .send(Event::status(format!(
648 "⚠ Tool '{}' received malformed arguments from model",
649 tool_state.name
650 )))
651 .await;
652 }
653 } else {
654 crate::logging::warn(format!(
655 "Tool '{}' input buffer is empty, using initial input: {:?}",
656 tool_state.name, tool_state.input
657 ));
658 }
659
660 // Now that the input is finalized, announce the
661 // tool call to the UI. Deferring to here is what
662 // keeps the cell from rendering `<command>` /
663 // `<file>` placeholders during the brief window
664 // between block start and the last InputJsonDelta.
665 let _ = self
666 .tx_event
667 .send(Event::ToolCallStarted {
668 id: tool_state.id.clone(),
669 name: tool_state.name.clone(),
670 input: final_tool_input(tool_state),
671 })
672 .await;
673 }
674 }
675 StreamEvent::MessageDelta {
676 usage: delta_usage, ..
677 } => {
678 if let Some(u) = delta_usage {
679 usage = u;
680 }
681 }
682 StreamEvent::MessageStop | StreamEvent::Ping => {}
683 }
684 }
685
686 // #103 Phase 3 — transparent retry. The inner loop above bails
687 // when reqwest yields chunk decode errors three times in a row;
688 // most of the time those are recoverable proxy / HTTP/2 issues
689 // and the request can simply be re-issued. Re-issue silently up
690 // to MAX_STREAM_RETRIES, but only when the stream produced
691 // nothing actionable — if any tool call landed or text was
692 // streamed, ship the partial state to the rest of the turn
693 // pipeline so we don't double-bill the user by re-running it.
694 let stream_died_with_nothing = stream_errors > 0
695 && tool_uses.is_empty()
696 && current_text_visible.trim().is_empty()
697 && current_thinking.trim().is_empty()
698 && !pending_message_complete;
699 if stream_died_with_nothing {
700 if stream_retry_attempts < MAX_STREAM_RETRIES {
701 stream_retry_attempts = stream_retry_attempts.saturating_add(1);
702 crate::logging::warn(format!(
703 "Stream died with no content (attempt {}/{}); retrying request",
704 stream_retry_attempts, MAX_STREAM_RETRIES
705 ));
706 let _ = self
707 .tx_event
708 .send(Event::status(format!(
709 "Connection interrupted; retrying ({}/{})",
710 stream_retry_attempts, MAX_STREAM_RETRIES
711 )))
712 .await;
713 // Don't preserve the per-stream `turn_error` — we're
714 // about to retry, and a successful retry should not
715 // surface the transient error as the turn outcome.
716 turn_error = None;
717 continue;
718 }
719 crate::logging::warn(format!(
720 "Stream retry budget exhausted ({} attempts); failing turn",
721 stream_retry_attempts
722 ));
723 } else if stream_errors == 0 {
724 // Healthy round → reset retry budget so we don't carry over
725 // state from a previous bad round.
726 stream_retry_attempts = 0;
727 }
728
729 // Update turn usage
730 turn.add_usage(&usage);
731
732 // Build content blocks. If this assistant turn produced tool
733 // calls, ensure a Thinking block is present even when the model
734 // didn't stream any reasoning text — DeepSeek's thinking-mode
735 // API requires `reasoning_content` to accompany every tool-call
736 // assistant message in the conversation history. Saving a
737 // placeholder here keeps the on-disk session structurally
738 // correct so subsequent requests won't 400.
739 let needs_thinking_block =
740 !tool_uses.is_empty() || tool_parser::has_tool_call_markers(&current_text_raw);
741 let thinking_to_persist = if !current_thinking.is_empty() {
742 Some(current_thinking.clone())
743 } else if needs_thinking_block {
744 Some(String::from("(reasoning omitted)"))
745 } else {
746 None
747 };
748 if let Some(thinking) = thinking_to_persist {
749 content_blocks.push(ContentBlock::Thinking { thinking });
750 }
751 let mut final_text = current_text_visible.clone();
752 if tool_uses.is_empty() && tool_parser::has_tool_call_markers(&current_text_raw) {
753 let parsed = tool_parser::parse_tool_calls(&current_text_raw);
754 final_text = parsed.clean_text;
755 for call in parsed.tool_calls {
756 let _ = self
757 .tx_event
758 .send(Event::ToolCallStarted {
759 id: call.id.clone(),
760 name: call.name.clone(),
761 input: call.args.clone(),
762 })
763 .await;
764 tool_uses.push(ToolUseState {
765 id: call.id,
766 name: call.name,
767 input: call.args,
768 caller: None,
769 input_buffer: String::new(),
770 });
771 }
772 }
773
774 if !final_text.is_empty() {
775 content_blocks.push(ContentBlock::Text {
776 text: final_text,
777 cache_control: None,
778 });
779 }
780 for tool in &tool_uses {
781 content_blocks.push(ContentBlock::ToolUse {
782 id: tool.id.clone(),
783 name: tool.name.clone(),
784 input: tool.input.clone(),
785 caller: tool.caller.clone(),
786 });
787 }
788
789 if pending_message_complete {
790 let index = last_text_index.unwrap_or(0);
791 let _ = self.tx_event.send(Event::MessageComplete { index }).await;
792 }
793
794 // RLM is a structured tool call (`rlm_query`) handled by the
795 // normal tool dispatch path; inline ```repl blocks (paper §2)
796 // are executed below when tool_uses is empty.
797 // DeepSeek chat API rejects assistant messages that contain only
798 // Keep thinking for UI stream events, but persist only sendable
799 // assistant turns in the conversation state.
800 let has_sendable_assistant_content = content_blocks.iter().any(|block| {
801 matches!(
802 block,
803 ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
804 )
805 });
806
807 // Add assistant message to session
808 if has_sendable_assistant_content {
809 self.add_session_message(Message {
810 role: "assistant".to_string(),
811 content: content_blocks,
812 })
813 .await;
814 }
815
816 // If no tool uses, check for inline REPL blocks (paper §2) or
817 // finish the turn.
818 if tool_uses.is_empty() {
819 if !pending_steers.is_empty() {
820 for steer in pending_steers.drain(..) {
821 self.session
822 .working_set
823 .observe_user_message(&steer, &self.session.workspace);
824 self.add_session_message(Message {
825 role: "user".to_string(),
826 content: vec![ContentBlock::Text {
827 text: steer,
828 cache_control: None,
829 }],
830 })
831 .await;
832 }
833 turn.next_step();
834 continue;
835 }
836
837 // Sub-agent completion handoff (issue #756). The model finished
838 // streaming with no tool calls — but if it has direct children
839 // still running (or completions queued from children that
840 // finished while we were inferring), surface their
841 // `<deepseek:subagent.done>` sentinels into the transcript and
842 // resume instead of ending the turn. This fulfils the contract
843 // already documented in `prompts/base.md`: the parent is
844 // promised it'll see the sentinel when a child finishes.
845 let mut completions: Vec<crate::tools::subagent::SubAgentCompletion> = Vec::new();
846 while let Ok(c) = self.rx_subagent_completion.try_recv() {
847 completions.push(c);
848 }
849 if completions.is_empty() {
850 let running = {
851 let mgr = self.subagent_manager.read().await;
852 mgr.running_count()
853 };
854 if running > 0 {
855 let _ = self
856 .tx_event
857 .send(Event::status(format!(
858 "Waiting on {running} sub-agent(s) to complete..."
859 )))
860 .await;
861 tokio::select! {
862 biased;
863 () = self.cancel_token.cancelled() => {
864 let _ = self
865 .tx_event
866 .send(Event::status(
867 "Request cancelled while waiting for sub-agents",
868 ))
869 .await;
870 return (TurnOutcomeStatus::Interrupted, None);
871 }
872 Some(c) = self.rx_subagent_completion.recv() => {
873 completions.push(c);
874 while let Ok(extra) = self.rx_subagent_completion.try_recv() {
875 completions.push(extra);
876 }
877 }
878 Some(steer) = self.rx_steer.recv() => {
879 let trimmed = steer.trim().to_string();
880 if !trimmed.is_empty() {
881 self.session
882 .working_set
883 .observe_user_message(&trimmed, &self.session.workspace);
884 self.add_session_message(Message {
885 role: "user".to_string(),
886 content: vec![ContentBlock::Text {
887 text: trimmed.clone(),
888 cache_control: None,
889 }],
890 })
891 .await;
892 let _ = self
893 .tx_event
894 .send(Event::status(format!(
895 "Steer input accepted: {}",
896 summarize_text(&trimmed, 120)
897 )))
898 .await;
899 }
900 turn.next_step();
901 continue;
902 }
903 }
904 }
905 }
906 if !completions.is_empty() {
907 let count = completions.len();
908 for c in completions {
909 self.session
910 .working_set
911 .observe_user_message(&c.payload, &self.session.workspace);
912 self.add_session_message(Message {
913 role: "user".to_string(),
914 content: vec![ContentBlock::Text {
915 text: c.payload,
916 cache_control: None,
917 }],
918 })
919 .await;
920 }
921 let _ = self
922 .tx_event
923 .send(Event::status(format!(
924 "Resuming turn with {count} sub-agent completion(s)"
925 )))
926 .await;
927 turn.next_step();
928 continue;
929 }
930
931 // Inline ```repl execution — paper-spec RLM integration.
932 if has_sendable_assistant_content
933 && crate::repl::sandbox::has_repl_block(&current_text_visible)
934 {
935 let repl_blocks =
936 crate::repl::sandbox::extract_repl_blocks(&current_text_visible);
937 let mut runtime = match crate::repl::runtime::PythonRuntime::new().await {
938 Ok(rt) => rt,
939 Err(e) => {
940 let _ = self
941 .tx_event
942 .send(Event::status(format!("REPL init failed: {e}")))
943 .await;
944 break;
945 }
946 };
947
948 let mut final_result: Option<String> = None;
949 for (i, block) in repl_blocks.iter().enumerate() {
950 let round_num = i + 1;
951 let _ = self
952 .tx_event
953 .send(Event::status(format!(
954 "REPL round {round_num}: executing..."
955 )))
956 .await;
957
958 match runtime.execute(&block.code).await {
959 Ok(round) => {
960 if let Some(val) = &round.final_value {
961 let _ = self
962 .tx_event
963 .send(Event::status(format!(
964 "REPL round {round_num}: FINAL result obtained"
965 )))
966 .await;
967 final_result = Some(val.clone());
968 break;
969 }
970
971 // No FINAL — feed truncated stdout back as user metadata.
972 let feedback = if round.has_error {
973 format!(
974 "[REPL round {round_num} error]\nstdout:\n{}\nstderr:\n{}",
975 round.stdout, round.stderr
976 )
977 } else {
978 format!("[REPL round {round_num} output]\n{}", round.stdout)
979 };
980 self.add_session_message(Message {
981 role: "user".to_string(),
982 content: vec![ContentBlock::Text {
983 text: feedback,
984 cache_control: None,
985 }],
986 })
987 .await;
988 }
989 Err(e) => {
990 let _ = self
991 .tx_event
992 .send(Event::status(format!(
993 "REPL round {round_num} failed: {e}"
994 )))
995 .await;
996 self.add_session_message(Message {
997 role: "user".to_string(),
998 content: vec![ContentBlock::Text {
999 text: format!(
1000 "[REPL round {round_num} execution failed]\n{e}"
1001 ),
1002 cache_control: None,
1003 }],
1004 })
1005 .await;
1006 }
1007 }
1008 }
1009
1010 if let Some(final_val) = final_result {
1011 // Replace the assistant's text with the FINAL answer.
1012 if let Some(last_msg) = self.session.messages.last_mut()
1013 && last_msg.role == "assistant"
1014 {
1015 for block in &mut last_msg.content {
1016 if let ContentBlock::Text { text, .. } = block {
1017 *text = final_val;
1018 break;
1019 }
1020 }
1021 }
1022 self.emit_session_updated().await;
1023 break;
1024 }
1025
1026 // No FINAL — let the model iterate with the feedback.
1027 turn.next_step();
1028 continue;
1029 }
1030
1031 break;
1032 }
1033
1034 // Execute tools
1035 let tool_exec_lock = self.tool_exec_lock.clone();
1036 let mcp_pool = if tool_uses
1037 .iter()
1038 .any(|tool| McpPool::is_mcp_tool(&tool.name))
1039 {
1040 match self.ensure_mcp_pool().await {
1041 Ok(pool) => Some(pool),
1042 Err(err) => {
1043 let _ = self.tx_event.send(Event::status(err.to_string())).await;
1044 None
1045 }
1046 }
1047 } else {
1048 None
1049 };
1050
1051 let mut plans: Vec<ToolExecutionPlan> = Vec::with_capacity(tool_uses.len());
1052 for (index, tool) in tool_uses.iter_mut().enumerate() {
1053 let tool_id = tool.id.clone();
1054 let mut tool_name = tool.name.clone();
1055 let tool_input = tool.input.clone();
1056 let tool_caller = tool.caller.clone();
1057 crate::logging::info(format!(
1058 "Planning tool '{}' with input: {:?}",
1059 tool_name, tool_input
1060 ));
1061
1062 let interactive = (tool_name == "exec_shell"
1063 && tool_input
1064 .get("interactive")
1065 .and_then(serde_json::Value::as_bool)
1066 == Some(true))
1067 || tool_name == REQUEST_USER_INPUT_NAME;
1068
1069 let mut approval_required = false;
1070 let mut approval_description = "Tool execution requires approval".to_string();
1071 let mut supports_parallel = false;
1072 let mut read_only = false;
1073 let mut blocked_error: Option<ToolError> = None;
1074 let mut guard_result: Option<ToolResult> = None;
1075 if maybe_activate_requested_deferred_tool(
1076 &tool_name,
1077 &tool_catalog,
1078 &mut active_tool_names,
1079 ) {
1080 let _ = self
1081 .tx_event
1082 .send(Event::status(format!(
1083 "Auto-loaded deferred tool '{tool_name}' after model request."
1084 )))
1085 .await;
1086 }
1087 let mut tool_def = tool_catalog.iter().find(|def| def.name == tool_name);
1088
1089 // Resolve hallucinated tool names when the model emits a
1090 // non-canonical variant (Read_file, readFile, read-file, etc.).
1091 if tool_def.is_none()
1092 && let Some(registry) = tool_registry
1093 && let Some(canonical) = registry.resolve(&tool_name)
1094 {
1095 crate::logging::info(format!(
1096 "Resolved hallucinated tool name '{}' -> '{}'",
1097 tool_name, canonical
1098 ));
1099 tool_def = tool_catalog.iter().find(|d| d.name == canonical);
1100 if tool_def.is_some() {
1101 tool_name = canonical.to_string();
1102 // Update the tool_uses entry so the result is
1103 // attributed to the canonical name.
1104 tool.name = tool_name.clone();
1105 // Re-run the deferred-activation check with the
1106 // canonical name.
1107 if maybe_activate_requested_deferred_tool(
1108 &tool_name,
1109 &tool_catalog,
1110 &mut active_tool_names,
1111 ) {
1112 let _ = self
1113 .tx_event
1114 .send(Event::status(format!(
1115 "Auto-loaded deferred tool '{}' after resolving '{}'.",
1116 tool_name, tool_name
1117 )))
1118 .await;
1119 }
1120 }
1121 }
1122
1123 if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) {
1124 blocked_error = Some(ToolError::permission_denied(format!(
1125 "Tool '{tool_name}' does not allow caller '{}'",
1126 caller_type_for_tool_use(tool_caller.as_ref())
1127 )));
1128 }
1129
1130 if blocked_error.is_none()
1131 && tool_def.is_none()
1132 && !McpPool::is_mcp_tool(&tool_name)
1133 && tool_name != CODE_EXECUTION_TOOL_NAME
1134 && !is_tool_search_tool(&tool_name)
1135 {
1136 blocked_error = Some(ToolError::not_available(missing_tool_error_message(
1137 &tool_name,
1138 &tool_catalog,
1139 )));
1140 }
1141
1142 if McpPool::is_mcp_tool(&tool_name) {
1143 read_only = mcp_tool_is_read_only(&tool_name);
1144 supports_parallel = mcp_tool_is_parallel_safe(&tool_name);
1145 approval_required = !read_only;
1146 approval_description = mcp_tool_approval_description(&tool_name);
1147 } else if let Some(registry) = tool_registry
1148 && let Some(spec) = registry.get(&tool_name)
1149 {
1150 approval_required = spec.approval_requirement() != ApprovalRequirement::Auto;
1151 approval_description = spec.description().to_string();
1152 supports_parallel = spec.supports_parallel();
1153 read_only = spec.is_read_only();
1154 } else if tool_name == CODE_EXECUTION_TOOL_NAME {
1155 approval_required = true;
1156 approval_description =
1157 "Run model-provided Python code in local execution sandbox".to_string();
1158 supports_parallel = false;
1159 read_only = false;
1160 } else if is_tool_search_tool(&tool_name) {
1161 approval_required = false;
1162 approval_description = "Search tool catalog".to_string();
1163 supports_parallel = false;
1164 read_only = true;
1165 }
1166
1167 if blocked_error.is_none()
1168 && let AttemptDecision::Block(message) =
1169 loop_guard.record_attempt(&tool_name, &tool_input)
1170 {
1171 crate::logging::warn(message.clone());
1172 guard_result = Some(
1173 ToolResult::success(message)
1174 .with_metadata(json!({"loop_guard": "identical_tool_call"})),
1175 );
1176 }
1177
1178 plans.push(ToolExecutionPlan {
1179 index,
1180 id: tool_id,
1181 name: tool_name,
1182 input: tool_input,
1183 caller: tool_caller,
1184 interactive,
1185 approval_required,
1186 approval_description,
1187 supports_parallel,
1188 read_only,
1189 blocked_error,
1190 guard_result,
1191 });
1192 }
1193
1194 let parallel_allowed = should_parallelize_tool_batch(&plans);
1195 if parallel_allowed && plans.len() > 1 {
1196 let _ = self
1197 .tx_event
1198 .send(Event::status(format!(
1199 "Executing {} read-only tools in parallel",
1200 plans.len()
1201 )))
1202 .await;
1203 } else if plans.len() > 1 {
1204 let _ = self
1205 .tx_event
1206 .send(Event::status(
1207 "Executing tools sequentially (writes, approvals, or non-parallel tools detected)",
1208 ))
1209 .await;
1210 }
1211
1212 let mut outcomes: Vec<Option<ToolExecOutcome>> = Vec::with_capacity(plans.len());
1213 outcomes.resize_with(plans.len(), || None);
1214
1215 if parallel_allowed {
1216 let mut tool_tasks = FuturesUnordered::new();
1217 for plan in plans {
1218 if let Some(result) = plan.guard_result.clone() {
1219 let result = Ok(result);
1220 let _ = self
1221 .tx_event
1222 .send(Event::ToolCallComplete {
1223 id: plan.id.clone(),
1224 name: plan.name.clone(),
1225 result: result.clone(),
1226 })
1227 .await;
1228 outcomes[plan.index] = Some(ToolExecOutcome {
1229 index: plan.index,
1230 id: plan.id,
1231 name: plan.name,
1232 input: plan.input,
1233 started_at: Instant::now(),
1234 result,
1235 });
1236 continue;
1237 }
1238 if let Some(err) = plan.blocked_error.clone() {
1239 outcomes[plan.index] = Some(ToolExecOutcome {
1240 index: plan.index,
1241 id: plan.id,
1242 name: plan.name,
1243 input: plan.input,
1244 started_at: Instant::now(),
1245 result: Err(err),
1246 });
1247 continue;
1248 }
1249 let registry = tool_registry;
1250 let lock = tool_exec_lock.clone();
1251 let mcp_pool = mcp_pool.clone();
1252 let tx_event = self.tx_event.clone();
1253 let started_at = Instant::now();
1254
1255 tool_tasks.push(async move {
1256 let mut result = Engine::execute_tool_with_lock(
1257 lock,
1258 plan.supports_parallel,
1259 plan.interactive,
1260 tx_event.clone(),
1261 plan.name.clone(),
1262 plan.input.clone(),
1263 registry,
1264 mcp_pool,
1265 None,
1266 )
1267 .await;
1268
1269 // #500: spill outsized output before fanout (mirror
1270 // of the sequential path below). Emit a
1271 // `tool.spillover` audit event so operators can
1272 // correlate large-output episodes with disk usage.
1273 if let Ok(tool_result) = result.as_mut()
1274 && let Some(path) =
1275 crate::tools::truncate::apply_spillover(tool_result, &plan.id)
1276 {
1277 emit_tool_audit(json!({
1278 "event": "tool.spillover",
1279 "tool_id": plan.id.clone(),
1280 "tool_name": plan.name.clone(),
1281 "path": path.display().to_string(),
1282 }));
1283 }
1284
1285 let _ = tx_event
1286 .send(Event::ToolCallComplete {
1287 id: plan.id.clone(),
1288 name: plan.name.clone(),
1289 result: result.clone(),
1290 })
1291 .await;
1292
1293 ToolExecOutcome {
1294 index: plan.index,
1295 id: plan.id,
1296 name: plan.name,
1297 input: plan.input,
1298 started_at,
1299 result,
1300 }
1301 });
1302 }
1303
1304 while let Some(outcome) = tool_tasks.next().await {
1305 let index = outcome.index;
1306 outcomes[index] = Some(outcome);
1307 }
1308 } else {
1309 for plan in plans {
1310 let tool_id = plan.id.clone();
1311 let tool_name = plan.name.clone();
1312 let tool_input = plan.input.clone();
1313 let tool_caller = plan.caller.clone();
1314
1315 if let Some(result) = plan.guard_result.clone() {
1316 let result = Ok(result);
1317 let _ = self
1318 .tx_event
1319 .send(Event::ToolCallComplete {
1320 id: tool_id.clone(),
1321 name: tool_name.clone(),
1322 result: result.clone(),
1323 })
1324 .await;
1325 outcomes[plan.index] = Some(ToolExecOutcome {
1326 index: plan.index,
1327 id: tool_id,
1328 name: tool_name,
1329 input: tool_input,
1330 started_at: Instant::now(),
1331 result,
1332 });
1333 continue;
1334 }
1335
1336 if let Some(err) = plan.blocked_error.clone() {
1337 let result = Err(err);
1338 let _ = self
1339 .tx_event
1340 .send(Event::ToolCallComplete {
1341 id: tool_id.clone(),
1342 name: tool_name.clone(),
1343 result: result.clone(),
1344 })
1345 .await;
1346 outcomes[plan.index] = Some(ToolExecOutcome {
1347 index: plan.index,
1348 id: tool_id,
1349 name: tool_name,
1350 input: tool_input,
1351 started_at: Instant::now(),
1352 result,
1353 });
1354 continue;
1355 }
1356
1357 if tool_name == MULTI_TOOL_PARALLEL_NAME {
1358 let started_at = Instant::now();
1359 let result = self
1360 .execute_parallel_tool(
1361 tool_input.clone(),
1362 tool_registry,
1363 tool_exec_lock.clone(),
1364 )
1365 .await;
1366
1367 let _ = self
1368 .tx_event
1369 .send(Event::ToolCallComplete {
1370 id: tool_id.clone(),
1371 name: tool_name.clone(),
1372 result: result.clone(),
1373 })
1374 .await;
1375
1376 outcomes[plan.index] = Some(ToolExecOutcome {
1377 index: plan.index,
1378 id: tool_id,
1379 name: tool_name,
1380 input: tool_input,
1381 started_at,
1382 result,
1383 });
1384 continue;
1385 }
1386
1387 if tool_name == CODE_EXECUTION_TOOL_NAME {
1388 let started_at = Instant::now();
1389 let result =
1390 execute_code_execution_tool(&tool_input, &self.session.workspace).await;
1391
1392 let _ = self
1393 .tx_event
1394 .send(Event::ToolCallComplete {
1395 id: tool_id.clone(),
1396 name: tool_name.clone(),
1397 result: result.clone(),
1398 })
1399 .await;
1400
1401 outcomes[plan.index] = Some(ToolExecOutcome {
1402 index: plan.index,
1403 id: tool_id,
1404 name: tool_name,
1405 input: tool_input,
1406 started_at,
1407 result,
1408 });
1409 continue;
1410 }
1411
1412 if is_tool_search_tool(&tool_name) {
1413 let started_at = Instant::now();
1414 let result = execute_tool_search(
1415 &tool_name,
1416 &tool_input,
1417 &tool_catalog,
1418 &mut active_tool_names,
1419 );
1420
1421 let _ = self
1422 .tx_event
1423 .send(Event::ToolCallComplete {
1424 id: tool_id.clone(),
1425 name: tool_name.clone(),
1426 result: result.clone(),
1427 })
1428 .await;
1429
1430 outcomes[plan.index] = Some(ToolExecOutcome {
1431 index: plan.index,
1432 id: tool_id,
1433 name: tool_name,
1434 input: tool_input,
1435 started_at,
1436 result,
1437 });
1438 continue;
1439 }
1440
1441 if tool_name == REQUEST_USER_INPUT_NAME {
1442 let started_at = Instant::now();
1443 let result = match UserInputRequest::from_value(&tool_input) {
1444 Ok(request) => self.await_user_input(&tool_id, request).await.and_then(
1445 |response| {
1446 ToolResult::json(&response)
1447 .map_err(|e| ToolError::execution_failed(e.to_string()))
1448 },
1449 ),
1450 Err(err) => Err(err),
1451 };
1452
1453 let _ = self
1454 .tx_event
1455 .send(Event::ToolCallComplete {
1456 id: tool_id.clone(),
1457 name: tool_name.clone(),
1458 result: result.clone(),
1459 })
1460 .await;
1461
1462 outcomes[plan.index] = Some(ToolExecOutcome {
1463 index: plan.index,
1464 id: tool_id,
1465 name: tool_name,
1466 input: tool_input,
1467 started_at,
1468 result,
1469 });
1470 continue;
1471 }
1472
1473 // Handle approval flow: returns (result_override, context_override)
1474 let (result_override, context_override): (
1475 Option<Result<ToolResult, ToolError>>,
1476 Option<crate::tools::ToolContext>,
1477 ) = if plan.approval_required {
1478 emit_tool_audit(json!({
1479 "event": "tool.approval_required",
1480 "tool_id": tool_id.clone(),
1481 "tool_name": tool_name.clone(),
1482 }));
1483 let approval_key = crate::tools::approval_cache::build_approval_key(
1484 &tool_name,
1485 &tool_input,
1486 )
1487 .0;
1488 let _ = self
1489 .tx_event
1490 .send(Event::ApprovalRequired {
1491 id: tool_id.clone(),
1492 tool_name: tool_name.clone(),
1493 description: plan.approval_description.clone(),
1494 approval_key,
1495 })
1496 .await;
1497
1498 match self.await_tool_approval(&tool_id).await {
1499 Ok(ApprovalResult::Approved) => {
1500 emit_tool_audit(json!({
1501 "event": "tool.approval_decision",
1502 "tool_id": tool_id.clone(),
1503 "tool_name": tool_name.clone(),
1504 "decision": "approved",
1505 "caller": caller_type_for_tool_use(tool_caller.as_ref()),
1506 }));
1507 (None, None)
1508 }
1509 Ok(ApprovalResult::Denied) => {
1510 emit_tool_audit(json!({
1511 "event": "tool.approval_decision",
1512 "tool_id": tool_id.clone(),
1513 "tool_name": tool_name.clone(),
1514 "decision": "denied",
1515 "caller": caller_type_for_tool_use(tool_caller.as_ref()),
1516 }));
1517 (
1518 Some(Err(ToolError::permission_denied(format!(
1519 "Tool '{tool_name}' denied by user"
1520 )))),
1521 None,
1522 )
1523 }
1524 Ok(ApprovalResult::RetryWithPolicy(policy)) => {
1525 emit_tool_audit(json!({
1526 "event": "tool.approval_decision",
1527 "tool_id": tool_id.clone(),
1528 "tool_name": tool_name.clone(),
1529 "decision": "retry_with_policy",
1530 "policy": format!("{policy:?}"),
1531 "caller": caller_type_for_tool_use(tool_caller.as_ref()),
1532 }));
1533 let elevated_context = tool_registry.map(|r| {
1534 r.context().clone().with_elevated_sandbox_policy(policy)
1535 });
1536 (None, elevated_context)
1537 }
1538 Err(err) => (Some(Err(err)), None),
1539 }
1540 } else {
1541 (None, None)
1542 };
1543
1544 // Per-tool snapshot for surgical undo (#384): capture workspace
1545 // state before file-modifying tools execute so `/undo` can
1546 // revert the most recent write_file/edit_file/apply_patch.
1547 if result_override.is_none()
1548 && matches!(
1549 tool_name.as_str(),
1550 "write_file" | "edit_file" | "apply_patch"
1551 )
1552 {
1553 let ws = self.session.workspace.clone();
1554 let tid = tool_id.clone();
1555 let _ = tokio::task::spawn_blocking(move || {
1556 crate::core::turn::pre_tool_snapshot(&ws, &tid)
1557 })
1558 .await;
1559 }
1560
1561 let started_at = Instant::now();
1562 let mut result = if let Some(result_override) = result_override {
1563 result_override
1564 } else {
1565 Self::execute_tool_with_lock(
1566 tool_exec_lock.clone(),
1567 plan.supports_parallel,
1568 plan.interactive,
1569 self.tx_event.clone(),
1570 tool_name.clone(),
1571 tool_input.clone(),
1572 tool_registry,
1573 mcp_pool.clone(),
1574 context_override,
1575 )
1576 .await
1577 };
1578
1579 // #500: spill outsized tool outputs to disk before the
1580 // result fans out to the model context and the UI cell.
1581 // Both consumers see the same truncated content + the
1582 // `spillover_path` metadata pointing at the full file.
1583 // Emit a discrete `tool.spillover` audit event so
1584 // operators can correlate large-output episodes with
1585 // disk-usage growth in `~/.deepseek/tool_outputs/`.
1586 if let Ok(tool_result) = result.as_mut()
1587 && let Some(path) =
1588 crate::tools::truncate::apply_spillover(tool_result, &tool_id)
1589 {
1590 emit_tool_audit(json!({
1591 "event": "tool.spillover",
1592 "tool_id": tool_id.clone(),
1593 "tool_name": tool_name.clone(),
1594 "path": path.display().to_string(),
1595 }));
1596 }
1597
1598 let _ = self
1599 .tx_event
1600 .send(Event::ToolCallComplete {
1601 id: tool_id.clone(),
1602 name: tool_name.clone(),
1603 result: result.clone(),
1604 })
1605 .await;
1606
1607 outcomes[plan.index] = Some(ToolExecOutcome {
1608 index: plan.index,
1609 id: tool_id,
1610 name: tool_name,
1611 input: tool_input,
1612 started_at,
1613 result,
1614 });
1615 }
1616 }
1617
1618 let mut step_error_count = 0usize;
1619 // Categorized tool errors collected this step. Feeds the capacity
1620 // controller's error-escalation checkpoint so it can distinguish
1621 // (e.g.) a Tool failure that should escalate from a permission
1622 // denial that should not.
1623 let mut step_error_categories: Vec<ErrorCategory> = Vec::new();
1624 let mut stop_after_plan_tool = false;
1625 let mut loop_guard_halt: Option<String> = None;
1626
1627 for outcome in outcomes.into_iter().flatten() {
1628 let duration = outcome.started_at.elapsed();
1629 let tool_input = outcome.input.clone();
1630 let tool_name_for_ws = outcome.name.clone();
1631 let mut tool_call =
1632 TurnToolCall::new(outcome.id.clone(), outcome.name.clone(), outcome.input);
1633 let should_stop_this_turn =
1634 should_stop_after_plan_tool(mode, &outcome.name, &outcome.result);
1635
1636 match outcome.result {
1637 Ok(output) => {
1638 match loop_guard.record_outcome(&outcome.name, output.success) {
1639 OutcomeDecision::Continue => {}
1640 OutcomeDecision::Warn(message) => {
1641 crate::logging::warn(message.clone());
1642 let _ = self.tx_event.send(Event::status(message)).await;
1643 }
1644 OutcomeDecision::Halt(message) => {
1645 loop_guard_halt.get_or_insert(message);
1646 }
1647 }
1648 emit_tool_audit(json!({
1649 "event": "tool.result",
1650 "tool_id": outcome.id.clone(),
1651 "tool_name": outcome.name.clone(),
1652 "success": output.success,
1653 }));
1654 let output_for_context = compact_tool_result_for_context(
1655 &self.session.model,
1656 &outcome.name,
1657 &output,
1658 );
1659 let output_content = output.content;
1660
1661 tool_call.set_result(output_content.clone(), duration);
1662 self.session.working_set.observe_tool_call(
1663 &tool_name_for_ws,
1664 &tool_input,
1665 Some(&output_for_context),
1666 &self.session.workspace,
1667 );
1668
1669 // #136: post-edit LSP diagnostics hook. We only run
1670 // this on success — failed edits leave the file
1671 // untouched, so polling for diagnostics would just
1672 // surface stale state.
1673 if output.success {
1674 self.run_post_edit_lsp_hook(&outcome.name, &tool_input)
1675 .await;
1676 }
1677
1678 self.add_session_message(Message {
1679 role: "user".to_string(),
1680 content: vec![ContentBlock::ToolResult {
1681 tool_use_id: outcome.id,
1682 content: output_for_context,
1683 is_error: None,
1684 content_blocks: None,
1685 }],
1686 })
1687 .await;
1688 }
1689 Err(e) => {
1690 match loop_guard.record_outcome(&outcome.name, false) {
1691 OutcomeDecision::Continue => {}
1692 OutcomeDecision::Warn(message) => {
1693 crate::logging::warn(message.clone());
1694 let _ = self.tx_event.send(Event::status(message)).await;
1695 }
1696 OutcomeDecision::Halt(message) => {
1697 loop_guard_halt.get_or_insert(message);
1698 }
1699 }
1700 let envelope: ErrorEnvelope = e.clone().into();
1701 emit_tool_audit(json!({
1702 "event": "tool.result",
1703 "tool_id": outcome.id.clone(),
1704 "tool_name": outcome.name.clone(),
1705 "success": false,
1706 "error": e.to_string(),
1707 "category": envelope.category.to_string(),
1708 "severity": envelope.severity.to_string(),
1709 }));
1710 step_error_count += 1;
1711 step_error_categories.push(envelope.category);
1712 let error = format_tool_error(&e, &outcome.name);
1713 tool_call.set_error(error.clone(), duration);
1714 self.session.working_set.observe_tool_call(
1715 &tool_name_for_ws,
1716 &tool_input,
1717 Some(&error),
1718 &self.session.workspace,
1719 );
1720 self.add_session_message(Message {
1721 role: "user".to_string(),
1722 content: vec![ContentBlock::ToolResult {
1723 tool_use_id: outcome.id,
1724 content: format!("Error: {error}"),
1725 is_error: Some(true),
1726 content_blocks: None,
1727 }],
1728 })
1729 .await;
1730 }
1731 }
1732
1733 turn.record_tool_call(tool_call);
1734 stop_after_plan_tool |= should_stop_this_turn;
1735 }
1736
1737 if stop_after_plan_tool {
1738 break;
1739 }
1740
1741 if let Some(message) = loop_guard_halt {
1742 crate::logging::warn(message.clone());
1743 let _ = self.tx_event.send(Event::status(message)).await;
1744 break;
1745 }
1746
1747 if self
1748 .run_capacity_post_tool_checkpoint(
1749 turn,
1750 mode,
1751 tool_registry,
1752 tool_exec_lock.clone(),
1753 mcp_pool.clone(),
1754 step_error_count,
1755 consecutive_tool_error_steps,
1756 )
1757 .await
1758 {
1759 turn.next_step();
1760 continue;
1761 }
1762
1763 if !pending_steers.is_empty() {
1764 for steer in pending_steers.drain(..) {
1765 self.session
1766 .working_set
1767 .observe_user_message(&steer, &self.session.workspace);
1768 self.add_session_message(Message {
1769 role: "user".to_string(),
1770 content: vec![ContentBlock::Text {
1771 text: steer,
1772 cache_control: None,
1773 }],
1774 })
1775 .await;
1776 }
1777 }
1778
1779 if step_error_count > 0 {
1780 consecutive_tool_error_steps = consecutive_tool_error_steps.saturating_add(1);
1781 } else {
1782 consecutive_tool_error_steps = 0;
1783 }
1784
1785 if self
1786 .run_capacity_error_escalation_checkpoint(
1787 turn,
1788 mode,
1789 step_error_count,
1790 consecutive_tool_error_steps,
1791 &step_error_categories,
1792 )
1793 .await
1794 {
1795 turn.next_step();
1796 continue;
1797 }
1798
1799 turn.next_step();
1800 }
1801
1802 if self.cancel_token.is_cancelled() {
1803 return (TurnOutcomeStatus::Interrupted, None);
1804 }
1805 if let Some(err) = turn_error {
1806 return (TurnOutcomeStatus::Failed, Some(err));
1807 }
1808 (TurnOutcomeStatus::Completed, None)
1809 }
1810
1811 pub(super) fn messages_with_turn_metadata(&self) -> Vec<Message> {
1812 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1813 let working_set_summary = self
1814 .session
1815 .working_set
1816 .summary_block(&self.config.workspace)
1817 .map(|s| s.trim().to_string())
1818 .filter(|s| !s.is_empty());
1819
1820 let summary = if let Some(working_set_summary) = working_set_summary {
1821 format!("Current local date: {today}\n{working_set_summary}")
1822 } else {
1823 format!("Current local date: {today}")
1824 };
1825
1826 let mut messages = self.session.messages.clone();
1827 // v0.8.11 hotfix: tool-result messages are stored as role="user" in
1828 // our internal representation but serialize to role="tool" on the
1829 // wire. Prepending a Text block onto a tool-result message breaks
1830 // the assistant→tool_result invariant — the API rejects the request
1831 // with `"insufficient tool messages following tool_calls"`. Inject
1832 // only into actual user-typed messages, recognizable by having at
1833 // least one Text content block (and no ToolResult blocks).
1834 let Some(last_user) = messages.iter_mut().rev().find(|message| {
1835 message.role == "user"
1836 && message
1837 .content
1838 .iter()
1839 .all(|block| !matches!(block, ContentBlock::ToolResult { .. }))
1840 && message
1841 .content
1842 .iter()
1843 .any(|block| matches!(block, ContentBlock::Text { .. }))
1844 }) else {
1845 // No real user message in the trailing slice (e.g. mid-turn
1846 // after a tool call). Skip injection — the working_set will
1847 // surface again on the next genuine user prompt.
1848 return messages;
1849 };
1850
1851 let turn_meta = format!("<turn_meta>\n{summary}\n</turn_meta>");
1852 last_user.content.insert(
1853 0,
1854 ContentBlock::Text {
1855 text: turn_meta,
1856 cache_control: None,
1857 },
1858 );
1859 messages
1860 }
1861 }
1862
1863 /// Resolve an `"auto"` reasoning-effort tier to a concrete value.
1864 ///
1865 /// When the configured effort is `"auto"`, inspects the last user message
1866 /// and calls [`crate::auto_reasoning::select`] to pick the actual tier.
1867 /// Non-`"auto"` values pass through unchanged.
1868 fn resolve_auto_effort(reasoning_effort: Option<&str>, messages: &[Message]) -> Option<String> {
1869 match reasoning_effort {
1870 Some("auto") => {
1871 // Find the last user message in the conversation.
1872 let last_msg = messages
1873 .iter()
1874 .rev()
1875 .find(|m| m.role == "user")
1876 .map(|m| {
1877 m.content
1878 .iter()
1879 .filter_map(|block| {
1880 if let ContentBlock::Text { text, .. } = block {
1881 Some(text.as_str())
1882 } else {
1883 None
1884 }
1885 })
1886 .collect::<Vec<&str>>()
1887 .join(" ")
1888 })
1889 .unwrap_or_default();
1890
1891 // is_subagent is false here — handle_deepseek_turn runs in the
1892 // main engine (not a sub-agent's inner loop). Sub-agents have
1893 // their own turn pass and can pass is_subagent=true when they
1894 // call this function directly.
1895 let tier = crate::auto_reasoning::select(false, &last_msg);
1896 let resolved = tier.as_setting().to_string();
1897 tracing::debug!(
1898 reasoning_effort = %resolved,
1899 is_subagent = false,
1900 "auto_reasoning: resolved auto tier from user message"
1901 );
1902 Some(resolved)
1903 }
1904 Some(other) => Some(other.to_string()),
1905 None => None,
1906 }
1907 }
1908
1908 lines RUST