1use crate::agent::Agent;
4use crate::context::RunContext;
5use crate::errors::{AgentError, Result};
6use crate::result::RunResult;
7use crate::types::{InputItem, ModelResponse, ModelSettings, RunItem, Usage};
8use serde_json::{json, Value};
9use tracing::{debug, info, warn};
10
11pub const DEFAULT_MAX_TURNS: usize = 10;
13
14#[derive(Debug, Clone)]
16pub struct RunConfig {
17 pub max_turns: usize,
19
20 pub api_base: String,
22
23 pub api_key: Option<String>,
25
26 pub model_settings: Option<ModelSettings>,
28
29 pub include_tool_calls: bool,
31}
32
33impl RunConfig {
34 pub fn new() -> Self {
36 Self::default()
37 }
38
39 pub fn with_api_base(mut self, url: impl Into<String>) -> Self {
41 self.api_base = url.into();
42 self
43 }
44
45 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
47 self.api_key = Some(key.into());
48 self
49 }
50
51 pub fn with_max_turns(mut self, turns: usize) -> Self {
53 self.max_turns = turns;
54 self
55 }
56}
57
58impl Default for RunConfig {
59 fn default() -> Self {
60 Self {
61 max_turns: DEFAULT_MAX_TURNS,
62 api_base: std::env::var("OPENAI_API_BASE")
63 .unwrap_or_else(|_| "https://api.openai.com/v1".to_string()),
64 api_key: std::env::var("OPENAI_API_KEY").ok(),
65 model_settings: None,
66 include_tool_calls: true,
67 }
68 }
69}
70
71pub struct Runner;
73
74impl Runner {
75 pub async fn run(agent: &Agent, input: String, config: &RunConfig) -> Result<RunResult> {
84 let mut ctx = RunContext::new();
85 let current_agent = agent;
86 let mut turn = 0;
87 let original_input = vec![InputItem::user_message(input)];
88 let mut generated_items: Vec<RunItem> = Vec::new();
89 let mut model_responses: Vec<ModelResponse> = Vec::new();
90
91 info!("Starting agent run: {}", agent.name);
92
93 loop {
94 turn += 1;
95 if turn > config.max_turns {
96 warn!("Max turns ({}) exceeded", config.max_turns);
97 return Err(AgentError::MaxTurnsExceeded(config.max_turns));
98 }
99
100 debug!("Turn {}: Running agent {}", turn, current_agent.name);
101
102 let mut messages = original_input.clone();
104 messages.extend(generated_items.iter().map(|item| item.to_input_item()));
105
106 let mut system_messages = Vec::new();
108 if let Some(prompt) = current_agent.system_prompt() {
109 system_messages.push(InputItem::system_message(prompt));
110 }
111
112 let response =
114 Self::call_llm(current_agent, &system_messages, &messages, config, &mut ctx)
115 .await?;
116
117 model_responses.push(response.clone());
118 ctx.add_usage(&response.usage);
119
120 let (next_step, new_items) =
122 Self::process_response(current_agent, response, &mut ctx, config).await?;
123
124 generated_items.extend(new_items);
125
126 match next_step {
127 NextStep::FinalOutput(output) => {
128 info!("Agent completed with output");
129 return Ok(RunResult::new(
130 original_input,
131 generated_items,
132 model_responses,
133 output,
134 ctx.usage().clone(),
135 ));
136 }
137 NextStep::RunAgain => {
138 debug!("Continuing agent loop (tools executed)");
139 continue;
140 }
141 NextStep::Handoff(_new_agent) => {
142 warn!("Handoff not yet implemented");
144 return Err(AgentError::Configuration(
145 "Handoff not yet implemented".to_string(),
146 ));
147 }
148 }
149 }
150 }
151
152 async fn call_llm(
154 agent: &Agent,
155 system_messages: &[InputItem],
156 messages: &[InputItem],
157 config: &RunConfig,
158 _ctx: &mut RunContext,
159 ) -> Result<ModelResponse> {
160 let client = reqwest::Client::new();
161
162 let mut all_messages = Vec::new();
164 all_messages.extend(Self::items_to_openai_messages(system_messages));
165 all_messages.extend(Self::items_to_openai_messages(messages));
166
167 let mut body = json!({
168 "model": agent.model,
169 "messages": all_messages,
170 });
171
172 if !agent.tools.is_empty() {
174 let tools: Vec<Value> = agent
175 .tools
176 .iter()
177 .map(|t| {
178 json!({
179 "type": "function",
180 "function": {
181 "name": t.name(),
182 "description": t.description(),
183 "parameters": t.json_schema(),
184 }
185 })
186 })
187 .collect();
188 body["tools"] = json!(tools);
189 }
190
191 let settings = config
193 .model_settings
194 .as_ref()
195 .unwrap_or(&agent.model_settings);
196 if let Some(temp) = settings.temperature {
197 body["temperature"] = json!(temp);
198 }
199 if let Some(top_p) = settings.top_p {
200 body["top_p"] = json!(top_p);
201 }
202 if let Some(max_tokens) = settings.max_tokens {
203 body["max_tokens"] = json!(max_tokens);
204 }
205
206 debug!("Calling LLM: {}", agent.model);
207
208 let api_key = config
210 .api_key
211 .as_ref()
212 .ok_or_else(|| AgentError::Configuration("API key not set".to_string()))?;
213
214 let response = client
215 .post(format!("{}/chat/completions", config.api_base))
216 .header("Authorization", format!("Bearer {}", api_key))
217 .header("Content-Type", "application/json")
218 .json(&body)
219 .send()
220 .await?;
221
222 if !response.status().is_success() {
223 let status = response.status();
224 let error_text = response.text().await.unwrap_or_default();
225 return Err(AgentError::ModelError(format!(
226 "LLM API error {}: {}",
227 status, error_text
228 )));
229 }
230
231 let response_json: Value = response.json().await?;
232 debug!("LLM response: {:?}", response_json);
233
234 Self::parse_llm_response(response_json)
235 }
236
237 fn parse_llm_response(response: Value) -> Result<ModelResponse> {
239 let choice = response["choices"]
240 .get(0)
241 .ok_or_else(|| AgentError::ModelBehavior("No choices in response".to_string()))?;
242
243 let message = &choice["message"];
244 let mut output = Vec::new();
245
246 if let Some(content) = message["content"].as_str() {
248 if !content.is_empty() {
249 output.push(RunItem::Message {
250 role: "assistant".to_string(),
251 content: content.to_string(),
252 });
253 }
254 }
255
256 if let Some(tool_calls) = message["tool_calls"].as_array() {
258 for call in tool_calls {
259 let id = call["id"]
260 .as_str()
261 .ok_or_else(|| AgentError::ModelBehavior("Missing tool call id".to_string()))?;
262 let function = &call["function"];
263 let name = function["name"]
264 .as_str()
265 .ok_or_else(|| AgentError::ModelBehavior("Missing tool name".to_string()))?;
266 let args = function["arguments"].as_str().ok_or_else(|| {
267 AgentError::ModelBehavior("Missing tool arguments".to_string())
268 })?;
269
270 output.push(RunItem::ToolCall {
271 id: id.to_string(),
272 name: name.to_string(),
273 arguments: args.to_string(),
274 });
275 }
276 }
277
278 let usage = if let Some(u) = response["usage"].as_object() {
280 Usage {
281 requests: 1,
282 input_tokens: u["prompt_tokens"].as_u64().unwrap_or(0) as usize,
283 output_tokens: u["completion_tokens"].as_u64().unwrap_or(0) as usize,
284 total_tokens: u["total_tokens"].as_u64().unwrap_or(0) as usize,
285 }
286 } else {
287 Usage::default()
288 };
289
290 Ok(ModelResponse {
291 output,
292 usage,
293 id: response["id"].as_str().map(|s| s.to_string()),
294 })
295 }
296
297 async fn process_response(
299 agent: &Agent,
300 response: ModelResponse,
301 ctx: &mut RunContext,
302 _config: &RunConfig,
303 ) -> Result<(NextStep, Vec<RunItem>)> {
304 let mut new_items = Vec::new();
305
306 let tool_calls: Vec<_> = response
308 .output
309 .iter()
310 .filter_map(|item| {
311 if let RunItem::ToolCall {
312 id,
313 name,
314 arguments,
315 } = item
316 {
317 Some((id.clone(), name.clone(), arguments.clone()))
318 } else {
319 None
320 }
321 })
322 .collect();
323
324 if !tool_calls.is_empty() {
325 debug!("Executing {} tool calls", tool_calls.len());
326
327 for (id, name, args) in &tool_calls {
329 new_items.push(RunItem::ToolCall {
330 id: id.clone(),
331 name: name.clone(),
332 arguments: args.clone(),
333 });
334 }
335
336 for (id, name, args) in tool_calls {
338 let tool = agent
339 .tools
340 .iter()
341 .find(|t| t.name() == name)
342 .ok_or_else(|| AgentError::ToolError {
343 tool_name: name.clone(),
344 message: "Tool not found".to_string(),
345 })?;
346
347 debug!("Invoking tool: {}", name);
348 let result = tool
349 .invoke(ctx, &args)
350 .await
351 .map_err(|e| AgentError::ToolError {
352 tool_name: name.clone(),
353 message: e.to_string(),
354 })?;
355
356 new_items.push(RunItem::ToolResult {
357 tool_call_id: id,
358 content: result,
359 });
360 }
361
362 return Ok((NextStep::RunAgain, new_items));
363 }
364
365 for item in &response.output {
367 if let RunItem::Message { content, .. } = item {
368 new_items.push(item.clone());
369 return Ok((NextStep::FinalOutput(content.clone()), new_items));
370 }
371 }
372
373 Err(AgentError::ModelBehavior(
375 "Model produced no tool calls or text output".to_string(),
376 ))
377 }
378
379 fn items_to_openai_messages(items: &[InputItem]) -> Vec<Value> {
381 items
382 .iter()
383 .map(|item| match item {
384 InputItem::Message { role, content } => {
385 json!({
386 "role": role,
387 "content": content,
388 })
389 }
390 InputItem::ToolResult {
391 tool_call_id,
392 content,
393 } => {
394 json!({
395 "role": "tool",
396 "tool_call_id": tool_call_id,
397 "content": content,
398 })
399 }
400 })
401 .collect()
402 }
403}
404
405#[derive(Debug)]
407enum NextStep {
408 FinalOutput(String),
410
411 RunAgain,
413
414 #[allow(dead_code)]
416 Handoff(Agent),
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn test_run_config_builder() {
425 let config = RunConfig::new()
426 .with_max_turns(5)
427 .with_api_base("https://example.com");
428
429 assert_eq!(config.max_turns, 5);
430 assert_eq!(config.api_base, "https://example.com");
431 }
432}