Skip to main content

ic_rig/
agent.rs

1//! High-level agent that drives the completion ↔ tool-call loop.
2//!
3//! An [`Agent`] wraps a [`CompletionModel`], a system prompt, an optional set
4//! of [`Tool`]s, and common sampling parameters. Call [`Agent::prompt`] for a
5//! one-shot exchange or [`Agent::chat`] to continue an existing history.
6//!
7//! # Agentic loop
8//!
9//! When the model returns [`ModelChoice::ToolCall`], the agent:
10//! 1. Executes each requested tool.
11//! 2. Appends the results as a user-turn message.
12//! 3. Sends a new completion request.
13//!
14//! This repeats until the model returns [`ModelChoice::Message`] or the
15//! `max_iterations` limit is reached.
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! let agent = Agent::builder(model)
21//!     .preamble("You are a helpful assistant.")
22//!     .tool(calculator)
23//!     .max_tokens(1024)
24//!     .build();
25//!
26//! let reply = agent.prompt("What is 3 * 7?").await?;
27//! println!("{reply}");
28//! ```
29
30use crate::{
31    completion::{CompletionModel, CompletionRequest, ModelChoice},
32    message::{AssistantContent, Message, ToolCall, ToolResult, UserContent},
33    tool::{ToolError, ToolSet},
34};
35use thiserror::Error;
36
37// ── AgentError ────────────────────────────────────────────────────────────────
38
39/// Errors that can occur while running the agentic loop.
40///
41/// Not using `#[from]` on both variants because when `M = ToolError` the
42/// blanket impls would conflict. Instead, conversions are explicit at the
43/// call sites inside [`Agent`].
44#[derive(Debug, Error)]
45pub enum AgentError<M: std::error::Error + 'static> {
46    #[error("Completion error: {0}")]
47    Completion(M),
48
49    #[error("Tool error: {0}")]
50    Tool(ToolError),
51
52    /// The model kept issuing tool calls beyond the iteration cap.
53    #[error("Exceeded maximum iterations ({0}) without a final text reply")]
54    MaxIterations(u32),
55}
56
57// ── Agent ─────────────────────────────────────────────────────────────────────
58
59/// A configured LLM agent.
60///
61/// Build one with [`Agent::builder`] then call [`prompt`](Agent::prompt)
62/// or [`chat`](Agent::chat).
63pub struct Agent<M> {
64    model: M,
65    preamble: Option<String>,
66    tools: ToolSet,
67    temperature: Option<f64>,
68    max_tokens: Option<u32>,
69    /// Explicit thinking/reasoning toggle passed through to the provider.
70    /// See [`CompletionRequest::thinking`].
71    thinking: Option<bool>,
72    /// Hard cap on tool-call iterations to prevent infinite loops.
73    max_iterations: u32,
74    /// Static context documents prepended before the first user message.
75    context: Vec<String>,
76}
77
78impl<M: CompletionModel> Agent<M> {
79    /// Create a builder for this model.
80    pub fn builder(model: M) -> AgentBuilder<M> {
81        AgentBuilder::new(model)
82    }
83
84    /// Send a single prompt and return the model's text reply.
85    ///
86    /// Any tool calls are resolved internally; the caller receives the final
87    /// text response.
88    pub async fn prompt(&self, prompt: &str) -> Result<String, AgentError<M::Error>> {
89        self.chat(prompt, vec![]).await
90    }
91
92    /// Continue a conversation with `history` and a new `prompt`.
93    ///
94    /// Returns the model's final text reply after resolving any tool calls.
95    pub async fn chat(
96        &self,
97        prompt: &str,
98        history: Vec<Message>,
99    ) -> Result<String, AgentError<M::Error>> {
100        let mut messages = self.build_messages(prompt, history);
101
102        for _ in 0..self.max_iterations {
103            let request = self.build_request(messages.clone());
104            let response = self.model.complete(request).await.map_err(AgentError::Completion)?;
105
106            match response.choice {
107                ModelChoice::Message(text) => return Ok(text),
108                ModelChoice::ToolCall(calls) => {
109                    // Append the assistant's tool-call turn.
110                    messages.push(Message::Assistant {
111                        content: calls
112                            .iter()
113                            .map(|c| AssistantContent::ToolCall(c.clone()))
114                            .collect(),
115                    });
116
117                    // Execute all requested tool calls and collect results.
118                    let mut results: Vec<UserContent> = Vec::with_capacity(calls.len());
119                    for call in &calls {
120                        let result = self.dispatch_tool(call).await;
121                        results.push(UserContent::ToolResult(result));
122                    }
123
124                    // Feed results back as a user turn.
125                    messages.push(Message::User { content: results });
126                }
127            }
128        }
129
130        Err(AgentError::MaxIterations(self.max_iterations))
131    }
132
133    // ── Private helpers ───────────────────────────────────────────────────────
134
135    fn build_messages(&self, prompt: &str, mut history: Vec<Message>) -> Vec<Message> {
136        let mut messages: Vec<Message> = Vec::new();
137
138        if let Some(preamble) = &self.preamble {
139            messages.push(Message::system(preamble));
140        }
141
142        // Static context documents become a single user message prepended
143        // before the history so the model has them in its window.
144        if !self.context.is_empty() {
145            let combined = self.context.join("\n\n");
146            messages.push(Message::user(combined));
147        }
148
149        messages.append(&mut history);
150        messages.push(Message::user(prompt));
151        messages
152    }
153
154    fn build_request(&self, messages: Vec<Message>) -> CompletionRequest {
155        let mut req = CompletionRequest::new(messages);
156        req.tools = self.tools.definitions();
157        req.temperature = self.temperature;
158        req.max_tokens = self.max_tokens;
159        req.thinking = self.thinking;
160        req
161    }
162
163    async fn dispatch_tool(&self, call: &ToolCall) -> ToolResult {
164        let result = self.tools.call(&call.name, call.arguments.clone()).await;
165        let content = match result {
166            Ok(v) => v.to_string(),
167            // Surface tool errors as plain-text so the model can react.
168            Err(e) => format!("Error: {e}"),
169        };
170        ToolResult {
171            call_id: call.id.clone(),
172            name: call.name.clone(),
173            content,
174        }
175    }
176}
177
178// ── AgentBuilder ──────────────────────────────────────────────────────────────
179
180/// Fluent builder for [`Agent`].
181pub struct AgentBuilder<M> {
182    model: M,
183    preamble: Option<String>,
184    tools: ToolSet,
185    temperature: Option<f64>,
186    max_tokens: Option<u32>,
187    thinking: Option<bool>,
188    max_iterations: u32,
189    context: Vec<String>,
190}
191
192impl<M: CompletionModel> AgentBuilder<M> {
193    fn new(model: M) -> Self {
194        Self {
195            model,
196            preamble: None,
197            tools: ToolSet::new(),
198            temperature: None,
199            max_tokens: None,
200            thinking: None,
201            max_iterations: 10,
202            context: Vec::new(),
203        }
204    }
205
206    /// Set the system prompt.
207    pub fn preamble(mut self, preamble: impl Into<String>) -> Self {
208        self.preamble = Some(preamble.into());
209        self
210    }
211
212    /// Register a tool.
213    pub fn tool<T: crate::tool::Tool + 'static>(mut self, tool: T) -> Self {
214        self.tools.add(tool);
215        self
216    }
217
218    /// Set the sampling temperature.
219    pub fn temperature(mut self, temperature: f64) -> Self {
220        self.temperature = Some(temperature);
221        self
222    }
223
224    /// Set the maximum tokens to generate.
225    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
226        self.max_tokens = Some(max_tokens);
227        self
228    }
229
230    /// Override the default iteration cap (10) for the tool-call loop.
231    pub fn max_iterations(mut self, n: u32) -> Self {
232        self.max_iterations = n;
233        self
234    }
235
236    /// Explicitly turn the model's thinking/reasoning mode on (`true`) or
237    /// off (`false`). Leave unset to use the provider's own default.
238    ///
239    /// Best-effort and provider-specific — see
240    /// [`CompletionRequest::thinking`](crate::completion::CompletionRequest::thinking)
241    /// for exactly how each provider interprets this. Regardless of this
242    /// setting, [`Agent::prompt`]/[`Agent::chat`] only ever return the final
243    /// answer text — a reasoning trace, if the provider returns one, is
244    /// never mixed into it.
245    pub fn thinking(mut self, enabled: bool) -> Self {
246        self.thinking = Some(enabled);
247        self
248    }
249
250    /// Append a static context document (e.g. retrieved RAG chunks).
251    pub fn context(mut self, doc: impl Into<String>) -> Self {
252        self.context.push(doc.into());
253        self
254    }
255
256    /// Consume the builder and produce an [`Agent`].
257    pub fn build(self) -> Agent<M> {
258        Agent {
259            model: self.model,
260            preamble: self.preamble,
261            tools: self.tools,
262            temperature: self.temperature,
263            max_tokens: self.max_tokens,
264            thinking: self.thinking,
265            max_iterations: self.max_iterations,
266            context: self.context,
267        }
268    }
269}