//! 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 std::str::FromStr; use std::time::Instant; use anyhow::Context; use serde_json::Value; use tracing::{info, warn}; use crate::{ open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice}, sandbox::{Sandbox, tools}, text::{excerpt, truncate_bytes}, }; /// Number of characters of the arguments of a tool call kept in the logs. const MAX_LOGGED_ARGUMENTS: usize = 200; /// Number of characters of a tool result kept in the logs. /// /// Sizes alone make a broken tool invisible: a command that fails returns a short /// `error: …` instead of the expected content, which is exactly what this excerpt /// makes obvious. const MAX_LOGGED_OUTPUT: usize = 120; /// Maximum number of bytes of a tool result handed to the model. /// /// A result stays in the conversation and is re-sent on every following /// iteration, so an unbounded one (a whole file, a match on every line) inflates /// the context and the cost of the whole rest of the run — and can overflow the /// model context window outright. const MAX_TOOL_OUTPUT_BYTES: usize = 32 * 1024; /// Asked of the model when its answer does not satisfy the caller's contract. const RETRY_PROMPT: &str = " Your answer is not valid: {error} Answer again with the requested format only: a raw json object, without any markdown code fence and without any text before or after it. "; /// Asked of the model on the last turn, in place of one more exploration turn. /// /// Without it, a model still exploring on its last turn would ask for another /// tool, and the run would end on the iteration budget with no answer at all. const FINAL_PROMPT: &str = " You are out of turns: this is your last one. Answer now, with the requested format, from what you have already gathered, without calling any tool. "; /// Final output of an agent run. pub struct AgentResult { /// Answer of the model, parsed into the type the caller asked for. pub answer: A, 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`]). /// /// The answer of the model is parsed into `A`, which is how the caller states the /// format it expects: while [`FromStr`] rejects the answer, the error is sent back /// to the model so that it can correct itself, which costs an iteration. The /// parsing happens here because that is where the conversation and the remaining /// budget are at hand. pub async fn run( open_router: &OpenRouterClient, sandbox: &Sandbox, tool_definitions: Vec, system_prompt: &str, user_prompt: &str, max_iterations: usize, ) -> anyhow::Result> where A: FromStr, A::Err: std::fmt::Display, { let mut messages = vec![ Message::new(Role::System, system_prompt), Message::new(Role::User, user_prompt), ]; info!( tools = %tool_definitions .iter() .map(|tool| tool.function.name.as_str()) .collect::>() .join(", "), max_iterations, "Starting tool-calling loop" ); let mut total_cost = 0.0_f64; let mut has_cost = false; let mut last_rejection: Option = None; for iteration in 1..=max_iterations { // Le dernier tour n'est plus un tour d'exploration : le modèle doit rendre // sa réponse, avec ce qu'il a déjà vu. let last = iteration == max_iterations; if last { info!(iteration, "Last turn: asking for the final answer"); messages.push(Message::new(Role::User, FINAL_PROMPT)); } let started = Instant::now(); let response = open_router .chat_with_tools( &messages, &tool_definitions, if last { ToolChoice::None } else { ToolChoice::Auto }, ) .await?; if let Some(cost) = response.cost { total_cost += cost; has_cost = true; } info!( iteration, max_iterations, last_turn = last, tool_calls = response.tool_calls.len(), answer_bytes = response.message.as_deref().map_or(0, str::len), elapsed_ms = started.elapsed().as_millis() as u64, "Model answered" ); // Au dernier tour, les outils sont refusés : même si le fournisseur les // renvoie malgré tout, on ne les exécute pas et on tente la réponse. if last || response.tool_calls.is_empty() { let answer = response.message.unwrap_or_default(); match answer.parse::() { Ok(parsed) => { return Ok(AgentResult { answer: parsed, cost: has_cost.then_some(total_cost), iterations: iteration, }); } Err(err) => { warn!(iteration, %err, "Model answer was rejected, asking for another one"); // Keep the rejected answer in the history, so that the model // sees what it has to fix. messages.push(Message::new(Role::Assistant, answer)); messages.push(Message::new( Role::User, RETRY_PROMPT.replace("{error}", &err.to_string()), )); last_rejection = Some(err.to_string()); continue; } } } messages.push(Message::assistant_with_tool_calls( response.message.unwrap_or_default(), response.tool_calls.clone(), )); for call in &response.tool_calls { let started = Instant::now(); let (content, truncated) = execute(sandbox, call).await; info!( iteration, tool = call.name(), arguments = %excerpt(call.arguments_json(), MAX_LOGGED_ARGUMENTS), output_bytes = content.len(), truncated, // Les sauts de ligne casseraient la lisibilité d'une ligne de log. output = %excerpt(&content, MAX_LOGGED_OUTPUT).replace('\n', " "), elapsed_ms = started.elapsed().as_millis() as u64, "Tool call finished" ); messages.push(Message::tool_response(call.id(), content)); } } warn!( max_iterations, "Agent used its last turn without a valid answer" ); match last_rejection { Some(error) => anyhow::bail!( "agent exceeded the maximum number of iterations ({max_iterations}), \ last answer rejected: {error}" ), None => anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})"), } } /// Runs a tool call and returns its result for the model, along with whether the /// result had to be truncated. async fn execute(sandbox: &Sandbox, call: &ToolCall) -> (String, bool) { let args = match parse_args(call) { Ok(args) => args, Err(err) => return cap_output(format!("error: {err}")), }; match tools::dispatch(sandbox, call.name(), &args).await { Ok(output) => cap_output(output), Err(err) => cap_output(format!("error: {err}")), } } /// Bounds [`MAX_TOOL_OUTPUT_BYTES`] of a tool result, telling the model what was /// dropped so that it can narrow its request. fn cap_output(mut output: String) -> (String, bool) { let total = output.len(); if !truncate_bytes(&mut output, MAX_TOOL_OUTPUT_BYTES) { return (output, false); } let kept = output.len(); output.push_str(&format!( "\n… output truncated: {total} bytes in total, the first {kept} are shown. \ Narrow the request (path, pattern or line range) to see the rest." )); (output, true) } 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()); } #[test] fn a_small_tool_output_is_kept_as_is() { let (content, truncated) = cap_output(String::from("src/main.rs")); assert_eq!(content, "src/main.rs"); assert!(!truncated); } #[test] fn a_large_tool_output_is_truncated_and_told_to_the_model() { let (content, truncated) = cap_output("a".repeat(MAX_TOOL_OUTPUT_BYTES + 1)); assert!(truncated); assert!(content.starts_with(&"a".repeat(MAX_TOOL_OUTPUT_BYTES))); assert!(content.contains("output truncated")); } #[test] fn a_truncated_tool_output_stays_valid_utf8() { let (content, truncated) = cap_output("é".repeat(MAX_TOOL_OUTPUT_BYTES)); assert!(truncated); assert!(content.starts_with('é')); } }