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:
+1
-1
@@ -20,5 +20,5 @@ RUST_BACKTRACE=1
|
|||||||
METRICS_BIND_ADDR=
|
METRICS_BIND_ADDR=
|
||||||
|
|
||||||
# Sandboxed tool execution
|
# Sandboxed tool execution
|
||||||
CONTAINER_RUNTIME=docker
|
# DOCKER_HOST=
|
||||||
SANDBOX_MAX_ITERATIONS=8
|
SANDBOX_MAX_ITERATIONS=8
|
||||||
|
|||||||
Generated
+133
-3
@@ -295,6 +295,49 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bollard"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bollard-stubs",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"http 1.5.0",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-named-pipe",
|
||||||
|
"hyper-util",
|
||||||
|
"hyperlocal",
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bollard-stubs"
|
||||||
|
version = "1.53.1-rc.29.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -432,11 +475,16 @@ name = "devcontainer-rs"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"bollard",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -488,7 +536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -508,6 +556,16 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -830,6 +888,20 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-named-pipe"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
|
||||||
|
dependencies = [
|
||||||
|
"hex",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -869,6 +941,21 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyperlocal"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
|
||||||
|
dependencies = [
|
||||||
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1777,7 +1864,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2066,6 +2153,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_repr"
|
||||||
|
version = "0.1.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -2179,6 +2277,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -2189,7 +2298,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2299,6 +2408,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -2796,6 +2916,16 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ resolver = "3"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls", "stream"] }
|
||||||
tokio = { version = "1.53", features = ["full"] }
|
tokio = { version = "1.53", features = ["full"] }
|
||||||
|
tokio-stream = "0.1"
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -24,6 +25,7 @@ anyhow = { version = "1", features = ["backtrace"] }
|
|||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
bytes = "1.1"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
||||||
|
|
||||||
|
|||||||
+30
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.97-trixie as builder
|
FROM rust:1.98-trixie as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,6 +10,33 @@ RUN cargo build --release --package herald-server
|
|||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
# git clones the pull request. ca-certificates is what every HTTPS call needs
|
||||||
|
# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary
|
||||||
|
# links against, as reported by `ldd target/release/herald-server`; libc, libm
|
||||||
|
# and libgcc_s come from the base image.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
libssl3t64 \
|
||||||
|
libzstd1 \
|
||||||
|
zlib1g \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Herald drives the container daemon through its socket (DOCKER_HOST, default
|
||||||
|
# unix:///var/run/docker.sock), so neither docker nor podman is needed here: the
|
||||||
|
# compose file mounts the socket. Reaching that socket is what this user needs,
|
||||||
|
# and the socket is root-equivalent, so either run the container as root or give
|
||||||
|
# it the socket's group, e.g. `group_add: ["<gid of the host docker group>"]`.
|
||||||
|
RUN useradd --create-home --shell /usr/sbin/nologin --uid 10001 herald
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/release/herald-server .
|
COPY --from=builder /app/target/release/herald-server ./herald-server
|
||||||
CMD [ "./herald-server" ]
|
|
||||||
|
# git looks for its configuration under $HOME.
|
||||||
|
ENV HOME=/home/herald
|
||||||
|
|
||||||
|
USER herald
|
||||||
|
|
||||||
|
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut
|
||||||
|
# down gracefully.
|
||||||
|
CMD ["./herald-server"]
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ Herald reads its configuration from environment variables (a `.env` file is supp
|
|||||||
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
|
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
|
||||||
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
||||||
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
||||||
| `CONTAINER_RUNTIME` | *(optional)* Container runtime binary used for the sandbox (`docker` or `podman`). Defaults to `docker` |
|
|
||||||
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
|
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
|
||||||
|
| `DOCKER_HOST` | *(optional)* Container daemon socket Herald drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` |
|
||||||
|
|
||||||
## Sandboxed reviews
|
## Sandboxed reviews
|
||||||
|
|
||||||
@@ -56,8 +56,16 @@ Herald reviews pull requests inside an ephemeral
|
|||||||
4. posts the review, anchoring each comment on the added or removed line it
|
4. posts the review, anchoring each comment on the added or removed line it
|
||||||
refers to, and removes the container and the temporary clone.
|
refers to, and removes the container and the temporary clone.
|
||||||
|
|
||||||
The container runtime is selected with `CONTAINER_RUNTIME` (`docker` or
|
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
||||||
`podman`). The repository must contain a `.devcontainer/devcontainer.json`.
|
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
||||||
|
Docker-compatible socket. The repository must contain a
|
||||||
|
`.devcontainer/devcontainer.json`.
|
||||||
|
|
||||||
|
The `runArgs` of that file are read but deliberately **not** passed to the daemon:
|
||||||
|
they come from an untrusted pull request, and one of them (`--network host`) would
|
||||||
|
attach the container to another network and quietly
|
||||||
|
defeat the network cut described below. A devcontainer that relies on them
|
||||||
|
(`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
|
||||||
|
|
||||||
Each sandbox is isolated: it gets its own image tag, container and network. The
|
Each sandbox is isolated: it gets its own image tag, container and network. The
|
||||||
container starts with network access so the `postCreateCommand` /
|
container starts with network access so the `postCreateCommand` /
|
||||||
@@ -70,7 +78,7 @@ network and image are removed when the review ends (including on failure).
|
|||||||
|
|
||||||
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
|
The easiest way to get started is with the provided [Dev Container](https://containers.dev/) (VS Code or Zed with the dev container extension).
|
||||||
|
|
||||||
Open the project and reopen it in the container — the Rust toolchain is pre-installed.
|
Open the project and reopen it in the container — the Rust toolchain is pre-installed, along with rootless podman, so sandboxed reviews can be exercised locally: start its API socket with `podman system service --time=0 &` and set `DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`.
|
||||||
|
|
||||||
**Without Dev Container**, you just need a Rust toolchain:
|
**Without Dev Container**, you just need a Rust toolchain:
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bollard = "0.21"
|
||||||
|
bytes = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
|
tar = "0.4"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tokio-stream = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
anyhow = { 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)]
|
#[serde(rename = "remoteUser", default)]
|
||||||
pub remote_user: Option<String>,
|
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)]
|
#[serde(rename = "runArgs", default)]
|
||||||
pub run_args: Vec<String>,
|
pub run_args: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -52,7 +58,6 @@ pub struct DevContainer {
|
|||||||
pub post_create_command: Option<String>,
|
pub post_create_command: Option<String>,
|
||||||
pub post_start_command: Option<String>,
|
pub post_start_command: Option<String>,
|
||||||
pub remote_user: Option<String>,
|
pub remote_user: Option<String>,
|
||||||
pub run_args: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -114,7 +119,6 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
|||||||
post_create_command: schema.post_create_command,
|
post_create_command: schema.post_create_command,
|
||||||
post_start_command: schema.post_start_command,
|
post_start_command: schema.post_start_command,
|
||||||
remote_user: schema.remote_user,
|
remote_user: schema.remote_user,
|
||||||
run_args: schema.run_args,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,8 +207,7 @@ mod tests {
|
|||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"RUST_LOG": "debug"
|
"RUST_LOG": "debug"
|
||||||
},
|
},
|
||||||
"remoteUser": "dev",
|
"remoteUser": "dev"
|
||||||
"runArgs": ["--userns=keep-id"]
|
|
||||||
}"#,
|
}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -217,7 +220,6 @@ mod tests {
|
|||||||
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
||||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||||
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
||||||
assert_eq!(config.run_args, vec!["--userns=keep-id"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use tracing::{info, instrument, warn};
|
use tracing::{info, instrument, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -7,6 +9,7 @@ use crate::{
|
|||||||
metrics,
|
metrics,
|
||||||
open_router::{OpenRouterClient, Tool},
|
open_router::{OpenRouterClient, Tool},
|
||||||
sandbox::{Sandbox, SandboxConfig, agent},
|
sandbox::{Sandbox, SandboxConfig, agent},
|
||||||
|
text::excerpt,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
#[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("{comment}", &review_payload.comment.body)
|
||||||
.replace("{changes}", &changes);
|
.replace("{changes}", &changes);
|
||||||
|
|
||||||
let (message, cost) = run_sandboxed_review(
|
let (mut review_result, cost) = run_sandboxed_review(
|
||||||
gitea_api,
|
gitea_api,
|
||||||
open_router_client,
|
open_router_client,
|
||||||
sandbox_config,
|
sandbox_config,
|
||||||
@@ -69,7 +72,6 @@ pub async fn exec_review(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut review_result = serde_json::from_str::<ReviewResult>(&message)?;
|
|
||||||
resolve_review_sides(&mut review_result, &changed_lines);
|
resolve_review_sides(&mut review_result, &changed_lines);
|
||||||
|
|
||||||
review_result.cost = cost;
|
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
|
/// Runs the review inside a sandbox container, letting the model explore the
|
||||||
/// repository with tools before answering.
|
/// 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(
|
async fn run_sandboxed_review(
|
||||||
gitea_api: &GiteaAPI,
|
gitea_api: &GiteaAPI,
|
||||||
open_router_client: &OpenRouterClient,
|
open_router_client: &OpenRouterClient,
|
||||||
@@ -119,7 +125,7 @@ async fn run_sandboxed_review(
|
|||||||
tools: Vec<Tool>,
|
tools: Vec<Tool>,
|
||||||
review_payload: &ReviewPayload,
|
review_payload: &ReviewPayload,
|
||||||
bot_request: &str,
|
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 repo_url = gitea_api.repo_clone_url(&review_payload.repository.full_name);
|
||||||
|
|
||||||
let sandbox = Sandbox::create(
|
let sandbox = Sandbox::create(
|
||||||
@@ -151,7 +157,7 @@ async fn run_sandboxed_review(
|
|||||||
"Sandboxed review finished"
|
"Sandboxed review finished"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok((result.message, result.cost))
|
Ok((result.answer, result.cost))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
fn review_result_to_markdown(review_result: &ReviewResult) -> String {
|
||||||
@@ -452,6 +458,61 @@ fn format_line_numbers(lines: &[u64]) -> String {
|
|||||||
.join(", ")
|
.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
|
/// Resolves the side each review is anchored on and drops the reviews that do
|
||||||
/// not match a line the pull request changes.
|
/// not match a line the pull request changes.
|
||||||
///
|
///
|
||||||
@@ -824,6 +885,45 @@ mod tests {
|
|||||||
assert_eq!(review_result.reviews[0].side, Some(ReviewSide::Removed));
|
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]
|
#[test]
|
||||||
fn odd_sides_from_the_model_are_tolerated() {
|
fn odd_sides_from_the_model_are_tolerated() {
|
||||||
let changed_lines = parse_changed_lines(DIFF);
|
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
|
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.
|
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 = "
|
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
|
Return your feedback, in french, with only this json format, reviews must contain each review
|
||||||
All fields are mandatory.
|
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
|
(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
|
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)
|
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_token: String,
|
||||||
pub gitea_timeout: u64,
|
pub gitea_timeout: u64,
|
||||||
pub metrics_bind_addr: Option<String>,
|
pub metrics_bind_addr: Option<String>,
|
||||||
pub container_runtime: String,
|
|
||||||
pub sandbox_max_iterations: usize,
|
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_token = try_get_env("GITEA_TOKEN")?;
|
||||||
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
|
||||||
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
|
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")
|
let sandbox_max_iterations = std::env::var("SANDBOX_MAX_ITERATIONS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|value| value.parse().ok())
|
.and_then(|value| value.parse().ok())
|
||||||
@@ -45,7 +42,6 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
|
|||||||
gitea_token,
|
gitea_token,
|
||||||
gitea_timeout,
|
gitea_timeout,
|
||||||
metrics_bind_addr,
|
metrics_bind_addr,
|
||||||
container_runtime,
|
|
||||||
sandbox_max_iterations,
|
sandbox_max_iterations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bot::Bot,
|
bot::Bot,
|
||||||
gitea::{GiteaAPI, WebhookType},
|
gitea::{GiteaAPI, WebhookType},
|
||||||
@@ -23,6 +25,13 @@ mod metrics;
|
|||||||
mod open_router;
|
mod open_router;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
mod state;
|
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<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
@@ -79,14 +88,14 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
|
|
||||||
let sandbox = SandboxConfig {
|
let sandbox = SandboxConfig {
|
||||||
runtime: devcontainer_rs::ContainerRuntime::new(config.container_runtime.clone()),
|
runtime: devcontainer_rs::ContainerRuntime::connect()?,
|
||||||
max_iterations: config.sandbox_max_iterations,
|
max_iterations: config.sandbox_max_iterations,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !sandbox.runtime.available().await {
|
if !sandbox.runtime.available().await {
|
||||||
warn!(
|
warn!(
|
||||||
runtime = sandbox.runtime.program(),
|
endpoint = sandbox.runtime.endpoint(),
|
||||||
"Container runtime is not available, every review will fail"
|
"Container daemon is not reachable, every review will fail"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +128,28 @@ async fn run() -> anyhow::Result<()> {
|
|||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let shutdown_deadline = async {
|
||||||
|
shutdown.cancelled().await;
|
||||||
|
tokio::time::sleep(SHUTDOWN_TIMEOUT).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = async {
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
bot.start(rx, shutdown.clone()),
|
bot.start(rx, shutdown.clone()),
|
||||||
api::start(app_state, shutdown.clone()),
|
api::start(app_state, shutdown.clone()),
|
||||||
signal
|
signal
|
||||||
)?;
|
)
|
||||||
|
} => {
|
||||||
|
result?;
|
||||||
|
}
|
||||||
|
() = shutdown_deadline => {
|
||||||
|
warn!(
|
||||||
|
timeout = ?SHUTDOWN_TIMEOUT,
|
||||||
|
"Shutdown did not finish in time, exiting anyway"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!("Shutdown complete");
|
info!("Shutdown complete");
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,24 @@ use tracing::instrument;
|
|||||||
/// OpenRouter API root, version prefix included.
|
/// OpenRouter API root, version prefix included.
|
||||||
const BASE_URL: &str = "https://openrouter.ai/api/v1";
|
const BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||||
|
|
||||||
/// The model decides on its own which tool to call.
|
/// What the model is allowed to do on a given turn.
|
||||||
const TOOL_CHOICE_AUTO: &str = "auto";
|
#[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.
|
/// Result of a completion that may contain tool calls.
|
||||||
pub struct ToolChatResult {
|
pub struct ToolChatResult {
|
||||||
@@ -250,13 +266,18 @@ impl OpenRouterClient {
|
|||||||
|
|
||||||
/// Sends a conversation with tool definitions and returns either a final
|
/// Sends a conversation with tool definitions and returns either a final
|
||||||
/// message or the tool calls requested by the model.
|
/// 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)]
|
#[instrument(skip(self, messages, tools), err)]
|
||||||
pub async fn chat_with_tools(
|
pub async fn chat_with_tools(
|
||||||
&self,
|
&self,
|
||||||
messages: Vec<Message>,
|
messages: &[Message],
|
||||||
tools: Vec<Tool>,
|
tools: &[Tool],
|
||||||
|
tool_choice: ToolChoice,
|
||||||
) -> anyhow::Result<ToolChatResult> {
|
) -> 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 cost = response.usage.and_then(|usage| usage.cost);
|
||||||
let message = response
|
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 {
|
let request = ChatRequest {
|
||||||
model: &self.model,
|
model: &self.model,
|
||||||
messages,
|
messages,
|
||||||
reasoning: Reasoning { enabled: true },
|
reasoning: Reasoning { enabled: true },
|
||||||
tools,
|
tools,
|
||||||
tool_choice: TOOL_CHOICE_AUTO,
|
tool_choice: tool_choice.as_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
@@ -364,7 +390,7 @@ mod tests {
|
|||||||
messages: &messages,
|
messages: &messages,
|
||||||
reasoning: Reasoning { enabled: true },
|
reasoning: Reasoning { enabled: true },
|
||||||
tools: &tools,
|
tools: &tools,
|
||||||
tool_choice: TOOL_CHOICE_AUTO,
|
tool_choice: ToolChoice::Auto.as_str(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -375,6 +401,12 @@ mod tests {
|
|||||||
assert_eq!(request["messages"][0]["role"], "user");
|
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]
|
#[test]
|
||||||
fn response_parses_tool_calls_and_cost() {
|
fn response_parses_tool_calls_and_cost() {
|
||||||
let response: ChatResponse = serde_json::from_value(json!({
|
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
|
//! back, until the model produces a final message or the iteration budget is
|
||||||
//! exhausted.
|
//! exhausted.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{debug, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall},
|
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
|
||||||
sandbox::{Sandbox, tools},
|
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.
|
/// Final output of an agent run.
|
||||||
pub struct AgentResult {
|
pub struct AgentResult<A> {
|
||||||
pub message: String,
|
/// Answer of the model, parsed into the type the caller asked for.
|
||||||
|
pub answer: A,
|
||||||
pub cost: Option<f64>,
|
pub cost: Option<f64>,
|
||||||
pub iterations: usize,
|
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
|
/// `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 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,
|
open_router: &OpenRouterClient,
|
||||||
sandbox: &Sandbox,
|
sandbox: &Sandbox,
|
||||||
tool_definitions: Vec<Tool>,
|
tool_definitions: Vec<Tool>,
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
user_prompt: &str,
|
user_prompt: &str,
|
||||||
max_iterations: usize,
|
max_iterations: usize,
|
||||||
) -> anyhow::Result<AgentResult> {
|
) -> anyhow::Result<AgentResult<A>>
|
||||||
|
where
|
||||||
|
A: FromStr,
|
||||||
|
A::Err: std::fmt::Display,
|
||||||
|
{
|
||||||
let mut messages = vec![
|
let mut messages = vec![
|
||||||
Message::new(Role::System, system_prompt),
|
Message::new(Role::System, system_prompt),
|
||||||
Message::new(Role::User, user_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 total_cost = 0.0_f64;
|
||||||
let mut has_cost = false;
|
let mut has_cost = false;
|
||||||
|
let mut last_rejection: Option<String> = None;
|
||||||
|
|
||||||
for iteration in 1..=max_iterations {
|
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
|
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?;
|
.await?;
|
||||||
|
|
||||||
if let Some(cost) = response.cost {
|
if let Some(cost) = response.cost {
|
||||||
@@ -52,13 +131,45 @@ pub async fn run(
|
|||||||
has_cost = true;
|
has_cost = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.tool_calls.is_empty() {
|
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 {
|
return Ok(AgentResult {
|
||||||
message: response.message.unwrap_or_default(),
|
answer: parsed,
|
||||||
cost: has_cost.then_some(total_cost),
|
cost: has_cost.then_some(total_cost),
|
||||||
iterations: iteration,
|
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(
|
messages.push(Message::assistant_with_tool_calls(
|
||||||
response.message.unwrap_or_default(),
|
response.message.unwrap_or_default(),
|
||||||
@@ -66,28 +177,71 @@ pub async fn run(
|
|||||||
));
|
));
|
||||||
|
|
||||||
for call in &response.tool_calls {
|
for call in &response.tool_calls {
|
||||||
debug!(tool = call.name(), "Executing tool call");
|
let started = Instant::now();
|
||||||
let content = execute(sandbox, call).await;
|
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));
|
messages.push(Message::tool_response(call.id(), content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!(max_iterations, "Agent reached the iteration limit");
|
warn!(
|
||||||
anyhow::bail!("agent exceeded the maximum number of iterations ({max_iterations})")
|
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) {
|
let args = match parse_args(call) {
|
||||||
Ok(args) => args,
|
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 {
|
match tools::dispatch(sandbox, call.name(), &args).await {
|
||||||
Ok(output) => output,
|
Ok(output) => cap_output(output),
|
||||||
Err(err) => format!("error: {err}"),
|
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> {
|
fn parse_args(call: &ToolCall) -> anyhow::Result<Value> {
|
||||||
let raw = call.arguments_json().trim();
|
let raw = call.arguments_json().trim();
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
@@ -134,4 +288,29 @@ mod tests {
|
|||||||
let call = tool_call("ls", "not json");
|
let call = tool_call("ls", "not json");
|
||||||
assert!(parse_args(&call).is_err());
|
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.
|
/// Sandbox-related runtime configuration.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SandboxConfig {
|
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,
|
pub runtime: ContainerRuntime,
|
||||||
/// Maximum number of tool-calling iterations per agent run.
|
/// Maximum number of tool-calling iterations per agent run.
|
||||||
pub max_iterations: usize,
|
pub max_iterations: usize,
|
||||||
@@ -35,6 +35,8 @@ pub struct SandboxConfig {
|
|||||||
pub struct Sandbox {
|
pub struct Sandbox {
|
||||||
// Owns the temporary directory; dropping it cleans up the clone.
|
// Owns the temporary directory; dropping it cleans up the clone.
|
||||||
_workspace: TempDir,
|
_workspace: TempDir,
|
||||||
|
/// Path of the clone, as bind-mounted into the container.
|
||||||
|
repo_dir: PathBuf,
|
||||||
container: Container,
|
container: Container,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ impl Sandbox {
|
|||||||
let repo_dir = workspace.path().join("repo");
|
let repo_dir = workspace.path().join("repo");
|
||||||
|
|
||||||
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
|
clone_pull_request(repo_url, token, pull_request_number, &repo_dir).await?;
|
||||||
|
make_readable(&repo_dir).await?;
|
||||||
|
|
||||||
let devcontainer_path = find_devcontainer(&repo_dir)
|
let devcontainer_path = find_devcontainer(&repo_dir)
|
||||||
.with_context(|| format!("no devcontainer found in `{repo_url}`"))?;
|
.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");
|
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
||||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||||
|
|
||||||
Ok(Self {
|
let sandbox = Self {
|
||||||
_workspace: workspace,
|
_workspace: workspace,
|
||||||
|
repo_dir,
|
||||||
container,
|
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).
|
/// Executes a command in the container as an argv vector (no shell).
|
||||||
@@ -141,6 +213,32 @@ async fn clone_pull_request(
|
|||||||
Ok(())
|
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
|
/// Runs git with the token injected through `http.extraHeader`, keeping the
|
||||||
/// secret out of the process arguments.
|
/// secret out of the process arguments.
|
||||||
async fn run_git(token: &str, args: &[String]) -> anyhow::Result<()> {
|
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