Remove the openrouter-rs dependency in favor of a minimal in-tree OpenRouter chat-completions client, and drop the BOT_NAME and SANDBOX_ENABLED config options. Reviews now always run inside the sandbox, and the review prompt asks the model to read files with the available tools instead of embedding the diff.
138 lines
4.0 KiB
Rust
138 lines
4.0 KiB
Rust
//! 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<f64>,
|
|
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<Tool>,
|
|
system_prompt: &str,
|
|
user_prompt: &str,
|
|
max_iterations: usize,
|
|
) -> anyhow::Result<AgentResult> {
|
|
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<Value> {
|
|
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());
|
|
}
|
|
}
|