use std::path::PathBuf;
use anyhow::Result;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::agent::r#loop::AgentEvent;
use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::Config;
use crate::evolution::{
EvolutionEvent,
adapter::ColletEvolvable,
benchmarks::{FileBenchmark, NullBenchmark, SingleTaskBenchmark, SweBenchAdapter},
config::EvolveConfig,
engines::SkillforgeEngine,
r#loop::EvolutionLoop,
trial::TrialRunner,
};
pub fn print_evolve_usage() {
println!("Usage: collet evolve [TASK] [OPTIONS]");
println!();
println!("Run the self-improvement evolution loop on an agent workspace.");
println!();
println!(" TASK Natural-language task (agent solves it, then improves)");
println!();
println!("Examples:");
println!(" collet evolve \"fix the login bug\" # solve + 1 improvement");
println!(" collet evolve \"add input validation\" --cycles 5 # solve + 5 iterations");
println!(" collet evolve --benchmark file --tasks t.jsonl # batch benchmark loop");
println!(" collet evolve --benchmark swebench --tasks s.jsonl # SWE-bench loop");
println!();
println!("Options:");
println!(" --cycles <n> Max evolution cycles [default: 1 with TASK, 10 without]");
println!(" --batch <n> Tasks per cycle [default: 5]");
println!(" --benchmark <name> Benchmark adapter [null|file|swebench]");
println!(" --tasks <path> Task JSONL file (required for file/swebench)");
println!(" --eval-dir <path> Scratch dir for evaluation [default: .collet/eval]");
println!(" --docker Use Docker in SWE-bench harness");
println!(" --score-cmd <cmd> External scorer command for file benchmark");
println!(" --model <name> Evolver model override");
println!(" --workspace <path> Agent workspace root [default: .collet/workspace]");
println!(" --dir <path> Working directory for the agent");
println!(" --no-evolve-prompts Disable prompt mutation");
println!(" --no-evolve-skills Disable skill mutation");
println!(" --no-evolve-memory Disable memory mutation");
println!(" --evolve-tools Enable tool mutation");
println!(" -h, --help Show this help");
}
struct EvolveArgs {
task_text: Option<String>,
workspace: Option<PathBuf>,
cycles: Option<u32>,
batch: usize,
model: Option<String>,
dir: Option<String>,
no_evolve_prompts: bool,
no_evolve_skills: bool,
no_evolve_memory: bool,
evolve_tools: bool,
benchmark: Option<String>,
tasks: Option<PathBuf>,
eval_dir: Option<PathBuf>,
docker: bool,
score_cmd: Option<String>,
}
fn parse_evolve_args(args: &[String]) -> EvolveArgs {
let mut workspace = None;
let mut cycles: Option<u32> = None;
let mut batch = 5usize;
let mut model = None;
let mut dir = None;
let mut no_evolve_prompts = false;
let mut no_evolve_skills = false;
let mut no_evolve_memory = false;
let mut evolve_tools = false;
let mut benchmark: Option<String> = None;
let mut tasks: Option<PathBuf> = None;
let mut eval_dir: Option<PathBuf> = None;
let mut docker = false;
let mut score_cmd: Option<String> = None;
let mut task_words: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--workspace" => {
i += 1;
workspace = args.get(i).map(PathBuf::from);
}
"--cycles" => {
i += 1;
cycles = args.get(i).and_then(|s| s.parse().ok());
}
"--batch" => {
i += 1;
batch = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(5);
}
"--model" => {
i += 1;
model = args.get(i).cloned();
}
"--dir" => {
i += 1;
dir = args.get(i).cloned();
}
"--benchmark" => {
i += 1;
benchmark = args.get(i).cloned();
}
"--tasks" => {
i += 1;
tasks = args.get(i).map(PathBuf::from);
}
"--eval-dir" => {
i += 1;
eval_dir = args.get(i).map(PathBuf::from);
}
"--score-cmd" => {
i += 1;
score_cmd = args.get(i).cloned();
}
"--docker" => docker = true,
"--no-evolve-prompts" => no_evolve_prompts = true,
"--no-evolve-skills" => no_evolve_skills = true,
"--no-evolve-memory" => no_evolve_memory = true,
"--evolve-tools" => evolve_tools = true,
other if other.starts_with("--") => {} word => task_words.push(word.to_string()), }
i += 1;
}
let task_text = if task_words.is_empty() {
None
} else {
Some(task_words.join(" "))
};
EvolveArgs {
task_text,
workspace,
cycles,
batch,
model,
dir,
no_evolve_prompts,
no_evolve_skills,
no_evolve_memory,
evolve_tools,
benchmark,
tasks,
eval_dir,
docker,
score_cmd,
}
}
pub async fn cmd_evolve(args: &[String]) -> Result<()> {
let ea = parse_evolve_args(args);
let mut config = Config::load().map_err(|e| anyhow::anyhow!("Config error: {e}"))?;
if let Some(ref m) = ea.model {
config.model = m.clone();
}
let working_dir = match &ea.dir {
Some(d) => d.clone(),
None => std::env::current_dir()
.unwrap_or_default()
.display()
.to_string(),
};
let workspace_root = ea.workspace.clone().unwrap_or_else(|| {
PathBuf::from(&working_dir)
.join(".collet")
.join("workspace")
});
let evolve_config_base = EvolveConfig {
batch_size: ea.batch,
max_cycles: ea.cycles.unwrap_or(10), evolve_prompts: !ea.no_evolve_prompts,
evolve_skills: !ea.no_evolve_skills,
evolve_memory: !ea.no_evolve_memory,
evolve_tools: ea.evolve_tools,
evolver_model: config.model.clone(),
..Default::default()
};
tokio::fs::create_dir_all(&workspace_root).await?;
let client = OpenAiCompatibleProvider::from_config(&config)
.map_err(|e| anyhow::anyhow!("Provider error: {e}"))?;
let evolvable = ColletEvolvable::new(
client.clone(),
config.clone(),
workspace_root.clone(),
working_dir.clone(),
);
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
eprintln!("\nโก Evolution interrupted by user");
cancel_clone.cancel();
}
});
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let AgentEvent::Evolution(evo_event) = event {
print_evolution_event(&evo_event);
}
}
});
let eval_dir = ea
.eval_dir
.clone()
.unwrap_or_else(|| PathBuf::from(&working_dir).join(".collet").join("eval"));
let (benchmark, benchmark_label, cycles_default): (
Box<dyn crate::evolution::trial::BenchmarkAdapter>,
String,
u32,
) = if let Some(ref text) = ea.task_text {
(
Box::new(SingleTaskBenchmark::from_text(text.clone())),
format!("inline: \"{text}\""),
1,
)
} else {
match ea.benchmark.as_deref().unwrap_or("null") {
"file" => {
let path = ea.tasks.clone().ok_or_else(|| {
anyhow::anyhow!("--tasks <path> is required for --benchmark file")
})?;
let mut fb = FileBenchmark::new(path.clone());
if let Some(cmd) = ea.score_cmd.clone() {
fb = fb.with_score_cmd(cmd);
}
(Box::new(fb), format!("file({})", path.display()), 10)
}
"swebench" | "swe-bench" => {
let path = ea.tasks.clone().ok_or_else(|| {
anyhow::anyhow!("--tasks <path> is required for --benchmark swebench")
})?;
tokio::fs::create_dir_all(&eval_dir).await?;
(
Box::new(
SweBenchAdapter::new(path.clone(), eval_dir.clone()).with_docker(ea.docker),
),
format!("swebench({})", path.display()),
10,
)
}
_ => (Box::new(NullBenchmark), "null".to_string(), 10),
}
};
let max_cycles = ea.cycles.unwrap_or(cycles_default);
println!("๐งฌ Starting evolution loop");
println!(" Workspace: {}", workspace_root.display());
println!(" Working dir: {working_dir}");
println!(" Cycles: {max_cycles}");
println!(" Engine: skillforge");
println!(" Benchmark: {benchmark_label}");
println!(" Model: {}", evolve_config_base.evolver_model);
println!();
let trial = TrialRunner::new(Box::new(evolvable), benchmark);
let evo_event_tx = wrap_evo_tx(event_tx);
let evolve_config = EvolveConfig {
max_cycles,
..evolve_config_base
};
let mut evo_loop =
EvolutionLoop::new(workspace_root, trial, evolve_config.clone(), evo_event_tx)?;
let mut engine = SkillforgeEngine::new(evolve_config, client);
let result = evo_loop.run(&mut engine, cancel).await?;
println!();
println!("โ
Evolution complete");
println!(" Cycles: {}", result.cycles_completed);
println!(" Final score: {:.3}", result.final_score);
println!(
" Converged: {}",
if result.converged { "yes" } else { "no" }
);
if !result.score_history.is_empty() {
let curve: Vec<String> = result
.score_history
.iter()
.enumerate()
.map(|(i, s)| format!("{}: {:.3}", i + 1, s))
.collect();
println!(" Score curve: {}", curve.join(" โ "));
}
Ok(())
}
fn wrap_evo_tx(
agent_tx: mpsc::UnboundedSender<AgentEvent>,
) -> mpsc::UnboundedSender<EvolutionEvent> {
let (evo_tx, mut evo_rx) = mpsc::unbounded_channel::<EvolutionEvent>();
tokio::spawn(async move {
while let Some(evt) = evo_rx.recv().await {
let _ = agent_tx.send(AgentEvent::Evolution(evt));
}
});
evo_tx
}
fn print_evolution_event(event: &EvolutionEvent) {
use crate::evolution::EvolutionEvent::*;
match event {
CycleStarted { cycle, max_cycles } => {
println!("โโ Cycle {cycle}/{max_cycles} โโโโโโโโโโโโโโโโโโโโโโโโโ");
}
SolveStarted { task_count } => {
println!(" ๐ง Solving {task_count} tasks...");
}
SolveCompleted { score } => {
println!(" ๐ Batch score: {score:.3}");
}
EngineStepStarted { engine_name } => {
println!(" ๐ง [{engine_name}] Analyzing observations...");
}
EngineStepCompleted { mutated, summary } => {
if *mutated {
println!(" โ
Mutated: {summary}");
} else {
println!(" โญ No mutation: {summary}");
}
}
CycleCompleted {
cycle,
score,
mutated,
} => {
let m = if *mutated { "โ" } else { "ยท" };
println!(" {m} Cycle {cycle} done โ score: {score:.3}");
}
Converged { cycle, final_score } => {
println!();
println!("๐ฏ Converged at cycle {cycle} โ final score: {final_score:.3}");
}
Error(msg) => {
eprintln!(" โ Error: {msg}");
}
Done(_) => {}
}
}