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//! ®istry,
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}