Skip to main content

embacle/
agent.rs

1// ABOUTME: Configurable agent loop building on text-based tool simulation
2// ABOUTME: Provides AgentExecutor with multi-turn tool calling, callbacks, and token accumulation
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! # Agent Loop
8//!
9//! [`AgentExecutor`] provides a configurable multi-turn agent loop that:
10//!
11//! 1. Injects a tool catalog into the conversation
12//! 2. Calls `provider.complete()` and parses tool calls from the response
13//! 3. Executes tools via the provided handler
14//! 4. Feeds results back and repeats until no tool calls remain or
15//!    `max_turns` is reached
16//!
17//! Builds on [`tool_simulation`](crate::tool_simulation) types and functions.
18//!
19//! ## Observability
20//!
21//! An optional [`OnTurnCallback`] is invoked after each turn with a
22//! [`TurnInfo`] snapshot, enabling logging, metrics, or UI updates.
23
24use 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
35/// Default maximum turns for the agent loop
36const DEFAULT_MAX_TURNS: u32 = 10;
37
38/// Absolute ceiling for `max_turns` to prevent runaway loops
39const MAX_TURNS_CEILING: u32 = 50;
40
41/// Callback invoked after each agent turn for observability
42pub type OnTurnCallback = Arc<dyn Fn(&TurnInfo) + Send + Sync>;
43
44/// Information about a single agent turn, passed to [`OnTurnCallback`]
45#[derive(Debug, Clone)]
46pub struct TurnInfo {
47    /// Turn number (1-based)
48    pub turn: u32,
49    /// Tool calls made during this turn
50    pub tool_calls: Vec<FunctionCall>,
51    /// Text content from the LLM (tool call blocks stripped)
52    pub content: String,
53    /// Token usage for this turn (if reported by the provider)
54    pub usage: Option<TokenUsage>,
55}
56
57/// Result of an agent execution run
58#[derive(Debug, Clone)]
59pub struct AgentResult {
60    /// Final text content from the LLM
61    pub content: String,
62    /// All tool calls made across all turns
63    pub tool_calls: Vec<FunctionCall>,
64    /// Total number of turns executed
65    pub total_turns: u32,
66    /// Accumulated token usage across all turns
67    pub total_usage: TokenUsage,
68    /// Finish reason (e.g., "stop", "`max_turns`")
69    pub finish_reason: Option<String>,
70}
71
72/// Configurable agent loop with tool calling support.
73///
74/// # Usage
75///
76/// ```rust,no_run
77/// # use embacle::agent::AgentExecutor;
78/// # use embacle::tool_simulation::{FunctionDeclaration, FunctionResponse, TextToolHandler};
79/// # use embacle::types::{ChatMessage, LlmProvider};
80/// # use serde_json::json;
81/// # use std::sync::Arc;
82/// # async fn example(provider: &dyn LlmProvider) -> Result<(), embacle::types::RunnerError> {
83/// let declarations = vec![FunctionDeclaration::new(
84///     "search",
85///     "Search the web",
86///     Some(json!({"type": "object", "properties": {"q": {"type": "string"}}})),
87/// )];
88///
89/// let handler: TextToolHandler = Arc::new(|name, _args| {
90///     FunctionResponse { name: name.to_owned(), response: json!({"results": []}) }
91/// });
92///
93/// let executor = AgentExecutor::new(provider, declarations, handler);
94/// let messages = vec![ChatMessage::user("Search for Rust tutorials")];
95/// let result = executor.run(messages).await?;
96/// println!("{}", result.content);
97/// # Ok(())
98/// # }
99/// ```
100pub 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    /// Create a new agent executor with default settings (`max_turns=10`)
111    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    /// Set the maximum number of turns (clamped to ceiling of 50)
127    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    /// Set an observability callback invoked after each turn
133    pub fn with_on_turn(mut self, callback: OnTurnCallback) -> Self {
134        self.on_turn = Some(callback);
135        self
136    }
137
138    /// Use a request template so each turn inherits the caller's model and
139    /// sampling parameters (temperature, `max_tokens`, `top_p`, stop sequences).
140    ///
141    /// The template's `messages` are replaced with the running conversation on
142    /// every turn; all other fields are preserved.
143    pub fn with_request_template(mut self, template: ChatRequest) -> Self {
144        self.request_template = Some(template);
145        self
146    }
147
148    /// Build the per-turn request, applying the template if one was set.
149    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    /// Run the agent loop with the given initial messages.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`RunnerError`] if any `provider.complete()` call fails.
165    pub async fn run(
166        &self,
167        initial_messages: Vec<ChatMessage>,
168    ) -> Result<AgentResult, RunnerError> {
169        let mut messages = initial_messages;
170
171        // Inject tool catalog into the conversation
172        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            // Accumulate token usage
208            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            // Parse tool calls from the response
221            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                // No tool calls — final response
226                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            // Execute tool calls
254            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            // Build turn info for callback
261            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            // Append assistant response and tool results to conversation
275            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
285/// Add one turn's usage into the running total.
286///
287/// The optional categories sum as options: a category stays `None` until
288/// some turn reports it, so a run that never measured cache or reasoning
289/// does not claim a measured zero.
290fn 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
304/// Sum two optional counts, `None` only when neither was reported.
305fn 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"); // Safe: test assertion
359            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"); // Safe: test assertion
408
409        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            // Turn 1: LLM calls a tool
423            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            // Turn 2: LLM responds with the result
428            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"); // Safe: test assertion
444
445        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        // Neither turn reported a breakdown, so none is claimed.
452        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            // Turn 1: a cold prefix — a cache write, no read, no reasoning reported
461            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            // Turn 2: the warm prefix is read back, and reasoning is reported
466            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"); // Safe: test assertion
488
489        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); // Safe: test assertion
533        });
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"); // Safe: test assertion
539
540        let logged = turn_log.lock().expect("lock").clone(); // Safe: test assertion
541        assert_eq!(logged, vec![1, 2]);
542    }
543
544    #[tokio::test]
545    async fn max_turns_exhaustion() {
546        // Provider always returns tool calls — should exhaust max_turns
547        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"); // Safe: test assertion
567
568        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        // Provider captures the request it receives so we can assert on it.
576        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(); // Safe: test assertion
600                *self.seen_temp.lock().expect("lock") = request.temperature; // Safe: test assertion
601                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"); // Safe: test assertion
629
630        assert_eq!(
631            provider.seen_model.lock().expect("lock").as_deref(), // Safe: test assertion
632            Some("gpt-5.4")
633        );
634        assert_eq!(*provider.seen_temp.lock().expect("lock"), Some(0.3)); // Safe: test assertion
635    }
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"); // Safe: test assertion
657
658        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}