1use std::sync::Arc;
25
26use tracing::{debug, info};
27
28use crate::tool_simulation::{
29 format_tool_results_as_text, generate_tool_catalog, inject_tool_catalog,
30 parse_tool_call_blocks, strip_tool_call_blocks, FunctionCall, FunctionDeclaration,
31 TextToolHandler,
32};
33use crate::types::{ChatMessage, ChatRequest, LlmProvider, RunnerError, TokenUsage};
34
35const DEFAULT_MAX_TURNS: u32 = 10;
37
38const MAX_TURNS_CEILING: u32 = 50;
40
41pub type OnTurnCallback = Arc<dyn Fn(&TurnInfo) + Send + Sync>;
43
44#[derive(Debug, Clone)]
46pub struct TurnInfo {
47 pub turn: u32,
49 pub tool_calls: Vec<FunctionCall>,
51 pub content: String,
53 pub usage: Option<TokenUsage>,
55}
56
57#[derive(Debug, Clone)]
59pub struct AgentResult {
60 pub content: String,
62 pub tool_calls: Vec<FunctionCall>,
64 pub total_turns: u32,
66 pub total_usage: TokenUsage,
68 pub finish_reason: Option<String>,
70}
71
72pub struct AgentExecutor<'a> {
101 provider: &'a dyn LlmProvider,
102 declarations: Vec<FunctionDeclaration>,
103 tool_handler: TextToolHandler,
104 max_turns: u32,
105 on_turn: Option<OnTurnCallback>,
106 request_template: Option<ChatRequest>,
107}
108
109impl<'a> AgentExecutor<'a> {
110 pub fn new(
112 provider: &'a dyn LlmProvider,
113 declarations: Vec<FunctionDeclaration>,
114 tool_handler: TextToolHandler,
115 ) -> Self {
116 Self {
117 provider,
118 declarations,
119 tool_handler,
120 max_turns: DEFAULT_MAX_TURNS,
121 on_turn: None,
122 request_template: None,
123 }
124 }
125
126 pub fn with_max_turns(mut self, max_turns: u32) -> Self {
128 self.max_turns = max_turns.min(MAX_TURNS_CEILING);
129 self
130 }
131
132 pub fn with_on_turn(mut self, callback: OnTurnCallback) -> Self {
134 self.on_turn = Some(callback);
135 self
136 }
137
138 pub fn with_request_template(mut self, template: ChatRequest) -> Self {
144 self.request_template = Some(template);
145 self
146 }
147
148 fn build_turn_request(&self, messages: Vec<ChatMessage>) -> ChatRequest {
150 match &self.request_template {
151 Some(template) => {
152 let mut request = template.clone();
153 request.messages = messages;
154 request
155 }
156 None => ChatRequest::new(messages),
157 }
158 }
159
160 pub async fn run(
166 &self,
167 initial_messages: Vec<ChatMessage>,
168 ) -> Result<AgentResult, RunnerError> {
169 let mut messages = initial_messages;
170
171 let catalog = generate_tool_catalog(&self.declarations);
173 inject_tool_catalog(&mut messages, &catalog);
174
175 debug!(
176 tool_count = self.declarations.len(),
177 max_turns = self.max_turns,
178 "agent: starting loop"
179 );
180
181 let mut all_tool_calls: Vec<FunctionCall> = Vec::new();
182 let mut total_usage = TokenUsage::default();
183 let mut turn: u32 = 0;
184
185 loop {
186 turn += 1;
187 if turn > self.max_turns {
188 info!(max_turns = self.max_turns, "agent: max turns reached");
189 return Ok(AgentResult {
190 content: String::new(),
191 tool_calls: all_tool_calls,
192 total_turns: turn - 1,
193 total_usage,
194 finish_reason: Some("max_turns".to_owned()),
195 });
196 }
197
198 let request = self.build_turn_request(messages.clone());
199 tracing::info!(
200 turn,
201 provider = self.provider.name(),
202 message_count = request.messages.len(),
203 "agent: dispatching turn to provider"
204 );
205 let response = self.provider.complete(&request).await?;
206
207 if let Some(ref usage) = response.usage {
209 accumulate_usage(&mut total_usage, usage);
210 }
211
212 tracing::info!(
213 turn,
214 content_len = response.content.len(),
215 tool_calls_inline = response.tool_calls.as_ref().map_or(0, Vec::len),
216 finish_reason = response.finish_reason.as_deref().unwrap_or("none"),
217 "agent: provider response received"
218 );
219
220 let parsed_calls = parse_tool_call_blocks(&response.content);
222 let content = strip_tool_call_blocks(&response.content);
223
224 if parsed_calls.is_empty() {
225 let turn_info = TurnInfo {
227 turn,
228 tool_calls: vec![],
229 content: content.clone(),
230 usage: response.usage.clone(),
231 };
232
233 if let Some(ref callback) = self.on_turn {
234 callback(&turn_info);
235 }
236
237 debug!(turn, "agent: final response (no tool calls)");
238 return Ok(AgentResult {
239 content,
240 tool_calls: all_tool_calls,
241 total_turns: turn,
242 total_usage,
243 finish_reason: response.finish_reason,
244 });
245 }
246
247 info!(
248 turn,
249 call_count = parsed_calls.len(),
250 "agent: executing tool calls"
251 );
252
253 let mut function_responses = Vec::with_capacity(parsed_calls.len());
255 for call in &parsed_calls {
256 let resp = (self.tool_handler)(&call.name, &call.args);
257 function_responses.push(resp);
258 }
259
260 let turn_info = TurnInfo {
262 turn,
263 tool_calls: parsed_calls.clone(),
264 content: content.clone(),
265 usage: response.usage,
266 };
267
268 if let Some(ref callback) = self.on_turn {
269 callback(&turn_info);
270 }
271
272 all_tool_calls.extend(parsed_calls);
273
274 if !content.is_empty() {
276 messages.push(ChatMessage::assistant(content));
277 }
278
279 let tool_results_text = format_tool_results_as_text(&function_responses);
280 messages.push(ChatMessage::user(tool_results_text));
281 }
282 }
283}
284
285fn accumulate_usage(total: &mut TokenUsage, turn: &TokenUsage) {
291 total.prompt_tokens = total.prompt_tokens.saturating_add(turn.prompt_tokens);
292 total.completion_tokens = total
293 .completion_tokens
294 .saturating_add(turn.completion_tokens);
295 total.total_tokens = total.total_tokens.saturating_add(turn.total_tokens);
296 total.cached_read_tokens = add_reported(total.cached_read_tokens, turn.cached_read_tokens);
297 total.cached_write_tokens = add_reported(total.cached_write_tokens, turn.cached_write_tokens);
298 total.cached_write_1h_tokens =
299 add_reported(total.cached_write_1h_tokens, turn.cached_write_1h_tokens);
300 total.reasoning_tokens = add_reported(total.reasoning_tokens, turn.reasoning_tokens);
301 total.model_requests = add_reported(total.model_requests, turn.model_requests);
302}
303
304fn add_reported(total: Option<u32>, turn: Option<u32>) -> Option<u32> {
306 match (total, turn) {
307 (Some(a), Some(b)) => Some(a.saturating_add(b)),
308 (a, b) => a.or(b),
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::tool_simulation::FunctionResponse;
316 use crate::types::{
317 ChatMessage, ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider,
318 RunnerError, TokenUsage,
319 };
320 use async_trait::async_trait;
321 use serde_json::json;
322 use std::sync::atomic::{AtomicU32, Ordering};
323 use std::sync::Mutex;
324
325 struct TestProvider {
326 responses: Mutex<Vec<Result<ChatResponse, RunnerError>>>,
327 call_count: AtomicU32,
328 }
329
330 impl TestProvider {
331 fn new(responses: Vec<Result<ChatResponse, RunnerError>>) -> Self {
332 Self {
333 responses: Mutex::new(responses),
334 call_count: AtomicU32::new(0),
335 }
336 }
337 }
338
339 #[async_trait]
340 impl LlmProvider for TestProvider {
341 fn name(&self) -> &'static str {
342 "test"
343 }
344 fn display_name(&self) -> &str {
345 "Test Provider"
346 }
347 fn capabilities(&self) -> LlmCapabilities {
348 LlmCapabilities::text_only()
349 }
350 fn default_model(&self) -> &'static str {
351 "test-model"
352 }
353 fn available_models(&self) -> &[String] {
354 &[]
355 }
356 async fn complete(&self, _request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
357 self.call_count.fetch_add(1, Ordering::SeqCst);
358 let mut responses = self.responses.lock().expect("test lock"); if responses.is_empty() {
360 Err(RunnerError::internal("no more test responses"))
361 } else {
362 responses.remove(0)
363 }
364 }
365 async fn complete_stream(&self, _request: &ChatRequest) -> Result<ChatStream, RunnerError> {
366 Err(RunnerError::internal("not supported"))
367 }
368 async fn health_check(&self) -> Result<bool, RunnerError> {
369 Ok(true)
370 }
371 }
372
373 fn make_response(content: &str, usage: Option<TokenUsage>) -> ChatResponse {
374 ChatResponse {
375 content: content.to_owned(),
376 model: "test-model".to_owned(),
377 usage,
378 finish_reason: Some("stop".to_owned()),
379 warnings: None,
380 tool_calls: None,
381 }
382 }
383
384 fn noop_handler() -> TextToolHandler {
385 Arc::new(|name: &str, _args: &serde_json::Value| FunctionResponse {
386 name: name.to_owned(),
387 response: json!({"status": "ok"}),
388 })
389 }
390
391 #[tokio::test]
392 async fn single_turn_no_tool_calls() {
393 let provider = TestProvider::new(vec![Ok(make_response(
394 "Here is a direct answer without tool calls.",
395 Some(TokenUsage::new(10, 8, 18)),
396 ))]);
397
398 let declarations = vec![FunctionDeclaration {
399 name: "search".to_owned(),
400 description: "Search the web".to_owned(),
401 parameters: None,
402 defer_loading: false,
403 }];
404
405 let executor = AgentExecutor::new(&provider, declarations, noop_handler());
406 let messages = vec![ChatMessage::user("Hello")];
407 let result = executor.run(messages).await.expect("should succeed"); assert_eq!(
410 result.content,
411 "Here is a direct answer without tool calls."
412 );
413 assert!(result.tool_calls.is_empty());
414 assert_eq!(result.total_turns, 1);
415 assert_eq!(result.total_usage.prompt_tokens, 10);
416 assert_eq!(result.finish_reason, Some("stop".to_owned()));
417 }
418
419 #[tokio::test]
420 async fn multi_turn_with_tool_calls() {
421 let provider = TestProvider::new(vec![
422 Ok(make_response(
424 "Let me search for that.\n<tool_call>\n{\"name\": \"search\", \"arguments\": {\"q\": \"rust\"}}\n</tool_call>",
425 Some(TokenUsage::new(10, 15, 25)),
426 )),
427 Ok(make_response(
429 "Based on the search results, Rust is a systems programming language.",
430 Some(TokenUsage::new(30, 12, 42)),
431 )),
432 ]);
433
434 let declarations = vec![FunctionDeclaration {
435 name: "search".to_owned(),
436 description: "Search the web".to_owned(),
437 parameters: Some(json!({"type": "object", "properties": {"q": {"type": "string"}}})),
438 defer_loading: false,
439 }];
440
441 let executor = AgentExecutor::new(&provider, declarations, noop_handler());
442 let messages = vec![ChatMessage::user("What is Rust?")];
443 let result = executor.run(messages).await.expect("should succeed"); assert!(result.content.contains("systems programming"));
446 assert_eq!(result.tool_calls.len(), 1);
447 assert_eq!(result.tool_calls[0].name, "search");
448 assert_eq!(result.total_turns, 2);
449 assert_eq!(result.total_usage.prompt_tokens, 40);
450 assert_eq!(result.total_usage.completion_tokens, 27);
451 assert_eq!(result.total_usage.cached_read_tokens, None);
453 assert_eq!(result.total_usage.cached_write_tokens, None);
454 assert_eq!(result.total_usage.reasoning_tokens, None);
455 }
456
457 #[tokio::test]
458 async fn multi_turn_sums_cache_and_reasoning_as_reported() {
459 let provider = TestProvider::new(vec![
460 Ok(make_response(
462 "<tool_call>\n{\"name\": \"search\", \"arguments\": {}}\n</tool_call>",
463 Some(TokenUsage::new(100, 10, 110).with_cache(None, Some(80))),
464 )),
465 Ok(make_response(
467 "Done.",
468 Some(
469 TokenUsage::new(120, 5, 145)
470 .with_cache(Some(80), Some(0))
471 .with_reasoning(Some(20)),
472 ),
473 )),
474 ]);
475
476 let declarations = vec![FunctionDeclaration {
477 name: "search".to_owned(),
478 description: "Search the web".to_owned(),
479 parameters: None,
480 defer_loading: false,
481 }];
482
483 let executor = AgentExecutor::new(&provider, declarations, noop_handler());
484 let result = executor
485 .run(vec![ChatMessage::user("Go")])
486 .await
487 .expect("should succeed"); assert_eq!(result.total_turns, 2);
490 assert_eq!(result.total_usage.prompt_tokens, 220);
491 assert_eq!(result.total_usage.completion_tokens, 15);
492 assert_eq!(result.total_usage.total_tokens, 255);
493 assert_eq!(result.total_usage.cached_read_tokens, Some(80));
494 assert_eq!(result.total_usage.cached_write_tokens, Some(80));
495 assert_eq!(result.total_usage.reasoning_tokens, Some(20));
496 }
497
498 #[test]
499 fn accumulated_counts_saturate_instead_of_overflowing() {
500 let mut total = TokenUsage::new(u32::MAX - 1, u32::MAX, u32::MAX).with_reasoning(Some(1));
501 accumulate_usage(
502 &mut total,
503 &TokenUsage::new(5, 5, 10).with_reasoning(Some(u32::MAX)),
504 );
505 assert_eq!(total.prompt_tokens, u32::MAX);
506 assert_eq!(total.completion_tokens, u32::MAX);
507 assert_eq!(total.total_tokens, u32::MAX);
508 assert_eq!(total.reasoning_tokens, Some(u32::MAX));
509 }
510
511 #[tokio::test]
512 async fn on_turn_callback_invoked() {
513 let provider = TestProvider::new(vec![
514 Ok(make_response(
515 "<tool_call>\n{\"name\": \"ping\", \"arguments\": {}}\n</tool_call>",
516 None,
517 )),
518 Ok(make_response("Done!", None)),
519 ]);
520
521 let declarations = vec![FunctionDeclaration {
522 name: "ping".to_owned(),
523 description: "Ping".to_owned(),
524 parameters: None,
525 defer_loading: false,
526 }];
527
528 let turn_log: Arc<Mutex<Vec<u32>>> = Arc::new(Mutex::new(Vec::new()));
529 let turn_log_clone = Arc::clone(&turn_log);
530
531 let callback: OnTurnCallback = Arc::new(move |info: &TurnInfo| {
532 turn_log_clone.lock().expect("lock").push(info.turn); });
534
535 let executor =
536 AgentExecutor::new(&provider, declarations, noop_handler()).with_on_turn(callback);
537 let messages = vec![ChatMessage::user("ping")];
538 executor.run(messages).await.expect("should succeed"); let logged = turn_log.lock().expect("lock").clone(); assert_eq!(logged, vec![1, 2]);
542 }
543
544 #[tokio::test]
545 async fn max_turns_exhaustion() {
546 let mut responses = Vec::new();
548 for _ in 0..5 {
549 responses.push(Ok(make_response(
550 "<tool_call>\n{\"name\": \"loop\", \"arguments\": {}}\n</tool_call>",
551 None,
552 )));
553 }
554 let provider = TestProvider::new(responses);
555
556 let declarations = vec![FunctionDeclaration {
557 name: "loop".to_owned(),
558 description: "Loop forever".to_owned(),
559 parameters: None,
560 defer_loading: false,
561 }];
562
563 let executor =
564 AgentExecutor::new(&provider, declarations, noop_handler()).with_max_turns(3);
565 let messages = vec![ChatMessage::user("go")];
566 let result = executor.run(messages).await.expect("should not error"); assert_eq!(result.finish_reason, Some("max_turns".to_owned()));
569 assert_eq!(result.total_turns, 3);
570 assert_eq!(result.tool_calls.len(), 3);
571 }
572
573 #[tokio::test]
574 async fn request_template_preserves_params() {
575 struct CapturingProvider {
577 seen_model: Mutex<Option<String>>,
578 seen_temp: Mutex<Option<f32>>,
579 }
580
581 #[async_trait]
582 impl LlmProvider for CapturingProvider {
583 fn name(&self) -> &'static str {
584 "capture"
585 }
586 fn display_name(&self) -> &str {
587 "Capture"
588 }
589 fn capabilities(&self) -> LlmCapabilities {
590 LlmCapabilities::text_only()
591 }
592 fn default_model(&self) -> &'static str {
593 "default-model"
594 }
595 fn available_models(&self) -> &[String] {
596 &[]
597 }
598 async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
599 *self.seen_model.lock().expect("lock") = request.model.clone(); *self.seen_temp.lock().expect("lock") = request.temperature; Ok(make_response("done", None))
602 }
603 async fn complete_stream(
604 &self,
605 _request: &ChatRequest,
606 ) -> Result<ChatStream, RunnerError> {
607 Err(RunnerError::internal("not supported"))
608 }
609 async fn health_check(&self) -> Result<bool, RunnerError> {
610 Ok(true)
611 }
612 }
613
614 let provider = CapturingProvider {
615 seen_model: Mutex::new(None),
616 seen_temp: Mutex::new(None),
617 };
618
619 let mut template = ChatRequest::new(vec![]);
620 template.model = Some("gpt-5.4".to_owned());
621 template.temperature = Some(0.3);
622
623 let executor =
624 AgentExecutor::new(&provider, vec![], noop_handler()).with_request_template(template);
625 executor
626 .run(vec![ChatMessage::user("hi")])
627 .await
628 .expect("should succeed"); assert_eq!(
631 provider.seen_model.lock().expect("lock").as_deref(), Some("gpt-5.4")
633 );
634 assert_eq!(*provider.seen_temp.lock().expect("lock"), Some(0.3)); }
636
637 #[tokio::test]
638 async fn token_accumulation() {
639 let provider = TestProvider::new(vec![
640 Ok(make_response(
641 "<tool_call>\n{\"name\": \"a\", \"arguments\": {}}\n</tool_call>",
642 Some(TokenUsage::new(10, 5, 15)),
643 )),
644 Ok(make_response("final", Some(TokenUsage::new(20, 3, 23)))),
645 ]);
646
647 let declarations = vec![FunctionDeclaration {
648 name: "a".to_owned(),
649 description: "Tool A".to_owned(),
650 parameters: None,
651 defer_loading: false,
652 }];
653
654 let executor = AgentExecutor::new(&provider, declarations, noop_handler());
655 let messages = vec![ChatMessage::user("go")];
656 let result = executor.run(messages).await.expect("should succeed"); assert_eq!(result.total_usage.prompt_tokens, 30);
659 assert_eq!(result.total_usage.completion_tokens, 8);
660 assert_eq!(result.total_usage.total_tokens, 38);
661 }
662}