Skip to main content

llm_stack/tool/
config.rs

1//! Tool loop configuration types.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde_json::Value;
7
8use crate::chat::{ChatResponse, ToolCall, ToolResult};
9use crate::usage::Usage;
10
11/// Callback type for tool call approval.
12pub type ToolApprovalFn = Arc<dyn Fn(&ToolCall) -> ToolApproval + Send + Sync>;
13
14/// Callback type for tool loop events.
15pub type ToolLoopEventFn = Arc<dyn Fn(ToolLoopEvent) + Send + Sync>;
16
17/// Callback type for stop conditions.
18pub type StopConditionFn = Arc<dyn Fn(&StopContext) -> StopDecision + Send + Sync>;
19
20/// Context provided to stop condition callbacks.
21///
22/// Contains information about the current state of the tool loop
23/// to help decide whether to stop early.
24#[derive(Debug)]
25pub struct StopContext<'a> {
26    /// Current iteration number (1-indexed).
27    pub iteration: u32,
28    /// The response from this iteration.
29    pub response: &'a ChatResponse,
30    /// Accumulated usage across all iterations so far.
31    pub total_usage: &'a Usage,
32    /// Total number of tool calls executed so far (across all iterations).
33    pub tool_calls_executed: usize,
34    /// Tool results from the most recent execution (empty on first response).
35    pub last_tool_results: &'a [ToolResult],
36}
37
38/// Decision returned by a stop condition callback.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum StopDecision {
41    /// Continue the tool loop normally.
42    Continue,
43    /// Stop the loop immediately, using the current response as final.
44    Stop,
45    /// Stop the loop with a reason (for observability/debugging).
46    StopWithReason(String),
47}
48
49/// Configuration for detecting repeated tool calls (stuck agents).
50///
51/// When an agent repeatedly makes the same tool call with identical arguments,
52/// it's usually stuck in a loop. This configuration detects that pattern and
53/// takes action to break the cycle.
54///
55/// # Example
56///
57/// ```rust
58/// use llm_stack::tool::{LoopDetectionConfig, LoopAction};
59///
60/// let config = LoopDetectionConfig {
61///     threshold: 3,  // Trigger after 3 consecutive identical calls
62///     action: LoopAction::InjectWarning,  // Tell the agent it's looping
63/// };
64/// ```
65#[derive(Debug, Clone)]
66pub struct LoopDetectionConfig {
67    /// Number of consecutive identical tool calls before triggering.
68    ///
69    /// A tool call is "identical" if it has the same name and arguments
70    /// (compared via JSON equality). Default: 3.
71    pub threshold: u32,
72
73    /// Action to take when a loop is detected.
74    pub action: LoopAction,
75}
76
77impl Default for LoopDetectionConfig {
78    fn default() -> Self {
79        Self {
80            threshold: 3,
81            action: LoopAction::Warn,
82        }
83    }
84}
85
86/// Action to take when a tool call loop is detected.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum LoopAction {
89    /// Emit [`ToolLoopEvent::LoopDetected`] and continue execution.
90    ///
91    /// Use this for monitoring/alerting without interrupting the agent.
92    Warn,
93
94    /// Stop the loop immediately with an error.
95    ///
96    /// Returns `LlmError::ToolExecution` describing the loop.
97    Stop,
98
99    /// Inject a warning message into the conversation and continue.
100    ///
101    /// Adds a system message like "You have called {tool} with identical
102    /// arguments {n} times. Try a different approach." This often helps
103    /// the agent break out of the loop.
104    InjectWarning,
105}
106
107/// Events emitted during tool loop execution for observability.
108///
109/// These events allow UIs to show real-time progress:
110/// - "Iteration 3 starting"
111/// - "Calling tool `search`..."
112/// - "Tool `search` completed in 200ms"
113///
114/// # Example
115///
116/// ```rust,no_run
117/// use llm_stack::tool::{ToolLoopConfig, ToolLoopEvent};
118/// use std::sync::Arc;
119///
120/// let config = ToolLoopConfig {
121///     on_event: Some(Arc::new(|event| {
122///         match event {
123///             ToolLoopEvent::IterationStart { iteration, .. } => {
124///                 println!("Starting iteration {iteration}");
125///             }
126///             ToolLoopEvent::ToolExecutionStart { tool_name, .. } => {
127///                 println!("Calling {tool_name}...");
128///             }
129///             ToolLoopEvent::ToolExecutionEnd { tool_name, duration, .. } => {
130///                 println!("{tool_name} completed in {duration:?}");
131///             }
132///             _ => {}
133///         }
134///     })),
135///     ..Default::default()
136/// };
137/// ```
138#[derive(Debug, Clone)]
139pub enum ToolLoopEvent {
140    /// A new iteration of the tool loop is starting.
141    IterationStart {
142        /// The iteration number (1-indexed).
143        iteration: u32,
144        /// Number of messages in the conversation so far.
145        message_count: usize,
146    },
147
148    /// About to execute a tool.
149    ToolExecutionStart {
150        /// The tool call ID from the LLM.
151        call_id: String,
152        /// Name of the tool being called.
153        tool_name: String,
154        /// Arguments passed to the tool.
155        arguments: Value,
156    },
157
158    /// Tool execution completed.
159    ToolExecutionEnd {
160        /// The tool call ID from the LLM.
161        call_id: String,
162        /// Name of the tool that was called.
163        tool_name: String,
164        /// The result from the tool.
165        result: ToolResult,
166        /// How long the tool took to execute.
167        duration: Duration,
168    },
169
170    /// LLM response received for this iteration.
171    LlmResponseReceived {
172        /// The iteration number (1-indexed).
173        iteration: u32,
174        /// Whether the response contains tool calls.
175        has_tool_calls: bool,
176        /// Length of any text content in the response.
177        text_length: usize,
178    },
179
180    /// A tool call loop was detected.
181    ///
182    /// Emitted when the same tool is called with identical arguments
183    /// for `threshold` consecutive times. Only emitted when
184    /// [`LoopDetectionConfig`] is configured.
185    LoopDetected {
186        /// Name of the tool being called repeatedly.
187        tool_name: String,
188        /// Number of consecutive identical calls detected.
189        consecutive_count: u32,
190        /// The action being taken in response.
191        action: LoopAction,
192    },
193}
194
195/// Configuration for [`tool_loop`](super::tool_loop) and [`tool_loop_stream`](super::tool_loop_stream).
196pub struct ToolLoopConfig {
197    /// Maximum number of generate-execute iterations. Default: 10.
198    pub max_iterations: u32,
199    /// Whether to execute multiple tool calls in parallel. Default: true.
200    pub parallel_tool_execution: bool,
201    /// Optional callback to approve, deny, or modify each tool call
202    /// before execution.
203    pub on_tool_call: Option<ToolApprovalFn>,
204    /// Optional callback invoked during loop execution for observability.
205    ///
206    /// Receives [`ToolLoopEvent`]s at key points: iteration start,
207    /// tool execution start/end, and LLM response received.
208    pub on_event: Option<ToolLoopEventFn>,
209    /// Optional stop condition checked after each LLM response.
210    ///
211    /// Receives a [`StopContext`] with information about the current
212    /// iteration and returns a [`StopDecision`]. Use this to implement:
213    ///
214    /// - `final_answer` tool patterns (stop when a specific tool is called)
215    /// - Token budget enforcement
216    /// - Total tool call limits
217    /// - Content pattern matching
218    ///
219    /// # Example
220    ///
221    /// ```rust,no_run
222    /// use llm_stack::tool::{ToolLoopConfig, StopDecision};
223    /// use std::sync::Arc;
224    ///
225    /// let config = ToolLoopConfig {
226    ///     stop_when: Some(Arc::new(|ctx| {
227    ///         // Stop if we've executed 5 or more tool calls
228    ///         if ctx.tool_calls_executed >= 5 {
229    ///             StopDecision::StopWithReason("Tool call limit reached".into())
230    ///         } else {
231    ///             StopDecision::Continue
232    ///         }
233    ///     })),
234    ///     ..Default::default()
235    /// };
236    /// ```
237    pub stop_when: Option<StopConditionFn>,
238
239    /// Optional loop detection to catch stuck agents.
240    ///
241    /// When enabled, tracks consecutive identical tool calls (same name
242    /// and arguments) and takes action when the threshold is reached.
243    ///
244    /// # Example
245    ///
246    /// ```rust
247    /// use llm_stack::tool::{ToolLoopConfig, LoopDetectionConfig, LoopAction};
248    ///
249    /// let config = ToolLoopConfig {
250    ///     loop_detection: Some(LoopDetectionConfig {
251    ///         threshold: 3,
252    ///         action: LoopAction::InjectWarning,
253    ///     }),
254    ///     ..Default::default()
255    /// };
256    /// ```
257    pub loop_detection: Option<LoopDetectionConfig>,
258
259    /// Maximum wall-clock time for the entire tool loop.
260    ///
261    /// If exceeded, returns with [`TerminationReason::Timeout`].
262    /// This is useful for enforcing time budgets in production systems.
263    ///
264    /// # Example
265    ///
266    /// ```rust
267    /// use llm_stack::tool::ToolLoopConfig;
268    /// use std::time::Duration;
269    ///
270    /// let config = ToolLoopConfig {
271    ///     timeout: Some(Duration::from_secs(30)),
272    ///     ..Default::default()
273    /// };
274    /// ```
275    pub timeout: Option<Duration>,
276
277    /// Maximum allowed nesting depth for recursive tool loops.
278    ///
279    /// When a tool calls `tool_loop` internally (e.g., spawning a sub-agent),
280    /// the depth is tracked via the context's [`LoopDepth`](super::LoopDepth)
281    /// implementation. If `ctx.loop_depth() >= max_depth` at entry,
282    /// returns `Err(LlmError::MaxDepthExceeded)`.
283    ///
284    /// - `Some(n)`: Error if depth >= n
285    /// - `None`: No limit (dangerous, use with caution)
286    ///
287    /// Default: `Some(3)` (allows master → worker → one more level)
288    ///
289    /// # Example
290    ///
291    /// ```rust
292    /// use llm_stack::tool::ToolLoopConfig;
293    ///
294    /// // Master/Worker pattern: master=0, worker=1, no grandchildren
295    /// let config = ToolLoopConfig {
296    ///     max_depth: Some(2),
297    ///     ..Default::default()
298    /// };
299    /// ```
300    pub max_depth: Option<u32>,
301}
302
303impl Default for ToolLoopConfig {
304    fn default() -> Self {
305        Self {
306            max_iterations: 10,
307            parallel_tool_execution: true,
308            on_tool_call: None,
309            on_event: None,
310            stop_when: None,
311            loop_detection: None,
312            timeout: None,
313            max_depth: Some(3),
314        }
315    }
316}
317
318impl std::fmt::Debug for ToolLoopConfig {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        f.debug_struct("ToolLoopConfig")
321            .field("max_iterations", &self.max_iterations)
322            .field("parallel_tool_execution", &self.parallel_tool_execution)
323            .field("has_on_tool_call", &self.on_tool_call.is_some())
324            .field("has_on_event", &self.on_event.is_some())
325            .field("has_stop_when", &self.stop_when.is_some())
326            .field("loop_detection", &self.loop_detection)
327            .field("timeout", &self.timeout)
328            .field("max_depth", &self.max_depth)
329            .finish()
330    }
331}
332
333/// Result of approving a tool call before execution.
334#[derive(Debug, Clone)]
335pub enum ToolApproval {
336    /// Allow the tool call to proceed as-is.
337    Approve,
338    /// Deny the tool call. The reason is sent back to the LLM as an
339    /// error tool result.
340    Deny(String),
341    /// Modify the tool call arguments before execution.
342    Modify(Value),
343}
344
345/// The result of a completed tool loop.
346#[derive(Debug)]
347pub struct ToolLoopResult {
348    /// The final response from the LLM (after all tool iterations).
349    pub response: ChatResponse,
350    /// How many generate-execute iterations were performed.
351    pub iterations: u32,
352    /// Accumulated usage across all iterations.
353    pub total_usage: Usage,
354    /// Why the loop terminated.
355    ///
356    /// This provides observability into the loop's completion reason,
357    /// useful for debugging and monitoring agent behavior.
358    pub termination_reason: TerminationReason,
359}
360
361/// Why a tool loop terminated.
362///
363/// Used for observability and debugging. Each variant captures specific
364/// information about why the loop ended.
365///
366/// # Example
367///
368/// ```rust,no_run
369/// use llm_stack::tool::TerminationReason;
370/// use std::time::Duration;
371///
372/// # fn check_result(reason: TerminationReason) {
373/// match reason {
374///     TerminationReason::Complete => println!("Task completed naturally"),
375///     TerminationReason::StopCondition { reason } => {
376///         println!("Custom stop: {}", reason.as_deref().unwrap_or("no reason"));
377///     }
378///     TerminationReason::MaxIterations { limit } => {
379///         println!("Hit iteration limit: {limit}");
380///     }
381///     TerminationReason::LoopDetected { tool_name, count } => {
382///         println!("Stuck calling {tool_name} {count} times");
383///     }
384///     TerminationReason::Timeout { limit } => {
385///         println!("Exceeded timeout: {limit:?}");
386///     }
387/// }
388/// # }
389/// ```
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub enum TerminationReason {
392    /// LLM returned a response with no tool calls (natural completion).
393    Complete,
394
395    /// Custom stop condition returned [`StopDecision::Stop`] or
396    /// [`StopDecision::StopWithReason`].
397    StopCondition {
398        /// The reason provided via [`StopDecision::StopWithReason`], if any.
399        reason: Option<String>,
400    },
401
402    /// Hit the `max_iterations` limit.
403    MaxIterations {
404        /// The configured limit that was reached.
405        limit: u32,
406    },
407
408    /// Loop detection triggered with [`LoopAction::Stop`].
409    LoopDetected {
410        /// Name of the tool being called repeatedly.
411        tool_name: String,
412        /// Number of consecutive identical calls.
413        count: u32,
414    },
415
416    /// Wall-clock timeout exceeded.
417    Timeout {
418        /// The configured timeout that was exceeded.
419        limit: Duration,
420    },
421}