Skip to main content

llm_stack/tool/
loop_sync.rs

1//! Synchronous (non-streaming) tool loop implementation.
2
3use std::time::Instant;
4
5use crate::chat::{ChatMessage, ChatResponse, ContentBlock, StopReason, ToolCall, ToolResult};
6use crate::error::LlmError;
7use crate::provider::{ChatParams, DynProvider};
8use crate::usage::Usage;
9
10use super::LoopDepth;
11use super::ToolRegistry;
12use super::approval::approve_calls;
13use super::config::{
14    StopContext, StopDecision, TerminationReason, ToolLoopConfig, ToolLoopEvent, ToolLoopResult,
15};
16use super::execution::execute_with_events;
17use super::loop_detection::{IterationSnapshot, LoopDetectionState, handle_loop_detection};
18
19/// Runs the LLM in a tool-calling loop until completion.
20///
21/// Each iteration:
22/// 1. Calls `provider.generate_boxed()` with the current messages
23/// 2. If the response contains tool calls, executes them via the registry
24/// 3. Appends tool results as messages and repeats
25/// 4. Stops when the LLM returns without tool calls, or max iterations
26///    is reached
27///
28/// # Depth Tracking
29///
30/// If `Ctx` implements [`LoopDepth`], nested calls are tracked automatically.
31/// When `config.max_depth` is set and the context's depth exceeds the limit,
32/// returns `Err(LlmError::MaxDepthExceeded)`.
33///
34/// # Events
35///
36/// If `config.on_event` is set, the callback will be invoked with
37/// [`ToolLoopEvent`]s at key points during execution:
38/// - [`ToolLoopEvent::IterationStart`] at the beginning of each iteration
39/// - [`ToolLoopEvent::LlmResponseReceived`] after the LLM responds
40/// - [`ToolLoopEvent::ToolExecutionStart`] before each tool executes
41/// - [`ToolLoopEvent::ToolExecutionEnd`] after each tool completes
42///
43/// # Errors
44///
45/// Returns `LlmError` if:
46/// - The provider returns an error
47/// - Max depth is exceeded (returns `LlmError::MaxDepthExceeded`)
48/// - Max iterations is exceeded (returns in result with `TerminationReason::MaxIterations`)
49pub async fn tool_loop<Ctx: LoopDepth + Send + Sync + 'static>(
50    provider: &dyn DynProvider,
51    registry: &ToolRegistry<Ctx>,
52    mut params: ChatParams,
53    config: ToolLoopConfig,
54    ctx: &Ctx,
55) -> Result<ToolLoopResult, LlmError> {
56    // Check depth limit at entry
57    let current_depth = ctx.loop_depth();
58    if let Some(max_depth) = config.max_depth {
59        if current_depth >= max_depth {
60            return Err(LlmError::MaxDepthExceeded {
61                current: current_depth,
62                limit: max_depth,
63            });
64        }
65    }
66
67    // Create context with incremented depth for tool execution
68    let nested_ctx = ctx.with_depth(current_depth + 1);
69
70    let mut total_usage = Usage::default();
71    let mut iterations = 0u32;
72    let mut tool_calls_executed = 0usize;
73    let mut last_tool_results: Vec<ToolResult> = Vec::new();
74    let mut loop_state = LoopDetectionState::default();
75
76    // Track start time for timeout
77    let start_time = Instant::now();
78    let timeout_limit = config.timeout;
79
80    loop {
81        // Check timeout at the start of each iteration
82        if let Some(limit) = timeout_limit {
83            if start_time.elapsed() >= limit {
84                // Build a minimal response for timeout case
85                return Ok(ToolLoopResult {
86                    response: ChatResponse::empty(),
87                    iterations,
88                    total_usage,
89                    termination_reason: TerminationReason::Timeout { limit },
90                });
91            }
92        }
93
94        iterations += 1;
95
96        // Emit iteration start event
97        let msg_count = params.messages.len();
98        emit_event(&config, || ToolLoopEvent::IterationStart {
99            iteration: iterations,
100            message_count: msg_count,
101        });
102
103        let response = provider.generate_boxed(&params).await?;
104        total_usage += &response.usage;
105
106        // Get references for checks before potentially consuming response
107        let call_refs: Vec<&ToolCall> = response.tool_calls();
108        let text_length = response.text().map_or(0, str::len);
109        let has_tool_calls = !call_refs.is_empty();
110
111        // Emit response received event
112        emit_event(&config, || ToolLoopEvent::LlmResponseReceived {
113            iteration: iterations,
114            has_tool_calls,
115            text_length,
116        });
117
118        // Build the iteration snapshot once for both checks
119        let snap = IterationSnapshot {
120            response: &response,
121            call_refs: &call_refs,
122            iterations,
123            total_usage: &total_usage,
124            tool_calls_executed,
125            last_tool_results: &last_tool_results,
126            config: &config,
127        };
128
129        // Check stop condition and natural termination
130        if let Some(result) = check_stop_condition(&snap) {
131            return Ok(result);
132        }
133
134        if iterations > config.max_iterations {
135            return Ok(ToolLoopResult {
136                response,
137                iterations,
138                total_usage,
139                termination_reason: TerminationReason::MaxIterations {
140                    limit: config.max_iterations,
141                },
142            });
143        }
144
145        // Check for loop detection before executing tools
146        if let Some(result) = handle_loop_detection(&mut loop_state, &snap, &mut params.messages) {
147            return Ok(result);
148        }
149
150        // Now consume response and extract tool calls (no clone needed)
151        let (calls, other_content) = response.partition_content();
152
153        // Apply approval callback and execute with events
154        // Tools receive nested_ctx with incremented depth
155        let (approved_calls, denied_results) = approve_calls(calls, &config);
156        let results = execute_with_events(
157            registry,
158            approved_calls,
159            denied_results,
160            config.parallel_tool_execution,
161            &config,
162            &nested_ctx,
163        )
164        .await;
165
166        // Track executed tool calls
167        tool_calls_executed += results.len();
168        last_tool_results.clone_from(&results);
169
170        // Append assistant response + tool results to message history
171        // other_content already excludes ToolResult blocks (filtered by partition_content)
172        params.messages.push(ChatMessage {
173            role: crate::chat::ChatRole::Assistant,
174            content: other_content,
175        });
176
177        for result in results {
178            params.messages.push(ChatMessage::tool_result_full(result));
179        }
180    }
181}
182
183/// Emit an event if the callback is configured.
184///
185/// Takes a closure that produces the event, avoiding allocation when no callback is set.
186#[inline]
187pub(crate) fn emit_event<F>(config: &ToolLoopConfig, event_fn: F)
188where
189    F: FnOnce() -> ToolLoopEvent,
190{
191    if let Some(ref callback) = config.on_event {
192        callback(event_fn());
193    }
194}
195
196/// Check stop condition and natural termination, returning result if should stop.
197fn check_stop_condition(snap: &IterationSnapshot<'_>) -> Option<ToolLoopResult> {
198    // Check custom stop condition
199    if let Some(ref stop_fn) = snap.config.stop_when {
200        let ctx = StopContext {
201            iteration: snap.iterations,
202            response: snap.response,
203            total_usage: snap.total_usage,
204            tool_calls_executed: snap.tool_calls_executed,
205            last_tool_results: snap.last_tool_results,
206        };
207        match stop_fn(&ctx) {
208            StopDecision::Continue => {}
209            StopDecision::Stop => {
210                return Some(ToolLoopResult {
211                    response: snap.response.clone(),
212                    iterations: snap.iterations,
213                    total_usage: snap.total_usage.clone(),
214                    termination_reason: TerminationReason::StopCondition { reason: None },
215                });
216            }
217            StopDecision::StopWithReason(reason) => {
218                return Some(ToolLoopResult {
219                    response: snap.response.clone(),
220                    iterations: snap.iterations,
221                    total_usage: snap.total_usage.clone(),
222                    termination_reason: TerminationReason::StopCondition {
223                        reason: Some(reason),
224                    },
225                });
226            }
227        }
228    }
229
230    // Check natural termination (no tool calls)
231    if snap.call_refs.is_empty() || snap.response.stop_reason != StopReason::ToolUse {
232        return Some(ToolLoopResult {
233            response: snap.response.clone(),
234            iterations: snap.iterations,
235            total_usage: snap.total_usage.clone(),
236            termination_reason: TerminationReason::Complete,
237        });
238    }
239
240    None
241}
242
243// ── ChatMessage helper ──────────────────────────────────────────────
244
245impl ChatMessage {
246    /// Creates a tool result message from a [`ToolResult`].
247    pub fn tool_result_full(result: ToolResult) -> Self {
248        Self {
249            role: crate::chat::ChatRole::Tool,
250            content: vec![ContentBlock::ToolResult(result)],
251        }
252    }
253}