Skip to main content

hanzo_agent/
runner.rs

1//! Agent execution runner
2
3use 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
11/// Default maximum turns for agent execution
12pub const DEFAULT_MAX_TURNS: usize = 10;
13
14/// Configuration for agent run
15#[derive(Debug, Clone)]
16pub struct RunConfig {
17    /// Maximum number of turns (LLM invocations)
18    pub max_turns: usize,
19
20    /// The API base URL for the LLM provider
21    pub api_base: String,
22
23    /// API key for authentication
24    pub api_key: Option<String>,
25
26    /// Global model settings override
27    pub model_settings: Option<ModelSettings>,
28
29    /// Whether to include tool calls in the context
30    pub include_tool_calls: bool,
31}
32
33impl RunConfig {
34    /// Create a new run config with defaults
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Set the API base URL
40    pub fn with_api_base(mut self, url: impl Into<String>) -> Self {
41        self.api_base = url.into();
42        self
43    }
44
45    /// Set the API key
46    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
47        self.api_key = Some(key.into());
48        self
49    }
50
51    /// Set maximum turns
52    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
71/// Runner executes the agent loop
72pub struct Runner;
73
74impl Runner {
75    /// Run the agent with the given input
76    ///
77    /// This executes the agent loop:
78    /// 1. Send input to LLM
79    /// 2. If tool calls are returned, execute them
80    /// 3. If a final output is returned, complete
81    /// 4. If handoff occurs, switch to new agent
82    /// 5. Repeat until max_turns or final output
83    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            // Build the messages for this turn
103            let mut messages = original_input.clone();
104            messages.extend(generated_items.iter().map(|item| item.to_input_item()));
105
106            // Add system prompt if present
107            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            // Get response from LLM
113            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            // Process the response
121            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                    // TODO: Implement handoff logic
143                    warn!("Handoff not yet implemented");
144                    return Err(AgentError::Configuration(
145                        "Handoff not yet implemented".to_string(),
146                    ));
147                }
148            }
149        }
150    }
151
152    /// Call the LLM with the current messages
153    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        // Build the request body
163        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        // Add tools if present
173        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        // Add model settings
192        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        // Make the request
209        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    /// Parse the LLM response into a ModelResponse
238    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        // Check for text content
247        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        // Check for tool calls
257        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        // Parse usage
279        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    /// Process the model response
298    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        // Check if there are tool calls to execute
307        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            // Add the tool calls to new items
328            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            // Execute each tool
337            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        // Check for final output (text message)
366        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        // No tool calls and no text - this is an error
374        Err(AgentError::ModelBehavior(
375            "Model produced no tool calls or text output".to_string(),
376        ))
377    }
378
379    /// Convert InputItems to OpenAI message format
380    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/// Next step in the agent loop
406#[derive(Debug)]
407enum NextStep {
408    /// Agent produced final output
409    FinalOutput(String),
410
411    /// Run the agent again (after tool execution)
412    RunAgain,
413
414    /// Handoff to another agent (planned for multi-agent workflows)
415    #[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}