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 crate::chat::{ChatMessage, ChatResponse, ContentBlock, ToolCall, ToolResult};
59use crate::error::LlmError;
60use crate::provider::{ChatParams, DynProvider};
61use crate::usage::Usage;
62
63use super::LoopDepth;
64use super::ToolRegistry;
65use super::config::{LoopEvent, TerminationReason, ToolLoopConfig, ToolLoopResult};
66use super::loop_core::{CompletedData, ErrorData, IterationOutcome, LoopCore};
67
68// ── Shared macros for Yielded-like types ─────────────────────────────
69
70/// Implements the common methods on a `Yielded`-like struct.
71///
72/// Both `Yielded` and `OwnedYielded` have identical field layouts and
73/// method bodies. The only difference is the handle type they borrow.
74/// This macro eliminates the duplication.
75///
76/// Expects the struct to have fields: `handle`, `assistant_content`,
77/// `tool_calls`, `results`, `iteration`, `total_usage`.
78macro_rules! impl_yielded_methods {
79    ($yielded:ident < $($lt:lifetime),* >) => {
80        impl<$($lt,)* Ctx: LoopDepth + Send + Sync + 'static> $yielded<$($lt,)* Ctx> {
81            /// Continue with the given command.
82            pub fn resume(self, command: LoopCommand) {
83                self.handle.resume(command);
84            }
85
86            /// Convenience: continue to the next LLM iteration with no injected messages.
87            pub fn continue_loop(self) {
88                self.resume(LoopCommand::Continue);
89            }
90
91            /// Convenience: inject messages and continue.
92            pub fn inject_and_continue(self, messages: Vec<ChatMessage>) {
93                self.resume(LoopCommand::InjectMessages(messages));
94            }
95
96            /// Convenience: stop the loop.
97            pub fn stop(self, reason: Option<String>) {
98                self.resume(LoopCommand::Stop(reason));
99            }
100
101            /// Extract text from `assistant_content` blocks.
102            ///
103            /// Returns the LLM's "thinking aloud" text emitted alongside tool calls,
104            /// or `None` if there were no text blocks.
105            pub fn assistant_text(&self) -> Option<String> {
106                let text: String = self
107                    .assistant_content
108                    .iter()
109                    .filter_map(|block| match block {
110                        ContentBlock::Text(t) => Some(t.as_str()),
111                        _ => None,
112                    })
113                    .collect::<Vec<_>>()
114                    .join("\n");
115                if text.is_empty() { None } else { Some(text) }
116            }
117
118            /// Access the full message history (read-only).
119            pub fn messages(&self) -> &[ChatMessage] {
120                self.handle.messages()
121            }
122
123            /// Access the full message history (mutable, for context compaction).
124            pub fn messages_mut(&mut self) -> &mut Vec<ChatMessage> {
125                self.handle.messages_mut()
126            }
127        }
128    };
129}
130
131pub(crate) use impl_yielded_methods;
132
133/// Converts an [`IterationOutcome`] into a `TurnResult`-like enum.
134///
135/// Both `TurnResult` and `OwnedTurnResult` need identical destructuring
136/// of `IterationOutcome` into their respective `Yielded`/`Completed`/`Error`
137/// variants. This macro generates that conversion.
138macro_rules! outcome_to_turn_result {
139    ($outcome:expr, $handle:expr, $turn_ty:ident, $yielded_ty:ident) => {{
140        // Drain buffered events before constructing the result.
141        // This moves them into the turn variant so callers get events
142        // co-located with the turn data — no separate drain step needed.
143        let events = $handle.core.drain_events();
144        match $outcome {
145            IterationOutcome::ToolsExecuted {
146                tool_calls,
147                results,
148                assistant_content,
149                iteration,
150                total_usage,
151            } => $turn_ty::Yielded($yielded_ty {
152                handle: $handle,
153                tool_calls,
154                results,
155                assistant_content,
156                iteration,
157                total_usage,
158                events,
159            }),
160            IterationOutcome::Completed(CompletedData {
161                response,
162                termination_reason,
163                iterations,
164                total_usage,
165            }) => $turn_ty::Completed(Completed {
166                response,
167                termination_reason,
168                iterations,
169                total_usage,
170                events,
171            }),
172            IterationOutcome::Error(ErrorData {
173                error,
174                iterations,
175                total_usage,
176            }) => $turn_ty::Error(TurnError {
177                error,
178                iterations,
179                total_usage,
180                events,
181            }),
182        }
183    }};
184}
185
186pub(crate) use outcome_to_turn_result;
187
188/// Result of one turn of the tool loop.
189///
190/// Match on this to determine what happened and what you can do next.
191/// Each variant carries the data from the turn AND (for `Yielded`) a handle
192/// scoped to valid operations for that state.
193///
194/// This follows the same pattern as [`std::collections::hash_map::Entry`] —
195/// the variant gives you exactly the methods that make sense for that state.
196#[must_use = "a TurnResult must be matched — Yielded requires resume() to continue"]
197pub enum TurnResult<'a, 'h, Ctx: LoopDepth + Send + Sync + 'static> {
198    /// Tools were executed. The caller MUST consume this via `resume()`,
199    /// `continue_loop()`, `inject_and_continue()`, or `stop()`.
200    ///
201    /// While this variant exists, the `ToolLoopHandle` is mutably borrowed
202    /// and cannot be used directly. Consuming the `Yielded` releases the
203    /// borrow.
204    Yielded(Yielded<'a, 'h, Ctx>),
205
206    /// The loop completed (no tool calls, stop condition, max iterations, or timeout).
207    Completed(Completed),
208
209    /// An unrecoverable error occurred.
210    Error(TurnError),
211}
212
213/// Handle returned when tools were executed. Borrows the [`ToolLoopHandle`]
214/// mutably, so the caller cannot call `next_turn()` again until this is
215/// consumed via `resume()`, `continue_loop()`, `inject_and_continue()`, or
216/// `stop()`.
217///
218/// The text content the LLM produced alongside tool calls is available
219/// directly via [`assistant_content`](Self::assistant_content) and
220/// [`assistant_text()`](Self::assistant_text) — no need to scan
221/// `messages()`.
222#[must_use = "must call .resume(), .continue_loop(), .inject_and_continue(), or .stop() to continue"]
223pub struct Yielded<'a, 'h, Ctx: LoopDepth + Send + Sync + 'static> {
224    handle: &'h mut ToolLoopHandle<'a, Ctx>,
225
226    /// The tool calls the LLM requested.
227    pub tool_calls: Vec<ToolCall>,
228
229    /// Results from executing those tool calls.
230    pub results: Vec<ToolResult>,
231
232    /// Text content from the LLM's response alongside the tool calls.
233    ///
234    /// This is the `other_content` from `partition_content()` — `Text`,
235    /// `Reasoning`, `Image`, etc. — everything that isn't a `ToolCall` or
236    /// `ToolResult`. Previously only accessible by scanning `messages()`.
237    pub assistant_content: Vec<ContentBlock>,
238
239    /// Current iteration number (1-indexed).
240    pub iteration: u32,
241
242    /// Accumulated usage across all iterations so far.
243    pub total_usage: Usage,
244
245    /// Lifecycle events from this turn (`IterationStart`, `ToolExecutionStart/End`, etc.).
246    ///
247    /// Pre-drained from the internal buffer — no need to call
248    /// [`ToolLoopHandle::drain_events()`] separately.
249    pub events: Vec<LoopEvent>,
250}
251
252impl_yielded_methods!(Yielded<'a, 'h>);
253
254/// Terminal: the loop completed successfully.
255pub struct Completed {
256    /// The final LLM response. Use `.text()` to get the response text.
257    pub response: ChatResponse,
258    /// Why the loop terminated.
259    pub termination_reason: TerminationReason,
260    /// Total iterations performed.
261    pub iterations: u32,
262    /// Accumulated usage.
263    pub total_usage: Usage,
264    /// Lifecycle events from the final turn.
265    pub events: Vec<LoopEvent>,
266}
267
268/// Terminal: the loop errored.
269pub struct TurnError {
270    /// The error.
271    pub error: LlmError,
272    /// Iterations completed before the error.
273    pub iterations: u32,
274    /// Usage accumulated before the error.
275    pub total_usage: Usage,
276    /// Lifecycle events from the final turn (may include `IterationStart`
277    /// even though the turn errored).
278    pub events: Vec<LoopEvent>,
279}
280
281/// Commands sent by the caller to control the resumable loop.
282///
283/// Passed to [`Yielded::resume()`] after receiving a
284/// [`TurnResult::Yielded`].
285#[derive(Debug)]
286pub enum LoopCommand {
287    /// Continue to the next LLM iteration normally.
288    Continue,
289
290    /// Inject additional messages before the next LLM call.
291    ///
292    /// The injected messages are appended after the tool results from
293    /// the current round. Use this to provide additional context
294    /// (e.g., worker agent results, user follow-ups).
295    InjectMessages(Vec<ChatMessage>),
296
297    /// Stop the loop immediately.
298    ///
299    /// Returns a `Completed` event with `TerminationReason::StopCondition`.
300    Stop(Option<String>),
301}
302
303// ── ToolLoopHandle ──────────────────────────────────────────────────
304
305/// Caller-driven resumable tool loop.
306///
307/// Unlike [`tool_loop`](super::tool_loop) which runs autonomously, this struct
308/// gives the caller control between each tool execution round. Call
309/// [`next_turn()`](Self::next_turn) to advance the loop, inspect the result,
310/// then consume the [`Yielded`] handle to control what happens next.
311///
312/// # No spawning required
313///
314/// This is a direct state machine — no background tasks, no channels. The
315/// caller drives it by calling `next_turn()` which performs one iteration
316/// (LLM call + tool execution) and returns.
317///
318/// # Lifecycle
319///
320/// 1. Create with [`new()`](Self::new)
321/// 2. Call [`next_turn()`](Self::next_turn) to get the first result
322/// 3. If `Yielded`, consume via `resume()` / `continue_loop()` / etc., then
323///    call `next_turn()` again
324/// 4. Repeat until `Completed` or `Error`
325/// 5. Optionally call [`into_result()`](Self::into_result) for a `ToolLoopResult`
326pub struct ToolLoopHandle<'a, Ctx: LoopDepth + Send + Sync + 'static> {
327    provider: &'a dyn DynProvider,
328    registry: &'a ToolRegistry<Ctx>,
329    core: LoopCore<Ctx>,
330}
331
332impl<'a, Ctx: LoopDepth + Send + Sync + 'static> ToolLoopHandle<'a, Ctx> {
333    /// Create a new resumable tool loop.
334    ///
335    /// Does not start execution — call [`next_turn()`](Self::next_turn) to
336    /// begin the first iteration.
337    ///
338    /// # Depth Tracking
339    ///
340    /// Same as [`tool_loop`](super::tool_loop) — if `Ctx` implements [`LoopDepth`],
341    /// nested calls are tracked and `max_depth` is enforced. If the depth limit
342    /// is already exceeded, the first call to `next_turn()` returns `Error`.
343    pub fn new(
344        provider: &'a dyn DynProvider,
345        registry: &'a ToolRegistry<Ctx>,
346        params: ChatParams,
347        config: ToolLoopConfig,
348        ctx: &Ctx,
349    ) -> Self {
350        Self {
351            provider,
352            registry,
353            core: LoopCore::new(params, config, ctx),
354        }
355    }
356
357    /// Advance the loop and return the result of this turn.
358    ///
359    /// Each call performs one iteration: LLM generation, tool execution (if
360    /// applicable), and returns the result.
361    ///
362    /// Returns a [`TurnResult`] that must be matched:
363    /// - [`TurnResult::Yielded`] — tools ran, consume via `resume()` /
364    ///   `continue_loop()` / `inject_and_continue()` / `stop()` to continue
365    /// - [`TurnResult::Completed`] — loop is done, read `.response`
366    /// - [`TurnResult::Error`] — loop failed, read `.error`
367    ///
368    /// After `Completed` or `Error`, all subsequent calls return the same
369    /// terminal result.
370    pub async fn next_turn(&mut self) -> TurnResult<'a, '_, Ctx> {
371        let outcome = self.core.do_iteration(self.provider, self.registry).await;
372        outcome_to_turn_result!(outcome, self, TurnResult, Yielded)
373    }
374
375    /// Tell the loop how to proceed before the next [`next_turn()`](Self::next_turn) call.
376    ///
377    /// When using [`TurnResult::Yielded`], prefer the convenience methods on
378    /// [`Yielded`] (`continue_loop()`, `inject_and_continue()`, `stop()`),
379    /// which consume the yielded handle and call this internally.
380    ///
381    /// This method is useful when you need to set a command on the handle
382    /// directly — for example, when driving the handle from an external
383    /// event loop that receives the command asynchronously after the
384    /// `Yielded` has already been consumed.
385    ///
386    /// Has no effect after `Completed` or `Error`.
387    pub fn resume(&mut self, command: LoopCommand) {
388        self.core.resume(command);
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.core.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        self.core.messages_mut()
404    }
405
406    /// Get the accumulated usage across all iterations so far.
407    pub fn total_usage(&self) -> &Usage {
408        self.core.total_usage()
409    }
410
411    /// Get the current iteration count.
412    pub fn iterations(&self) -> u32 {
413        self.core.iterations()
414    }
415
416    /// Whether the loop has finished (returned Completed or Error).
417    pub fn is_finished(&self) -> bool {
418        self.core.is_finished()
419    }
420
421    /// Drain any remaining buffered [`LoopEvent`]s.
422    ///
423    /// Most callers should use the `events` field on [`Yielded`], [`Completed`],
424    /// or [`TurnError`] instead — those are pre-populated by [`next_turn()`](Self::next_turn).
425    ///
426    /// This method exists for edge cases where events may accumulate between
427    /// turns (e.g., after calling [`resume()`](Self::resume) directly from an
428    /// external event loop).
429    pub fn drain_events(&mut self) -> Vec<LoopEvent> {
430        self.core.drain_events()
431    }
432
433    /// Consume the handle and return a `ToolLoopResult`.
434    ///
435    /// If the loop hasn't completed yet, returns a result with current
436    /// iteration count and `TerminationReason::Complete`.
437    pub fn into_result(self) -> ToolLoopResult {
438        self.core.into_result()
439    }
440
441    /// Convert this borrowed handle into an owned handle.
442    ///
443    /// The provider and registry must be provided as `Arc` since this
444    /// handle only holds references. The loop state (iterations, messages,
445    /// usage, etc.) is transferred as-is.
446    pub fn into_owned(
447        self,
448        provider: std::sync::Arc<dyn DynProvider>,
449        registry: std::sync::Arc<ToolRegistry<Ctx>>,
450    ) -> super::OwnedToolLoopHandle<Ctx> {
451        super::OwnedToolLoopHandle::from_core(provider, registry, self.core)
452    }
453}
454
455impl<Ctx: LoopDepth + Send + Sync + 'static> std::fmt::Debug for ToolLoopHandle<'_, Ctx> {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        f.debug_struct("ToolLoopHandle")
458            .field("core", &self.core)
459            .finish_non_exhaustive()
460    }
461}