Skip to main content

recursive/
run_core.rs

1//! Stateless execution kernel — `RunCore<'a>` and `RunInnerOutcome`.
2//!
3//! Extracted from `src/agent.rs` (Goal 213).  `RunCore` owns all the state
4//! needed for one ReAct loop iteration and is consumed by `run_inner()`.
5//! The containing `AgentKernel` constructs a `RunCore`, calls `run_inner()`,
6//! and integrates the returned `RunInnerOutcome`.
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use tokio::sync::mpsc;
12use tokio_util::sync::CancellationToken;
13use tracing::{debug, warn};
14
15/// Maximum number of automatic retries for retryable LLM errors (rate limits, timeouts).
16const LLM_MAX_RETRIES: u32 = 3;
17/// Base delay for exponential back-off on LLM retries (milliseconds).
18const LLM_RETRY_BASE_MS: u64 = 1_000;
19
20use crate::compact::Compactor;
21use crate::error::{Error, Result};
22use crate::hooks::{HookAction, HookEvent, HookRegistry};
23use crate::llm::{Completion, LlmProvider, StreamSender, TokenUsage, ToolCall};
24use crate::message::Message;
25use crate::permissions::PermissionMode;
26use crate::tools::ToolRegistry;
27
28use crate::agent::{FinishReason, PermissionDecision, PlanningMode};
29use crate::event::AgentEvent;
30use crate::tools::PermissionHook;
31
32/// Placeholder text used when trimming old tool results to fit the transcript budget.
33pub(crate) const TRIM_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";
34
35/// Threshold for consecutive identical failing tool calls before declaring stuck.
36const STUCK_THRESHOLD: usize = 3;
37
38/// Render a [`FinishReason`] as the `reason` field of [`AgentEvent::TurnFinished`].
39///
40/// The old `From<StepEvent>` bridge used a hard-coded `"finished"` placeholder
41/// (see `event::From<StepEvent>` prior to Goal 219), which made the reason
42/// unobservable downstream. This helper restores per-variant text so SDK and
43/// TUI consumers can distinguish termination causes.
44fn finish_reason_str(reason: &FinishReason) -> String {
45    match reason {
46        FinishReason::NoMoreToolCalls => "no_more_tool_calls".to_string(),
47        FinishReason::BudgetExceeded => "budget_exceeded".to_string(),
48        FinishReason::ProviderStop(s) => format!("provider_stop({s})"),
49        FinishReason::Stuck { .. } => "stuck".to_string(),
50        FinishReason::TranscriptLimit { .. } => "transcript_limit".to_string(),
51        FinishReason::PlanPending => "plan_pending".to_string(),
52        FinishReason::Cancelled => "cancelled".to_string(),
53        FinishReason::PermissionDenialLimit => "permission_denial_limit".to_string(),
54    }
55}
56
57/// Outcome returned by the stateless [`run_inner`] loop.
58pub(crate) struct RunInnerOutcome {
59    pub(crate) messages: Vec<Message>,
60    pub(crate) final_message: Option<String>,
61    pub(crate) finish_reason: FinishReason,
62    pub(crate) total_usage: TokenUsage,
63    pub(crate) total_llm_latency_ms: u64,
64    pub(crate) steps: usize,
65    pub(crate) plan_buffer: Option<Vec<ToolCall>>,
66    pub(crate) plan_confirmed: bool,
67    /// Goal-153: audit metadata for tool results, keyed by `tool_call_id`.
68    /// Only entries for successfully-dispatched tool calls are present;
69    /// the key matches `Message.tool_call_id` on `Role::Tool` messages.
70    pub(crate) tool_audits: std::collections::HashMap<String, crate::tools::AuditMeta>,
71}
72
73/// Private core holding all state needed for one run of the ReAct loop.
74///
75/// Borrows immutable config from the parent [`Agent`]; owns the mutable
76/// transcript and plan state.  `run_inner()` consumes `self` so the
77/// loop cannot accidentally leave stale state behind.
78pub(crate) struct RunCore<'a> {
79    pub(crate) messages: Vec<Message>,
80    pub(crate) llm: Arc<dyn LlmProvider>,
81    pub(crate) tools: Arc<ToolRegistry>,
82    pub(crate) max_steps: usize,
83    pub(crate) max_transcript_chars: Option<usize>,
84    pub(crate) events: Option<mpsc::UnboundedSender<AgentEvent>>,
85    pub(crate) streaming: bool,
86    pub(crate) compactor: Option<Compactor>,
87    pub(crate) permission_hook: Option<Arc<dyn PermissionHook>>,
88    pub(crate) hooks: &'a HookRegistry,
89    pub(crate) planning_mode: PlanningMode,
90    pub(crate) total_llm_latency_ms: u64,
91    pub(crate) plan_buffer: Option<Vec<ToolCall>>,
92    pub(crate) plan_confirmed: bool,
93    /// Goal-165: shared flag set by `EnterPlanModeTool`; blocks write tools
94    /// while the agent is in read-only exploring / planning mode.
95    pub(crate) exploring_plan_mode: Arc<AtomicBool>,
96    #[allow(dead_code)]
97    /// Goal-190: default permission mode for tools not covered by explicit
98    /// config lists. Used to check if a tool requires plan mode.
99    pub(crate) permission_mode: PermissionMode,
100    /// Optional cancellation token. When cancelled, the step loop
101    /// terminates at the next step boundary with
102    /// [`FinishReason::Cancelled`].
103    pub(crate) shutdown_token: Option<CancellationToken>,
104
105    /// Optional mailbox for receiving mid-run messages from a coordinator.
106    ///
107    /// Drained at the start of every step so the agent sees coordinator
108    /// instructions as user-role messages injected into the conversation.
109    pub(crate) mailbox: Option<crate::tools::send_message::WorkerMailbox>,
110}
111
112impl<'a> RunCore<'a> {
113    fn emit(&self, event: AgentEvent) {
114        if let Some(ref tx) = self.events {
115            let _ = tx.send(event);
116        }
117    }
118
119    fn push_message(&mut self, msg: Message) {
120        self.messages.push(msg);
121    }
122
123    /// Call the LLM with exponential back-off retry for retryable errors.
124    ///
125    /// Retries up to `LLM_MAX_RETRIES` times on `RateLimited` or `Timeout`.
126    /// For `RateLimited` with a `retry_after_ms` hint, waits exactly that
127    /// duration; otherwise uses `LLM_RETRY_BASE_MS * 2^attempt`.
128    async fn call_llm_with_retry(
129        &self,
130        specs: &[crate::llm::ToolSpec],
131        stream_sender: Option<crate::llm::StreamSender>,
132        step: usize,
133    ) -> crate::error::Result<Completion> {
134        let mut attempt = 0u32;
135        loop {
136            let result = if let Some(ref tx) = stream_sender {
137                self.llm
138                    .stream(&self.messages, specs, Some(tx.clone()))
139                    .await
140            } else {
141                self.llm.complete(&self.messages, specs).await
142            };
143            match result {
144                Ok(c) => return Ok(c),
145                Err(e) if e.is_retryable() && attempt < LLM_MAX_RETRIES => {
146                    let wait_ms =
147                        if let crate::error::Error::RateLimited { retry_after_ms, .. } = &e {
148                            *retry_after_ms
149                        } else {
150                            LLM_RETRY_BASE_MS << attempt
151                        };
152                    warn!(
153                        step,
154                        attempt,
155                        wait_ms,
156                        error = %e,
157                        "llm retryable error — backing off"
158                    );
159                    tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
160                    attempt += 1;
161                }
162                Err(e) => return Err(e),
163            }
164        }
165    }
166
167    /// Trim old tool results to fit the transcript under `limit` chars.
168    fn maybe_trim_transcript(&mut self, limit: usize, step: usize) {
169        let mut chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
170        if chars < limit {
171            return;
172        }
173
174        let mut trimmed_count: usize = 0;
175        let placeholder_len = TRIM_PLACEHOLDER.len();
176
177        for msg in self.messages.iter_mut().skip(1) {
178            if msg.role == crate::message::Role::Tool && msg.content.len() > 200 {
179                let old_len = msg.content.len();
180                msg.content = TRIM_PLACEHOLDER.to_string();
181                trimmed_count += 1;
182                chars = chars
183                    .saturating_sub(old_len)
184                    .saturating_add(placeholder_len);
185                if chars < limit {
186                    break;
187                }
188            }
189        }
190
191        if trimmed_count > 0 {
192            let note = format!(
193                "[trimmed {} old tool result{} to fit budget]",
194                trimmed_count,
195                if trimmed_count == 1 { "" } else { "s" }
196            );
197            self.emit(AgentEvent::AssistantText { text: note, step });
198        }
199    }
200
201    /// Compact the transcript if a [`Compactor`] is configured and the
202    /// character budget is exceeded.
203    async fn maybe_compact(&mut self, step: usize) -> Result<()> {
204        let compactor = match &self.compactor {
205            Some(c) => c,
206            None => return Ok(()),
207        };
208
209        let chars = Compactor::estimate_chars(&self.messages);
210        if chars < compactor.threshold_chars {
211            return Ok(());
212        }
213
214        let kept_before = self.messages.len();
215        if let Some((removed, summary_chars)) = compactor
216            .apply_to_transcript(self.llm.as_ref(), &mut self.messages)
217            .await?
218        {
219            let kept = kept_before - removed;
220            self.hooks.dispatch(HookEvent::PostCompact {
221                removed,
222                summary_chars,
223            });
224            self.emit(AgentEvent::Compacted {
225                removed,
226                kept,
227                summary_chars,
228                step,
229            });
230        }
231
232        Ok(())
233    }
234
235    /// Execute a set of tool calls, returning `(id, name, output, args)` for each.
236    /// Read-only calls are batched and executed in parallel; write calls run
237    /// sequentially to preserve ordering guarantees.
238    async fn execute_tool_calls(
239        &self,
240        calls: &[ToolCall],
241    ) -> Vec<(
242        String,
243        String,
244        String,
245        serde_json::Value,
246        Option<crate::tools::AuditMeta>,
247    )> {
248        type ResultRow = (
249            String,
250            String,
251            String,
252            serde_json::Value,
253            Option<crate::tools::AuditMeta>,
254        );
255        let mut results: Vec<ResultRow> = Vec::new();
256
257        struct PendingCall {
258            id: String,
259            name: String,
260            args: serde_json::Value,
261        }
262        let mut pending: Vec<PendingCall> = Vec::new();
263
264        for call in calls {
265            // Goal-165: while in agent-driven plan mode, block any write tool
266            // that is not `exit_plan_mode` itself.
267            if self.exploring_plan_mode.load(Ordering::Relaxed)
268                && !self.tools.is_readonly(&call.name)
269                && call.name != "exit_plan_mode"
270            {
271                results.push((
272                    call.id.clone(),
273                    call.name.clone(),
274                    format!(
275                        "ERROR: Cannot execute '{}' in plan mode. \
276                         You are in read-only planning mode. \
277                         Explore freely with read tools, then call \
278                         exit_plan_mode with your plan.",
279                        call.name
280                    ),
281                    call.arguments.clone(),
282                    None,
283                ));
284                continue;
285            }
286
287            // Goal-190: if a tool is in Plan mode and we're not in plan mode,
288            // tell the agent to enter plan mode first.
289            if !self.exploring_plan_mode.load(Ordering::Relaxed)
290                && self.tools.is_plan_mode(&call.name)
291                && call.name != "enter_plan_mode"
292                && call.name != "exit_plan_mode"
293            {
294                results.push((
295                    call.id.clone(),
296                    call.name.clone(),
297                    format!(
298                        "ERROR: Tool '{}' requires plan mode. \
299                         Call enter_plan_mode first to explore and plan, \
300                         then call exit_plan_mode with your plan before using this tool.",
301                        call.name
302                    ),
303                    call.arguments.clone(),
304                    None,
305                ));
306                continue;
307            }
308
309            let effective_args = if let Some(ref hook) = self.permission_hook {
310                match hook.check(&call.name, &call.arguments).await {
311                    PermissionDecision::Allow => call.arguments.clone(),
312                    PermissionDecision::Deny(reason) => {
313                        let result = format!("ERROR: {reason}");
314                        results.push((
315                            call.id.clone(),
316                            call.name.clone(),
317                            result,
318                            call.arguments.clone(),
319                            None,
320                        ));
321                        continue;
322                    }
323                    PermissionDecision::Transform(new_args) => new_args,
324                }
325            } else {
326                call.arguments.clone()
327            };
328
329            let hook_action = self.hooks.dispatch(HookEvent::PreToolCall {
330                name: &call.name,
331                args: &effective_args,
332            });
333            match hook_action {
334                HookAction::Skip => {
335                    let result = "ERROR: tool call skipped by hook".to_string();
336                    results.push((
337                        call.id.clone(),
338                        call.name.clone(),
339                        result,
340                        call.arguments.clone(),
341                        None,
342                    ));
343                    continue;
344                }
345                HookAction::Error(msg) => {
346                    let result = format!("ERROR: {msg}");
347                    results.push((
348                        call.id.clone(),
349                        call.name.clone(),
350                        result,
351                        call.arguments.clone(),
352                        None,
353                    ));
354                    continue;
355                }
356                HookAction::Continue => {}
357            }
358
359            pending.push(PendingCall {
360                id: call.id.clone(),
361                name: call.name.clone(),
362                args: effective_args,
363            });
364        }
365
366        let mut i = 0;
367        while i < pending.len() {
368            if self
369                .tools
370                .is_readonly_for_call(&pending[i].name, &pending[i].args)
371            {
372                let batch_start = i;
373                while i < pending.len()
374                    && self
375                        .tools
376                        .is_readonly_for_call(&pending[i].name, &pending[i].args)
377                {
378                    i += 1;
379                }
380                let batch: Vec<PendingCall> = pending.drain(batch_start..i).collect();
381                i = batch_start;
382
383                let mut join_set = tokio::task::JoinSet::new();
384                for pc in &batch {
385                    let name = pc.name.clone();
386                    let id = pc.id.clone();
387                    let args = pc.args.clone();
388                    let tools = Arc::clone(&self.tools);
389                    join_set.spawn(async move {
390                        let tool_start = std::time::Instant::now();
391                        let dispatch = tools.invoke_with_audit(&name, args.clone()).await;
392                        let result = match dispatch.result {
393                            Ok(output) => output,
394                            Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
395                                "ERROR_DENIAL_LIMIT:".to_string()
396                            }
397                            Err(err) => format!("ERROR: {err}"),
398                        };
399                        let duration_ms = tool_start.elapsed().as_millis() as u64;
400                        (id, name, result, args, dispatch.audit, duration_ms)
401                    });
402                }
403
404                type BatchRow = (
405                    String,
406                    String,
407                    String,
408                    serde_json::Value,
409                    crate::tools::AuditMeta,
410                    u64,
411                );
412                let mut batch_results: Vec<BatchRow> = Vec::new();
413                while let Some(res) = join_set.join_next().await {
414                    match res {
415                        Ok(row) => batch_results.push(row),
416                        Err(e) => {
417                            tracing::error!(target: "recursive::agent", "parallel tool task panicked: {e}");
418                        }
419                    }
420                }
421
422                for pc in &batch {
423                    let Some((_, _, result, _, audit, duration_ms)) = batch_results
424                        .iter()
425                        .find(|(id, _, _, _, _, _)| id == &pc.id)
426                    else {
427                        continue;
428                    };
429                    results.push((
430                        pc.id.clone(),
431                        pc.name.clone(),
432                        result.clone(),
433                        pc.args.clone(),
434                        Some(audit.clone()),
435                    ));
436                    self.hooks.dispatch(HookEvent::PostToolCall {
437                        name: &pc.name,
438                        args: &pc.args,
439                        result,
440                        duration_ms: *duration_ms,
441                    });
442                }
443            } else {
444                let pc = pending.remove(i);
445                let tool_start = std::time::Instant::now();
446                let dispatch = self
447                    .tools
448                    .invoke_with_audit(&pc.name, pc.args.clone())
449                    .await;
450                let result = match dispatch.result {
451                    Ok(output) => output,
452                    Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
453                        "ERROR_DENIAL_LIMIT:".to_string()
454                    }
455                    Err(err) => format!("ERROR: {err}"),
456                };
457                let duration_ms = tool_start.elapsed().as_millis() as u64;
458                results.push((
459                    pc.id.clone(),
460                    pc.name.clone(),
461                    result.clone(),
462                    pc.args.clone(),
463                    Some(dispatch.audit),
464                ));
465                self.hooks.dispatch(HookEvent::PostToolCall {
466                    name: &pc.name,
467                    args: &pc.args,
468                    result: &result,
469                    duration_ms,
470                });
471            }
472        }
473
474        results
475    }
476
477    /// Stateless core ReAct loop.  Consumes `self` and returns a
478    /// [`RunInnerOutcome`] that the wrapper integrates into the parent agent.
479    pub(crate) async fn run_inner(mut self) -> Result<RunInnerOutcome> {
480        let specs = self.tools.specs();
481
482        let mut final_message: Option<String> = None;
483        let mut last_call_key: Option<(String, String)> = None;
484        let mut consecutive_errors: usize = 0;
485        let mut total_usage = TokenUsage::default();
486        // Goal-153: audit metadata for tool calls, keyed by tool_call_id.
487        let mut tool_audits: std::collections::HashMap<String, crate::tools::AuditMeta> =
488            std::collections::HashMap::new();
489        self.total_llm_latency_ms = 0;
490
491        for step in 1..=self.max_steps {
492            let step_span = tracing::info_span!("agent.step", step);
493            let _guard = step_span.enter();
494
495            // ---- shutdown cancellation -------------------------------------------
496            // Termination check, parallel to the BudgetExceeded /
497            // TranscriptLimit blocks below. If a CancellationToken was
498            // configured (via AgentRuntimeBuilder::shutdown_token / etc.)
499            // and it fired between steps, finish cleanly with
500            // FinishReason::Cancelled. Reports `step - 1` because the
501            // current step's LLM call has not been started yet — the
502            // last fully-completed step is the previous one.
503            if let Some(ref token) = self.shutdown_token {
504                if token.is_cancelled() {
505                    let finished_steps = step.saturating_sub(1);
506                    let finish = FinishReason::Cancelled;
507                    self.emit(AgentEvent::TurnFinished {
508                        reason: finish_reason_str(&finish),
509                        steps: finished_steps,
510                    });
511                    tracing::info!(
512                        target: "recursive::agent",
513                        steps = finished_steps,
514                        tokens_in = total_usage.prompt_tokens,
515                        tokens_out = total_usage.completion_tokens,
516                        finish = ?finish,
517                        llm_latency_ms = self.total_llm_latency_ms,
518                        "agent.run.complete"
519                    );
520                    return Ok(RunInnerOutcome {
521                        messages: self.messages,
522                        final_message,
523                        finish_reason: finish,
524                        total_usage,
525                        total_llm_latency_ms: self.total_llm_latency_ms,
526                        steps: finished_steps,
527                        plan_buffer: self.plan_buffer,
528                        plan_confirmed: self.plan_confirmed,
529                        tool_audits,
530                    });
531                }
532            }
533
534            // ---- mailbox drain (coordinator → worker mid-run messages) -----------
535            // Drain any messages that a coordinator pushed via `send_message`.
536            // Each pending message is appended as a user-role turn so the LLM
537            // sees coordinator instructions on the next reasoning step.
538            if let Some(ref mailbox) = self.mailbox {
539                let pending = mailbox.drain_all().await;
540                for msg_text in pending {
541                    self.push_message(Message {
542                        role: crate::message::Role::User,
543                        content: format!("[coordinator]: {msg_text}"),
544                        tool_calls: vec![],
545                        tool_call_id: None,
546                        reasoning_content: None,
547                    });
548                }
549            }
550
551            // ---- transcript budget ------------------------------------------------
552            if let Some(limit) = self.max_transcript_chars {
553                self.maybe_trim_transcript(limit, step);
554                let chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
555                if chars >= limit {
556                    let finish = FinishReason::TranscriptLimit { chars, limit };
557                    self.emit(AgentEvent::TurnFinished {
558                        reason: finish_reason_str(&finish),
559                        steps: step,
560                    });
561                    tracing::info!(
562                        target: "recursive::agent",
563                        steps = step,
564                        tokens_in = total_usage.prompt_tokens,
565                        tokens_out = total_usage.completion_tokens,
566                        finish = ?finish,
567                        llm_latency_ms = self.total_llm_latency_ms,
568                        "agent.run.complete"
569                    );
570                    return Ok(RunInnerOutcome {
571                        messages: self.messages,
572                        final_message,
573                        finish_reason: finish,
574                        total_usage,
575                        total_llm_latency_ms: self.total_llm_latency_ms,
576                        steps: step,
577                        plan_buffer: self.plan_buffer,
578                        plan_confirmed: self.plan_confirmed,
579                        tool_audits,
580                    });
581                }
582            }
583
584            // ---- compaction -------------------------------------------------------
585            self.hooks.dispatch(HookEvent::PreCompact {
586                transcript_len: self.messages.iter().map(|m| m.content.len()).sum(),
587            });
588            self.maybe_compact(step).await?;
589
590            // ---- plan-confirmed execution -----------------------------------------
591            if self.plan_confirmed {
592                self.plan_confirmed = false;
593                if let Some(calls) = self.plan_buffer.take() {
594                    let results = self.execute_tool_calls(&calls).await;
595                    for (id, name, output, _args, _audit) in results {
596                        if output == "ERROR_DENIAL_LIMIT:" {
597                            let finish = FinishReason::PermissionDenialLimit;
598                            self.emit(AgentEvent::TurnFinished {
599                                reason: finish_reason_str(&finish),
600                                steps: step,
601                            });
602                            return Ok(RunInnerOutcome {
603                                messages: self.messages,
604                                final_message,
605                                finish_reason: finish,
606                                total_usage,
607                                total_llm_latency_ms: self.total_llm_latency_ms,
608                                steps: step,
609                                plan_buffer: self.plan_buffer,
610                                plan_confirmed: self.plan_confirmed,
611                                tool_audits,
612                            });
613                        }
614                        self.emit(AgentEvent::ToolResult {
615                            id: id.clone(),
616                            name: name.clone(),
617                            output: output.clone(),
618                            step,
619                        });
620                        self.push_message(Message::tool_result(id, output));
621                    }
622                    continue;
623                }
624            }
625
626            // ---- LLM call (with retry) --------------------------------------------
627            debug!(target: "recursive::agent", step, "calling llm");
628            let start = std::time::Instant::now();
629            let stream_tx: Option<StreamSender> = if self.streaming {
630                let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
631                let events_tx = self.events.clone();
632                tokio::spawn(async move {
633                    while let Some(text) = delta_rx.recv().await {
634                        if let Some(ref tx) = events_tx {
635                            let _ = tx.send(AgentEvent::PartialToken { text, step });
636                        }
637                    }
638                });
639                Some(delta_tx)
640            } else {
641                None
642            };
643            let completion: Completion = self.call_llm_with_retry(&specs, stream_tx, step).await?;
644            let llm_ms = start.elapsed().as_millis() as u64;
645            self.total_llm_latency_ms = self.total_llm_latency_ms.saturating_add(llm_ms);
646            self.emit(AgentEvent::Latency { step, llm_ms });
647
648            if let Some(u) = completion.usage {
649                total_usage = total_usage.accumulate(u);
650                self.emit(AgentEvent::Usage {
651                    input_tokens: u.prompt_tokens,
652                    output_tokens: u.completion_tokens,
653                    step,
654                });
655            }
656
657            // Surface reasoning / thinking content to UI consumers
658            // (TUI) as a separate event so it can be rendered as a
659            // `thinking…` block. Providers that stream reasoning
660            // tokens accumulate them into the final string before
661            // this point; we emit exactly once per step.
662            //
663            // Emit this BEFORE AssistantText so the TUI's
664            // `Reasoning { text }` block lands above the matching
665            // `Assistant { text }` block in the transcript — the
666            // model thinks first, then speaks, and the visual order
667            // should match.
668            if let Some(reasoning) = &completion.reasoning_content {
669                if !reasoning.is_empty() {
670                    self.emit(AgentEvent::Reasoning {
671                        text: reasoning.clone(),
672                        step,
673                    });
674                }
675            }
676
677            if !completion.content.is_empty() {
678                self.emit(AgentEvent::AssistantText {
679                    text: completion.content.clone(),
680                    step,
681                });
682                final_message = Some(completion.content.clone());
683            }
684
685            // ---- no tool calls → finish -------------------------------------------
686            if completion.tool_calls.is_empty() {
687                if matches!(completion.finish_reason.as_deref(), Some("length")) {
688                    let finish = FinishReason::ProviderStop("length".into());
689                    self.emit(AgentEvent::TurnFinished {
690                        reason: finish_reason_str(&finish),
691                        steps: step,
692                    });
693                    return Err(Error::ProviderTruncated("length".into()));
694                }
695
696                self.push_message(Message::assistant(completion.content.clone()));
697                if completion.reasoning_content.is_some() {
698                    if let Some(msg) = self.messages.last_mut() {
699                        msg.reasoning_content = completion.reasoning_content.clone();
700                    }
701                }
702                let finish = match completion.finish_reason {
703                    Some(r) if r != "stop" && r != "end_turn" => FinishReason::ProviderStop(r),
704                    _ => FinishReason::NoMoreToolCalls,
705                };
706                self.emit(AgentEvent::TurnFinished {
707                    reason: finish_reason_str(&finish),
708                    steps: step,
709                });
710                let outcome = RunInnerOutcome {
711                    messages: self.messages,
712                    final_message,
713                    finish_reason: finish,
714                    total_usage,
715                    total_llm_latency_ms: self.total_llm_latency_ms,
716                    steps: step,
717                    plan_buffer: self.plan_buffer,
718                    plan_confirmed: self.plan_confirmed,
719                    tool_audits,
720                };
721                return Ok(outcome);
722            }
723
724            self.push_message(Message::assistant_with_tool_calls(
725                completion.content.clone(),
726                completion.tool_calls.clone(),
727            ));
728            if completion.reasoning_content.is_some() {
729                if let Some(msg) = self.messages.last_mut() {
730                    msg.reasoning_content = completion.reasoning_content.clone();
731                }
732            }
733
734            for call in &completion.tool_calls {
735                self.emit(AgentEvent::ToolCall {
736                    name: call.name.clone(),
737                    id: call.id.clone(),
738                    arguments: call.arguments.to_string(),
739                    step,
740                });
741            }
742
743            // ---- planning mode ----------------------------------------------------
744            if self.planning_mode == PlanningMode::PlanFirst && self.plan_buffer.is_none() {
745                self.plan_buffer = Some(completion.tool_calls.clone());
746
747                let plan_text = completion
748                    .tool_calls
749                    .iter()
750                    .map(|tc| {
751                        let args_str = serde_json::to_string(&tc.arguments).unwrap_or_default();
752                        format!("  - {}({})", tc.name, args_str)
753                    })
754                    .collect::<Vec<_>>()
755                    .join("\n");
756                let plan_text = format!(
757                    "The agent proposes the following steps:\n{}\n\nConfirm or reject this plan.",
758                    plan_text
759                );
760
761                self.emit(AgentEvent::PlanProposed {
762                    plan_text: plan_text.clone(),
763                    tool_calls: completion
764                        .tool_calls
765                        .iter()
766                        .map(|tc| {
767                            serde_json::json!({
768                                "name": tc.name,
769                                "id": tc.id,
770                                "arguments": tc.arguments,
771                            })
772                        })
773                        .collect(),
774                });
775
776                tracing::info!(
777                    target: "recursive::agent",
778                    steps = step,
779                    tokens_in = 0usize,
780                    tokens_out = 0usize,
781                    finish = ?FinishReason::PlanPending,
782                    llm_latency_ms = self.total_llm_latency_ms,
783                    "agent.run.complete"
784                );
785                return Ok(RunInnerOutcome {
786                    messages: self.messages,
787                    final_message: Some(plan_text),
788                    finish_reason: FinishReason::PlanPending,
789                    total_usage: TokenUsage::default(),
790                    total_llm_latency_ms: self.total_llm_latency_ms,
791                    steps: step,
792                    plan_buffer: self.plan_buffer,
793                    plan_confirmed: self.plan_confirmed,
794                    tool_audits,
795                });
796            }
797
798            // ---- tool execution ---------------------------------------------------
799            let results = self.execute_tool_calls(&completion.tool_calls).await;
800
801            for (id, name, result, args, audit) in &results {
802                // B1 fix: auto-classifier denial limit — stop the agent immediately.
803                if result == "ERROR_DENIAL_LIMIT:" {
804                    let finish = FinishReason::PermissionDenialLimit;
805                    self.emit(AgentEvent::TurnFinished {
806                        reason: finish_reason_str(&finish),
807                        steps: step,
808                    });
809                    return Ok(RunInnerOutcome {
810                        messages: self.messages,
811                        final_message,
812                        finish_reason: finish,
813                        total_usage,
814                        total_llm_latency_ms: self.total_llm_latency_ms,
815                        steps: step,
816                        plan_buffer: self.plan_buffer,
817                        plan_confirmed: self.plan_confirmed,
818                        tool_audits,
819                    });
820                }
821
822                self.emit(AgentEvent::ToolResult {
823                    id: id.clone(),
824                    name: name.clone(),
825                    output: result.clone(),
826                    step,
827                });
828                // Goal-153: accumulate audit keyed by tool_call_id.
829                if let Some(a) = audit {
830                    tool_audits.insert(id.clone(), a.clone());
831                }
832
833                let call_key = (
834                    name.clone(),
835                    serde_json::to_string(args).unwrap_or_default(),
836                );
837                let is_error = result.starts_with("ERROR:");
838
839                if is_error {
840                    if last_call_key == Some(call_key.clone()) {
841                        consecutive_errors += 1;
842                    } else {
843                        consecutive_errors = 1;
844                    }
845                } else {
846                    consecutive_errors = 0;
847                }
848
849                last_call_key = Some(call_key);
850
851                if consecutive_errors >= STUCK_THRESHOLD {
852                    let repeated_call = name.clone();
853                    let repeats = consecutive_errors;
854                    let finish = FinishReason::Stuck {
855                        repeated_call,
856                        repeats,
857                    };
858                    self.emit(AgentEvent::TurnFinished {
859                        reason: finish_reason_str(&finish),
860                        steps: step,
861                    });
862                    let outcome = RunInnerOutcome {
863                        messages: self.messages,
864                        final_message,
865                        finish_reason: finish,
866                        total_usage,
867                        total_llm_latency_ms: self.total_llm_latency_ms,
868                        steps: step,
869                        plan_buffer: self.plan_buffer,
870                        plan_confirmed: self.plan_confirmed,
871                        tool_audits,
872                    };
873                    return Ok(outcome);
874                }
875
876                self.push_message(Message::tool_result(id.clone(), result.clone()));
877            }
878        }
879
880        warn!(target: "recursive::agent", "step budget exceeded");
881        let finish = FinishReason::BudgetExceeded;
882        self.emit(AgentEvent::TurnFinished {
883            reason: finish_reason_str(&finish),
884            steps: self.max_steps,
885        });
886        let outcome = RunInnerOutcome {
887            messages: self.messages,
888            final_message,
889            finish_reason: finish,
890            total_usage,
891            total_llm_latency_ms: self.total_llm_latency_ms,
892            steps: self.max_steps,
893            plan_buffer: self.plan_buffer,
894            plan_confirmed: self.plan_confirmed,
895            tool_audits,
896        };
897        Ok(outcome)
898    }
899}