use crate::llm::LLMProvider;
use crate::llm::client::ChatSession;
use crate::llm::context::AgentContextBuilder;
use crate::llm::tool_executor::ToolExecutor;
use crate::llm::types::{
ChatCompletionRequest, ChatMessage, ContentPart, ImageUrl, LLMResult, MessageContent, Role,
Tool,
};
use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct AgentLoopConfig {
pub max_tool_iterations: usize,
pub default_model: String,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
}
impl Default for AgentLoopConfig {
fn default() -> Self {
Self {
max_tool_iterations: 10,
default_model: "gpt-4o-mini".to_string(),
temperature: Some(0.7),
max_tokens: None,
}
}
}
pub struct AgentLoop {
provider: Arc<dyn LLMProvider>,
tools: Arc<dyn ToolExecutor>,
config: AgentLoopConfig,
}
pub struct AgentLoopRunner {
agent_loop: AgentLoop,
context_builder: Option<AgentContextBuilder>,
session: Option<ChatSession>,
model: Option<String>,
}
impl AgentLoop {
pub fn new(
provider: Arc<dyn LLMProvider>,
tools: Arc<dyn ToolExecutor>,
config: AgentLoopConfig,
) -> Self {
Self {
provider,
tools,
config,
}
}
pub fn with_defaults(provider: Arc<dyn LLMProvider>, tools: Arc<dyn ToolExecutor>) -> Self {
Self::new(provider, tools, AgentLoopConfig::default())
}
pub async fn process_message(
&self,
context: Vec<ChatMessage>,
content: &str,
media: Option<Vec<String>>,
) -> Result<String> {
self.process_with_options(context, content, media, None)
.await
}
pub async fn process_with_model(
&self,
context: Vec<ChatMessage>,
content: &str,
media: Option<Vec<String>>,
model: &str,
) -> Result<String> {
self.process_with_options(context, content, media, Some(model))
.await
}
pub async fn process_with_options(
&self,
mut context: Vec<ChatMessage>,
content: &str,
media: Option<Vec<String>>,
model: Option<&str>,
) -> Result<String> {
let user_msg = if let Some(media_paths) = media {
if !media_paths.is_empty() {
Self::build_vision_message(content, &media_paths)?
} else {
ChatMessage::user(content)
}
} else {
ChatMessage::user(content)
};
context.push(user_msg);
let tools = self
.tools
.available_tools()
.await
.map_err(|e| anyhow::anyhow!(e))?;
self.run_agent_loop(context, &tools, model).await
}
pub async fn process_with_context_builder(
&self,
context_builder: &AgentContextBuilder,
history: Vec<ChatMessage>,
content: &str,
media: Option<Vec<String>>,
model: Option<&str>,
) -> Result<String> {
let context = context_builder
.build_messages(history, content, media)
.await?;
let tools = self
.tools
.available_tools()
.await
.map_err(|e| anyhow::anyhow!(e))?;
self.run_agent_loop(context, &tools, model).await
}
pub async fn process_with_session(
&self,
session: &mut ChatSession,
content: &str,
media: Option<Vec<String>>,
context_builder: Option<&AgentContextBuilder>,
model: Option<&str>,
) -> Result<String> {
let history = session.messages().to_vec();
let context = if let Some(builder) = context_builder {
builder
.build_messages(history, content, media.clone())
.await?
} else {
let mut messages = history;
let user_msg = if let Some(media_paths) = media.clone() {
if !media_paths.is_empty() {
Self::build_vision_message(content, &media_paths)?
} else {
ChatMessage::user(content)
}
} else {
ChatMessage::user(content)
};
messages.push(user_msg);
messages
};
let tools = self
.tools
.available_tools()
.await
.map_err(|e| anyhow::anyhow!(e))?;
let response = self.run_agent_loop(context, &tools, model).await?;
let user_msg = if let Some(media_paths) = media {
if !media_paths.is_empty() {
Self::build_vision_message(content, &media_paths)?
} else {
ChatMessage::user(content)
}
} else {
ChatMessage::user(content)
};
session.messages_mut().push(user_msg);
session
.messages_mut()
.push(ChatMessage::assistant(&response));
Ok(response)
}
async fn run_agent_loop(
&self,
mut messages: Vec<ChatMessage>,
tools: &[Tool],
model: Option<&str>,
) -> Result<String> {
let model = model.unwrap_or(&self.config.default_model);
for _iteration in 0..self.config.max_tool_iterations {
let mut request = ChatCompletionRequest::new(model);
request.messages = messages.clone();
request.temperature = self.config.temperature;
request.max_tokens = self.config.max_tokens;
if !tools.is_empty() {
request.tools = Some(tools.to_vec());
}
let response = self.provider.chat(request).await?;
if let Some(tool_calls) = response.tool_calls()
&& !tool_calls.is_empty()
{
messages.push(ChatMessage::assistant_with_tool_calls(tool_calls.clone()));
for tool_call in tool_calls {
tracing::debug!(
"Executing tool: {} with args: {:?}",
tool_call.function.name,
tool_call.function.arguments
);
let result = self
.execute_tool(&tool_call.function.name, &tool_call.function.arguments)
.await;
messages.push(ChatMessage::tool_result(
&tool_call.id,
result.unwrap_or_else(|e| format!("Error: {}", e)),
));
}
continue;
}
if let Some(content) = response.content() {
return Ok(content.to_string());
} else {
return Ok("No response generated.".to_string());
}
}
tracing::warn!(
"Agent loop exceeded max iterations ({})",
self.config.max_tool_iterations
);
Ok("I've completed processing but hit the maximum iteration limit.".to_string())
}
async fn execute_tool(&self, name: &str, arguments: &str) -> LLMResult<String> {
self.tools.execute(name, arguments).await
}
fn build_vision_message(text: &str, image_paths: &[String]) -> Result<ChatMessage> {
let mut parts = vec![ContentPart::Text {
text: text.to_string(),
}];
for path in image_paths {
let image_url = Self::encode_image_data_url(Path::new(path))?;
parts.push(ContentPart::Image { image_url });
}
Ok(ChatMessage {
role: Role::User,
content: Some(MessageContent::Parts(parts)),
name: None,
tool_calls: None,
tool_call_id: None,
})
}
fn encode_image_data_url(path: &Path) -> Result<ImageUrl> {
use base64::Engine;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use std::fs;
let bytes = fs::read(path)?;
let mime_type = infer::get_from_path(path)?
.ok_or_else(|| anyhow::anyhow!("Unknown MIME type for: {:?}", path))?
.mime_type()
.to_string();
let base64 = STANDARD_NO_PAD.encode(&bytes);
let url = format!("data:{};base64,{}", mime_type, base64);
Ok(ImageUrl { url, detail: None })
}
pub fn config(&self) -> &AgentLoopConfig {
&self.config
}
}
impl AgentLoopRunner {
pub fn new(agent_loop: AgentLoop) -> Self {
Self {
agent_loop,
context_builder: None,
session: None,
model: None,
}
}
pub fn with_context_builder(mut self, context_builder: AgentContextBuilder) -> Self {
self.context_builder = Some(context_builder);
self
}
pub fn with_session(mut self, session: ChatSession) -> Self {
self.session = Some(session);
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn session_mut(&mut self) -> Option<&mut ChatSession> {
self.session.as_mut()
}
pub async fn run(&mut self, content: &str, media: Option<Vec<String>>) -> Result<String> {
if let Some(session) = self.session.as_mut() {
let builder = self.context_builder.as_ref();
return self
.agent_loop
.process_with_session(session, content, media, builder, self.model.as_deref())
.await;
}
if let Some(builder) = self.context_builder.as_ref() {
return self
.agent_loop
.process_with_context_builder(
builder,
Vec::new(),
content,
media,
self.model.as_deref(),
)
.await;
}
self.agent_loop
.process_with_options(Vec::new(), content, media, self.model.as_deref())
.await
}
}
pub struct SimpleToolExecutor {
tools: HashMap<String, Box<dyn Fn(&str) -> Result<String> + Send + Sync>>,
}
impl SimpleToolExecutor {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn register<F>(&mut self, name: impl Into<String>, handler: F) -> &mut Self
where
F: Fn(&str) -> Result<String> + Send + Sync + 'static,
{
self.tools.insert(name.into(), Box::new(handler));
self
}
}
impl Default for SimpleToolExecutor {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ToolExecutor for SimpleToolExecutor {
async fn execute(&self, name: &str, arguments: &str) -> LLMResult<String> {
if let Some(handler) = self.tools.get(name) {
handler(arguments).map_err(|e| crate::llm::types::LLMError::Other(e.to_string()))
} else {
Err(crate::llm::types::LLMError::Other(format!(
"Unknown tool: {}",
name
)))
}
}
async fn available_tools(&self) -> LLMResult<Vec<Tool>> {
Ok(Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_loop_config_default() {
let config = AgentLoopConfig::default();
assert_eq!(config.max_tool_iterations, 10);
assert_eq!(config.default_model, "gpt-4o-mini");
}
#[test]
fn test_agent_loop_config_custom() {
let config = AgentLoopConfig {
max_tool_iterations: 5,
default_model: "gpt-4".to_string(),
temperature: Some(0.5),
max_tokens: Some(1000),
};
assert_eq!(config.max_tool_iterations, 5);
assert_eq!(config.default_model, "gpt-4");
}
}