Skip to main content

llm_stack/tool/
loop_resumable.rs

1//! Resumable tool loop with caller-controlled iteration.
2//!
3//! Unlike [`tool_loop`](super::tool_loop) which runs to completion autonomously,
4//! the resumable loop yields control to the caller after each tool execution
5//! round. The caller can then decide to continue, inject messages, or stop.
6//!
7//! This enables orchestration patterns like:
8//! - Multi-agent systems where a master inspects tool results between iterations
9//! - External event injection (user follow-ups, worker completions)
10//! - Context compaction between iterations
11//! - Custom routing logic based on which tools were called
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use llm_stack::tool::{ToolLoopConfig, ToolRegistry, ToolLoopHandle, TurnResult, LoopCommand};
17//! use llm_stack::{ChatParams, ChatMessage};
18//!
19//! # async fn example(provider: &dyn llm_stack::DynProvider) -> Result<(), llm_stack::LlmError> {
20//! let registry: ToolRegistry<()> = ToolRegistry::new();
21//! let params = ChatParams {
22//!     messages: vec![ChatMessage::user("Hello")],
23//!     ..Default::default()
24//! };
25//!
26//! let mut handle = ToolLoopHandle::new(
27//!     provider,
28//!     &registry,
29//!     params,
30//!     ToolLoopConfig::default(),
31//!     &(),
32//! );
33//!
34//! loop {
35//!     match handle.next_turn().await {
36//!         TurnResult::Yielded(turn) => {
37//!             // Text from this turn is directly available
38//!             if let Some(text) = turn.assistant_text() {
39//!                 println!("LLM said: {text}");
40//!             }
41//!             // Inspect results, decide what to do
42//!             turn.continue_loop();
43//!         }
44//!         TurnResult::Completed(done) => {
45//!             println!("Done: {:?}", done.response.text());
46//!             break;
47//!         }
48//!         TurnResult::Error(err) => {
49//!             eprintln!("Error: {}", err.error);
50//!             break;
51//!         }
52//!     }
53//! }
54//! # Ok(())
55//! # }
56//! ```
57
58use std::time::Instant;
59
60use crate::chat::{ChatMessage, ChatResponse, ContentBlock, StopReason, ToolCall, ToolResult};
61use crate::error::LlmError;
62use crate::provider::{ChatParams, DynProvider};
63use crate::usage::Usage;
64
65use super::LoopDepth;
66use super::ToolRegistry;
67use super::approval::approve_calls;
68use super::config::{
69    StopContext, StopDecision, TerminationReason, ToolLoopConfig, ToolLoopEvent, ToolLoopResult,
70};
71use super::execution::execute_with_events;
72use super::loop_detection::{IterationSnapshot, LoopDetectionState, handle_loop_detection};
73use super::loop_sync::emit_event;
74
75/// Result of one turn of the tool loop.
76///
77/// Match on this to determine what happened and what you can do next.
78/// Each variant carries the data from the turn AND (for `Yielded`) a handle
79/// scoped to valid operations for that state.
80///
81/// This follows the same pattern as [`std::collections::hash_map::Entry`] —
82/// the variant gives you exactly the methods that make sense for that state.
83#[must_use = "a TurnResult must be matched — Yielded requires resume() to continue"]
84pub enum TurnResult<'a, 'h, Ctx: LoopDepth + Send + Sync + 'static> {
85    /// Tools were executed. The caller MUST consume this via `resume()`,
86    /// `continue_loop()`, `inject_and_continue()`, or `stop()`.
87    ///
88    /// While this variant exists, the `ToolLoopHandle` is mutably borrowed
89    /// and cannot be used directly. Consuming the `Yielded` releases the
90    /// borrow.
91    Yielded(Yielded<'a, 'h, Ctx>),
92
93    /// The loop completed (no tool calls, stop condition, max iterations, or timeout).
94    Completed(Completed),
95
96    /// An unrecoverable error occurred.
97    Error(TurnError),
98}
99
100/// Handle returned when tools were executed. Borrows the [`ToolLoopHandle`]
101/// mutably, so the caller cannot call `next_turn()` again until this is
102/// consumed via `resume()`, `continue_loop()`, `inject_and_continue()`, or
103/// `stop()`.
104///
105/// The text content the LLM produced alongside tool calls is available
106/// directly via [`assistant_content`](Self::assistant_content) and
107/// [`assistant_text()`](Self::assistant_text) — no need to scan
108/// `messages()`.
109#[must_use = "must call .resume(), .continue_loop(), .inject_and_continue(), or .stop() to continue"]
110pub struct Yielded<'a, 'h, Ctx: LoopDepth + Send + Sync + 'static> {
111    handle: &'h mut ToolLoopHandle<'a, Ctx>,
112
113    /// The tool calls the LLM requested.
114    pub tool_calls: Vec<ToolCall>,
115
116    /// Results from executing those tool calls.
117    pub results: Vec<ToolResult>,
118
119    /// Text content from the LLM's response alongside the tool calls.
120    ///
121    /// This is the `other_content` from `partition_content()` — `Text`,
122    /// `Reasoning`, `Image`, etc. — everything that isn't a `ToolCall` or
123    /// `ToolResult`. Previously only accessible by scanning `messages()`.
124    pub assistant_content: Vec<ContentBlock>,
125
126    /// Current iteration number (1-indexed).
127    pub iteration: u32,
128
129    /// Accumulated usage across all iterations so far.
130    pub total_usage: Usage,
131}
132
133impl<Ctx: LoopDepth + Send + Sync + 'static> Yielded<'_, '_, Ctx> {
134    /// Continue with the given command.
135    pub fn resume(self, command: LoopCommand) {
136        self.handle.resume(command);
137    }
138
139    /// Convenience: continue to the next LLM iteration with no injected messages.
140    pub fn continue_loop(self) {
141        self.resume(LoopCommand::Continue);
142    }
143
144    /// Convenience: inject messages and continue.
145    pub fn inject_and_continue(self, messages: Vec<ChatMessage>) {
146        self.resume(LoopCommand::InjectMessages(messages));
147    }
148
149    /// Convenience: stop the loop.
150    pub fn stop(self, reason: Option<String>) {
151        self.resume(LoopCommand::Stop(reason));
152    }
153
154    /// Extract text from `assistant_content` blocks.
155    ///
156    /// Returns the LLM's "thinking aloud" text emitted alongside tool calls,
157    /// or `None` if there were no text blocks.
158    pub fn assistant_text(&self) -> Option<String> {
159        let text: String = self
160            .assistant_content
161            .iter()
162            .filter_map(|block| match block {
163                ContentBlock::Text(t) => Some(t.as_str()),
164                _ => None,
165            })
166            .collect::<Vec<_>>()
167            .join("\n");
168        if text.is_empty() { None } else { Some(text) }
169    }
170
171    /// Access the full message history (read-only).
172    pub fn messages(&self) -> &[ChatMessage] {
173        self.handle.messages()
174    }
175
176    /// Access the full message history (mutable, for context compaction).
177    pub fn messages_mut(&mut self) -> &mut Vec<ChatMessage> {
178        self.handle.messages_mut()
179    }
180}
181
182/// Terminal: the loop completed successfully.
183pub struct Completed {
184    /// The final LLM response. Use `.text()` to get the response text.
185    pub response: ChatResponse,
186    /// Why the loop terminated.
187    pub termination_reason: TerminationReason,
188    /// Total iterations performed.
189    pub iterations: u32,
190    /// Accumulated usage.
191    pub total_usage: Usage,
192}
193
194/// Terminal: the loop errored.
195pub struct TurnError {
196    /// The error.
197    pub error: LlmError,
198    /// Iterations completed before the error.
199    pub iterations: u32,
200    /// Usage accumulated before the error.
201    pub total_usage: Usage,
202}
203
204/// Commands sent by the caller to control the resumable loop.
205///
206/// Passed to [`Yielded::resume()`] after receiving a
207/// [`TurnResult::Yielded`].
208#[derive(Debug)]
209pub enum LoopCommand {
210    /// Continue to the next LLM iteration normally.
211    Continue,
212
213    /// Inject additional messages before the next LLM call.
214    ///
215    /// The injected messages are appended after the tool results from
216    /// the current round. Use this to provide additional context
217    /// (e.g., worker agent results, user follow-ups).
218    InjectMessages(Vec<ChatMessage>),
219
220    /// Stop the loop immediately.
221    ///
222    /// Returns a `Completed` event with `TerminationReason::StopCondition`.
223    Stop(Option<String>),
224}
225
226// ── Internal: owned data from one iteration ──────────────────────────
227
228/// Intermediate result from `do_iteration`. Holds owned data (no borrows on
229/// the handle) so that `next_turn` can construct `TurnResult` with a fresh
230/// `&mut self` afterwards.
231enum IterationOutcome {
232    ToolsExecuted {
233        tool_calls: Vec<ToolCall>,
234        results: Vec<ToolResult>,
235        assistant_content: Vec<ContentBlock>,
236        iteration: u32,
237        total_usage: Usage,
238    },
239    Completed(Completed),
240    Error(TurnError),
241}
242
243/// Caller-driven resumable tool loop.
244///
245/// Unlike [`tool_loop`](super::tool_loop) which runs autonomously, this struct
246/// gives the caller control between each tool execution round. Call
247/// [`next_turn()`](Self::next_turn) to advance the loop, inspect the result,
248/// then consume the [`Yielded`] handle to control what happens next.
249///
250/// # No spawning required
251///
252/// This is a direct state machine — no background tasks, no channels. The
253/// caller drives it by calling `next_turn()` which performs one iteration
254/// (LLM call + tool execution) and returns.
255///
256/// # Lifecycle
257///
258/// 1. Create with [`new()`](Self::new)
259/// 2. Call [`next_turn()`](Self::next_turn) to get the first result
260/// 3. If `Yielded`, consume via `resume()` / `continue_loop()` / etc., then
261///    call `next_turn()` again
262/// 4. Repeat until `Completed` or `Error`
263/// 5. Optionally call [`into_result()`](Self::into_result) for a `ToolLoopResult`
264pub struct ToolLoopHandle<'a, Ctx: LoopDepth + Send + Sync + 'static> {
265    provider: &'a dyn DynProvider,
266    registry: &'a ToolRegistry<Ctx>,
267    params: ChatParams,
268    config: ToolLoopConfig,
269    nested_ctx: Ctx,
270    // Loop state
271    total_usage: Usage,
272    iterations: u32,
273    tool_calls_executed: usize,
274    last_tool_results: Vec<ToolResult>,
275    loop_state: LoopDetectionState,
276    start_time: Instant,
277    // Whether we've finished (terminal event returned)
278    finished: bool,
279    // Pending command from the caller (set by resume())
280    pending_command: Option<LoopCommand>,
281    // Cached final result
282    final_result: Option<ToolLoopResult>,
283    // Depth error to return on first next_turn() call
284    depth_error: Option<LlmError>,
285}
286
287impl<'a, Ctx: LoopDepth + Send + Sync + 'static> ToolLoopHandle<'a, Ctx> {
288    /// Create a new resumable tool loop.
289    ///
290    /// Does not start execution — call [`next_turn()`](Self::next_turn) to
291    /// begin the first iteration.
292    ///
293    /// # Depth Tracking
294    ///
295    /// Same as [`tool_loop`](super::tool_loop) — if `Ctx` implements [`LoopDepth`],
296    /// nested calls are tracked and `max_depth` is enforced. If the depth limit
297    /// is already exceeded, the first call to `next_turn()` returns `Error`.
298    pub fn new(
299        provider: &'a dyn DynProvider,
300        registry: &'a ToolRegistry<Ctx>,
301        params: ChatParams,
302        config: ToolLoopConfig,
303        ctx: &Ctx,
304    ) -> Self {
305        let current_depth = ctx.loop_depth();
306        let depth_error = config.max_depth.and_then(|max_depth| {
307            if current_depth >= max_depth {
308                Some(LlmError::MaxDepthExceeded {
309                    current: current_depth,
310                    limit: max_depth,
311                })
312            } else {
313                None
314            }
315        });
316
317        let nested_ctx = ctx.with_depth(current_depth + 1);
318
319        Self {
320            provider,
321            registry,
322            params,
323            config,
324            nested_ctx,
325            total_usage: Usage::default(),
326            iterations: 0,
327            tool_calls_executed: 0,
328            last_tool_results: Vec::new(),
329            loop_state: LoopDetectionState::default(),
330            start_time: Instant::now(),
331            finished: false,
332            pending_command: None,
333            final_result: None,
334            depth_error,
335        }
336    }
337
338    /// Advance the loop and return the result of this turn.
339    ///
340    /// Each call performs one iteration: LLM generation, tool execution (if
341    /// applicable), and returns the result.
342    ///
343    /// Returns a [`TurnResult`] that must be matched:
344    /// - [`TurnResult::Yielded`] — tools ran, consume via `resume()` /
345    ///   `continue_loop()` / `inject_and_continue()` / `stop()` to continue
346    /// - [`TurnResult::Completed`] — loop is done, read `.response`
347    /// - [`TurnResult::Error`] — loop failed, read `.error`
348    ///
349    /// After `Completed` or `Error`, all subsequent calls return the same
350    /// terminal result.
351    pub async fn next_turn(&mut self) -> TurnResult<'a, '_, Ctx> {
352        let outcome = self.do_iteration().await;
353        match outcome {
354            IterationOutcome::ToolsExecuted {
355                tool_calls,
356                results,
357                assistant_content,
358                iteration,
359                total_usage,
360            } => TurnResult::Yielded(Yielded {
361                handle: self,
362                tool_calls,
363                results,
364                assistant_content,
365                iteration,
366                total_usage,
367            }),
368            IterationOutcome::Completed(c) => TurnResult::Completed(c),
369            IterationOutcome::Error(e) => TurnResult::Error(e),
370        }
371    }
372
373    /// Tell the loop how to proceed before the next [`next_turn()`](Self::next_turn) call.
374    ///
375    /// When using [`TurnResult::Yielded`], prefer the convenience methods on
376    /// [`Yielded`] (`continue_loop()`, `inject_and_continue()`, `stop()`),
377    /// which consume the yielded handle and call this internally.
378    ///
379    /// This method is useful when you need to set a command on the handle
380    /// directly — for example, when driving the handle from an external
381    /// event loop that receives the command asynchronously after the
382    /// `Yielded` has already been consumed.
383    ///
384    /// Has no effect after `Completed` or `Error`.
385    pub fn resume(&mut self, command: LoopCommand) {
386        if !self.finished {
387            self.pending_command = Some(command);
388        }
389    }
390
391    /// Get a snapshot of the current conversation messages.
392    ///
393    /// Useful for context window management or debugging.
394    pub fn messages(&self) -> &[ChatMessage] {
395        &self.params.messages
396    }
397
398    /// Get a mutable reference to the conversation messages.
399    ///
400    /// Allows direct manipulation of the message history between iterations
401    /// (e.g., for context compaction/summarization).
402    pub fn messages_mut(&mut self) -> &mut Vec<ChatMessage> {
403        &mut self.params.messages
404    }
405
406    /// Get the accumulated usage across all iterations so far.
407    pub fn total_usage(&self) -> &Usage {
408        &self.total_usage
409    }
410
411    /// Get the current iteration count.
412    pub fn iterations(&self) -> u32 {
413        self.iterations
414    }
415
416    /// Whether the loop has finished (returned Completed or Error).
417    pub fn is_finished(&self) -> bool {
418        self.finished
419    }
420
421    /// Consume the handle and return a `ToolLoopResult`.
422    ///
423    /// If the loop hasn't completed yet, returns a result with current
424    /// iteration count and `TerminationReason::Complete`.
425    pub fn into_result(self) -> ToolLoopResult {
426        self.final_result.unwrap_or_else(|| ToolLoopResult {
427            response: ChatResponse::empty(),
428            iterations: self.iterations,
429            total_usage: self.total_usage,
430            termination_reason: TerminationReason::Complete,
431        })
432    }
433
434    // ── Core iteration logic ────────────────────────────────────────
435
436    /// Perform one iteration and return owned data. Does NOT borrow `&mut self`
437    /// beyond this call — the returned `IterationOutcome` is fully owned.
438    async fn do_iteration(&mut self) -> IterationOutcome {
439        // Phase 1: Pre-iteration guards
440        if let Some(outcome) = self.check_preconditions() {
441            return outcome;
442        }
443
444        self.iterations += 1;
445
446        // Emit iteration start event
447        let msg_count = self.params.messages.len();
448        let iterations = self.iterations;
449        emit_event(&self.config, || ToolLoopEvent::IterationStart {
450            iteration: iterations,
451            message_count: msg_count,
452        });
453
454        // Phase 2: LLM call
455        let response = match self.provider.generate_boxed(&self.params).await {
456            Ok(r) => r,
457            Err(e) => return self.finish_error(e),
458        };
459        self.total_usage += &response.usage;
460
461        // Emit response received event
462        let call_refs: Vec<&ToolCall> = response.tool_calls();
463        let text_length = response.text().map_or(0, str::len);
464        let has_tool_calls = !call_refs.is_empty();
465        let iterations = self.iterations;
466        emit_event(&self.config, || ToolLoopEvent::LlmResponseReceived {
467            iteration: iterations,
468            has_tool_calls,
469            text_length,
470        });
471
472        // Phase 3: Termination checks
473        if let Some(outcome) = self.check_termination(&response, &call_refs) {
474            return outcome;
475        }
476
477        // Phase 4: Execute tools and build outcome
478        self.execute_tools(response).await
479    }
480
481    // ── Pre-iteration guards ────────────────────────────────────────
482
483    /// Handle depth errors, already-finished state, pending commands, and timeout.
484    ///
485    /// Returns `Some(outcome)` if the loop should not proceed to an LLM call.
486    fn check_preconditions(&mut self) -> Option<IterationOutcome> {
487        // Depth error deferred from new()
488        if let Some(error) = self.depth_error.take() {
489            return Some(self.finish_error(error));
490        }
491
492        // Already finished — return cached terminal event
493        if self.finished {
494            return Some(self.make_terminal_outcome());
495        }
496
497        // Apply pending command from previous resume() call
498        if let Some(command) = self.pending_command.take() {
499            match command {
500                LoopCommand::Continue => {}
501                LoopCommand::InjectMessages(messages) => {
502                    self.params.messages.extend(messages);
503                }
504                LoopCommand::Stop(reason) => {
505                    return Some(self.finish(
506                        ChatResponse::empty(),
507                        TerminationReason::StopCondition { reason },
508                    ));
509                }
510            }
511        }
512
513        // Timeout
514        if let Some(limit) = self.config.timeout {
515            if self.start_time.elapsed() >= limit {
516                return Some(
517                    self.finish(ChatResponse::empty(), TerminationReason::Timeout { limit }),
518                );
519            }
520        }
521
522        None
523    }
524
525    // ── Post-response termination checks ────────────────────────────
526
527    /// Check stop condition, natural completion, max iterations, and loop detection.
528    ///
529    /// Returns `Some(outcome)` if the loop should terminate after this response.
530    fn check_termination(
531        &mut self,
532        response: &ChatResponse,
533        call_refs: &[&ToolCall],
534    ) -> Option<IterationOutcome> {
535        // Custom stop condition
536        if let Some(ref stop_fn) = self.config.stop_when {
537            let ctx = StopContext {
538                iteration: self.iterations,
539                response,
540                total_usage: &self.total_usage,
541                tool_calls_executed: self.tool_calls_executed,
542                last_tool_results: &self.last_tool_results,
543            };
544            match stop_fn(&ctx) {
545                StopDecision::Continue => {}
546                StopDecision::Stop => {
547                    return Some(self.finish(
548                        response.clone(),
549                        TerminationReason::StopCondition { reason: None },
550                    ));
551                }
552                StopDecision::StopWithReason(reason) => {
553                    return Some(self.finish(
554                        response.clone(),
555                        TerminationReason::StopCondition {
556                            reason: Some(reason),
557                        },
558                    ));
559                }
560            }
561        }
562
563        // Natural completion (no tool calls)
564        if call_refs.is_empty() || response.stop_reason != StopReason::ToolUse {
565            return Some(self.finish(response.clone(), TerminationReason::Complete));
566        }
567
568        // Max iterations
569        if self.iterations > self.config.max_iterations {
570            return Some(self.finish(
571                response.clone(),
572                TerminationReason::MaxIterations {
573                    limit: self.config.max_iterations,
574                },
575            ));
576        }
577
578        // Loop detection
579        let snap = IterationSnapshot {
580            response,
581            call_refs,
582            iterations: self.iterations,
583            total_usage: &self.total_usage,
584            tool_calls_executed: self.tool_calls_executed,
585            last_tool_results: &self.last_tool_results,
586            config: &self.config,
587        };
588        if let Some(result) =
589            handle_loop_detection(&mut self.loop_state, &snap, &mut self.params.messages)
590        {
591            return Some(self.finish(result.response, result.termination_reason));
592        }
593
594        None
595    }
596
597    // ── Tool execution ──────────────────────────────────────────────
598
599    /// Extract tool calls, execute them, append results, and return owned outcome.
600    async fn execute_tools(&mut self, response: ChatResponse) -> IterationOutcome {
601        let (calls, other_content) = response.partition_content();
602
603        // Clone calls for the outcome return; originals move into approval/execution
604        let event_calls = calls.clone();
605        let (approved_calls, denied_results) = approve_calls(calls, &self.config);
606        let results = execute_with_events(
607            self.registry,
608            approved_calls,
609            denied_results,
610            self.config.parallel_tool_execution,
611            &self.config,
612            &self.nested_ctx,
613        )
614        .await;
615
616        self.tool_calls_executed += results.len();
617        self.last_tool_results.clone_from(&results);
618
619        // Append assistant message with non-tool content to conversation
620        self.params.messages.push(ChatMessage {
621            role: crate::chat::ChatRole::Assistant,
622            content: other_content.clone(),
623        });
624
625        // Append tool results to conversation
626        for result in &results {
627            self.params
628                .messages
629                .push(ChatMessage::tool_result_full(result.clone()));
630        }
631
632        let iteration = self.iterations;
633        let total_usage = self.total_usage.clone();
634
635        IterationOutcome::ToolsExecuted {
636            tool_calls: event_calls,
637            results,
638            assistant_content: other_content,
639            iteration,
640            total_usage,
641        }
642    }
643
644    // ── Terminal outcome helpers ─────────────────────────────────────
645
646    /// Mark the loop as finished and return a `Completed` outcome.
647    fn finish(
648        &mut self,
649        response: ChatResponse,
650        termination_reason: TerminationReason,
651    ) -> IterationOutcome {
652        self.finished = true;
653        self.final_result = Some(ToolLoopResult {
654            response: response.clone(),
655            iterations: self.iterations,
656            total_usage: self.total_usage.clone(),
657            termination_reason: termination_reason.clone(),
658        });
659        IterationOutcome::Completed(Completed {
660            response,
661            termination_reason,
662            iterations: self.iterations,
663            total_usage: self.total_usage.clone(),
664        })
665    }
666
667    /// Mark the loop as finished and return an `Error` outcome.
668    fn finish_error(&mut self, error: LlmError) -> IterationOutcome {
669        self.finished = true;
670        self.final_result = Some(ToolLoopResult {
671            response: ChatResponse::empty(),
672            iterations: self.iterations,
673            total_usage: self.total_usage.clone(),
674            termination_reason: TerminationReason::Complete,
675        });
676        IterationOutcome::Error(TurnError {
677            error,
678            iterations: self.iterations,
679            total_usage: self.total_usage.clone(),
680        })
681    }
682
683    /// Build a terminal outcome from cached state (for repeated calls after finish).
684    fn make_terminal_outcome(&self) -> IterationOutcome {
685        if let Some(ref result) = self.final_result {
686            IterationOutcome::Completed(Completed {
687                response: result.response.clone(),
688                termination_reason: result.termination_reason.clone(),
689                iterations: result.iterations,
690                total_usage: result.total_usage.clone(),
691            })
692        } else {
693            IterationOutcome::Completed(Completed {
694                response: ChatResponse::empty(),
695                termination_reason: TerminationReason::Complete,
696                iterations: self.iterations,
697                total_usage: self.total_usage.clone(),
698            })
699        }
700    }
701}
702
703impl<Ctx: LoopDepth + Send + Sync + 'static> std::fmt::Debug for ToolLoopHandle<'_, Ctx> {
704    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
705        f.debug_struct("ToolLoopHandle")
706            .field("iterations", &self.iterations)
707            .field("tool_calls_executed", &self.tool_calls_executed)
708            .field("finished", &self.finished)
709            .field("has_pending_command", &self.pending_command.is_some())
710            .finish()
711    }
712}
713
714/// Convenience function to create a resumable tool loop.
715///
716/// Equivalent to [`ToolLoopHandle::new()`].
717pub fn tool_loop_resumable<'a, Ctx: LoopDepth + Send + Sync + 'static>(
718    provider: &'a dyn DynProvider,
719    registry: &'a ToolRegistry<Ctx>,
720    params: ChatParams,
721    config: ToolLoopConfig,
722    ctx: &Ctx,
723) -> ToolLoopHandle<'a, Ctx> {
724    ToolLoopHandle::new(provider, registry, params, config, ctx)
725}