use crate::tool::{ToolBox, ToolError};
use genai::Client;
pub use genai::adapter::AdapterKind;
pub use genai::chat::ChatOptions;
use genai::chat::{ChatMessage, ChatRequest, JsonSpec, ToolCall, ToolResponse};
use genai::resolver::{AuthData, Endpoint};
use log::{debug, trace};
use schemars::{JsonSchema, schema_for};
use serde::de::DeserializeOwned;
use serde_json::{Value, from_str, json};
use std::any::TypeId;
use thiserror::Error;
#[cfg(test)]
mod tests;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum AgentError {
#[error("failed to create the LLM client: {0}")]
ClientInit(#[source] genai::Error),
#[error("LLM request failed: {0}")]
Llm(#[source] genai::Error),
#[error("invalid answer schema: {0}")]
Schema(String),
#[error("failed to parse the model answer: {source}")]
InvalidAnswer {
raw: String,
#[source]
source: serde_json::Error,
},
#[error("no final answer after {0} iterations")]
MaxIterationsReached(usize),
#[error("tool '{tool}' failed: {source}")]
Tool {
tool: String,
#[source]
source: ToolError,
},
}
impl AgentError {
pub fn is_transient(&self) -> bool {
let Self::Llm(err) = self else {
return false;
};
if let Some(status) = err.status() {
return status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429);
}
match err {
genai::Error::WebModelCall { webc_error, .. }
| genai::Error::WebAdapterCall { webc_error, .. } => match webc_error {
genai::webc::Error::Reqwest(err) => err.is_timeout() || err.is_connect(),
_ => false,
},
genai::Error::WebStream { .. } => true,
_ => false,
}
}
}
pub const DEFAULT_MAX_ITERATIONS: usize = 5;
pub const DEFAULT_TEMPERATURE: f64 = 0.2;
#[derive(Clone)]
pub struct Agent {
client: Client,
history: Vec<ChatMessage>,
max_iterations: usize,
chat_options: ChatOptions,
}
impl Agent {
pub fn new(system: &str) -> Result<Self, AgentError> {
let client = Client::new().map_err(AgentError::ClientInit)?;
Ok(Self::new_with_client(client, system))
}
pub fn new_with_client(client: Client, system: &str) -> Self {
Self {
client,
history: vec![ChatMessage::system(system.trim())],
max_iterations: DEFAULT_MAX_ITERATIONS,
chat_options: ChatOptions::default().with_temperature(DEFAULT_TEMPERATURE),
}
}
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = max_iterations;
self
}
pub fn with_chat_options(mut self, chat_options: ChatOptions) -> Self {
self.chat_options = chat_options;
self
}
pub fn max_iterations(&self) -> usize {
self.max_iterations
}
pub fn chat_options(&self) -> &ChatOptions {
&self.chat_options
}
pub fn new_with_url(base_url: &str, api_key: &str, system: &str) -> Result<Self, AgentError> {
Self::new_with_provider(AdapterKind::OpenAI, Some(base_url), api_key, system)
}
pub fn new_with_provider(
provider: AdapterKind,
base_url: Option<&str>,
api_key: &str,
system: &str,
) -> Result<Self, AgentError> {
let auth = AuthData::from_single(api_key);
let builder = Client::builder().with_adapter_kind(provider);
let builder = match base_url {
Some(base_url) => builder.append_provider_config(
provider,
(Endpoint::from_owned(base_url.to_string()), auth),
),
None => builder.append_provider_config(provider, auth),
};
let client = builder.build().map_err(AgentError::ClientInit)?;
Ok(Self::new_with_client(client, system))
}
pub async fn run<D>(
&mut self,
model: &str,
prompt: &str,
toolbox: Option<&dyn ToolBox>,
) -> Result<D, AgentError>
where
D: DeserializeOwned + JsonSchema + 'static,
{
debug!("Agent Question: {prompt}");
self.history.push(ChatMessage::user(prompt));
let mut chat_opts = self.chat_options.clone();
let is_answer_string = TypeId::of::<String>() == TypeId::of::<D>();
if !is_answer_string {
let mut response_schema = serde_json::to_value(schema_for!(D))
.map_err(|err| AgentError::Schema(err.to_string()))?;
let obj = response_schema.as_object_mut().ok_or_else(|| {
AgentError::Schema("JSON schema of the answer type is not an object".to_string())
})?;
obj.remove("$schema");
obj.remove("title");
chat_opts = chat_opts.with_response_format(JsonSpec::new("ResponseFormat", json!(obj)));
}
let max_iterations = self.max_iterations;
for iteration in 0..max_iterations {
debug!("Agent iteration: {iteration}");
let mut chat_req = ChatRequest::new(self.history.clone());
if let Some(toolbox) = toolbox {
let definitions =
toolbox
.tools_definitions()
.map_err(|source| AgentError::Tool {
tool: String::new(),
source,
})?;
chat_req = chat_req.with_tools(definitions);
}
let chat_resp = self
.client
.exec_chat(model, chat_req, Some(&chat_opts))
.await
.map_err(AgentError::Llm)?;
let tool_calls: Vec<ToolCall> = chat_resp.tool_calls().into_iter().cloned().collect();
if !tool_calls.is_empty() {
if let Some(assistant_msg) = chat_resp.into_assistant_message_for_tool_use() {
self.history.push(assistant_msg);
}
let mut fatal: Option<AgentError> = None;
for tool_call in tool_calls {
if fatal.is_some() {
self.history.push(
ToolResponse::from_tool_call(
&tool_call,
"Tool was not executed because an earlier tool call failed"
.to_string(),
)
.into(),
);
continue;
}
trace!(
"Tool request: {} with arguments: {}",
tool_call.fn_name, tool_call.fn_arguments
);
let content = match toolbox {
Some(toolbox) => {
match toolbox
.call_tool(
tool_call.fn_name.clone(),
tool_call.fn_arguments.clone(),
)
.await
{
Ok(result) => {
trace!("Tool result: {result}");
result
}
Err(err) if err.is_recoverable() => {
trace!("Tool error: {err}");
err.to_string()
}
Err(source) => {
debug!("Fatal error in tool '{}': {source}", tool_call.fn_name);
let message = format!("Tool failed: {source}");
fatal = Some(AgentError::Tool {
tool: tool_call.fn_name.clone(),
source,
});
message
}
}
}
None => {
debug!(
"Model requested tool '{}' but no toolbox was provided",
tool_call.fn_name
);
format!(
"Tool '{}' is not available: no tools were provided",
tool_call.fn_name
)
}
};
self.history
.push(ToolResponse::from_tool_call(&tool_call, content).into());
}
if let Some(err) = fatal {
return Err(err);
}
continue;
}
let texts = chat_resp.content.into_texts();
if texts.is_empty() {
debug!("Agent iteration {iteration}: response has no text and no tool calls");
continue;
}
let raw = texts.join("");
debug!("Agent Answer: {raw}");
self.history.push(ChatMessage::assistant(raw.clone()));
let resp = if is_answer_string {
Value::String(raw.clone()).to_string()
} else {
raw.clone()
};
return from_str(&resp).map_err(|source| AgentError::InvalidAnswer { raw, source });
}
Err(AgentError::MaxIterationsReached(max_iterations))
}
}