1use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use tokio::sync::mpsc;
12use tokio_util::sync::CancellationToken;
13use tracing::{debug, warn};
14
15const LLM_MAX_RETRIES: u32 = 3;
17const 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
32pub(crate) const TRIM_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";
34
35const STUCK_THRESHOLD: usize = 3;
37
38fn 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
57pub(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 pub(crate) tool_audits: std::collections::HashMap<String, crate::tools::AuditMeta>,
71}
72
73pub(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 pub(crate) exploring_plan_mode: Arc<AtomicBool>,
96 #[allow(dead_code)]
97 pub(crate) permission_mode: PermissionMode,
100 pub(crate) shutdown_token: Option<CancellationToken>,
104
105 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let results = self.execute_tool_calls(&completion.tool_calls).await;
800
801 for (id, name, result, args, audit) in &results {
802 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 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}