add review type (perf, bug, security, ...) + fix tests
ci/woodpecker/push/tests Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
This commit is contained in:
@@ -51,11 +51,20 @@ Herald reviews pull requests inside an ephemeral
|
||||
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
||||
lines changed — additions and deletions, with the line numbers of the new and
|
||||
old versions of the file respectively — then lets it explore the repository
|
||||
with read-only tools (`ls`, `read_file`, `grep`, `find`) run inside the
|
||||
container: the code itself is not sent, so the model reads it at those lines,
|
||||
with read-only tools (`ls`, `file_size`, `read_file`, `grep`, `find`) run inside
|
||||
the container: the code itself is not sent, so the model reads it at those lines,
|
||||
4. posts the review, anchoring each comment on the added or removed line it
|
||||
refers to, and removes the container and the temporary clone.
|
||||
|
||||
Generated files are left out of the changes handed to the model: lockfiles
|
||||
(`Cargo.lock`, `package-lock.json`, `yarn.lock`, `go.sum`…) are machine-written
|
||||
dependency churn whose thousands of lines would drown the code under review, and
|
||||
they are never a place where a comment belongs.
|
||||
|
||||
Each comment is tagged with a severity — `bug`, `security`, `performance` or
|
||||
`maintainability` — shown at the start of the comment, and the summary also lists
|
||||
what the pull request does well.
|
||||
|
||||
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
||||
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
||||
Docker-compatible socket. The repository must contain a
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct ReviewItem {
|
||||
pub line: Option<u64>,
|
||||
#[serde(default, deserialize_with = "deserialize_side")]
|
||||
pub side: Option<ReviewSide>,
|
||||
#[serde(default, deserialize_with = "deserialize_severity")]
|
||||
pub severity: Option<ReviewSeverity>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
@@ -48,6 +50,46 @@ impl ReviewSide {
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of problem a review reports.
|
||||
///
|
||||
/// The categories are the ones the prompt asks for; a review whose severity is
|
||||
/// missing or unreadable falls back to [`ReviewSeverity::Maintainability`], the
|
||||
/// least alarming bucket, instead of failing the whole review.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReviewSeverity {
|
||||
/// Wrong behaviour.
|
||||
Bug,
|
||||
/// A vulnerability.
|
||||
Security,
|
||||
/// A resource problem.
|
||||
Performance,
|
||||
/// Everything else: readability, structure, naming, tests.
|
||||
Maintainability,
|
||||
}
|
||||
|
||||
impl ReviewSeverity {
|
||||
/// Reads the severity the model asked for, tolerating casing and synonyms.
|
||||
fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"bug" | "bugs" | "correctness" | "error" => Some(Self::Bug),
|
||||
"security" | "vulnerability" => Some(Self::Security),
|
||||
"performance" | "perf" => Some(Self::Performance),
|
||||
"maintainability" | "quality" | "style" => Some(Self::Maintainability),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower-case label used in the review markdown.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bug => "bug",
|
||||
Self::Security => "security",
|
||||
Self::Performance => "performance",
|
||||
Self::Maintainability => "maintainability",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the side the model asked for. An unreadable value is ignored instead of
|
||||
/// failing the whole review: the side is then resolved from the changed lines.
|
||||
fn deserialize_side<'de, D>(deserializer: D) -> Result<Option<ReviewSide>, D::Error>
|
||||
@@ -62,6 +104,20 @@ where
|
||||
.and_then(ReviewSide::parse))
|
||||
}
|
||||
|
||||
/// Reads the severity the model asked for, ignoring an unreadable value: the
|
||||
/// review is then reported as [`ReviewSeverity::Maintainability`].
|
||||
fn deserialize_severity<'de, D>(deserializer: D) -> Result<Option<ReviewSeverity>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
|
||||
Ok(raw
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(ReviewSeverity::parse))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bot {
|
||||
bot_name: String,
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::str::FromStr;
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::{
|
||||
bot::{ReviewResult, ReviewSide},
|
||||
bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide},
|
||||
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
||||
gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
|
||||
metrics,
|
||||
@@ -53,6 +53,7 @@ pub async fn exec_review(
|
||||
};
|
||||
|
||||
let mut changed_lines = parse_changed_lines(&git_diff);
|
||||
drop_generated_files(&mut changed_lines);
|
||||
resolve_filenames(&mut changed_lines, &files);
|
||||
|
||||
let changes = format_changes(&files, &changed_lines);
|
||||
@@ -126,11 +127,9 @@ async fn run_sandboxed_review(
|
||||
review_payload: &ReviewPayload,
|
||||
bot_request: &str,
|
||||
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
||||
let repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
|
||||
|
||||
let sandbox = Sandbox::create(
|
||||
&sandbox_config.runtime,
|
||||
&repo_url,
|
||||
&review_payload.repository.clone_url,
|
||||
gitea_api.token(),
|
||||
review_payload.pull_request.number,
|
||||
)
|
||||
@@ -172,6 +171,12 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||
review_result.reviews.len()
|
||||
));
|
||||
|
||||
let breakdown = severity_breakdown(&review_result.reviews);
|
||||
if !breakdown.is_empty() {
|
||||
md.push_str(&breakdown);
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
if !review_result.comment.is_empty() {
|
||||
md.push_str("\n---\n\n");
|
||||
md.push_str("### Summary\n\n");
|
||||
@@ -188,6 +193,30 @@ fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||
md
|
||||
}
|
||||
|
||||
/// Counts the reviews per severity, most severe first.
|
||||
///
|
||||
/// A review whose severity the model omitted or wrote unreadably is counted as
|
||||
/// [`ReviewSeverity::Maintainability`], the same fallback used when posting it.
|
||||
fn severity_breakdown(reviews: &[ReviewItem]) -> String {
|
||||
[
|
||||
ReviewSeverity::Bug,
|
||||
ReviewSeverity::Security,
|
||||
ReviewSeverity::Performance,
|
||||
ReviewSeverity::Maintainability,
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|severity| {
|
||||
let count = reviews
|
||||
.iter()
|
||||
.filter(|review| review.severity.unwrap_or(ReviewSeverity::Maintainability) == severity)
|
||||
.count();
|
||||
|
||||
(count > 0).then(|| format!("- {} {}", count, severity.label()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The lines a pull request changed, per file.
|
||||
///
|
||||
/// Line numbers are the ones Gitea expects to place a review comment:
|
||||
@@ -386,18 +415,61 @@ fn resolve_filenames(changed_lines: &mut ChangedLines, files: &[PullRequestFile]
|
||||
}
|
||||
}
|
||||
|
||||
/// Basenames of generated files that are never reviewed.
|
||||
///
|
||||
/// Their diffs are machine-written dependency churn: they run to thousands of
|
||||
/// lines, flood the prompt with line numbers and drown the code the model should
|
||||
/// look at. A lockfile is also never a place where a review comment belongs.
|
||||
const IGNORED_FILE_NAMES: [&str; 16] = [
|
||||
"Cargo.lock",
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"poetry.lock",
|
||||
"uv.lock",
|
||||
"Pipfile.lock",
|
||||
"go.sum",
|
||||
"packages.lock.json",
|
||||
"flake.lock",
|
||||
"pubspec.lock",
|
||||
];
|
||||
|
||||
/// Whether a changed path is a generated file that is never reviewed.
|
||||
///
|
||||
/// The comparison is on the basename, so a lockfile nested in a workspace (for
|
||||
/// example `crates/foo/Cargo.lock`) is matched too.
|
||||
fn is_ignored(filename: &str) -> bool {
|
||||
let basename = filename.rsplit('/').next().unwrap_or(filename);
|
||||
|
||||
IGNORED_FILE_NAMES.contains(&basename)
|
||||
}
|
||||
|
||||
/// Drops the generated files from the changed lines.
|
||||
///
|
||||
/// Removing them here keeps the prompt free of their line numbers, and also makes
|
||||
/// [`resolve_review_sides`] reject any review the model anchors on one of them.
|
||||
fn drop_generated_files(changed_lines: &mut ChangedLines) {
|
||||
changed_lines.retain(|file| !is_ignored(&file.filename));
|
||||
}
|
||||
|
||||
/// Renders the changes for the model: the files the pull request touches, then
|
||||
/// the lines to review per file.
|
||||
fn format_changes(files: &[PullRequestFile], changed_lines: &ChangedLines) -> String {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
if !files.is_empty() {
|
||||
let described = files
|
||||
.iter()
|
||||
.map(describe_file)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let described = files
|
||||
.iter()
|
||||
.filter(|file| !is_ignored(&file.filename))
|
||||
.map(describe_file)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
if !described.is_empty() {
|
||||
sections.push(format!("Files changed by the pull request: {described}"));
|
||||
}
|
||||
|
||||
@@ -666,6 +738,7 @@ mod tests {
|
||||
filename: String::from(filename),
|
||||
line,
|
||||
side,
|
||||
severity: None,
|
||||
message: String::from("message"),
|
||||
}
|
||||
}
|
||||
@@ -799,6 +872,56 @@ mod tests {
|
||||
assert_eq!(format_changes(&[], &changed_lines), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_lockfiles_are_ignored() {
|
||||
assert!(is_ignored("Cargo.lock"));
|
||||
assert!(is_ignored("crates/herald-server/Cargo.lock"));
|
||||
assert!(is_ignored("package-lock.json"));
|
||||
assert!(is_ignored("web/yarn.lock"));
|
||||
assert!(is_ignored("go.sum"));
|
||||
|
||||
assert!(!is_ignored("src/main.rs"));
|
||||
assert!(!is_ignored("Cargo.toml"));
|
||||
assert!(!is_ignored("docs/lockfiles.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_files_are_dropped_from_the_changed_lines() {
|
||||
const DIFF_WITH_LOCKFILE: &str = concat!(
|
||||
"diff --git a/Cargo.lock b/Cargo.lock\n",
|
||||
"--- a/Cargo.lock\n",
|
||||
"+++ b/Cargo.lock\n",
|
||||
"@@ -1,1 +1,2 @@\n",
|
||||
" [[package]]\n",
|
||||
"+name = \"x\"\n",
|
||||
"diff --git a/src/foo.rs b/src/foo.rs\n",
|
||||
"--- a/src/foo.rs\n",
|
||||
"+++ b/src/foo.rs\n",
|
||||
"@@ -1,1 +1,2 @@\n",
|
||||
" fn a() {}\n",
|
||||
"+fn b() {}\n",
|
||||
);
|
||||
|
||||
let mut changed_lines = parse_changed_lines(DIFF_WITH_LOCKFILE);
|
||||
drop_generated_files(&mut changed_lines);
|
||||
|
||||
assert_eq!(format_changed_lines(&changed_lines), "src/foo.rs: added 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generated_file_is_not_described_to_the_model() {
|
||||
let files = vec![
|
||||
pull_request_file("Cargo.lock", None, "modified"),
|
||||
pull_request_file("src/foo.rs", None, "modified"),
|
||||
];
|
||||
let changed_lines = parse_changed_lines(DIFF);
|
||||
|
||||
let changes = format_changes(&files, &changed_lines);
|
||||
|
||||
assert!(!changes.contains("Cargo.lock"));
|
||||
assert!(changes.contains("src/foo.rs (modified)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_file_is_described_with_its_previous_path() {
|
||||
let file = pull_request_file("src/new.rs", Some("src/old.rs"), "renamed");
|
||||
@@ -960,4 +1083,79 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severities_are_read_from_the_model() {
|
||||
let result: ReviewResult = serde_json::from_str(
|
||||
r#"{
|
||||
"reviews": [
|
||||
{ "filename": "a", "line": 1, "severity": "Bug", "message": "x" },
|
||||
{ "filename": "b", "line": 2, "severity": "SECURITY", "message": "x" },
|
||||
{ "filename": "c", "line": 3, "severity": "perf", "message": "x" },
|
||||
{ "filename": "d", "line": 4, "severity": "banana", "message": "x" }
|
||||
],
|
||||
"comment": ""
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let severities = result
|
||||
.reviews
|
||||
.iter()
|
||||
.map(|review| review.severity)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
severities,
|
||||
vec![
|
||||
Some(ReviewSeverity::Bug),
|
||||
Some(ReviewSeverity::Security),
|
||||
Some(ReviewSeverity::Performance),
|
||||
None,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_breakdown_counts_severities_most_severe_first() {
|
||||
let reviews = vec![
|
||||
ReviewItem {
|
||||
severity: Some(ReviewSeverity::Maintainability),
|
||||
..review("a", Some(1), None)
|
||||
},
|
||||
ReviewItem {
|
||||
severity: Some(ReviewSeverity::Bug),
|
||||
..review("b", Some(2), None)
|
||||
},
|
||||
ReviewItem {
|
||||
severity: Some(ReviewSeverity::Bug),
|
||||
..review("c", Some(3), None)
|
||||
},
|
||||
// A severity the model left out counts as maintainability.
|
||||
review("d", Some(4), None),
|
||||
];
|
||||
|
||||
assert_eq!(severity_breakdown(&reviews), "- 2 bug\n- 2 maintainability");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_markdown_breaks_the_issues_down_by_severity() {
|
||||
let mut result = review_result(vec![
|
||||
ReviewItem {
|
||||
severity: Some(ReviewSeverity::Bug),
|
||||
..review("a", Some(1), None)
|
||||
},
|
||||
ReviewItem {
|
||||
severity: Some(ReviewSeverity::Performance),
|
||||
..review("b", Some(2), None)
|
||||
},
|
||||
]);
|
||||
result.comment = String::from("Le découpage en crates est propre.");
|
||||
|
||||
let markdown = review_result_to_markdown(&result);
|
||||
|
||||
assert!(markdown.contains("- 1 bug"));
|
||||
assert!(markdown.contains("- 1 performance"));
|
||||
assert!(markdown.contains("Le découpage en crates est propre."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,50 +9,71 @@ pub const BOT_PROCESS_MSG: &str = "
|
||||
";
|
||||
|
||||
pub const SANDBOX_SYSTEM_PROMPT: &str = "
|
||||
You are a senior software engineer reviewing a pull request.
|
||||
You are a senior software engineer reviewing a pull request inside an
|
||||
isolated sandbox.
|
||||
|
||||
The repository is checked out in your working directory. Use the provided
|
||||
tools (ls, read_file, grep, find, file_size) to explore the code and gather the context
|
||||
you need before answering. Paths are relative to the repository root.
|
||||
The repository is checked out in your working directory. Explore it with the
|
||||
provided read-only tools (ls, file_size, read_file, grep, find); paths are
|
||||
relative to the repository root. Every line you read is prefixed with its
|
||||
absolute line number: that is the number you must cite to anchor a comment.
|
||||
|
||||
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.
|
||||
Read before you assert: a review anchored on a line you did not read is
|
||||
worthless, so never comment on code you have not seen. Gather enough context
|
||||
to be confident, but do not re-read what you already have.
|
||||
|
||||
When you answer, send the requested JSON only: a raw JSON object, no markdown
|
||||
code fence, nothing before or after it.
|
||||
";
|
||||
|
||||
pub const REVIEW_PROMPT: &str = "
|
||||
You are a senior software engineer reviewing code changes.
|
||||
You are a senior software engineer reviewing a pull request.
|
||||
|
||||
Check good practices and code quality.
|
||||
Judge the changes for correctness, security, resource use and
|
||||
maintainability. Report real problems; do not invent issues, and do not
|
||||
report pure formatting a formatter would fix.
|
||||
|
||||
Be exhaustive: one review per distinct issue, and cover every changed file
|
||||
that has something to report. Do not stop at the first few findings, and do
|
||||
not merge several issues into a single review.
|
||||
|
||||
This is the pull request subject: \"{subject}\"
|
||||
|
||||
This is the user comment: \"{comment}\"
|
||||
If the user asks about something precise, address that first.
|
||||
|
||||
The pull request changes these files and lines:
|
||||
|
||||
{changes}
|
||||
|
||||
`added` line numbers refer to the new version of the file, `removed` line
|
||||
numbers to the old version, as they appear in the diff.
|
||||
The code is not provided. Read the files you need with the available tools
|
||||
before answering.
|
||||
|
||||
The code is not provided: read the files you need with the available tools
|
||||
before answering. Review only the listed lines.
|
||||
`added` line numbers refer to the new version of the file, which is what your
|
||||
working directory contains now. `removed` line numbers refer to the old
|
||||
version, which is not in your working directory: you cannot read a removed
|
||||
line, only the code around where it used to be.
|
||||
|
||||
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)
|
||||
and comment must contain a final summary:
|
||||
Every review must anchor on one of the listed line numbers:
|
||||
- filename: the full path exactly as listed above,
|
||||
- line: one of the numbers listed for that file,
|
||||
- side: \"added\" for a number from the `added` list, \"removed\" for one from
|
||||
the `removed` list,
|
||||
- severity: exactly one of \"bug\", \"security\", \"performance\" or
|
||||
\"maintainability\" — a bug is wrong behaviour, security a vulnerability,
|
||||
performance a resource problem, maintainability everything else.
|
||||
|
||||
Answer in french, with the raw json object only: no markdown code fence, no
|
||||
text before or after it. All fields are mandatory. The `comment` field must
|
||||
hold a short summary that lists the issues, and also what the pull request
|
||||
does well: the author should get the compliments too.
|
||||
|
||||
{
|
||||
\"reviews\": [
|
||||
{
|
||||
\"filename\": \"\",
|
||||
\"line\": ,
|
||||
\"line\": 0,
|
||||
\"side\": \"\",
|
||||
\"severity\": \"\",
|
||||
\"message\": \"\"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@ use tokio_util::io::StreamReader;
|
||||
use tracing::{instrument, warn};
|
||||
|
||||
use crate::{
|
||||
bot::{ReviewResult, ReviewSide},
|
||||
bot::{ReviewResult, ReviewSeverity, ReviewSide},
|
||||
consts::MAX_DIFF_SIZE,
|
||||
errors::AppError,
|
||||
};
|
||||
@@ -49,15 +49,6 @@ impl GiteaAPI {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// HTTPS clone URL for a repository, suitable for `git clone`.
|
||||
pub fn repo_clone_url(&self, full_name: &str) -> String {
|
||||
format!(
|
||||
"{}/{}.git",
|
||||
self.base_url.trim_end_matches('/'),
|
||||
full_name.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
|
||||
let url = format!("{}/api/v1/user", self.base_url);
|
||||
@@ -231,7 +222,11 @@ impl GiteaAPI {
|
||||
.filter_map(|review| {
|
||||
let line = review.line?;
|
||||
let path = review.filename.clone();
|
||||
let body = review.message.clone();
|
||||
let severity = review
|
||||
.severity
|
||||
.unwrap_or(ReviewSeverity::Maintainability)
|
||||
.label();
|
||||
let body = format!("**[{severity}]** {}", review.message);
|
||||
|
||||
// A line removed by the pull request only exists in the old
|
||||
// version of the file, so it is anchored with `old_position`.
|
||||
@@ -333,6 +328,7 @@ pub struct User {
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Repository {
|
||||
pub full_name: String,
|
||||
pub clone_url: String,
|
||||
}
|
||||
|
||||
/// A file changed by a pull request, as reported by the API.
|
||||
@@ -390,7 +386,8 @@ mod tests {
|
||||
"title": "My PR"
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "owner/repo"
|
||||
"full_name": "owner/repo",
|
||||
"clone_url": "https://github.com/owner/repo.git"
|
||||
},
|
||||
"comment": {
|
||||
"id": 7,
|
||||
@@ -453,7 +450,8 @@ mod tests {
|
||||
"title": "My PR"
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "owner/repo"
|
||||
"full_name": "owner/repo",
|
||||
"clone_url": "https://github.com/owner/repo.git"
|
||||
},
|
||||
"comment": {
|
||||
"id": 1,
|
||||
@@ -485,7 +483,8 @@ mod tests {
|
||||
"title": "My PR"
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "owner/repo"
|
||||
"full_name": "owner/repo",
|
||||
"clone_url": "https://github.com/owner/repo.git"
|
||||
},
|
||||
"comment": {
|
||||
"id": 12,
|
||||
@@ -501,6 +500,10 @@ mod tests {
|
||||
assert_eq!(payload.action, "created");
|
||||
assert_eq!(payload.comment.id, 12);
|
||||
assert_eq!(payload.comment.body, "Needs work");
|
||||
assert_eq!(
|
||||
payload.repository.clone_url,
|
||||
"https://github.com/owner/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -521,7 +524,8 @@ mod tests {
|
||||
"title": "My PR"
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "owner/repo"
|
||||
"full_name": "owner/repo",
|
||||
"clone_url": "https://github.com/owner/repo.git"
|
||||
},
|
||||
"comment": {
|
||||
"id": 1,
|
||||
@@ -548,7 +552,8 @@ mod tests {
|
||||
"title": "My PR"
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "owner/repo"
|
||||
"full_name": "owner/repo",
|
||||
"clone_url": "https://github.com/owner/repo.git"
|
||||
},
|
||||
"comment": {
|
||||
"id": 1,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Le [`Sandbox`] : clone d'une pull request exécuté dans un devcontainer
|
||||
//! éphémère, et sa configuration.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
|
||||
use tempfile::TempDir;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
/// Devcontainer locations recognized within a repository, in priority order.
|
||||
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
|
||||
|
||||
/// Sandbox-related runtime configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct SandboxConfig {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// A cloned repository running inside an ephemeral devcontainer.
|
||||
pub struct Sandbox {
|
||||
// Owns the temporary directory; dropping it cleans up the clone.
|
||||
_workspace: TempDir,
|
||||
/// Path of the clone, as copied into the container.
|
||||
repo_dir: PathBuf,
|
||||
container: Container,
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
/// Clones the pull request head, builds the devcontainer and starts it.
|
||||
///
|
||||
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
|
||||
/// for both same-repository and forked pull requests.
|
||||
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
|
||||
pub async fn create(
|
||||
runtime: &ContainerRuntime,
|
||||
clone_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
) -> anyhow::Result<Self> {
|
||||
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
|
||||
let repo_dir = workspace.path().join("repo");
|
||||
|
||||
clone_pull_request(clone_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 `{clone_url}`"))?;
|
||||
|
||||
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
|
||||
|
||||
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||
|
||||
let sandbox = Self {
|
||||
_workspace: workspace,
|
||||
repo_dir,
|
||||
container,
|
||||
};
|
||||
|
||||
if let Err(err) = sandbox.check_workspace().await {
|
||||
let _ = sandbox.container.remove().await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(sandbox)
|
||||
}
|
||||
|
||||
/// Vérifie que le clone est bien visible dans le container.
|
||||
///
|
||||
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu
|
||||
/// recevoir le clone — 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).
|
||||
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
|
||||
Ok(self.container.exec(cmd).await?)
|
||||
}
|
||||
|
||||
/// Path of the repository inside the container.
|
||||
pub fn workspace_folder(&self) -> &str {
|
||||
self.container.workspace_folder()
|
||||
}
|
||||
|
||||
/// Stops and removes the container. The temporary clone is removed on drop.
|
||||
pub async fn cleanup(self) -> anyhow::Result<()> {
|
||||
self.container.remove().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
|
||||
DEVCONTAINER_PATHS
|
||||
.iter()
|
||||
.map(|relative| repo_dir.join(relative))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
async fn clone_pull_request(
|
||||
repo_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let dest = dest.display().to_string();
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"clone".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
repo_url.to_string(),
|
||||
dest.clone(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"-C".to_string(),
|
||||
dest.clone(),
|
||||
"fetch".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
"origin".to_string(),
|
||||
format!("refs/pull/{pull_request_number}/head"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"-C".to_string(),
|
||||
dest,
|
||||
"checkout".to_string(),
|
||||
"FETCH_HEAD".to_string(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
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<()> {
|
||||
let output = tokio::process::Command::new("git")
|
||||
.args(args)
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
||||
.env(
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
format!("Authorization: token {token}"),
|
||||
)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.context("failed to spawn git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_devcontainer_prefers_dot_devcontainer_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join(".devcontainer");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
|
||||
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_devcontainer(dir.path()),
|
||||
Some(nested.join("devcontainer.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_devcontainer_returns_none_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(find_devcontainer(dir.path()), None);
|
||||
}
|
||||
}
|
||||
@@ -7,290 +7,7 @@
|
||||
//! tool-calling loop against OpenRouter.
|
||||
|
||||
pub mod agent;
|
||||
mod instance;
|
||||
pub mod tools;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
|
||||
use tempfile::TempDir;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
/// Devcontainer locations recognized within a repository, in priority order.
|
||||
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
|
||||
|
||||
/// Sandbox-related runtime configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct SandboxConfig {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// A cloned repository running inside an ephemeral devcontainer.
|
||||
pub struct Sandbox {
|
||||
// Owns the temporary directory; dropping it cleans up the clone.
|
||||
_workspace: TempDir,
|
||||
/// Path of the clone, as copied into the container.
|
||||
repo_dir: PathBuf,
|
||||
container: Container,
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
/// Clones the pull request head, builds the devcontainer and starts it.
|
||||
///
|
||||
/// The clone is PR-aware: it fetches `refs/pull/<number>/head`, which works
|
||||
/// for both same-repository and forked pull requests.
|
||||
#[instrument(skip(runtime, token), fields(pr = pull_request_number))]
|
||||
pub async fn create(
|
||||
runtime: &ContainerRuntime,
|
||||
repo_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
) -> anyhow::Result<Self> {
|
||||
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
|
||||
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}`"))?;
|
||||
|
||||
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
|
||||
|
||||
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||
|
||||
let sandbox = Self {
|
||||
_workspace: workspace,
|
||||
repo_dir,
|
||||
container,
|
||||
};
|
||||
|
||||
if let Err(err) = sandbox.check_workspace().await {
|
||||
let _ = sandbox.container.remove().await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(sandbox)
|
||||
}
|
||||
|
||||
/// Vérifie que le clone est bien visible dans le container.
|
||||
///
|
||||
/// Sans ce contrôle, un workspace vide — par exemple un daemon qui n'a pas pu
|
||||
/// recevoir le clone — 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).
|
||||
pub async fn exec(&self, cmd: &[&str]) -> anyhow::Result<ExecOutput> {
|
||||
Ok(self.container.exec(cmd).await?)
|
||||
}
|
||||
|
||||
/// Path of the repository inside the container.
|
||||
pub fn workspace_folder(&self) -> &str {
|
||||
self.container.workspace_folder()
|
||||
}
|
||||
|
||||
/// Stops and removes the container. The temporary clone is removed on drop.
|
||||
pub async fn cleanup(self) -> anyhow::Result<()> {
|
||||
self.container.remove().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_devcontainer(repo_dir: &Path) -> Option<PathBuf> {
|
||||
DEVCONTAINER_PATHS
|
||||
.iter()
|
||||
.map(|relative| repo_dir.join(relative))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
async fn clone_pull_request(
|
||||
repo_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let dest = dest.display().to_string();
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"clone".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
repo_url.to_string(),
|
||||
dest.clone(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"-C".to_string(),
|
||||
dest.clone(),
|
||||
"fetch".to_string(),
|
||||
"--depth".to_string(),
|
||||
"1".to_string(),
|
||||
"origin".to_string(),
|
||||
format!("refs/pull/{pull_request_number}/head"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_git(
|
||||
token,
|
||||
&[
|
||||
"-C".to_string(),
|
||||
dest,
|
||||
"checkout".to_string(),
|
||||
"FETCH_HEAD".to_string(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
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<()> {
|
||||
let output = tokio::process::Command::new("git")
|
||||
.args(args)
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
||||
.env(
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
format!("Authorization: token {token}"),
|
||||
)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.context("failed to spawn git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_devcontainer_prefers_dot_devcontainer_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join(".devcontainer");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
std::fs::write(nested.join("devcontainer.json"), "{}").unwrap();
|
||||
std::fs::write(dir.path().join(".devcontainer.json"), "{}").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_devcontainer(dir.path()),
|
||||
Some(nested.join("devcontainer.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_devcontainer_returns_none_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(find_devcontainer(dir.path()), None);
|
||||
}
|
||||
}
|
||||
pub use instance::{Sandbox, SandboxConfig};
|
||||
|
||||
@@ -259,6 +259,7 @@ mod tests {
|
||||
},
|
||||
repository: Repository {
|
||||
full_name: "owner/repo".to_string(),
|
||||
clone_url: "https://github.com/owner/repo.git".to_string(),
|
||||
},
|
||||
comment: Comment {
|
||||
id: 1,
|
||||
@@ -274,7 +275,7 @@ mod tests {
|
||||
.map(|tool| tool.function.name)
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, vec!["ls", "read_file", "grep", "find", "file_size"]);
|
||||
assert_eq!(names, vec!["ls", "file_size", "read_file", "grep", "find"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user