//! Tool-calling loop driving the model against a [`Sandbox`]. //! //! The loop sends the conversation and the available tools to OpenRouter, //! executes any requested tool call inside the sandbox and feeds the results //! back, until the model produces a final message or the iteration budget is //! exhausted. use anyhow::Context; use serde_json::Value; use tracing::{debug, warn}; use crate::{ open_router::{Message, OpenRouterClient, Role, Tool, ToolCall}, sandbox::{Sandbox, tools}, }; /// Final output of an agent run. pub struct AgentResult { pub message: String, pub cost: Option, pub iterations: usize, } /// Runs the tool-calling loop until the model answers or `max_iterations` is /// reached. /// /// `tool_definitions` is the set of tools the model may call; it is selected by /// the caller based on the webhook action (see [`tools::for_webhook`]). pub async fn run( open_router: &OpenRouterClient, sandbox: &Sandbox, tool_definitions: Vec, system_prompt: &str, user_prompt: &str, max_iterations: usize, ) -> anyhow::Result { let mut messages = vec![ Message::new(Role::System, system_prompt), Message::new(Role::User, user_prompt), ]; let mut total_cost = 0.0_f64; let mut has_cost = false; for iteration in 1..=max_iterations { let response = open_router .chat_with_tools(messages.clone(), tool_definitions.clone()) .await?; if let Some(cost) = response.cost { total_cost += cost; has_cost = true; } if response.tool_calls.is_empty() { return Ok(AgentResult { message: response.message.unwrap_or_default(), cost: has_cost.then_some(total_cost), iterations: iteration, }); } messages.push(Message::assistant_with_tool_calls( response.message.unwrap_or_default(), response.tool_calls.clone(), )); for call in &response.tool_calls { debug!(tool = call.name(), "Executing tool call"); let content = execute(sandbox, call).await; messages.push(Message::tool_response(call.id(), content)); } } warn!(max_iterations, "Agent reached the iteration limit"); anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})") } async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String { let args = match parse_args(call) { Ok(args) => args, Err(err) => return format!("error: {err}"), }; match tools::dispatch(sandbox, call.name(), &args).await { Ok(output) => output, Err(err) => format!("error: {err}"), } } fn parse_args(call: &ToolCall) -> anyhow::Result { let raw = call.arguments_json().trim(); if raw.is_empty() { return Ok(Value::Object(serde_json::Map::new())); } serde_json::from_str(raw) .with_context(|| format!("invalid arguments for tool `{}`", call.name())) } #[cfg(test)] mod tests { use super::*; use serde_json::json; /// Builds a tool call the way the API returns one, so the fixture also /// covers deserialization. fn tool_call(name: &str, arguments: &str) -> ToolCall { serde_json::from_value(json!({ "id": "call_1", "type": "function", "function": { "name": name, "arguments": arguments } })) .unwrap() } #[test] fn parse_args_accepts_empty_arguments() { let call = tool_call("ls", ""); assert_eq!( parse_args(&call).unwrap(), Value::Object(serde_json::Map::new()) ); } #[test] fn parse_args_parses_json_object() { let call = tool_call("ls", r#"{"path":"src"}"#); assert_eq!(parse_args(&call).unwrap()["path"], "src"); } #[test] fn parse_args_rejects_invalid_json() { let call = tool_call("ls", "not json"); assert!(parse_args(&call).is_err()); } }