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    /// Hard cap on tool-call iterations to prevent infinite loops.
70    max_iterations: u32,
71    /// Static context documents prepended before the first user message.
72    context: Vec<String>,
73}
74
75impl<M: CompletionModel> Agent<M> {
76    /// Create a builder for this model.
77    pub fn builder(model: M) -> AgentBuilder<M> {
78        AgentBuilder::new(model)
79    }
80
81    /// Send a single prompt and return the model's text reply.
82    ///
83    /// Any tool calls are resolved internally; the caller receives the final
84    /// text response.
85    pub async fn prompt(&self, prompt: &str) -> Result<String, AgentError<M::Error>> {
86        self.chat(prompt, vec![]).await
87    }
88
89    /// Continue a conversation with `history` and a new `prompt`.
90    ///
91    /// Returns the model's final text reply after resolving any tool calls.
92    pub async fn chat(
93        &self,
94        prompt: &str,
95        history: Vec<Message>,
96    ) -> Result<String, AgentError<M::Error>> {
97        let mut messages = self.build_messages(prompt, history);
98
99        for _ in 0..self.max_iterations {
100            let request = self.build_request(messages.clone());
101            let response = self.model.complete(request).await.map_err(AgentError::Completion)?;
102
103            match response.choice {
104                ModelChoice::Message(text) => return Ok(text),
105                ModelChoice::ToolCall(calls) => {
106                    // Append the assistant's tool-call turn.
107                    messages.push(Message::Assistant {
108                        content: calls
109                            .iter()
110                            .map(|c| AssistantContent::ToolCall(c.clone()))
111                            .collect(),
112                    });
113
114                    // Execute all requested tool calls and collect results.
115                    let mut results: Vec<UserContent> = Vec::with_capacity(calls.len());
116                    for call in &calls {
117                        let result = self.dispatch_tool(call).await;
118                        results.push(UserContent::ToolResult(result));
119                    }
120
121                    // Feed results back as a user turn.
122                    messages.push(Message::User { content: results });
123                }
124            }
125        }
126
127        Err(AgentError::MaxIterations(self.max_iterations))
128    }
129
130    // ── Private helpers ───────────────────────────────────────────────────────
131
132    fn build_messages(&self, prompt: &str, mut history: Vec<Message>) -> Vec<Message> {
133        let mut messages: Vec<Message> = Vec::new();
134
135        if let Some(preamble) = &self.preamble {
136            messages.push(Message::system(preamble));
137        }
138
139        // Static context documents become a single user message prepended
140        // before the history so the model has them in its window.
141        if !self.context.is_empty() {
142            let combined = self.context.join("\n\n");
143            messages.push(Message::user(combined));
144        }
145
146        messages.append(&mut history);
147        messages.push(Message::user(prompt));
148        messages
149    }
150
151    fn build_request(&self, messages: Vec<Message>) -> CompletionRequest {
152        let mut req = CompletionRequest::new(messages);
153        req.tools = self.tools.definitions();
154        req.temperature = self.temperature;
155        req.max_tokens = self.max_tokens;
156        req
157    }
158
159    async fn dispatch_tool(&self, call: &ToolCall) -> ToolResult {
160        let result = self.tools.call(&call.name, call.arguments.clone()).await;
161        let content = match result {
162            Ok(v) => v.to_string(),
163            // Surface tool errors as plain-text so the model can react.
164            Err(e) => format!("Error: {e}"),
165        };
166        ToolResult {
167            call_id: call.id.clone(),
168            name: call.name.clone(),
169            content,
170        }
171    }
172}
173
174// ── AgentBuilder ──────────────────────────────────────────────────────────────
175
176/// Fluent builder for [`Agent`].
177pub struct AgentBuilder<M> {
178    model: M,
179    preamble: Option<String>,
180    tools: ToolSet,
181    temperature: Option<f64>,
182    max_tokens: Option<u32>,
183    max_iterations: u32,
184    context: Vec<String>,
185}
186
187impl<M: CompletionModel> AgentBuilder<M> {
188    fn new(model: M) -> Self {
189        Self {
190            model,
191            preamble: None,
192            tools: ToolSet::new(),
193            temperature: None,
194            max_tokens: None,
195            max_iterations: 10,
196            context: Vec::new(),
197        }
198    }
199
200    /// Set the system prompt.
201    pub fn preamble(mut self, preamble: impl Into<String>) -> Self {
202        self.preamble = Some(preamble.into());
203        self
204    }
205
206    /// Register a tool.
207    pub fn tool<T: crate::tool::Tool + 'static>(mut self, tool: T) -> Self {
208        self.tools.add(tool);
209        self
210    }
211
212    /// Set the sampling temperature.
213    pub fn temperature(mut self, temperature: f64) -> Self {
214        self.temperature = Some(temperature);
215        self
216    }
217
218    /// Set the maximum tokens to generate.
219    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
220        self.max_tokens = Some(max_tokens);
221        self
222    }
223
224    /// Override the default iteration cap (10) for the tool-call loop.
225    pub fn max_iterations(mut self, n: u32) -> Self {
226        self.max_iterations = n;
227        self
228    }
229
230    /// Append a static context document (e.g. retrieved RAG chunks).
231    pub fn context(mut self, doc: impl Into<String>) -> Self {
232        self.context.push(doc.into());
233        self
234    }
235
236    /// Consume the builder and produce an [`Agent`].
237    pub fn build(self) -> Agent<M> {
238        Agent {
239            model: self.model,
240            preamble: self.preamble,
241            tools: self.tools,
242            temperature: self.temperature,
243            max_tokens: self.max_tokens,
244            max_iterations: self.max_iterations,
245            context: self.context,
246        }
247    }
248}