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}