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}