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::{LoopDetectionState, handle_loop_detection_refs};
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        // Check stop condition and natural termination (uses references only)
119        if let Some(result) = check_stop_condition_refs(
120            &config,
121            &response,
122            iterations,
123            &total_usage,
124            tool_calls_executed,
125            &last_tool_results,
126            &call_refs,
127        ) {
128            return Ok(result);
129        }
130
131        if iterations > config.max_iterations {
132            return Ok(ToolLoopResult {
133                response,
134                iterations,
135                total_usage,
136                termination_reason: TerminationReason::MaxIterations {
137                    limit: config.max_iterations,
138                },
139            });
140        }
141
142        // Check for loop detection before executing tools (uses references only)
143        if let Some(result) = handle_loop_detection_refs(
144            &mut loop_state,
145            &call_refs,
146            config.loop_detection.as_ref(),
147            &config,
148            &mut params.messages,
149            &response,
150            iterations,
151            &total_usage,
152        ) {
153            return Ok(result);
154        }
155
156        // Now consume response and extract tool calls (no clone needed)
157        let (calls, other_content) = response.partition_content();
158
159        // Apply approval callback and execute with events
160        // Tools receive nested_ctx with incremented depth
161        let (approved_calls, denied_results) = approve_calls(&calls, &config);
162        let results = execute_with_events(
163            registry,
164            &approved_calls,
165            denied_results,
166            config.parallel_tool_execution,
167            &config,
168            &nested_ctx,
169        )
170        .await;
171
172        // Track executed tool calls
173        tool_calls_executed += results.len();
174        last_tool_results.clone_from(&results);
175
176        // Append assistant response + tool results to message history
177        // other_content already excludes ToolResult blocks (filtered by partition_content)
178        params.messages.push(ChatMessage {
179            role: crate::chat::ChatRole::Assistant,
180            content: other_content,
181        });
182
183        for result in results {
184            params.messages.push(ChatMessage::tool_result_full(result));
185        }
186    }
187}
188
189/// Emit an event if the callback is configured.
190///
191/// Takes a closure that produces the event, avoiding allocation when no callback is set.
192#[inline]
193pub(crate) fn emit_event<F>(config: &ToolLoopConfig, event_fn: F)
194where
195    F: FnOnce() -> ToolLoopEvent,
196{
197    if let Some(ref callback) = config.on_event {
198        callback(event_fn());
199    }
200}
201
202/// Check stop condition and natural termination, returning result if should stop.
203/// This version works with references to tool calls (before consuming response).
204#[allow(clippy::too_many_arguments)]
205fn check_stop_condition_refs(
206    config: &ToolLoopConfig,
207    response: &ChatResponse,
208    iterations: u32,
209    total_usage: &Usage,
210    tool_calls_executed: usize,
211    last_tool_results: &[ToolResult],
212    call_refs: &[&ToolCall],
213) -> Option<ToolLoopResult> {
214    // Check custom stop condition
215    if let Some(ref stop_fn) = config.stop_when {
216        let ctx = StopContext {
217            iteration: iterations,
218            response,
219            total_usage,
220            tool_calls_executed,
221            last_tool_results,
222        };
223        match stop_fn(&ctx) {
224            StopDecision::Continue => {}
225            StopDecision::Stop => {
226                return Some(ToolLoopResult {
227                    response: response.clone(),
228                    iterations,
229                    total_usage: total_usage.clone(),
230                    termination_reason: TerminationReason::StopCondition { reason: None },
231                });
232            }
233            StopDecision::StopWithReason(reason) => {
234                return Some(ToolLoopResult {
235                    response: response.clone(),
236                    iterations,
237                    total_usage: total_usage.clone(),
238                    termination_reason: TerminationReason::StopCondition {
239                        reason: Some(reason),
240                    },
241                });
242            }
243        }
244    }
245
246    // Check natural termination (no tool calls)
247    if call_refs.is_empty() || response.stop_reason != StopReason::ToolUse {
248        return Some(ToolLoopResult {
249            response: response.clone(),
250            iterations,
251            total_usage: total_usage.clone(),
252            termination_reason: TerminationReason::Complete,
253        });
254    }
255
256    None
257}
258
259// ── ChatMessage helper ──────────────────────────────────────────────
260
261impl ChatMessage {
262    /// Creates a tool result message from a [`ToolResult`].
263    pub fn tool_result_full(result: ToolResult) -> Self {
264        Self {
265            role: crate::chat::ChatRole::Tool,
266            content: vec![ContentBlock::ToolResult(result)],
267        }
268    }
269}