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}