use std::{env, error::Error, sync::Arc, time::Duration};
use basis::{
AllowAll, BudgetPool, Event, EventFanIn, MergedEvents, ModelSelector, NullSink, OutputReport,
OutputSpec, RunError, RunOutcome, RunSpec, TaggedEvent, TaggedSink, Workspace,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::task::JoinSet;
const DEADLINE: Duration = Duration::from_secs(300);
const TOOL_BUDGET: usize = 25;
const SUBMIT: &str = "Submit what you found, one entry per problem. \
Report nothing you did not see for yourself in the files you read.";
const DEFAULT_BUDGET: u64 = 200_000;
struct Dimension {
name: &'static str,
brief: &'static str,
}
const DIMENSIONS: &[Dimension] = &[
Dimension {
name: "correctness",
brief: "logic errors, unhandled failures, and inputs that would break it at runtime",
},
Dimension {
name: "clarity",
brief: "names, structure, and missing context that would slow down the next reader",
},
Dimension {
name: "tests",
brief: "behavior that nothing asserts, and assertions that would pass over broken code",
},
];
#[derive(Debug, Deserialize, Serialize)]
struct Findings {
findings: Vec<Finding>,
}
#[derive(Debug, Deserialize, Serialize)]
struct Finding {
file: String,
note: String,
blocking: bool,
}
#[derive(Debug, Deserialize)]
struct Verdict {
ship: bool,
rationale: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let mut args = env::args().skip(1);
let path = args.next().unwrap_or_else(|| ".".to_string());
let subject = args
.next()
.unwrap_or_else(|| "the code in this workspace".to_string());
let limit = args
.next()
.and_then(|raw| raw.parse().ok())
.unwrap_or(DEFAULT_BUDGET);
let workspace = Arc::new(
Workspace::builder(&path)
.with_model(selected_model())
.open()
.await?,
);
let pool = BudgetPool::new(limit);
println!(
"reviewing {subject} in {} with {} ({limit} tokens for the job)",
workspace.root().display(),
workspace.model()
);
let fan = EventFanIn::new();
let mut reviewers = JoinSet::new();
for dimension in DIMENSIONS {
let workspace = Arc::clone(&workspace);
let sink = fan.sink(dimension.name);
let spec = pool
.spec(brief(dimension, &subject))
.with_session_name(dimension.name)
.with_deadline(DEADLINE)
.with_tool_budget(TOOL_BUDGET);
reviewers.spawn(async move { (dimension.name, review(&workspace, spec, sink).await) });
}
let mut merged = fan.into_events();
let (reviewed, ()) = tokio::join!(collect(&mut reviewers), narrate(&mut merged));
println!("\n--- findings ---");
for (dimension, found) in &reviewed {
println!(
"[{dimension}] {} findings, {} blocking",
found.findings.len(),
found.findings.iter().filter(|f| f.blocking).count()
);
}
let blocking: Vec<&Finding> = reviewed
.iter()
.flat_map(|(_, found)| &found.findings)
.filter(|finding| finding.blocking)
.collect();
if blocking.is_empty() {
println!("\nverdict: ship — no reviewer raised anything blocking");
} else {
verify(&workspace, &pool, &subject, &reviewed).await?;
}
println!(
"\nthe review cost {} of {} tokens ({} left)",
pool.spent(),
pool.limit(),
pool.remaining()
);
Ok(())
}
async fn review(
workspace: &Workspace,
spec: RunSpec,
sink: TaggedSink<&'static str>,
) -> Result<Findings, Failure> {
let mut run = workspace.prepare(spec)?;
let report = run.execute(sink).await?;
if let RunOutcome::Error { message } = report.outcome {
return Err(Failure::Reading(message));
}
let OutputReport { value, .. } = run
.output::<Findings, _, _>(SUBMIT, findings_spec(), report.sink, AllowAll)
.await?;
Ok(value)
}
#[derive(Debug)]
enum Failure {
Reading(String),
Run(RunError),
}
impl From<RunError> for Failure {
fn from(error: RunError) -> Self {
Self::Run(error)
}
}
impl std::fmt::Display for Failure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Reading(message) => write!(f, "the reading turn failed: {message}"),
Self::Run(error) => write!(f, "{error}"),
}
}
}
async fn collect(
reviewers: &mut JoinSet<(&'static str, Result<Findings, Failure>)>,
) -> Vec<(&'static str, Findings)> {
let mut reviewed = Vec::new();
while let Some(joined) = reviewers.join_next().await {
match joined {
Ok((dimension, Ok(found))) => reviewed.push((dimension, found)),
Ok((dimension, Err(error))) => eprintln!("[{dimension}] gave up: {error}"),
Err(error) => eprintln!("a reviewer panicked: {error}"),
}
}
reviewed
}
async fn narrate(merged: &mut MergedEvents<&'static str>) {
while let Some(TaggedEvent { tag, event }) = merged.recv().await {
match event {
Event::ToolQueued { tool_name, .. } => println!("[{tag}] {tool_name}"),
Event::Notice { severity, message } => println!("[{tag}] {severity:?}: {message}"),
Event::RunFinished { outcome, .. } => println!("[{tag}] finished: {outcome:?}"),
_ => {}
}
}
}
async fn verify(
workspace: &Workspace,
pool: &BudgetPool,
subject: &str,
reviewed: &[(&'static str, Findings)],
) -> Result<(), RunError> {
let dossier: Value = reviewed
.iter()
.map(|(dimension, found)| ((*dimension).to_string(), json!(found.findings)))
.collect();
let prompt = format!(
"Three reviewers looked at {subject} and reported the findings below. \
Decide whether it ships. Judge only what is written here — do not open \
the files.\n\n{dossier:#}"
);
let mut judge = workspace.prepare(
RunSpec::default()
.with_session_name("verdict")
.with_budget(pool.clone())
.with_deadline(DEADLINE),
)?;
match judge
.output::<Verdict, _, _>(prompt, verdict_spec(), NullSink, AllowAll)
.await
{
Ok(OutputReport { value, .. }) => println!(
"\nverdict: {} — {}",
if value.ship { "ship" } else { "hold" },
value.rationale
),
Err(RunError::BudgetExhausted { limit, spent }) => {
println!("\nno verdict: the reviews spent {spent} of {limit} tokens");
}
Err(error) => return Err(error),
}
Ok(())
}
fn brief(dimension: &Dimension, subject: &str) -> String {
format!(
"Review {subject}. Look only for {}; another reviewer is covering \
everything else, so report nothing outside your dimension. Read the \
relevant files first, then submit what you found.",
dimension.brief
)
}
fn findings_spec() -> OutputSpec {
OutputSpec::new(
"submit_findings",
"Call this once you have read the relevant code and have nothing further \
to check. An empty list is a valid answer and is better than a padded one.",
json!({
"type": "object",
"properties": {
"findings": {
"type": "array",
"description": "One entry per problem worth a reader's time.",
"items": {
"type": "object",
"properties": {
"file": {
"type": "string",
"description": "Path relative to the workspace root."
},
"note": {
"type": "string",
"description": "What is wrong and why it matters, in one sentence."
},
"blocking": {
"type": "boolean",
"description": "True only when this must be fixed before the code ships. A style preference is never blocking."
}
},
"required": ["file", "note", "blocking"]
}
}
},
"required": ["findings"]
}),
)
}
fn verdict_spec() -> OutputSpec {
OutputSpec::new(
"submit_verdict",
"Call this once you have weighed every finding against every other.",
json!({
"type": "object",
"properties": {
"ship": {
"type": "boolean",
"description": "True when nothing reported should stop this from merging."
},
"rationale": {
"type": "string",
"description": "One or two sentences naming the findings that decided it."
}
},
"required": ["ship", "rationale"]
}),
)
}
fn selected_model() -> ModelSelector {
match env::var("BASIS_MODEL") {
Ok(id) => ModelSelector::Id(id),
Err(_) => ModelSelector::NewestAvailable,
}
}