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
14/// Callback type for tool call approval.
15pub type ToolApprovalFn = Arc<dyn Fn(&ToolCall) -> ToolApproval + Send + Sync>;
16
17/// Callback type for stop conditions.
18pub type StopConditionFn = Arc<dyn Fn(&StopContext) -> StopDecision + Send + Sync>;
19
20/// A pinned, boxed, `Send` stream of [`LoopEvent`] results.
21///
22/// The unified event stream from [`tool_loop_stream`](super::tool_loop_stream).
23/// Emits both LLM streaming events (text deltas, tool call fragments) and
24/// loop-level events (iteration boundaries, tool execution progress).
25/// Terminates with [`LoopEvent::Done`] carrying the final [`ToolLoopResult`].
26pub type LoopStream = Pin<Box<dyn Stream<Item = Result<LoopEvent, LlmError>> + Send>>;
27
28/// Context provided to stop condition callbacks.
29///
30/// Contains information about the current state of the tool loop
31/// to help decide whether to stop early.
32#[derive(Debug)]
33pub struct StopContext<'a> {
34    /// Current iteration number (1-indexed).
35    pub iteration: u32,
36    /// The response from this iteration.
37    pub response: &'a ChatResponse,
38    /// Accumulated usage across all iterations so far.
39    pub total_usage: &'a Usage,
40    /// Total number of tool calls executed so far (across all iterations).
41    pub tool_calls_executed: usize,
42    /// Tool results from the most recent execution (empty on first response).
43    pub last_tool_results: &'a [ToolResult],
44}
45
46/// Decision returned by a stop condition callback.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum StopDecision {
49    /// Continue the tool loop normally.
50    Continue,
51    /// Stop the loop immediately, using the current response as final.
52    Stop,
53    /// Stop the loop with a reason (for observability/debugging).
54    StopWithReason(String),
55}
56
57/// Configuration for detecting repeated tool calls (stuck agents).
58///
59/// When an agent repeatedly makes the same tool call with identical arguments,
60/// it's usually stuck in a loop. This configuration detects that pattern and
61/// takes action to break the cycle.
62///
63/// # Example
64///
65/// ```rust
66/// use llm_stack::tool::{LoopDetectionConfig, LoopAction};
67///
68/// let config = LoopDetectionConfig {
69///     threshold: 3,  // Trigger after 3 consecutive identical calls
70///     action: LoopAction::InjectWarning,  // Tell the agent it's looping
71/// };
72/// ```
73#[derive(Debug, Clone, Copy)]
74pub struct LoopDetectionConfig {
75    /// Number of consecutive identical tool calls before triggering.
76    ///
77    /// A tool call is "identical" if it has the same name and arguments
78    /// (compared via JSON equality). Default: 3.
79    pub threshold: u32,
80
81    /// Action to take when a loop is detected.
82    pub action: LoopAction,
83}
84
85impl Default for LoopDetectionConfig {
86    fn default() -> Self {
87        Self {
88            threshold: 3,
89            action: LoopAction::Warn,
90        }
91    }
92}
93
94/// Action to take when a tool call loop is detected.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum LoopAction {
97    /// Emit [`LoopEvent::LoopDetected`] and continue execution.
98    ///
99    /// Use this for monitoring/alerting without interrupting the agent.
100    Warn,
101
102    /// Stop the loop immediately with an error.
103    ///
104    /// Returns `LlmError::ToolExecution` describing the loop.
105    Stop,
106
107    /// Inject a warning message into the conversation and continue.
108    ///
109    /// Adds a system message like "You have called {tool} with identical
110    /// arguments {n} times. Try a different approach." This often helps
111    /// the agent break out of the loop.
112    ///
113    /// The warning fires at every multiple of `threshold` (3, 6, 9, …)
114    /// until the agent changes its approach. This prevents infinite loops
115    /// where the agent ignores the first warning.
116    InjectWarning,
117}
118
119/// Unified event emitted during tool loop execution.
120///
121/// `LoopEvent` merges LLM streaming events (text deltas, tool call fragments)
122/// with loop-level lifecycle events (iteration boundaries, tool execution
123/// progress) into a single stream. This gives consumers a complete, ordered
124/// view of everything happening inside the loop.
125///
126/// The stream terminates with [`Done`](Self::Done) carrying the final
127/// [`ToolLoopResult`].
128///
129/// # Example
130///
131/// ```rust,no_run
132/// use llm_stack::tool::{tool_loop_stream, ToolLoopConfig, LoopEvent};
133/// use futures::StreamExt;
134/// use std::sync::Arc;
135///
136/// # async fn example(
137/// #     provider: Arc<dyn llm_stack::DynProvider>,
138/// #     registry: Arc<llm_stack::ToolRegistry<()>>,
139/// #     params: llm_stack::ChatParams,
140/// # ) {
141/// let mut stream = tool_loop_stream(provider, registry, params, ToolLoopConfig::default(), Arc::new(()));
142/// while let Some(event) = stream.next().await {
143///     match event.unwrap() {
144///         LoopEvent::TextDelta(text) => print!("{text}"),
145///         LoopEvent::IterationStart { iteration, .. } => {
146///             println!("\n--- Iteration {iteration} ---");
147///         }
148///         LoopEvent::ToolExecutionStart { tool_name, .. } => {
149///             println!("[calling {tool_name}...]");
150///         }
151///         LoopEvent::ToolExecutionEnd { tool_name, duration, .. } => {
152///             println!("[{tool_name} completed in {duration:?}]");
153///         }
154///         LoopEvent::Done(result) => {
155///             println!("\nDone: {:?}", result.termination_reason);
156///             break;
157///         }
158///         _ => {}
159///     }
160/// }
161/// # }
162/// ```
163#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub enum LoopEvent {
166    // ── LLM streaming (translated from provider StreamEvent) ────
167    /// A fragment of the model's text output.
168    TextDelta(String),
169
170    /// A fragment of the model's reasoning (chain-of-thought) output.
171    ReasoningDelta(String),
172
173    /// Announces that a new tool call has started.
174    ToolCallStart {
175        /// Zero-based index identifying this call when multiple tools
176        /// are invoked in parallel.
177        index: u32,
178        /// Provider-assigned identifier linking start → deltas → complete.
179        id: String,
180        /// The name of the tool being called.
181        name: String,
182    },
183
184    /// A JSON fragment of the tool call's arguments.
185    ToolCallDelta {
186        /// The tool-call index this delta belongs to.
187        index: u32,
188        /// A chunk of the JSON arguments string.
189        json_chunk: String,
190    },
191
192    /// The fully assembled tool call, ready to execute.
193    ToolCallComplete {
194        /// The tool-call index this completion corresponds to.
195        index: u32,
196        /// The complete, parsed tool call.
197        call: ToolCall,
198    },
199
200    /// Token usage information for this LLM call.
201    Usage(Usage),
202
203    // ── Loop lifecycle ──────────────────────────────────────────
204    /// A new iteration of the tool loop is starting.
205    IterationStart {
206        /// The iteration number (1-indexed).
207        iteration: u32,
208        /// Number of messages in the conversation so far.
209        message_count: usize,
210    },
211
212    /// About to execute a tool.
213    ///
214    /// When `parallel_tool_execution` is true, events arrive in **completion
215    /// order** (whichever tool finishes first), not the order the LLM listed
216    /// the calls. Use `call_id` to correlate start/end pairs.
217    ToolExecutionStart {
218        /// The tool call ID from the LLM.
219        call_id: String,
220        /// Name of the tool being called.
221        tool_name: String,
222        /// Arguments passed to the tool.
223        arguments: Value,
224    },
225
226    /// Tool execution completed.
227    ///
228    /// When `parallel_tool_execution` is true, events arrive in **completion
229    /// order**. Use `call_id` to correlate with the corresponding
230    /// [`ToolExecutionStart`](Self::ToolExecutionStart).
231    ToolExecutionEnd {
232        /// The tool call ID from the LLM.
233        call_id: String,
234        /// Name of the tool that was called.
235        tool_name: String,
236        /// The result from the tool.
237        result: ToolResult,
238        /// How long the tool took to execute.
239        duration: Duration,
240    },
241
242    /// A tool call loop was detected.
243    ///
244    /// Emitted when the same tool is called with identical arguments
245    /// for `threshold` consecutive times. Only emitted when
246    /// [`LoopDetectionConfig`] is configured.
247    LoopDetected {
248        /// Name of the tool being called repeatedly.
249        tool_name: String,
250        /// Number of consecutive identical calls detected.
251        consecutive_count: u32,
252        /// The action being taken in response.
253        action: LoopAction,
254    },
255
256    // ── Terminal ────────────────────────────────────────────────
257    /// The loop has finished. Carries the final [`ToolLoopResult`]
258    /// with the accumulated response, usage, iteration count, and
259    /// termination reason.
260    Done(ToolLoopResult),
261}
262
263/// Configuration for [`tool_loop`](super::tool_loop) and [`tool_loop_stream`](super::tool_loop_stream).
264pub struct ToolLoopConfig {
265    /// Maximum number of generate-execute iterations. Default: 10.
266    pub max_iterations: u32,
267    /// Whether to execute multiple tool calls in parallel. Default: true.
268    pub parallel_tool_execution: bool,
269    /// Optional callback to approve, deny, or modify each tool call
270    /// before execution.
271    ///
272    /// Called once per tool call in the LLM response, **after** the response
273    /// is assembled but **before** any tool is executed. Receives the
274    /// [`ToolCall`](crate::chat::ToolCall) as parsed from the LLM output.
275    /// Modified arguments are re-validated against the tool's schema.
276    ///
277    /// Panics in the callback propagate and terminate the loop.
278    pub on_tool_call: Option<ToolApprovalFn>,
279    /// Optional stop condition checked after each LLM response.
280    ///
281    /// Called **after** the LLM response is received but **before** tools
282    /// are executed. If the callback returns [`StopDecision::Stop`] or
283    /// [`StopDecision::StopWithReason`], the loop terminates immediately
284    /// without executing the requested tool calls.
285    ///
286    /// Receives a [`StopContext`] with information about the current
287    /// iteration and returns a [`StopDecision`]. Use this to implement:
288    ///
289    /// - `final_answer` tool patterns (stop when a specific tool is called)
290    /// - Token budget enforcement
291    /// - Total tool call limits
292    /// - Content pattern matching
293    ///
294    /// # Example
295    ///
296    /// ```rust,no_run
297    /// use llm_stack::tool::{ToolLoopConfig, StopDecision};
298    /// use std::sync::Arc;
299    ///
300    /// let config = ToolLoopConfig {
301    ///     stop_when: Some(Arc::new(|ctx| {
302    ///         // Stop if we've executed 5 or more tool calls
303    ///         if ctx.tool_calls_executed >= 5 {
304    ///             StopDecision::StopWithReason("Tool call limit reached".into())
305    ///         } else {
306    ///             StopDecision::Continue
307    ///         }
308    ///     })),
309    ///     ..Default::default()
310    /// };
311    /// ```
312    pub stop_when: Option<StopConditionFn>,
313
314    /// Optional loop detection to catch stuck agents.
315    ///
316    /// When enabled, tracks consecutive identical tool calls (same name
317    /// and arguments) and takes action when the threshold is reached.
318    ///
319    /// # Example
320    ///
321    /// ```rust
322    /// use llm_stack::tool::{ToolLoopConfig, LoopDetectionConfig, LoopAction};
323    ///
324    /// let config = ToolLoopConfig {
325    ///     loop_detection: Some(LoopDetectionConfig {
326    ///         threshold: 3,
327    ///         action: LoopAction::InjectWarning,
328    ///     }),
329    ///     ..Default::default()
330    /// };
331    /// ```
332    pub loop_detection: Option<LoopDetectionConfig>,
333
334    /// Maximum wall-clock time for the entire tool loop.
335    ///
336    /// If exceeded, returns with [`TerminationReason::Timeout`].
337    /// This is useful for enforcing time budgets in production systems.
338    ///
339    /// # Example
340    ///
341    /// ```rust
342    /// use llm_stack::tool::ToolLoopConfig;
343    /// use std::time::Duration;
344    ///
345    /// let config = ToolLoopConfig {
346    ///     timeout: Some(Duration::from_secs(30)),
347    ///     ..Default::default()
348    /// };
349    /// ```
350    pub timeout: Option<Duration>,
351
352    /// Maximum allowed nesting depth for recursive tool loops.
353    ///
354    /// When a tool calls `tool_loop` internally (e.g., spawning a sub-agent),
355    /// the depth is tracked via the context's [`LoopDepth`](super::LoopDepth)
356    /// implementation. If `ctx.loop_depth() >= max_depth` at entry,
357    /// returns `Err(LlmError::MaxDepthExceeded)`.
358    ///
359    /// - `Some(n)`: Error if depth >= n
360    /// - `None`: No limit (dangerous, use with caution)
361    ///
362    /// Default: `Some(3)` (allows master → worker → one more level)
363    ///
364    /// # Example
365    ///
366    /// ```rust
367    /// use llm_stack::tool::ToolLoopConfig;
368    ///
369    /// // Master/Worker pattern: master=0, worker=1, no grandchildren
370    /// let config = ToolLoopConfig {
371    ///     max_depth: Some(2),
372    ///     ..Default::default()
373    /// };
374    /// ```
375    pub max_depth: Option<u32>,
376}
377
378impl Clone for ToolLoopConfig {
379    fn clone(&self) -> Self {
380        Self {
381            max_iterations: self.max_iterations,
382            parallel_tool_execution: self.parallel_tool_execution,
383            on_tool_call: self.on_tool_call.clone(),
384            stop_when: self.stop_when.clone(),
385            loop_detection: self.loop_detection,
386            timeout: self.timeout,
387            max_depth: self.max_depth,
388        }
389    }
390}
391
392impl Default for ToolLoopConfig {
393    fn default() -> Self {
394        Self {
395            max_iterations: 10,
396            parallel_tool_execution: true,
397            on_tool_call: None,
398            stop_when: None,
399            loop_detection: None,
400            timeout: None,
401            max_depth: Some(3),
402        }
403    }
404}
405
406impl std::fmt::Debug for ToolLoopConfig {
407    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408        f.debug_struct("ToolLoopConfig")
409            .field("max_iterations", &self.max_iterations)
410            .field("parallel_tool_execution", &self.parallel_tool_execution)
411            .field("has_on_tool_call", &self.on_tool_call.is_some())
412            .field("has_stop_when", &self.stop_when.is_some())
413            .field("loop_detection", &self.loop_detection)
414            .field("timeout", &self.timeout)
415            .field("max_depth", &self.max_depth)
416            .finish()
417    }
418}
419
420/// Result of approving a tool call before execution.
421#[derive(Debug, Clone)]
422pub enum ToolApproval {
423    /// Allow the tool call to proceed as-is.
424    Approve,
425    /// Deny the tool call. The reason is sent back to the LLM as an
426    /// error tool result.
427    Deny(String),
428    /// Modify the tool call arguments before execution.
429    Modify(Value),
430}
431
432/// The result of a completed tool loop.
433#[derive(Debug, Clone)]
434pub struct ToolLoopResult {
435    /// The final response from the LLM (after all tool iterations).
436    pub response: ChatResponse,
437    /// How many generate-execute iterations were performed.
438    pub iterations: u32,
439    /// Accumulated usage across all iterations.
440    pub total_usage: Usage,
441    /// Why the loop terminated.
442    ///
443    /// This provides observability into the loop's completion reason,
444    /// useful for debugging and monitoring agent behavior.
445    pub termination_reason: TerminationReason,
446}
447
448/// Why a tool loop terminated.
449///
450/// Used for observability and debugging. Each variant captures specific
451/// information about why the loop ended.
452///
453/// # Example
454///
455/// ```rust,no_run
456/// use llm_stack::tool::TerminationReason;
457/// use std::time::Duration;
458///
459/// # fn check_result(reason: TerminationReason) {
460/// match reason {
461///     TerminationReason::Complete => println!("Task completed naturally"),
462///     TerminationReason::StopCondition { reason } => {
463///         println!("Custom stop: {}", reason.as_deref().unwrap_or("no reason"));
464///     }
465///     TerminationReason::MaxIterations { limit } => {
466///         println!("Hit iteration limit: {limit}");
467///     }
468///     TerminationReason::LoopDetected { tool_name, count } => {
469///         println!("Stuck calling {tool_name} {count} times");
470///     }
471///     TerminationReason::Timeout { limit } => {
472///         println!("Exceeded timeout: {limit:?}");
473///     }
474/// }
475/// # }
476/// ```
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub enum TerminationReason {
479    /// LLM returned a response with no tool calls (natural completion).
480    Complete,
481
482    /// Custom stop condition returned [`StopDecision::Stop`] or
483    /// [`StopDecision::StopWithReason`].
484    StopCondition {
485        /// The reason provided via [`StopDecision::StopWithReason`], if any.
486        reason: Option<String>,
487    },
488
489    /// Hit the `max_iterations` limit.
490    MaxIterations {
491        /// The configured limit that was reached.
492        limit: u32,
493    },
494
495    /// Loop detection triggered with [`LoopAction::Stop`].
496    LoopDetected {
497        /// Name of the tool being called repeatedly.
498        tool_name: String,
499        /// Number of consecutive identical calls.
500        count: u32,
501    },
502
503    /// Wall-clock timeout exceeded.
504    Timeout {
505        /// The configured timeout that was exceeded.
506        limit: Duration,
507    },
508}