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}