use crate::agent::Agent;
use crate::context::RunContext;
use crate::errors::{AgentError, Result};
use crate::result::RunResult;
use crate::types::{InputItem, ModelResponse, ModelSettings, RunItem, Usage};
use serde_json::{json, Value};
use tracing::{debug, info, warn};
pub const DEFAULT_MAX_TURNS: usize = 10;
#[derive(Debug, Clone)]
pub struct RunConfig {
pub max_turns: usize,
pub api_base: String,
pub api_key: Option<String>,
pub model_settings: Option<ModelSettings>,
pub include_tool_calls: bool,
}
impl RunConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_api_base(mut self, url: impl Into<String>) -> Self {
self.api_base = url.into();
self
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn with_max_turns(mut self, turns: usize) -> Self {
self.max_turns = turns;
self
}
}
impl Default for RunConfig {
fn default() -> Self {
Self {
max_turns: DEFAULT_MAX_TURNS,
api_base: std::env::var("OPENAI_API_BASE")
.unwrap_or_else(|_| "https://api.openai.com/v1".to_string()),
api_key: std::env::var("OPENAI_API_KEY").ok(),
model_settings: None,
include_tool_calls: true,
}
}
}
pub struct Runner;
impl Runner {
pub async fn run(agent: &Agent, input: String, config: &RunConfig) -> Result<RunResult> {
let mut ctx = RunContext::new();
let current_agent = agent;
let mut turn = 0;
let original_input = vec![InputItem::user_message(input)];
let mut generated_items: Vec<RunItem> = Vec::new();
let mut model_responses: Vec<ModelResponse> = Vec::new();
info!("Starting agent run: {}", agent.name);
loop {
turn += 1;
if turn > config.max_turns {
warn!("Max turns ({}) exceeded", config.max_turns);
return Err(AgentError::MaxTurnsExceeded(config.max_turns));
}
debug!("Turn {}: Running agent {}", turn, current_agent.name);
let mut messages = original_input.clone();
messages.extend(generated_items.iter().map(|item| item.to_input_item()));
let mut system_messages = Vec::new();
if let Some(prompt) = current_agent.system_prompt() {
system_messages.push(InputItem::system_message(prompt));
}
let response =
Self::call_llm(current_agent, &system_messages, &messages, config, &mut ctx)
.await?;
model_responses.push(response.clone());
ctx.add_usage(&response.usage);
let (next_step, new_items) =
Self::process_response(current_agent, response, &mut ctx, config).await?;
generated_items.extend(new_items);
match next_step {
NextStep::FinalOutput(output) => {
info!("Agent completed with output");
return Ok(RunResult::new(
original_input,
generated_items,
model_responses,
output,
ctx.usage().clone(),
));
}
NextStep::RunAgain => {
debug!("Continuing agent loop (tools executed)");
continue;
}
NextStep::Handoff(_new_agent) => {
warn!("Handoff not yet implemented");
return Err(AgentError::Configuration(
"Handoff not yet implemented".to_string(),
));
}
}
}
}
async fn call_llm(
agent: &Agent,
system_messages: &[InputItem],
messages: &[InputItem],
config: &RunConfig,
_ctx: &mut RunContext,
) -> Result<ModelResponse> {
let client = reqwest::Client::new();
let mut all_messages = Vec::new();
all_messages.extend(Self::items_to_openai_messages(system_messages));
all_messages.extend(Self::items_to_openai_messages(messages));
let mut body = json!({
"model": agent.model,
"messages": all_messages,
});
if !agent.tools.is_empty() {
let tools: Vec<Value> = agent
.tools
.iter()
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.name(),
"description": t.description(),
"parameters": t.json_schema(),
}
})
})
.collect();
body["tools"] = json!(tools);
}
let settings = config
.model_settings
.as_ref()
.unwrap_or(&agent.model_settings);
if let Some(temp) = settings.temperature {
body["temperature"] = json!(temp);
}
if let Some(top_p) = settings.top_p {
body["top_p"] = json!(top_p);
}
if let Some(max_tokens) = settings.max_tokens {
body["max_tokens"] = json!(max_tokens);
}
debug!("Calling LLM: {}", agent.model);
let api_key = config
.api_key
.as_ref()
.ok_or_else(|| AgentError::Configuration("API key not set".to_string()))?;
let response = client
.post(format!("{}/chat/completions", config.api_base))
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AgentError::ModelError(format!(
"LLM API error {}: {}",
status, error_text
)));
}
let response_json: Value = response.json().await?;
debug!("LLM response: {:?}", response_json);
Self::parse_llm_response(response_json)
}
fn parse_llm_response(response: Value) -> Result<ModelResponse> {
let choice = response["choices"]
.get(0)
.ok_or_else(|| AgentError::ModelBehavior("No choices in response".to_string()))?;
let message = &choice["message"];
let mut output = Vec::new();
if let Some(content) = message["content"].as_str() {
if !content.is_empty() {
output.push(RunItem::Message {
role: "assistant".to_string(),
content: content.to_string(),
});
}
}
if let Some(tool_calls) = message["tool_calls"].as_array() {
for call in tool_calls {
let id = call["id"]
.as_str()
.ok_or_else(|| AgentError::ModelBehavior("Missing tool call id".to_string()))?;
let function = &call["function"];
let name = function["name"]
.as_str()
.ok_or_else(|| AgentError::ModelBehavior("Missing tool name".to_string()))?;
let args = function["arguments"].as_str().ok_or_else(|| {
AgentError::ModelBehavior("Missing tool arguments".to_string())
})?;
output.push(RunItem::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: args.to_string(),
});
}
}
let usage = if let Some(u) = response["usage"].as_object() {
Usage {
requests: 1,
input_tokens: u["prompt_tokens"].as_u64().unwrap_or(0) as usize,
output_tokens: u["completion_tokens"].as_u64().unwrap_or(0) as usize,
total_tokens: u["total_tokens"].as_u64().unwrap_or(0) as usize,
}
} else {
Usage::default()
};
Ok(ModelResponse {
output,
usage,
id: response["id"].as_str().map(|s| s.to_string()),
})
}
async fn process_response(
agent: &Agent,
response: ModelResponse,
ctx: &mut RunContext,
_config: &RunConfig,
) -> Result<(NextStep, Vec<RunItem>)> {
let mut new_items = Vec::new();
let tool_calls: Vec<_> = response
.output
.iter()
.filter_map(|item| {
if let RunItem::ToolCall {
id,
name,
arguments,
} = item
{
Some((id.clone(), name.clone(), arguments.clone()))
} else {
None
}
})
.collect();
if !tool_calls.is_empty() {
debug!("Executing {} tool calls", tool_calls.len());
for (id, name, args) in &tool_calls {
new_items.push(RunItem::ToolCall {
id: id.clone(),
name: name.clone(),
arguments: args.clone(),
});
}
for (id, name, args) in tool_calls {
let tool = agent
.tools
.iter()
.find(|t| t.name() == name)
.ok_or_else(|| AgentError::ToolError {
tool_name: name.clone(),
message: "Tool not found".to_string(),
})?;
debug!("Invoking tool: {}", name);
let result = tool
.invoke(ctx, &args)
.await
.map_err(|e| AgentError::ToolError {
tool_name: name.clone(),
message: e.to_string(),
})?;
new_items.push(RunItem::ToolResult {
tool_call_id: id,
content: result,
});
}
return Ok((NextStep::RunAgain, new_items));
}
for item in &response.output {
if let RunItem::Message { content, .. } = item {
new_items.push(item.clone());
return Ok((NextStep::FinalOutput(content.clone()), new_items));
}
}
Err(AgentError::ModelBehavior(
"Model produced no tool calls or text output".to_string(),
))
}
fn items_to_openai_messages(items: &[InputItem]) -> Vec<Value> {
items
.iter()
.map(|item| match item {
InputItem::Message { role, content } => {
json!({
"role": role,
"content": content,
})
}
InputItem::ToolResult {
tool_call_id,
content,
} => {
json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
})
}
})
.collect()
}
}
#[derive(Debug)]
enum NextStep {
FinalOutput(String),
RunAgain,
#[allow(dead_code)]
Handoff(Agent),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_config_builder() {
let config = RunConfig::new()
.with_max_turns(5)
.with_api_base("https://example.com");
assert_eq!(config.max_turns, 5);
assert_eq!(config.api_base, "https://example.com");
}
}