1use lellm_core::{ChatResponse, LlmError, Message};
17use lellm_provider::ResolvedModel;
18use std::sync::Arc;
19
20use super::config::{ToolUseConfig, ToolUseDeps, build_request_messages_inner, empty_response};
21use super::context::{
22 CompactionResult, ContextBudget, ContextCompactor, LocalCompactor, estimate_reasoning_block,
23 estimate_text, estimate_tokens,
24};
25use super::event::{AgentEvent, AgentStream, StopReason};
26use super::fallback::{FallbackAction, FallbackContext};
27use super::iteration::{StreamIterResult, do_stream_iteration, emit, execute_with_fallback};
28use super::tools::{ToolExecutor, ToolSnapshot, execute_batch_with};
29
30#[derive(Clone)]
36pub struct ResolvedRound {
37 pub snapshot: Arc<ToolSnapshot>,
39 pub definitions: Vec<lellm_core::ToolDefinition>,
41}
42
43impl ResolvedRound {
44 pub fn new(snapshot: Arc<ToolSnapshot>) -> Self {
45 Self {
46 definitions: snapshot.definitions().to_vec(),
47 snapshot,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
58pub struct LoopState {
59 pub messages: Vec<Message>,
60 pub estimated_tokens: usize,
62 pub iterations: usize,
63 pub tool_calls_executed: usize,
64 pub total_output_tokens: usize,
67 pub total_reasoning_tokens: usize,
70}
71
72impl LoopState {
73 pub fn new(messages: Vec<Message>) -> Self {
74 let estimated_tokens = estimate_tokens(&messages);
75 Self {
76 messages,
77 estimated_tokens,
78 iterations: 0,
79 tool_calls_executed: 0,
80 total_output_tokens: 0,
81 total_reasoning_tokens: 0,
82 }
83 }
84
85 pub fn add_output_tokens(&mut self, tokens: usize) {
87 self.total_output_tokens += tokens;
88 }
89
90 pub fn add_reasoning_tokens(&mut self, tokens: usize) {
92 self.total_reasoning_tokens += tokens;
93 }
94
95 pub fn exceeded_total_output(&self, max: Option<u32>) -> bool {
97 match max {
98 Some(limit) => self.total_output_tokens >= limit as usize,
99 None => false,
100 }
101 }
102
103 pub fn exceeded_total_reasoning(&self, max: Option<u32>) -> bool {
105 match max {
106 Some(limit) => self.total_reasoning_tokens >= limit as usize,
107 None => false,
108 }
109 }
110
111 pub fn add_output_from_content(&mut self, content: &[lellm_core::ContentBlock]) {
113 let mut output_tokens: usize = 0;
114 let mut reasoning_tokens: usize = 0;
115 for b in content {
116 match b {
117 lellm_core::ContentBlock::Text(t) => output_tokens += estimate_text(&t.text),
118 lellm_core::ContentBlock::Thinking(th) => {
119 reasoning_tokens += estimate_reasoning_block(th)
120 }
121 lellm_core::ContentBlock::Image { .. } | lellm_core::ContentBlock::ToolCall(_) => {}
122 }
123 }
124 self.total_output_tokens += output_tokens;
125 self.total_reasoning_tokens += reasoning_tokens;
126 }
127
128 pub fn push_assistant(&mut self, content: Vec<lellm_core::ContentBlock>) {
130 let msg = Message::Assistant {
131 content: content.clone(),
132 };
133 let tokens = estimate_tokens(&[msg]);
134 self.estimated_tokens += tokens;
135 self.messages.push(Message::Assistant { content });
136 }
137
138 pub fn push_tool_results(&mut self, results: Vec<Message>, budget: &ContextBudget) {
140 let results: Vec<Message> = results
141 .into_iter()
142 .map(|m| {
143 if let Message::ToolResult {
144 ref tool_call_id,
145 is_error: false,
146 ref content,
147 } = m
148 {
149 let truncated = budget.truncate_tool_result_blocks(content);
150 if truncated != *content {
151 return Message::ToolResult {
152 tool_call_id: tool_call_id.clone(),
153 is_error: false,
154 content: truncated,
155 };
156 }
157 }
158 m
159 })
160 .collect();
161 let tokens = estimate_tokens(&results);
162 self.estimated_tokens += tokens;
163 self.messages.extend(results);
164 }
165
166 pub fn add_tool_calls(&mut self, count: usize) {
168 self.tool_calls_executed += count;
169 }
170
171 pub fn next_iteration(&mut self) {
173 self.iterations += 1;
174 }
175
176 pub fn reached_max(&self, max_iterations: usize) -> bool {
178 self.iterations >= max_iterations
179 }
180
181 pub fn compact(
183 &mut self,
184 budget: &ContextBudget,
185 compactor: &dyn ContextCompactor,
186 ) -> Option<CompactionResult> {
187 if !budget.should_compact(self.estimated_tokens) {
188 return None;
189 }
190 let result = compactor.compact(&self.messages, budget);
191 self.messages = result.messages.clone();
192 self.estimated_tokens = result.after_tokens;
193 Some(result)
194 }
195
196 pub fn finish(&self, stop_reason: StopReason, response: ChatResponse) -> ToolUseResult {
198 ToolUseResult {
199 stop_reason,
200 response,
201 messages: self.messages.clone(),
202 iterations: self.iterations,
203 tool_calls_executed: self.tool_calls_executed,
204 }
205 }
206
207 pub fn finish_complete(&self, response: ChatResponse) -> ToolUseResult {
208 self.finish(StopReason::Complete, response)
209 }
210
211 pub fn finish_max_iterations(&self, response: ChatResponse) -> ToolUseResult {
212 self.finish(StopReason::MaxIterationsReached, response)
213 }
214
215 pub fn finish_cancelled(&self, response: ChatResponse) -> ToolUseResult {
216 self.finish(StopReason::Cancelled, response)
217 }
218
219 pub fn finish_output_budget(&self, response: ChatResponse) -> ToolUseResult {
220 self.finish(StopReason::OutputBudgetExceeded, response)
221 }
222
223 pub fn finish_reasoning_budget(&self, response: ChatResponse) -> ToolUseResult {
224 self.finish(StopReason::ReasoningBudgetExceeded, response)
225 }
226}
227
228#[derive(Debug, Clone)]
232pub struct ToolUseResult {
233 pub stop_reason: StopReason,
234 pub response: ChatResponse,
235 pub messages: Vec<Message>,
236 pub iterations: usize,
237 pub tool_calls_executed: usize,
238}
239
240impl ToolUseResult {
241 pub fn is_success(&self) -> bool {
242 matches!(self.stop_reason, StopReason::Complete)
243 }
244}
245
246#[derive(Clone)]
252pub struct ToolUseLoop {
253 model: ResolvedModel,
254 executor: ToolExecutor,
255 config: ToolUseConfig,
256 deps: ToolUseDeps,
257}
258
259impl ToolUseLoop {
260 pub fn new(
261 model: ResolvedModel,
262 executor: ToolExecutor,
263 config: ToolUseConfig,
264 deps: ToolUseDeps,
265 ) -> Self {
266 if config.stream_thinking {
267 let caps = model.provider.capabilities_for(&model.model);
268 if !caps.supports_stream_thinking {
269 tracing::warn!(
270 provider = %model.provider.provider_id(),
271 model = %model.model,
272 "stream_thinking=true but provider does not support thinking deltas; \
273 reasoning content will only be available in the final response"
274 );
275 }
276 }
277
278 Self {
279 model,
280 executor,
281 config,
282 deps,
283 }
284 }
285
286 pub fn simple(model: ResolvedModel, executor: ToolExecutor) -> Self {
288 Self::new(
289 model,
290 executor,
291 ToolUseConfig::default(),
292 ToolUseDeps::default(),
293 )
294 }
295
296 pub async fn execute(&self, messages: Vec<Message>) -> Result<ToolUseResult, LlmError> {
298 let initial_messages = build_request_messages_inner(&self.config, &messages)?;
299 let mut state = LoopState::new(initial_messages);
300 let mut last_response: Option<ChatResponse> = None;
301 let compactor: Box<dyn ContextCompactor> = Box::new(LocalCompactor::new());
302
303 loop {
304 if state.reached_max(self.config.max_iterations) {
305 return Ok(
306 state.finish_max_iterations(last_response.unwrap_or_else(empty_response))
307 );
308 }
309 state.next_iteration();
310 state.compact(&self.config.context_budget, &*compactor);
311
312 let round = ResolvedRound::new(self.executor.snapshot().await);
314
315 let req = super::config::build_request_inner_with_round(
317 &self.model,
318 &state.messages,
319 self.config.max_output_tokens,
320 &self.config.request_options,
321 state.iterations,
322 &round.definitions,
323 );
324
325 let iteration = state.iterations;
327 let msg_snapshot = state.messages.clone();
328 let response = execute_with_fallback(
329 &self.deps.fallback,
330 |_| true, || self.model.provider.call(&req),
332 iteration,
333 &msg_snapshot,
334 )
335 .await?;
336 last_response = Some(response.clone());
337
338 if let Some(limit) = self.config.request_options.max_reasoning_tokens {
340 let round_reasoning: usize = response
341 .content
342 .iter()
343 .filter_map(|b| match b {
344 lellm_core::ContentBlock::Thinking(th) => {
345 Some(estimate_reasoning_block(th))
346 }
347 _ => None,
348 })
349 .sum();
350 if round_reasoning > limit as usize {
351 tracing::warn!(
352 round_reasoning,
353 max_reasoning_tokens = limit,
354 "single-round reasoning budget exceeded (non-stream, soft limit)"
355 );
356 return Ok(state.finish_reasoning_budget(response));
357 }
358 }
359
360 state.add_output_from_content(&response.content);
362
363 if state.exceeded_total_output(self.config.max_total_output_tokens) {
364 return Ok(state.finish_output_budget(response));
365 }
366
367 if state.exceeded_total_reasoning(self.config.max_total_reasoning_tokens) {
368 return Ok(state.finish_reasoning_budget(response));
369 }
370
371 if !response.has_tool_calls() {
372 return Ok(state.finish_complete(response));
373 }
374
375 let tool_calls: Vec<_> = response.tool_calls().cloned().collect();
376 state.push_assistant(response.content.clone());
377 state.add_tool_calls(tool_calls.len());
378
379 let batch =
380 execute_batch_with(&tool_calls, &round.snapshot, &self.executor.retry_policy())
381 .await;
382
383 if batch.panicked {
384 tracing::warn!("tool batch task panicked — error results filled in by executor");
385 }
386
387 state.push_tool_results(batch.results, &self.config.context_budget);
388
389 tracing::debug!(
390 iteration = state.iterations,
391 tool_calls = tool_calls.len(),
392 "tool-use loop iteration"
393 );
394 }
395 }
396
397 pub fn execute_stream(&self, messages: Vec<Message>) -> AgentStream {
399 let (tx, rx) = tokio::sync::mpsc::channel(32);
400 let model = self.model.clone();
401 let executor = self.executor.clone();
402 let config = self.config.clone();
403 let deps = self.deps.clone();
404
405 tokio::spawn(async move {
406 let initial_messages = match build_request_messages_inner(&config, &messages) {
407 Ok(m) => m,
408 Err(e) => {
409 let _ = tokio::sync::mpsc::Sender::send(
410 &tx,
411 AgentEvent::LoopError {
412 error: e,
413 iterations: 0,
414 },
415 )
416 .await;
417 return;
418 }
419 };
420 let mut state = LoopState::new(initial_messages);
421 let mut last_response: Option<ChatResponse> = None;
422 let compactor: Box<dyn ContextCompactor> = Box::new(LocalCompactor::new());
423
424 loop {
425 if state.reached_max(config.max_iterations) {
426 let _ = emit(
427 &tx,
428 AgentEvent::LoopEnd {
429 result: state.finish_max_iterations(
430 last_response.unwrap_or_else(empty_response),
431 ),
432 },
433 )
434 .await;
435 return;
436 }
437
438 state.next_iteration();
439 if let Some(compact_result) = state.compact(&config.context_budget, &*compactor) {
440 let _ = emit(
441 &tx,
442 AgentEvent::ContextCompacted {
443 before_tokens: compact_result.before_tokens,
444 after_tokens: compact_result.after_tokens,
445 removed_messages: compact_result.removed_messages,
446 },
447 )
448 .await;
449 }
450
451 let round = ResolvedRound::new(executor.snapshot().await);
453
454 let req = super::config::build_request_inner_with_round(
455 &model,
456 &state.messages,
457 config.max_output_tokens,
458 &config.request_options,
459 state.iterations,
460 &round.definitions,
461 );
462
463 let iteration = state.iterations;
465 let attempt_state = state.clone();
466 let mut attempt: usize = 1;
467
468 let result = loop {
469 let iter_result = do_stream_iteration(
470 model.clone(),
471 tx.clone(),
472 executor.clone(),
473 attempt_state.clone(),
474 req.clone(),
475 config.context_budget.clone(),
476 config.max_output_tokens,
477 config.stream_thinking,
478 round.clone(),
479 )
480 .await;
481
482 match iter_result.result {
483 Ok(v) => break Ok(v),
484 Err(ref err) => {
485 tracing::warn!(
486 attempt = attempt,
487 error = %err,
488 stream_started = iter_result.stream_started,
489 "stream iteration failed, fallback handling"
490 );
491
492 if iter_result.stream_started {
493 let e: LlmError = err.clone();
494 break Err(e);
495 }
496
497 let ctx = FallbackContext {
498 error: err,
499 attempt,
500 iterations: iteration,
501 conversation: std::sync::Arc::from(
502 attempt_state.messages.as_slice(),
503 ),
504 };
505 match deps.fallback.handle(&ctx).await {
506 FallbackAction::Retry => {
507 attempt += 1;
508 }
509 FallbackAction::Abort => {
510 break Err(err.clone());
511 }
512 }
513 }
514 }
515 };
516
517 let result = match result {
519 Ok((r, s)) => {
520 state = s;
521 Ok(r)
522 }
523 Err(e) => Err(e),
524 };
525
526 if state.exceeded_total_output(config.max_total_output_tokens) {
528 let _ = emit(
529 &tx,
530 AgentEvent::LoopEnd {
531 result: state
532 .finish_output_budget(last_response.unwrap_or_else(empty_response)),
533 },
534 )
535 .await;
536 return;
537 }
538
539 if state.exceeded_total_reasoning(config.max_total_reasoning_tokens) {
540 let _ = emit(
541 &tx,
542 AgentEvent::LoopEnd {
543 result: state.finish_reasoning_budget(
544 last_response.unwrap_or_else(empty_response),
545 ),
546 },
547 )
548 .await;
549 return;
550 }
551
552 match result {
553 Ok(StreamIterResult::Continue { response }) => {
554 last_response = Some(response);
555 }
556 Ok(StreamIterResult::Complete { response }) => {
557 let _ = emit(
558 &tx,
559 AgentEvent::LoopEnd {
560 result: state.finish_complete(response),
561 },
562 )
563 .await;
564 return;
565 }
566 Ok(StreamIterResult::Cancelled { response }) => {
567 let resp = response
568 .or(last_response.take())
569 .unwrap_or_else(empty_response);
570 let _ = emit(
571 &tx,
572 AgentEvent::LoopEnd {
573 result: state.finish_cancelled(resp),
574 },
575 )
576 .await;
577 return;
578 }
579 Ok(StreamIterResult::OutputBudgetExceeded { response }) => {
580 tracing::warn!(
581 total_output_tokens = state.total_output_tokens,
582 "single-round output budget exceeded, stopping agent"
583 );
584 let _ = emit(
585 &tx,
586 AgentEvent::LoopEnd {
587 result: state.finish_output_budget(response),
588 },
589 )
590 .await;
591 return;
592 }
593 Ok(StreamIterResult::ReasoningBudgetExceeded { response }) => {
594 tracing::warn!(
595 total_reasoning_tokens = state.total_reasoning_tokens,
596 "single-round reasoning budget exceeded, stopping agent"
597 );
598 let _ = emit(
599 &tx,
600 AgentEvent::LoopEnd {
601 result: state.finish_reasoning_budget(response),
602 },
603 )
604 .await;
605 return;
606 }
607 Err(e) => {
608 let _ = emit(
609 &tx,
610 AgentEvent::LoopError {
611 error: e,
612 iterations: state.iterations,
613 },
614 )
615 .await;
616 return;
617 }
618 }
619 }
620 });
621
622 rx
623 }
624}