feat(missions): give the planner the repository's contents, not just its names

The root listing was not enough. Given names alone the planner wrote "optimise
the hot path" for a crate whose hot path is `add(a: i64, b: i64) -> i64` — a
mission that was unachievable from the moment it was written, and that nothing
discovered until an agent had built a benchmark harness in a VM to measure an
integer addition, honestly reported no improvement was possible, and the judge
correctly failed the phase.

`repo_digest` fetches the whole tree (so "does this have benches/" is a fact, not
an inference) and then file CONTENTS in priority order: manifests first — they
say what the project is — then the README, then source ascending by size, since
a planner learns more from twenty small files than from one large one. Lockfiles
and build output are dropped: enormous, and they say nothing a manifest does not.

THE RULE THIS ENFORCES, and the reason the rendering is its own tested module: a
digest of any repository worth planning against is partial, and a model shown a
partial view without being told it is partial plans as though it saw everything.
So every omission is stated — how many files exist, how many were shown, what
was cut from each, and "anything not shown you have NOT seen". Same distinction
as `Option<u32>` for the subagent probe: "we did not look" and "there is nothing
there" are different facts.

Failures degrade to a stated absence rather than an empty string, and the three
cases stay distinguishable: no repository, a tree that could not be read, and a
tree read but no contents fetched. An unreadable tree is never rendered as an
empty repository.

Two more things the prompt now says, both learned from that run: plan for the
repository as it IS rather than as the description implies (and if the
description asks for something the code cannot support, say so in the task and
plan the phase that establishes the truth, rather than a phase that must fail);
and a mission agent has NO package-registry access. The agent discovered the
second one mid-run and wrote a dependency-free `std::time::Instant` harness after
Criterion could not be added — good adaptation, but nothing had warned it.

549 tests pass, clippy clean. The budget/priority/truncation logic is pure and
tested; only the fetching touches the network.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-07 05:01:02 -07:00
co-authored by Claude Opus 5
parent 0aeae07db2
commit 08dd227a45
3 changed files with 348 additions and 43 deletions
+81 -43
View File
@@ -23,18 +23,17 @@ const PLANNER_MODEL: &str = "claude-opus-4-8";
/// What the repository actually contains, for the planner's prompt.
///
/// The planner used to see only the mission's title, description and whether a
/// repo was bound — and it planned accordingly. Its first real plan opened with
/// "run its benchmark harness" against a crate that has none, so the phase found
/// nothing to baseline and delivered zero files. A plan about a repository
/// written without seeing the repository is a guess.
/// Names were not enough. Given the root listing alone, the planner wrote
/// "optimise the hot path" for a crate whose hot path is
/// `add(a: i64, b: i64) -> i64` — a mission that was unachievable from the
/// moment it was written, and that nothing discovered until an agent had built a
/// benchmark harness to measure an integer addition.
///
/// Read from the FORGE rather than a checkout: at proposal time the mission is
/// still a draft and `ensure_checkout` has not run, so there is nothing on disk
/// to list. One API call, and a failure degrades to a stated absence rather than
/// to silence — a model told "the listing could not be read" can hedge; a model
/// told nothing assumes.
async fn repo_listing(pool: &sqlx::PgPool, mission_id: uuid::Uuid) -> String {
/// Read from the FORGE, not a checkout: at proposal time the mission is still a
/// draft and `ensure_checkout` has not run, so there is nothing on disk. Every
/// failure degrades to a STATED absence — a planner told "the listing could not
/// be read" can hedge; one told nothing assumes.
async fn repo_digest(pool: &sqlx::PgPool, mission_id: uuid::Uuid) -> String {
let row: Option<(Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT r.owner, r.name, r.default_branch
FROM missions m JOIN repos r ON r.id = m.repo_id
@@ -48,39 +47,72 @@ async fn repo_listing(pool: &sqlx::PgPool, mission_id: uuid::Uuid) -> String {
let Some((Some(owner), Some(name), branch)) = row else {
return "(this mission has no repository)".to_string();
};
let branch = branch.unwrap_or_else(|| "main".to_string());
let token = std::env::var("GITEA_TOKEN").unwrap_or_default();
let url = format!(
"https://git.redclaw.dev/api/v1/repos/{owner}/{name}/contents?ref={}",
branch.as_deref().unwrap_or("main")
);
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
{
Ok(c) => c,
Err(_) => return "(the repository listing could not be read)".to_string(),
else {
return "(the repository could not be read)".to_string();
};
let mut req = client.get(&url);
if !token.trim().is_empty() {
req = req.header("Authorization", format!("token {token}"));
}
let entries: Vec<serde_json::Value> = match req.send().await {
let auth = |r: reqwest::RequestBuilder| {
if token.trim().is_empty() {
r
} else {
r.header("Authorization", format!("token {token}"))
}
};
// The whole tree in one call, so "does this repo have benches/" is a fact
// rather than an inference from the root.
let tree_url = format!(
"https://git.redclaw.dev/api/v1/repos/{owner}/{name}/git/trees/{branch}?recursive=true&per_page=1000"
);
let tree: serde_json::Value = match auth(client.get(&tree_url)).send().await {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => return "(the repository listing could not be read)".to_string(),
_ => return "(the repository tree could not be read)".to_string(),
};
if entries.is_empty() {
return "(the repository root is empty)".to_string();
}
let mut names: Vec<String> = entries
.iter()
.filter_map(|e| {
let n = e.get("name")?.as_str()?;
let t = e.get("type").and_then(|v| v.as_str()).unwrap_or("file");
Some(if t == "dir" { format!("{n}/") } else { n.to_string() })
let entries: Vec<crate::repo_digest::FileEntry> = tree
.get("tree")
.and_then(|t| t.as_array())
.map(|items| {
items
.iter()
.filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("blob"))
.filter_map(|e| {
Some(crate::repo_digest::FileEntry {
path: e.get("path")?.as_str()?.to_string(),
size: e.get("size").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
})
})
.collect()
})
.collect();
names.sort();
names.join(", ")
.unwrap_or_default();
// Fetch in priority order until the budget is spent. Requested serially and
// capped: this runs inside one API request, and a repo with 500 useful files
// must not turn a proposal into 500 round trips.
let mut fetched: Vec<(String, String)> = Vec::new();
let mut spent = 0usize;
for e in crate::repo_digest::priority(&entries).into_iter().take(40) {
if spent >= crate::repo_digest::CONTENT_BUDGET {
break;
}
let raw = format!(
"https://git.redclaw.dev/api/v1/repos/{owner}/{name}/raw/{}?ref={branch}",
e.path
);
if let Ok(r) = auth(client.get(&raw)).send().await {
if r.status().is_success() {
if let Ok(text) = r.text().await {
spent += text.len().min(crate::repo_digest::PER_FILE_CAP);
fetched.push((e.path.clone(), text));
}
}
}
}
crate::repo_digest::render(&entries, &crate::repo_digest::fit(fetched))
}
const PLAN_SYSTEM: &str = "You decide what ONE software mission actually does — its phases, in order. \
@@ -125,14 +157,20 @@ pub async fn suggest(
.ok_or(ApiError::NotFound)?;
let prompt = format!(
"MISSION: {}\n\nDESCRIPTION:\n{}\n\nREPOSITORY ROOT: {}\nPlan for the repository as it \
ACTUALLY IS. If the work needs something the repository does not have — a benchmark \
harness, a test suite, a config file — the phase that needs it must CREATE it, and its \
task must say so.\n\nPHASE KINDS YOU MAY USE (nothing else runs): {}\nCEILING: \
"MISSION: {}\n\nDESCRIPTION:\n{}\n\n=== THE REPOSITORY ===\n{}\n=== END REPOSITORY \
===\n\nPlan for the repository as it ACTUALLY IS, not as the description implies it \
might be. If the work needs something absent — a benchmark harness, a test suite, a \
config file — the phase that needs it must CREATE it, and its task must say so. If the \
description asks for something this code cannot support (optimising a function with \
nothing to optimise, testing a module that does not exist), say so in the task text and \
plan the phase that would establish the truth, rather than a phase that must fail.\n\n\
NOTE: a mission agent has NO package-registry access — it cannot add dependencies. A \
phase needing tooling must build it from the standard library or from what is already \
vendored here.\n\nPHASE KINDS YOU MAY USE (nothing else runs): {}\nCEILING: \
{MAX_PHASES} phases.\n\nPropose the plan now (JSON only).",
mission.title,
mission.description.as_deref().unwrap_or("(none)"),
repo_listing(&state.pool, id).await,
repo_digest(&state.pool, id).await,
PLANNABLE_KINDS.join(", "),
);