Skip to main content

llm_stack/tool/
config.rs

1//! Tool loop configuration and event types.
2
3use std::pin::Pin;
4use std::sync::Arc;
5use std::time::Duration;
6
7use futures::Stream;
8use serde_json::Value;
9
10use crate::chat::{ChatResponse, ToolCall, ToolResult};
11use crate::error::LlmError;
12use crate::usage::Usage;
13
14use super::cacher::ToolResultCacher;
15use super::extractor::ToolResultExtractor;
16use super::processor::ToolResultProcessor;
17
18/// Callback type for tool call approval.
19pub type ToolApprovalFn = Arc<dyn Fn(&ToolCall) -> ToolApproval + Send + Sync>;
20
21/// Callback type for stop conditions.
22pub type StopConditionFn = Arc<dyn Fn(&StopContext) -> StopDecision + Send + Sync>;
23
24/// A pinned, boxed, `Send` stream of [`LoopEvent`] results.
25///
26/// The unified event stream from [`tool_loop_stream`](super::tool_loop_stream).
27/// Emits both LLM streaming events (text deltas, tool call fragments) and
28/// loop-level events (iteration boundaries, tool execution progress).
29/// Terminates with [`LoopEvent::Done`] carrying the final [`ToolLoopResult`].
30pub type LoopStream = Pin<Box<dyn Stream<Item = Result<LoopEvent, LlmError>> + Send>>;
31
32/// Context provided to stop condition callbacks.
33///
34/// Contains information about the current state of the tool loop
35/// to help decide whether to stop early.
36#[derive(Debug)]
37pub struct StopContext<'a> {
38    /// Current iteration number (1-indexed).
39    pub iteration: u32,
40    /// The response from this iteration.
41    pub response: &'a ChatResponse,
42    /// Accumulated usage across all iterations so far.
43    pub total_usage: &'a Usage,
44    /// Total number of tool calls executed so far (across all iterations).
45    pub tool_calls_executed: usize,
46    /// Tool results from the most recent execution (empty on first response).
47    pub last_tool_results: &'a [ToolResult],
48}
49
50/// Decision returned by a stop condition callback.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum StopDecision {
53    /// Continue the tool loop normally.
54    Continue,
55    /// Stop the loop immediately, using the current response as final.
56    Stop,
57    /// Stop the loop with a reason (for observability/debugging).
58    StopWithReason(String),
59}
60
61/// Configuration for detecting repeated tool calls (stuck agents).
62///
63/// When an agent repeatedly makes the same tool call with identical arguments,
64/// it's usually stuck in a loop. This configuration detects that pattern and
65/// takes action to break the cycle.
66///
67/// # Example
68///
69/// ```rust
70/// use llm_stack::tool::{LoopDetectionConfig, LoopAction};
71///
72/// let config = LoopDetectionConfig {
73///     threshold: 3,  // Trigger after 3 consecutive identical calls
74///     action: LoopAction::InjectWarning,  // Tell the agent it's looping
75/// };
76/// ```
77#[derive(Debug, Clone, Copy)]
78pub struct LoopDetectionConfig {
79    /// Number of consecutive identical tool calls before triggering.
80    ///
81    /// A tool call is "identical" if it has the same name and arguments
82    /// (compared via JSON equality). Default: 3.
83    pub threshold: u32,
84
85    /// Action to take when a loop is detected.
86    pub action: LoopAction,
87}
88
89impl Default for LoopDetectionConfig {
90    fn default() -> Self {
91        Self {
92            threshold: 3,
93            action: LoopAction::Warn,
94        }
95    }
96}
97
98/// Action to take when a tool call loop is detected.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum LoopAction {
101    /// Emit [`LoopEvent::LoopDetected`] and continue execution.
102    ///
103    /// Use this for monitoring/alerting without interrupting the agent.
104    Warn,
105
106    /// Stop the loop immediately with an error.
107    ///
108    /// Returns `LlmError::ToolExecution` describing the loop.
109    Stop,
110
111    /// Inject a warning message into the conversation and continue.
112    ///
113    /// Adds a system message like "You have called {tool} with identical
114    /// arguments {n} times. Try a different approach." This often helps
115    /// the agent break out of the loop.
116    ///
117    /// The warning fires at every multiple of `threshold` (3, 6, 9, …)
118    /// until the agent changes its approach. This prevents infinite loops
119    /// where the agent ignores the first warning.
120    InjectWarning,
121}
122
123/// Unified event emitted during tool loop execution.
124///
125/// `LoopEvent` merges LLM streaming events (text deltas, tool call fragments)
126/// with loop-level lifecycle events (iteration boundaries, tool execution
127/// progress) into a single stream. This gives consumers a complete, ordered
128/// view of everything happening inside the loop.
129///
130/// The stream terminates with [`Done`](Self::Done) carrying the final
131/// [`ToolLoopResult`].
132///
133/// # Example
134///
135/// ```rust,no_run
136/// use llm_stack::tool::{tool_loop_stream, ToolLoopConfig, LoopEvent};
137/// use futures::StreamExt;
138/// use std::sync::Arc;
139///
140/// # async fn example(
141/// #     provider: Arc<dyn llm_stack::DynProvider>,
142/// #     registry: Arc<llm_stack::ToolRegistry<()>>,
143/// #     params: llm_stack::ChatParams,
144/// # ) {
145/// let mut stream = tool_loop_stream(provider, registry, params, ToolLoopConfig::default(), Arc::new(()));
146/// while let Some(event) = stream.next().await {
147///     match event.unwrap() {
148///         LoopEvent::TextDelta(text) => print!("{text}"),
149///         LoopEvent::IterationStart { iteration, .. } => {
150///             println!("\n--- Iteration {iteration} ---");
151///         }
152///         LoopEvent::ToolExecutionStart { tool_name, .. } => {
153///             println!("[calling {tool_name}...]");
154///         }
155///         LoopEvent::ToolExecutionEnd { tool_name, duration, .. } => {
156///             println!("[{tool_name} completed in {duration:?}]");
157///         }
158///         LoopEvent::Done(result) => {
159///             println!("\nDone: {:?}", result.termination_reason);
160///             break;
161///         }
162///         _ => {}
163///     }
164/// }
165/// # }
166/// ```
167#[derive(Debug, Clone)]
168#[non_exhaustive]
169pub enum LoopEvent {
170    // ── LLM streaming (translated from provider StreamEvent) ────
171    /// A fragment of the model's text output.
172    TextDelta(String),
173
174    /// A fragment of the model's reasoning (chain-of-thought) output.
175    ReasoningDelta(String),
176
177    /// Announces that a new tool call has started.
178    ToolCallStart {
179        /// Zero-based index identifying this call when multiple tools
180        /// are invoked in parallel.
181        index: u32,
182        /// Provider-assigned identifier linking start → deltas → complete.
183        id: String,
184        /// The name of the tool being called.
185        name: String,
186    },
187
188    /// A JSON fragment of the tool call's arguments.
189    ToolCallDelta {
190        /// The tool-call index this delta belongs to.
191        index: u32,
192        /// A chunk of the JSON arguments string.
193        json_chunk: String,
194    },
195
196    /// The fully assembled tool call, ready to execute.
197    ToolCallComplete {
198        /// The tool-call index this completion corresponds to.
199        index: u32,
200        /// The complete, parsed tool call.
201        call: ToolCall,
202    },
203
204    /// Token usage information for this LLM call.
205    Usage(Usage),
206
207    // ── Loop lifecycle ──────────────────────────────────────────
208    /// A new iteration of the tool loop is starting.
209    IterationStart {
210        /// The iteration number (1-indexed).
211        iteration: u32,
212        /// Number of messages in the conversation so far.
213        message_count: usize,
214    },
215
216    /// About to execute a tool.
217    ///
218    /// When `parallel_tool_execution` is true, events arrive in **completion
219    /// order** (whichever tool finishes first), not the order the LLM listed
220    /// the calls. Use `call_id` to correlate start/end pairs.
221    ToolExecutionStart {
222        /// The tool call ID from the LLM.
223        call_id: String,
224        /// Name of the tool being called.
225        tool_name: String,
226        /// Arguments passed to the tool.
227        arguments: Value,
228    },
229
230    /// Tool execution completed.
231    ///
232    /// When `parallel_tool_execution` is true, events arrive in **completion
233    /// order**. Use `call_id` to correlate with the corresponding
234    /// [`ToolExecutionStart`](Self::ToolExecutionStart).
235    ToolExecutionEnd {
236        /// The tool call ID from the LLM.
237        call_id: String,
238        /// Name of the tool that was called.
239        tool_name: String,
240        /// The result from the tool.
241        result: ToolResult,
242        /// How long the tool took to execute.
243        duration: Duration,
244    },
245
246    /// A tool result was post-processed (compressed, truncated, etc.).
247    ///
248    /// Emitted when a [`ToolResultProcessor`](super::ToolResultProcessor)
249    /// modifies a tool's output before it enters the conversation context.
250    /// Use this for monitoring compression ratios and token savings.
251    ToolResultProcessed {
252        /// Name of the tool whose result was processed.
253        tool_name: String,
254        /// Estimated token count of the original output.
255        original_tokens: u32,
256        /// Estimated token count after processing.
257        processed_tokens: u32,
258    },
259
260    /// A tool result was semantically extracted (condensed by an LLM).
261    ///
262    /// Emitted when a [`ToolResultExtractor`](super::ToolResultExtractor)
263    /// condenses a large tool result into task-relevant content using an
264    /// async extraction call (typically a fast/cheap LLM like Haiku).
265    ToolResultExtracted {
266        /// Name of the tool whose result was extracted.
267        tool_name: String,
268        /// Estimated token count before extraction.
269        original_tokens: u32,
270        /// Estimated token count after extraction.
271        extracted_tokens: u32,
272    },
273
274    /// A tool result was cached out-of-context.
275    ///
276    /// Emitted when a [`ToolResultCacher`](super::ToolResultCacher) stores
277    /// an oversized result externally and replaces it with a compact summary.
278    ToolResultCached {
279        /// Name of the tool whose result was cached.
280        tool_name: String,
281        /// Estimated token count of the content that was cached.
282        original_tokens: u32,
283        /// Estimated token count of the summary that replaced it.
284        summary_tokens: u32,
285    },
286
287    /// Old tool results were masked before an LLM call.
288    ///
289    /// Emitted when observation masking replaces old tool results with
290    /// compact placeholders to reduce context size. The full results
291    /// may still be available in the result cache.
292    ObservationsMasked {
293        /// Number of tool results masked in this pass.
294        masked_count: usize,
295        /// Estimated total tokens saved by masking.
296        tokens_saved: u32,
297    },
298
299    /// A tool call loop was detected.
300    ///
301    /// Emitted when the same tool is called with identical arguments
302    /// for `threshold` consecutive times. Only emitted when
303    /// [`LoopDetectionConfig`] is configured.
304    LoopDetected {
305        /// Name of the tool being called repeatedly.
306        tool_name: String,
307        /// Number of consecutive identical calls detected.
308        consecutive_count: u32,
309        /// The action being taken in response.
310        action: LoopAction,
311    },
312
313    // ── Terminal ────────────────────────────────────────────────
314    /// The loop has finished. Carries the final [`ToolLoopResult`]
315    /// with the accumulated response, usage, iteration count, and
316    /// termination reason.
317    Done(ToolLoopResult),
318}
319
320/// Configuration for [`tool_loop`](super::tool_loop) and [`tool_loop_stream`](super::tool_loop_stream).
321pub struct ToolLoopConfig {
322    /// Maximum number of generate-execute iterations. Default: 10.
323    pub max_iterations: u32,
324    /// Whether to execute multiple tool calls in parallel. Default: true.
325    pub parallel_tool_execution: bool,
326    /// Optional callback to approve, deny, or modify each tool call
327    /// before execution.
328    ///
329    /// Called once per tool call in the LLM response, **after** the response
330    /// is assembled but **before** any tool is executed. Receives the
331    /// [`ToolCall`](crate::chat::ToolCall) as parsed from the LLM output.
332    /// Modified arguments are re-validated against the tool's schema.
333    ///
334    /// Panics in the callback propagate and terminate the loop.
335    pub on_tool_call: Option<ToolApprovalFn>,
336    /// Optional stop condition checked after each LLM response.
337    ///
338    /// Called **after** the LLM response is received but **before** tools
339    /// are executed. If the callback returns [`StopDecision::Stop`] or
340    /// [`StopDecision::StopWithReason`], the loop terminates immediately
341    /// without executing the requested tool calls.
342    ///
343    /// Receives a [`StopContext`] with information about the current
344    /// iteration and returns a [`StopDecision`]. Use this to implement:
345    ///
346    /// - `final_answer` tool patterns (stop when a specific tool is called)
347    /// - Token budget enforcement
348    /// - Total tool call limits
349    /// - Content pattern matching
350    ///
351    /// # Example
352    ///
353    /// ```rust,no_run
354    /// use llm_stack::tool::{ToolLoopConfig, StopDecision};
355    /// use std::sync::Arc;
356    ///
357    /// let config = ToolLoopConfig {
358    ///     stop_when: Some(Arc::new(|ctx| {
359    ///         // Stop if we've executed 5 or more tool calls
360    ///         if ctx.tool_calls_executed >= 5 {
361    ///             StopDecision::StopWithReason("Tool call limit reached".into())
362    ///         } else {
363    ///             StopDecision::Continue
364    ///         }
365    ///     })),
366    ///     ..Default::default()
367    /// };
368    /// ```
369    pub stop_when: Option<StopConditionFn>,
370
371    /// Optional loop detection to catch stuck agents.
372    ///
373    /// When enabled, tracks consecutive identical tool calls (same name
374    /// and arguments) and takes action when the threshold is reached.
375    ///
376    /// # Example
377    ///
378    /// ```rust
379    /// use llm_stack::tool::{ToolLoopConfig, LoopDetectionConfig, LoopAction};
380    ///
381    /// let config = ToolLoopConfig {
382    ///     loop_detection: Some(LoopDetectionConfig {
383    ///         threshold: 3,
384    ///         action: LoopAction::InjectWarning,
385    ///     }),
386    ///     ..Default::default()
387    /// };
388    /// ```
389    pub loop_detection: Option<LoopDetectionConfig>,
390
391    /// Maximum wall-clock time for the entire tool loop.
392    ///
393    /// If exceeded, returns with [`TerminationReason::Timeout`].
394    /// This is useful for enforcing time budgets in production systems.
395    ///
396    /// # Example
397    ///
398    /// ```rust
399    /// use llm_stack::tool::ToolLoopConfig;
400    /// use std::time::Duration;
401    ///
402    /// let config = ToolLoopConfig {
403    ///     timeout: Some(Duration::from_secs(30)),
404    ///     ..Default::default()
405    /// };
406    /// ```
407    pub timeout: Option<Duration>,
408
409    /// Optional processor that runs on tool results before they enter the
410    /// conversation context.
411    ///
412    /// When set, the processor's [`process`](ToolResultProcessor::process)
413    /// method is called on each tool result after execution. If it modifies
414    /// the content, a [`LoopEvent::ToolResultProcessed`] event is emitted
415    /// for observability.
416    ///
417    /// Default: `None` (no processing — results pass through unmodified).
418    ///
419    /// # Example
420    ///
421    /// ```rust,no_run
422    /// use llm_stack::tool::{ToolLoopConfig, ToolResultProcessor, ProcessedResult};
423    /// use std::sync::Arc;
424    ///
425    /// struct TruncateProcessor;
426    /// impl ToolResultProcessor for TruncateProcessor {
427    ///     fn process(&self, _tool_name: &str, output: &str) -> ProcessedResult {
428    ///         if output.len() > 10_000 {
429    ///             ProcessedResult {
430    ///                 content: output[..10_000].to_string(),
431    ///                 was_processed: true,
432    ///                 original_tokens_est: (output.len() as u32) / 4,
433    ///                 processed_tokens_est: 2500,
434    ///             }
435    ///         } else {
436    ///             ProcessedResult::unchanged()
437    ///         }
438    ///     }
439    /// }
440    ///
441    /// let config = ToolLoopConfig {
442    ///     result_processor: Some(Arc::new(TruncateProcessor)),
443    ///     ..Default::default()
444    /// };
445    /// ```
446    pub result_processor: Option<Arc<dyn ToolResultProcessor>>,
447
448    /// Async semantic extractor for large tool results.
449    ///
450    /// After the [`result_processor`](Self::result_processor) runs, if the
451    /// result still exceeds the extractor's [`extraction_threshold`](ToolResultExtractor::extraction_threshold),
452    /// the extractor condenses it using async work (e.g., a fast LLM call).
453    ///
454    /// The extractor receives the last user message for relevance-guided
455    /// extraction. Results below the threshold skip this stage entirely.
456    ///
457    /// Default: `None` (no semantic extraction).
458    pub result_extractor: Option<Arc<dyn ToolResultExtractor>>,
459
460    /// Out-of-context cacher for oversized tool results.
461    ///
462    /// After the [`result_processor`](Self::result_processor) and optional
463    /// [`result_extractor`](Self::result_extractor) run, if the result still
464    /// exceeds the cacher's [`inline_threshold`](ToolResultCacher::inline_threshold),
465    /// the cacher stores the full content externally and returns a compact
466    /// summary for the conversation.
467    ///
468    /// The caller decides how to store (disk, memory, KV, …). llm-stack
469    /// only provides the hook and the threshold check.
470    ///
471    /// Default: `None` (no caching — oversized results stay inline).
472    pub result_cacher: Option<Arc<dyn ToolResultCacher>>,
473
474    /// Observation masking: replace old tool results with compact
475    /// placeholders to reduce context size between iterations.
476    ///
477    /// When enabled, `LoopCore` scans the message history before each
478    /// LLM call and masks tool results from old iterations. Masking
479    /// preserves the tool call / result structure (so the LLM knows a
480    /// tool was called) but replaces the content with a short placeholder.
481    ///
482    /// Default: `None` (no masking — all tool results stay in context).
483    pub masking: Option<ObservationMaskingConfig>,
484
485    /// Agent-directed force-mask set for observation masking.
486    ///
487    /// When set, tool results from iterations listed in this set are
488    /// masked regardless of age. This enables tools like `context_release`
489    /// to mark specific iterations as stale during execution.
490    ///
491    /// The set is shared between the tool loop config and the tool that
492    /// writes to it (e.g., via `Arc::clone`). Thread-safe via `Mutex`.
493    ///
494    /// Default: `None` (only age-based masking applies).
495    pub force_mask_iterations: Option<Arc<std::sync::Mutex<std::collections::HashSet<u32>>>>,
496
497    /// Maximum allowed nesting depth for recursive tool loops.
498    ///
499    /// When a tool calls `tool_loop` internally (e.g., spawning a sub-agent),
500    /// the depth is tracked via the context's [`LoopDepth`](super::LoopDepth)
501    /// implementation. If `ctx.loop_depth() >= max_depth` at entry,
502    /// returns `Err(LlmError::MaxDepthExceeded)`.
503    ///
504    /// - `Some(n)`: Error if depth >= n
505    /// - `None`: No limit (dangerous, use with caution)
506    ///
507    /// Default: `Some(3)` (allows master → worker → one more level)
508    ///
509    /// # Example
510    ///
511    /// ```rust
512    /// use llm_stack::tool::ToolLoopConfig;
513    ///
514    /// // Master/Worker pattern: master=0, worker=1, no grandchildren
515    /// let config = ToolLoopConfig {
516    ///     max_depth: Some(2),
517    ///     ..Default::default()
518    /// };
519    /// ```
520    pub max_depth: Option<u32>,
521}
522
523/// Configuration for observation masking within the tool loop.
524///
525/// Observation masking replaces old tool results with compact placeholders
526/// to keep context size bounded during long tool loop runs. This is
527/// critical for agents that make many tool calls (10+) in a single
528/// request, where accumulated results can fill the context window.
529///
530/// # How it works
531///
532/// Tool results are tagged with the iteration they were produced in.
533/// Before each LLM call, results older than `max_iterations_to_keep`
534/// iterations are replaced with a placeholder like:
535///
536/// ```text
537/// [Masked — {tool_name} result from iteration {N}, {tokens} tokens.
538///  Use result_cache tool if available, or re-invoke tool.]
539/// ```
540///
541/// Only results larger than `min_tokens_to_mask` are masked. Small
542/// results (e.g., error messages, simple values) stay in-context.
543#[derive(Debug, Clone, Copy)]
544pub struct ObservationMaskingConfig {
545    /// Mask tool results older than this many iterations ago.
546    ///
547    /// For example, if `max_iterations_to_keep = 2` and we're on
548    /// iteration 5, results from iterations 1-2 may be masked.
549    ///
550    /// Default: 2 (keep results from the last 2 iterations).
551    pub max_iterations_to_keep: u32,
552
553    /// Only mask results with estimated token count above this threshold.
554    /// Small results (error messages, simple values) are kept inline.
555    ///
556    /// Default: 500 tokens (~2000 chars).
557    pub min_tokens_to_mask: u32,
558}
559
560impl Default for ObservationMaskingConfig {
561    fn default() -> Self {
562        Self {
563            max_iterations_to_keep: 2,
564            min_tokens_to_mask: 500,
565        }
566    }
567}
568
569impl Clone for ToolLoopConfig {
570    fn clone(&self) -> Self {
571        Self {
572            max_iterations: self.max_iterations,
573            parallel_tool_execution: self.parallel_tool_execution,
574            on_tool_call: self.on_tool_call.clone(),
575            stop_when: self.stop_when.clone(),
576            loop_detection: self.loop_detection,
577            timeout: self.timeout,
578            result_processor: self.result_processor.clone(),
579            result_extractor: self.result_extractor.clone(),
580            result_cacher: self.result_cacher.clone(),
581            masking: self.masking,
582            force_mask_iterations: self.force_mask_iterations.clone(),
583            max_depth: self.max_depth,
584        }
585    }
586}
587
588impl Default for ToolLoopConfig {
589    fn default() -> Self {
590        Self {
591            max_iterations: 10,
592            parallel_tool_execution: true,
593            on_tool_call: None,
594            stop_when: None,
595            loop_detection: None,
596            timeout: None,
597            result_processor: None,
598            result_extractor: None,
599            result_cacher: None,
600            masking: None,
601            force_mask_iterations: None,
602            max_depth: Some(3),
603        }
604    }
605}
606
607impl std::fmt::Debug for ToolLoopConfig {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        f.debug_struct("ToolLoopConfig")
610            .field("max_iterations", &self.max_iterations)
611            .field("parallel_tool_execution", &self.parallel_tool_execution)
612            .field("has_on_tool_call", &self.on_tool_call.is_some())
613            .field("has_stop_when", &self.stop_when.is_some())
614            .field("loop_detection", &self.loop_detection)
615            .field("timeout", &self.timeout)
616            .field("has_result_processor", &self.result_processor.is_some())
617            .field("has_result_extractor", &self.result_extractor.is_some())
618            .field("has_result_cacher", &self.result_cacher.is_some())
619            .field("masking", &self.masking)
620            .field(
621                "has_force_mask_iterations",
622                &self.force_mask_iterations.is_some(),
623            )
624            .field("max_depth", &self.max_depth)
625            .finish()
626    }
627}
628
629/// Result of approving a tool call before execution.
630#[derive(Debug, Clone)]
631pub enum ToolApproval {
632    /// Allow the tool call to proceed as-is.
633    Approve,
634    /// Deny the tool call. The reason is sent back to the LLM as an
635    /// error tool result.
636    Deny(String),
637    /// Modify the tool call arguments before execution.
638    Modify(Value),
639}
640
641/// The result of a completed tool loop.
642#[derive(Debug, Clone)]
643pub struct ToolLoopResult {
644    /// The final response from the LLM (after all tool iterations).
645    pub response: ChatResponse,
646    /// How many generate-execute iterations were performed.
647    pub iterations: u32,
648    /// Accumulated usage across all iterations.
649    pub total_usage: Usage,
650    /// Why the loop terminated.
651    ///
652    /// This provides observability into the loop's completion reason,
653    /// useful for debugging and monitoring agent behavior.
654    pub termination_reason: TerminationReason,
655}
656
657/// Why a tool loop terminated.
658///
659/// Used for observability and debugging. Each variant captures specific
660/// information about why the loop ended.
661///
662/// # Example
663///
664/// ```rust,no_run
665/// use llm_stack::tool::TerminationReason;
666/// use std::time::Duration;
667///
668/// # fn check_result(reason: TerminationReason) {
669/// match reason {
670///     TerminationReason::Complete => println!("Task completed naturally"),
671///     TerminationReason::StopCondition { reason } => {
672///         println!("Custom stop: {}", reason.as_deref().unwrap_or("no reason"));
673///     }
674///     TerminationReason::MaxIterations { limit } => {
675///         println!("Hit iteration limit: {limit}");
676///     }
677///     TerminationReason::LoopDetected { tool_name, count } => {
678///         println!("Stuck calling {tool_name} {count} times");
679///     }
680///     TerminationReason::Timeout { limit } => {
681///         println!("Exceeded timeout: {limit:?}");
682///     }
683/// }
684/// # }
685/// ```
686#[derive(Debug, Clone, PartialEq, Eq)]
687pub enum TerminationReason {
688    /// LLM returned a response with no tool calls (natural completion).
689    Complete,
690
691    /// Custom stop condition returned [`StopDecision::Stop`] or
692    /// [`StopDecision::StopWithReason`].
693    StopCondition {
694        /// The reason provided via [`StopDecision::StopWithReason`], if any.
695        reason: Option<String>,
696    },
697
698    /// Hit the `max_iterations` limit.
699    MaxIterations {
700        /// The configured limit that was reached.
701        limit: u32,
702    },
703
704    /// Loop detection triggered with [`LoopAction::Stop`].
705    LoopDetected {
706        /// Name of the tool being called repeatedly.
707        tool_name: String,
708        /// Number of consecutive identical calls.
709        count: u32,
710    },
711
712    /// Wall-clock timeout exceeded.
713    Timeout {
714        /// The configured timeout that was exceeded.
715        limit: Duration,
716    },
717}