collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Auto-evolution trigger — mirrors the Soul reflection pattern.
//!
//! After each agent task completes, if `[evolution] enabled = true` in config,
//! one evolution cycle runs asynchronously in the background.
//!
//! Workspace layout (same split as Soul):
//! - global (`"collet"`): `~/.collet/workspace/`
//! - per-agent:           `~/.collet/agents/{name}/workspace/`

use std::path::{Path, PathBuf};

use anyhow::Result;
use tokio_util::sync::CancellationToken;

use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::Config;
use crate::evolution::{
    adapter::ColletEvolvable, benchmarks::NullBenchmark, config::EvolveConfig,
    engines::SkillforgeEngine, r#loop::EvolutionLoop, trial::TrialRunner,
};

/// The special agent name whose workspace lives at `~/.collet/workspace/`
/// (analogous to `soul::GLOBAL_SOUL`).
pub const GLOBAL_AGENT: &str = "collet";

/// Resolve the evolution workspace path for an agent.
///
/// - `"collet"` → `<collet_home>/workspace/`
/// - other      → `<collet_home>/agents/<name>/workspace/`
pub fn workspace_path(collet_home: &Path, agent_name: &str) -> PathBuf {
    if agent_name == GLOBAL_AGENT {
        collet_home.join("workspace")
    } else {
        collet_home
            .join("agents")
            .join(agent_name)
            .join("workspace")
    }
}

/// Returns true when auto-evolution should fire after an agent task completes.
pub fn is_auto_enabled(config: &Config) -> bool {
    config.evolution_enabled
}

/// Run one auto-evolution cycle for the given agent (async, non-blocking caller).
///
/// Called via `tokio::spawn` from the `AgentEvent::Done` handler — mirrors
/// `soul::reflect_simple`.
pub async fn run_auto(
    client: OpenAiCompatibleProvider,
    config: Config,
    collet_home: PathBuf,
    agent_name: String,
    working_dir: String,
    cancel: CancellationToken,
) -> Result<()> {
    let workspace_root = workspace_path(&collet_home, &agent_name);
    std::fs::create_dir_all(&workspace_root)?;

    let cycles = config.evolution_cycles;
    let evolver_model = config
        .evolution_model
        .clone()
        .unwrap_or_else(|| config.model.clone());

    let evolve_config = EvolveConfig {
        max_cycles: cycles,
        batch_size: 1,
        evolver_model,
        ..Default::default()
    };

    let evolvable = ColletEvolvable::new(
        client.clone(),
        config.clone(),
        workspace_root.clone(),
        working_dir,
    );

    let benchmark = Box::new(NullBenchmark);
    let trial = TrialRunner::new(Box::new(evolvable), benchmark);

    // Discard events — auto mode is silent (no TUI channel needed)
    let (evo_tx, _evo_rx) = tokio::sync::mpsc::unbounded_channel();

    let mut evo_loop = EvolutionLoop::new(workspace_root, trial, evolve_config.clone(), evo_tx)?;

    // Build a dedicated evolver client when the evolver model differs from the main model.
    let evolver_client = if evolve_config.evolver_model != config.model {
        crate::api::provider::OpenAiCompatibleProvider::new(
            client.base_url().to_string(),
            client.api_key().to_string(),
            evolve_config.evolver_model.clone(),
            config.context_max_tokens,
        )
        .unwrap_or_else(|_| client.clone())
    } else {
        client
    };
    let mut engine = SkillforgeEngine::new(evolve_config, evolver_client);
    evo_loop.run(&mut engine, cancel).await?;

    Ok(())
}