replace ContainerRuntime CLI to Bollard crate (default: docker socket)
ci/woodpecker/push/tests Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
limit tool result to open router tool response (with truncated info for ai) Hard kill if graceful shutdown is too long
This commit is contained in:
@@ -4,7 +4,12 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bollard = "0.21"
|
||||
bytes = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
tar = "0.4"
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,12 @@ pub struct DevContainerSchema {
|
||||
#[serde(rename = "remoteUser", default)]
|
||||
pub remote_user: Option<String>,
|
||||
|
||||
/// Arguments passés à `docker run`.
|
||||
///
|
||||
/// Lus pour rester fidèle au format `devcontainer.json`, mais
|
||||
/// **délibérément pas transmis** au runtime : ils viennent d'une pull
|
||||
/// request non fiable et pourraient casser l'isolation de la sandbox (voir
|
||||
/// [`DevContainer::run_args`]).
|
||||
#[serde(rename = "runArgs", default)]
|
||||
pub run_args: Vec<String>,
|
||||
}
|
||||
@@ -52,7 +58,6 @@ pub struct DevContainer {
|
||||
pub post_create_command: Option<String>,
|
||||
pub post_start_command: Option<String>,
|
||||
pub remote_user: Option<String>,
|
||||
pub run_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -114,7 +119,6 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||
post_create_command: schema.post_create_command,
|
||||
post_start_command: schema.post_start_command,
|
||||
remote_user: schema.remote_user,
|
||||
run_args: schema.run_args,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,8 +207,7 @@ mod tests {
|
||||
"containerEnv": {
|
||||
"RUST_LOG": "debug"
|
||||
},
|
||||
"remoteUser": "dev",
|
||||
"runArgs": ["--userns=keep-id"]
|
||||
"remoteUser": "dev"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -217,7 +220,6 @@ mod tests {
|
||||
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
||||
assert_eq!(config.run_args, vec!["--userns=keep-id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
@@ -7,6 +9,7 @@ use crate::{
|
||||
metrics,
|
||||
open_router::{OpenRouterClient, Tool},
|
||||
sandbox::{Sandbox, SandboxConfig, agent},
|
||||
text::excerpt,
|
||||
};
|
||||
|
||||
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
||||
@@ -59,7 +62,7 @@ pub async fn exec_review(
|
||||
.replace("{comment}", &review_payload.comment.body)
|
||||
.replace("{changes}", &changes);
|
||||
|
||||
let (message, cost) = run_sandboxed_review(
|
||||
let (mut review_result, cost) = run_sandboxed_review(
|
||||
gitea_api,
|
||||
open_router_client,
|
||||
sandbox_config,
|
||||
@@ -69,7 +72,6 @@ pub async fn exec_review(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
|
||||
resolve_review_sides(&mut review_result, &changed_lines);
|
||||
|
||||
review_result.cost = cost;
|
||||
@@ -112,6 +114,10 @@ pub async fn exec_review(
|
||||
|
||||
/// Runs the review inside a sandbox container, letting the model explore the
|
||||
/// repository with tools before answering.
|
||||
///
|
||||
/// The answer of the model is parsed by [`ReviewResult::from_str`], which the
|
||||
/// agent loop enforces: an answer that is not a review is sent back to the model
|
||||
/// for correction.
|
||||
async fn run_sandboxed_review(
|
||||
gitea_api: &GiteaAPI,
|
||||
open_router_client: &OpenRouterClient,
|
||||
@@ -119,7 +125,7 @@ async fn run_sandboxed_review(
|
||||
tools: Vec<Tool>,
|
||||
review_payload: &ReviewPayload,
|
||||
bot_request: &str,
|
||||
) -> anyhow::Result<(String, Option<f64>)> {
|
||||
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
||||
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
|
||||
|
||||
let sandbox = Sandbox::create(
|
||||
@@ -151,7 +157,7 @@ async fn run_sandboxed_review(
|
||||
"Sandboxed review finished"
|
||||
);
|
||||
|
||||
Ok((result.message, result.cost))
|
||||
Ok((result.answer, result.cost))
|
||||
}
|
||||
|
||||
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||
@@ -452,6 +458,61 @@ fn format_line_numbers(lines: &[u64]) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// Number of characters of a model answer kept in the logs when it cannot be
|
||||
/// parsed.
|
||||
const MAX_LOGGED_ANSWER: usize = 500;
|
||||
|
||||
impl FromStr for ReviewResult {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
/// Parses the review the model answered with.
|
||||
///
|
||||
/// This is the contract the agent loop enforces: a rejected answer is sent
|
||||
/// back to the model, with the reason, so that it can correct itself.
|
||||
///
|
||||
/// The contract is a raw JSON object, but models sometimes wrap it in a
|
||||
/// markdown code fence or surround it with a sentence: the object is then
|
||||
/// extracted from the answer before failing, and the answer is logged so a
|
||||
/// breach of the contract can be diagnosed.
|
||||
fn from_str(message: &str) -> Result<Self, Self::Err> {
|
||||
let error = match serde_json::from_str::<Self>(message) {
|
||||
Ok(review_result) => return Ok(review_result),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
// A markdown code fence or a sentence around the object is tolerated, with
|
||||
// a warning: the contract asks for a raw JSON object.
|
||||
if let Some(json) = json_object(message)
|
||||
&& let Ok(review_result) = serde_json::from_str::<Self>(json)
|
||||
{
|
||||
warn!(
|
||||
"Model answer is not a raw JSON object, it was extracted from the surrounding text"
|
||||
);
|
||||
|
||||
return Ok(review_result);
|
||||
}
|
||||
|
||||
// The reason of the rejection is logged along with the answer: without it,
|
||||
// a broken answer is impossible to diagnose.
|
||||
warn!(
|
||||
answer = %excerpt(message, MAX_LOGGED_ANSWER),
|
||||
reason = %error,
|
||||
"Model answer is not the expected JSON"
|
||||
);
|
||||
|
||||
anyhow::bail!("the answer is not valid JSON: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the outermost `{...}` of an answer, which ignores a markdown code
|
||||
/// fence or any text around it.
|
||||
fn json_object(message: &str) -> Option<&str> {
|
||||
let start = message.find('{')?;
|
||||
let end = message.rfind('}')?;
|
||||
|
||||
(start < end).then(|| &message[start..=end])
|
||||
}
|
||||
|
||||
/// Resolves the side each review is anchored on and drops the reviews that do
|
||||
/// not match a line the pull request changes.
|
||||
///
|
||||
@@ -824,6 +885,45 @@ mod tests {
|
||||
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_answer_is_parsed() {
|
||||
let answer = r#"{"reviews": [], "comment": "ok"}"#;
|
||||
|
||||
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fenced_answer_is_extracted() {
|
||||
let answer = concat!(
|
||||
"Voici ma review :\n",
|
||||
"```json\n",
|
||||
"{\"reviews\": [], \"comment\": \"rien à signaler\"}\n",
|
||||
"```\n"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
answer.parse::<ReviewResult>().unwrap().comment,
|
||||
"rien à signaler"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_around_the_object_is_ignored() {
|
||||
let answer = r#"Rien à signaler. {"reviews": [], "comment": "ok"} Bonne journée !"#;
|
||||
|
||||
assert_eq!(answer.parse::<ReviewResult>().unwrap().comment, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_without_json_is_rejected() {
|
||||
assert!("Je n'ai rien relevé.".parse::<ReviewResult>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_is_an_object_but_not_a_review_is_rejected() {
|
||||
assert!(r#"{"message": "LGTM"}"#.parse::<ReviewResult>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_sides_from_the_model_are_tolerated() {
|
||||
let changed_lines = parse_changed_lines(DIFF);
|
||||
|
||||
@@ -15,7 +15,8 @@ pub const SANDBOX_SYSTEM_PROMPT: &str = "
|
||||
tools (ls, read_file, grep, find) to explore the code and gather the context
|
||||
you need before answering. Paths are relative to the repository root.
|
||||
|
||||
When you have enough information, answer with the requested JSON only.
|
||||
When you have enough information, answer with the requested JSON only, as a raw
|
||||
JSON object: no markdown code fence, nothing before or after it.
|
||||
";
|
||||
|
||||
pub const REVIEW_PROMPT: &str = "
|
||||
@@ -39,6 +40,8 @@ pub const REVIEW_PROMPT: &str = "
|
||||
|
||||
Return your feedback, in french, with only this json format, reviews must contain each review
|
||||
All fields are mandatory.
|
||||
Answer with the raw json object only: no markdown code fence, no text before or
|
||||
after it.
|
||||
(filename field must contain the full path with extension; line must be one of the
|
||||
listed line numbers for that file, and side must be \"added\" when the line comes
|
||||
from the `added` list or \"removed\" when it comes from the `removed` list)
|
||||
|
||||
@@ -12,7 +12,6 @@ pub struct EnvConfig {
|
||||
pub gitea_token: String,
|
||||
pub gitea_timeout: u64,
|
||||
pub metrics_bind_addr: Option<String>,
|
||||
pub container_runtime: String,
|
||||
pub sandbox_max_iterations: usize,
|
||||
}
|
||||
|
||||
@@ -27,8 +26,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
let gitea_token = try_get_env("GITEA_TOKEN")?;
|
||||
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
||||
let container_runtime =
|
||||
std::env::var("CONTAINER_RUNTIME").unwrap_or_else(|_| "docker".to_string());
|
||||
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
@@ -45,7 +42,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
||||
gitea_token,
|
||||
gitea_timeout,
|
||||
metrics_bind_addr,
|
||||
container_runtime,
|
||||
sandbox_max_iterations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
bot::Bot,
|
||||
gitea::{GiteaAPI, WebhookType},
|
||||
@@ -23,6 +25,13 @@ mod metrics;
|
||||
mod open_router;
|
||||
mod sandbox;
|
||||
mod state;
|
||||
mod text;
|
||||
|
||||
/// Délai laissé aux reviews en cours et aux serveurs pour s'arrêter proprement.
|
||||
///
|
||||
/// Sans lui, une review prise dans une sandbox récalcitrante garderait le processus
|
||||
/// en vie jusqu'à ce que le superviseur le tue.
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
dotenv().ok();
|
||||
@@ -79,14 +88,14 @@ async fn run() -> anyhow::Result<()> {
|
||||
let shutdown = CancellationToken::new();
|
||||
|
||||
let sandbox = SandboxConfig {
|
||||
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
|
||||
runtime: devcontainer_rs::ContainerRuntime::connect()?,
|
||||
max_iterations: config.sandbox_max_iterations,
|
||||
};
|
||||
|
||||
if !sandbox.runtime.available().await {
|
||||
warn!(
|
||||
runtime = sandbox.runtime.program(),
|
||||
"Container runtime is not available, every review will fail"
|
||||
endpoint = sandbox.runtime.endpoint(),
|
||||
"Container daemon is not reachable, every review will fail"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,11 +128,28 @@ async fn run() -> anyhow::Result<()> {
|
||||
anyhow::Ok(())
|
||||
};
|
||||
|
||||
tokio::try_join!(
|
||||
bot.start(rx, shutdown.clone()),
|
||||
api::start(app_state, shutdown.clone()),
|
||||
signal
|
||||
)?;
|
||||
let shutdown_deadline = async {
|
||||
shutdown.cancelled().await;
|
||||
tokio::time::sleep(SHUTDOWN_TIMEOUT).await;
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
result = async {
|
||||
tokio::try_join!(
|
||||
bot.start(rx, shutdown.clone()),
|
||||
api::start(app_state, shutdown.clone()),
|
||||
signal
|
||||
)
|
||||
} => {
|
||||
result?;
|
||||
}
|
||||
() = shutdown_deadline => {
|
||||
warn!(
|
||||
timeout = ?SHUTDOWN_TIMEOUT,
|
||||
"Shutdown did not finish in time, exiting anyway"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Shutdown complete");
|
||||
|
||||
|
||||
@@ -17,8 +17,24 @@ use tracing::instrument;
|
||||
/// OpenRouter API root, version prefix included.
|
||||
const BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
|
||||
/// The model decides on its own which tool to call.
|
||||
const TOOL_CHOICE_AUTO: &str = "auto";
|
||||
/// What the model is allowed to do on a given turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolChoice {
|
||||
/// The model decides whether to call a tool.
|
||||
Auto,
|
||||
/// The model must answer, without calling any tool. The tools stay declared,
|
||||
/// so the conversation remains the same shape as on the other turns.
|
||||
None,
|
||||
}
|
||||
|
||||
impl ToolChoice {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a completion that may contain tool calls.
|
||||
pub struct ToolChatResult {
|
||||
@@ -250,13 +266,18 @@ impl OpenRouterClient {
|
||||
|
||||
/// Sends a conversation with tool definitions and returns either a final
|
||||
/// message or the tool calls requested by the model.
|
||||
///
|
||||
/// The conversation is borrowed rather than taken by value: the caller keeps
|
||||
/// appending to it between iterations, and a copy per iteration would be pure
|
||||
/// waste.
|
||||
#[instrument(skip(self, messages, tools), err)]
|
||||
pub async fn chat_with_tools(
|
||||
&self,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<Tool>,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
tool_choice: ToolChoice,
|
||||
) -> anyhow::Result<ToolChatResult> {
|
||||
let response = self.complete(&messages, &tools).await?;
|
||||
let response = self.complete(messages, tools, tool_choice).await?;
|
||||
|
||||
let cost = response.usage.and_then(|usage| usage.cost);
|
||||
let message = response
|
||||
@@ -273,13 +294,18 @@ impl OpenRouterClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete(&self, messages: &[Message], tools: &[Tool]) -> anyhow::Result<ChatResponse> {
|
||||
async fn complete(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
tools: &[Tool],
|
||||
tool_choice: ToolChoice,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let request = ChatRequest {
|
||||
model: &self.model,
|
||||
messages,
|
||||
reasoning: Reasoning { enabled: true },
|
||||
tools,
|
||||
tool_choice: TOOL_CHOICE_AUTO,
|
||||
tool_choice: tool_choice.as_str(),
|
||||
};
|
||||
|
||||
let response = self
|
||||
@@ -364,7 +390,7 @@ mod tests {
|
||||
messages: &messages,
|
||||
reasoning: Reasoning { enabled: true },
|
||||
tools: &tools,
|
||||
tool_choice: TOOL_CHOICE_AUTO,
|
||||
tool_choice: ToolChoice::Auto.as_str(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -375,6 +401,12 @@ mod tests {
|
||||
assert_eq!(request["messages"][0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_answer_turn_disables_the_tools() {
|
||||
assert_eq!(ToolChoice::None.as_str(), "none");
|
||||
assert_eq!(ToolChoice::Auto.as_str(), "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_parses_tool_calls_and_cost() {
|
||||
let response: ChatResponse = serde_json::from_value(json!({
|
||||
|
||||
@@ -5,18 +5,58 @@
|
||||
//! 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::{debug, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall},
|
||||
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 {
|
||||
pub message: String,
|
||||
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,
|
||||
}
|
||||
@@ -26,25 +66,64 @@ pub struct AgentResult {
|
||||
///
|
||||
/// `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(
|
||||
///
|
||||
/// 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> {
|
||||
) -> 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.clone(), tool_definitions.clone())
|
||||
.chat_with_tools(
|
||||
&messages,
|
||||
&tool_definitions,
|
||||
if last {
|
||||
ToolChoice::None
|
||||
} else {
|
||||
ToolChoice::Auto
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(cost) = response.cost {
|
||||
@@ -52,12 +131,44 @@ pub async fn run(
|
||||
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,
|
||||
});
|
||||
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(
|
||||
@@ -66,28 +177,71 @@ pub async fn run(
|
||||
));
|
||||
|
||||
for call in &response.tool_calls {
|
||||
debug!(tool = call.name(), "Executing tool call");
|
||||
let content = execute(sandbox, call).await;
|
||||
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 reached the iteration limit");
|
||||
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
|
||||
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})"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(sandbox: &Sandbox, call: &ToolCall) -> String {
|
||||
/// 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 format!("error: {err}"),
|
||||
Err(err) => return cap_output(format!("error: {err}")),
|
||||
};
|
||||
|
||||
match tools::dispatch(sandbox, call.name(), &args).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => format!("error: {err}"),
|
||||
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() {
|
||||
@@ -134,4 +288,29 @@ mod tests {
|
||||
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('é'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devc
|
||||
/// Sandbox-related runtime configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct SandboxConfig {
|
||||
/// Container runtime binary to drive (e.g. `docker`, `podman`).
|
||||
/// Client du daemon de containers qui exécute la sandbox.
|
||||
pub runtime: ContainerRuntime,
|
||||
/// Maximum number of tool-calling iterations per agent run.
|
||||
pub max_iterations: usize,
|
||||
@@ -35,6 +35,8 @@ pub struct SandboxConfig {
|
||||
pub struct Sandbox {
|
||||
// Owns the temporary directory; dropping it cleans up the clone.
|
||||
_workspace: TempDir,
|
||||
/// Path of the clone, as bind-mounted into the container.
|
||||
repo_dir: PathBuf,
|
||||
container: Container,
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ impl Sandbox {
|
||||
let repo_dir = workspace.path().join("repo");
|
||||
|
||||
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
|
||||
make_readable(&repo_dir).await?;
|
||||
|
||||
let devcontainer_path = find_devcontainer(&repo_dir)
|
||||
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
|
||||
@@ -63,10 +66,79 @@ impl Sandbox {
|
||||
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||
|
||||
Ok(Self {
|
||||
let sandbox = Self {
|
||||
_workspace: workspace,
|
||||
repo_dir,
|
||||
container,
|
||||
})
|
||||
};
|
||||
|
||||
sandbox.check_workspace().await?;
|
||||
|
||||
Ok(sandbox)
|
||||
}
|
||||
|
||||
/// Vérifie que le clone est bien visible dans le container.
|
||||
///
|
||||
/// Sans ce contrôle, un montage vide — le daemon ne voit pas le clone, par
|
||||
/// exemple quand Herald est dans un container sur une autre machine — fait
|
||||
/// échouer chaque outil ; le modèle enchaîne alors les appels ratés jusqu'au
|
||||
/// budget d'itérations, sans jamais pouvoir reviewer quoi que ce soit.
|
||||
async fn check_workspace(&self) -> anyhow::Result<()> {
|
||||
let workspace_folder = self.workspace_folder();
|
||||
let probe = self
|
||||
.container
|
||||
.exec(&["ls", "-A", "--", workspace_folder])
|
||||
.await
|
||||
.with_context(|| format!("failed to list `{workspace_folder}` in the sandbox"))?;
|
||||
|
||||
if !probe.success() {
|
||||
let details = self.diagnose_workspace().await;
|
||||
|
||||
anyhow::bail!(
|
||||
"the sandbox cannot list `{workspace_folder}`: {} ({details})",
|
||||
probe.stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
if probe.stdout.trim().is_empty() {
|
||||
anyhow::bail!(
|
||||
"the sandbox workspace `{workspace_folder}` is empty: the container daemon does not see the clone"
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
clone = %self.repo_dir.display(),
|
||||
workspace = %workspace_folder,
|
||||
entries = probe.stdout.lines().count(),
|
||||
"Sandbox workspace is readable"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rassemble de quoi expliquer un refus d'accès au workspace.
|
||||
///
|
||||
/// Un `EACCES` a deux causes possibles, indistinguables dans le message de
|
||||
/// `ls` : les permissions du clone, ou un confinement du noyau qui bloque
|
||||
/// l'accès. L'identité de l'utilisateur d'exec et les permissions du point de
|
||||
/// montage permettent de trancher.
|
||||
async fn diagnose_workspace(&self) -> String {
|
||||
let mut details = Vec::new();
|
||||
|
||||
if let Ok(output) = self.container.exec(&["id"]).await {
|
||||
details.push(output.stdout.trim().to_string());
|
||||
}
|
||||
|
||||
if let Ok(output) = self
|
||||
.container
|
||||
.exec(&["ls", "-ld", "--", self.workspace_folder()])
|
||||
.await
|
||||
&& output.success()
|
||||
{
|
||||
details.push(output.stdout.trim().to_string());
|
||||
}
|
||||
|
||||
details.join(", ")
|
||||
}
|
||||
|
||||
/// Executes a command in the container as an argv vector (no shell).
|
||||
@@ -141,6 +213,32 @@ async fn clone_pull_request(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rend le clone lisible par les utilisateurs du container sandbox.
|
||||
///
|
||||
/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless
|
||||
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être
|
||||
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission
|
||||
/// denied` sur des fichiers que Herald vient de cloner lui-même.
|
||||
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
|
||||
let output = tokio::process::Command::new("chmod")
|
||||
.args(["-R", "a+rX"])
|
||||
.arg(repo_dir)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.context("failed to spawn chmod")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"chmod failed on `{}`: {}",
|
||||
repo_dir.display(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs git with the token injected through `http.extraHeader`, keeping the
|
||||
/// secret out of the process arguments.
|
||||
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Small text helpers shared by the modules that log what the model answered and
|
||||
//! bound what the tools return to it.
|
||||
|
||||
/// First `limit` characters of `text`, for logs.
|
||||
///
|
||||
/// Cuts on character boundaries, so the excerpt stays valid UTF-8, and marks a
|
||||
/// truncation with an ellipsis.
|
||||
pub fn excerpt(text: &str, limit: usize) -> String {
|
||||
let mut chars = text.chars();
|
||||
let excerpt = chars.by_ref().take(limit).collect::<String>();
|
||||
|
||||
if chars.next().is_some() {
|
||||
return format!("{excerpt}…");
|
||||
}
|
||||
|
||||
excerpt
|
||||
}
|
||||
|
||||
/// Truncates `text` in place to at most `limit` bytes, cutting on a character
|
||||
/// boundary so the result stays valid UTF-8.
|
||||
///
|
||||
/// Returns `true` when something was dropped.
|
||||
pub fn truncate_bytes(text: &mut String, limit: usize) -> bool {
|
||||
if text.len() <= limit {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut end = limit;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
text.truncate(end);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_short_text_is_kept_as_is() {
|
||||
assert_eq!(excerpt("abc", 3), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_text_is_truncated_and_marked() {
|
||||
assert_eq!(excerpt("abcdef", 3), "abc…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_cuts_on_character_boundaries() {
|
||||
assert_eq!(excerpt("ééé", 2), "éé…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_text_is_not_truncated() {
|
||||
let mut text = String::from("abc");
|
||||
|
||||
assert!(!truncate_bytes(&mut text, 3));
|
||||
assert_eq!(text, "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_text_is_truncated_within_the_limit() {
|
||||
let mut text = String::from("abcdef");
|
||||
|
||||
assert!(truncate_bytes(&mut text, 4));
|
||||
assert_eq!(text, "abcd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_truncation_never_splits_a_character() {
|
||||
let mut text = String::from("ééé");
|
||||
|
||||
// The limit falls in the middle of the second `é`: it is dropped.
|
||||
assert!(truncate_bytes(&mut text, 3));
|
||||
assert_eq!(text, "é");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user