130 lines
3.7 KiB
Rust
130 lines
3.7 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 openrouter_rs::{
|
|
Message,
|
|
types::{Role, Tool, ToolCall},
|
|
};
|
|
use serde_json::Value;
|
|
use tracing::{debug, warn};
|
|
|
|
use crate::{
|
|
open_router::OpenRouterClient,
|
|
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::*;
|
|
|
|
#[test]
|
|
fn parse_args_accepts_empty_arguments() {
|
|
let call = ToolCall::new("id", "ls", "");
|
|
assert_eq!(
|
|
parse_args(&call).unwrap(),
|
|
Value::Object(serde_json::Map::new())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_args_parses_json_object() {
|
|
let call = ToolCall::new("id", "ls", r#"{"path":"src"}"#);
|
|
assert_eq!(parse_args(&call).unwrap()["path"], "src");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_args_rejects_invalid_json() {
|
|
let call = ToolCall::new("id", "ls", "not json");
|
|
assert!(parse_args(&call).is_err());
|
|
}
|
|
}
|