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 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}