This commit is contained in:
1 parent
9f03384bc9
commit
f5f0d3999e
30 files changed
+96
-93
No files matched your search
@@ -0,0 +1,316 @@
|
||||
//! 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<A> {
|
||||
/// Answer of the model, parsed into the type the caller asked for.
|
||||
pub answer: A,
|
||||
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`]).
|
||||
///
|
||||
/// 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<A>(
|
||||
open_router: &OpenRouterClient,
|
||||
sandbox: &Sandbox,
|
||||
tool_definitions: Vec<Tool>,
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
max_iterations: usize,
|
||||
) -> anyhow::Result<AgentResult<A>>
|
||||
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::<Vec<_>>()
|
||||
.join(", "),
|
||||
max_iterations,
|
||||
"Starting tool-calling loop"
|
||||
);
|
||||
|
||||
let mut total_cost = 0.0_f64;
|
||||
let mut has_cost = false;
|
||||
let mut last_rejection: Option<String> = 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::<A>() {
|
||||
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<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());
|
||||
}
|
||||
|
||||
#[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('é'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user