Files
herald/crates/herald-server/src/sandbox/agent.rs
T
qpismont 04cc172848 Replace openrouter-rs with in-tree client and require sandbox
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.
2026-09-17 14:48:42 +00:00

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());
}
}