collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use tokio::sync::mpsc;

use crate::agent::approval::ApprovalGate;
use crate::agent::context::ConversationContext;

use super::core::run_loop;
use super::{AgentEvent, AgentParams, ExecutePlanParams};

/// Architect phase only: analyze the codebase and produce a plan.
///
/// After the architect finishes, sends `AgentEvent::PlanReady` back to the
/// TUI so the user can review the plan, optionally save it as a file, and
/// explicitly confirm before the code phase begins.
/// Run via `/architect <task>` in the TUI or programmatically for plan-first workflows.
pub async fn run_architect_code_loop(params: AgentParams) {
    let AgentParams {
        client,
        config,
        context,
        user_msg,
        working_dir,
        event_tx,
        cancel,
        lsp_manager,
        ..
    } = params;

    // ── Phase 1: Architect ──────────────────────────────────────────────────
    let _ = event_tx.send(AgentEvent::PhaseChange {
        label: "Architect — analyzing codebase and creating plan...".to_string(),
    });

    let arch_prompt = format!(
        "[ARCHITECT PHASE] Analyze the codebase thoroughly using file_read, search, and bash tools. \
         Produce a detailed, step-by-step implementation plan for the following task. \
         Include exact file paths, function signatures, and the specific changes required. \
         Do NOT write or modify any files — planning only.\n\n{user_msg}"
    );

    // Use architect model (falls back to current model if not configured)
    let mut arch_client = client.clone();
    if let Some(arch_agent) = config.agents.iter().find(|a| a.name == "architect") {
        arch_client.model = arch_agent.model.clone();
    }

    // Run architect phase on a private channel to capture its Done event
    let (arch_tx, mut arch_rx) = mpsc::unbounded_channel::<AgentEvent>();
    let arch_cancel = cancel.clone();
    let arch_config = config.clone();
    let arch_context = context.clone();
    let arch_working_dir = working_dir.clone();
    let arch_lsp = lsp_manager.clone();

    tokio::spawn(async move {
        run_loop(AgentParams {
            client: arch_client,
            config: arch_config,
            context: arch_context,
            user_msg: arch_prompt,
            working_dir: arch_working_dir,
            event_tx: arch_tx,
            cancel: arch_cancel,
            lsp_manager: arch_lsp,
            trust_level: crate::trust::TrustLevel::Full,
            approval_gate: ApprovalGate::yolo(),
            images: Vec::new(),
        })
        .await;
    });

    // Forward architect events to TUI; capture plan text from Done
    let mut plan_text = String::new();
    let mut arch_final_context: Option<ConversationContext> = None;

    while let Some(event) = arch_rx.recv().await {
        match event {
            AgentEvent::Done { context: ctx, .. } => {
                // Extract plan from the last assistant message
                plan_text = ctx
                    .messages()
                    .iter()
                    .rev()
                    .find(|m| m.role == "assistant")
                    .and_then(|m| m.content.as_ref().map(|c| c.text_content()))
                    .unwrap_or_default();
                arch_final_context = Some(ctx);
                break;
            }
            other => {
                let _ = event_tx.send(other);
            }
        }
    }

    if cancel.is_cancelled() || plan_text.is_empty() {
        let ctx = arch_final_context.unwrap_or(context);
        let _ = event_tx.send(AgentEvent::Done {
            context: ctx,
            stop_reason: None,
        });
        return;
    }

    // ── Return plan to TUI for user review ──────────────────────────────────
    let final_ctx = arch_final_context.unwrap_or(context);
    let _ = event_tx.send(AgentEvent::PlanReady {
        plan: plan_text,
        context: final_ctx,
        user_msg,
    });
}

/// Phase 2: execute a plan produced by the architect phase.
///
/// Called from `app.rs` after the user has reviewed and approved the plan.
/// `arch_context` is the completed architect conversation; key file paths it
/// examined are surfaced in the code prompt so the code agent can skip
/// redundant re-reads.
pub async fn execute_plan(params: ExecutePlanParams) {
    let ExecutePlanParams {
        client,
        config,
        system_prompt,
        plan,
        user_msg,
        working_dir,
        event_tx,
        cancel,
        lsp_manager,
        arch_context,
        approval_gate,
    } = params;

    let _ = event_tx.send(AgentEvent::PhaseChange {
        label: "Code — executing implementation plan...".to_string(),
    });

    // Code agent starts with a fresh context (same system prompt) so the
    // architect's tool-call history doesn't pollute the execution context.
    let code_context = ConversationContext::new(system_prompt);

    // Extract the set of files the architect already read so the code agent
    // can trust the plan without re-examining them from scratch.
    let arch_files_hint = arch_context
        .as_ref()
        .map(extract_arch_files)
        .unwrap_or_default();

    let files_section = if arch_files_hint.is_empty() {
        String::new()
    } else {
        format!(
            "## Files already analyzed by Architect\n\n\
             The following files were examined during planning — you can reference \
             the plan's findings without re-reading them unless you need to verify \
             a specific detail:\n{}\n\n",
            arch_files_hint
                .iter()
                .map(|f| format!("- {f}"))
                .collect::<Vec<_>>()
                .join("\n")
        )
    };

    let code_msg = format!(
        "Execute the following implementation plan.\n\n\
         ## Plan\n\n{plan}\n\n\
         {files_section}\
         ## Original task\n\n{user_msg}\n\n\
         Implement all changes described in the plan step by step."
    );

    // Use code model from config
    let mut code_client = client;
    if let Some(code_agent) = config.agents.iter().find(|a| a.name == "code") {
        code_client.model = code_agent.model.clone();
    }

    run_loop(AgentParams {
        client: code_client,
        config,
        context: code_context,
        user_msg: code_msg,
        working_dir,
        event_tx,
        cancel,
        lsp_manager,
        trust_level: crate::trust::TrustLevel::Full,
        approval_gate,
        images: Vec::new(),
    })
    .await;
}

/// Extract the unique set of file paths that the architect read during planning.
///
/// Looks at all assistant messages with `file_read` tool calls and collects
/// their `path` arguments. Used to hint the code agent about pre-analyzed files.
fn extract_arch_files(ctx: &ConversationContext) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    let mut paths = Vec::new();

    for msg in ctx.messages() {
        if msg.role != "assistant" {
            continue;
        }
        let Some(ref tool_calls) = msg.tool_calls else {
            continue;
        };
        for tc in tool_calls {
            if tc.function.name != "file_read" {
                continue;
            }
            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments)
                && let Some(path) = v.get("path").and_then(|p| p.as_str())
                && seen.insert(path.to_string())
            {
                paths.push(path.to_string());
            }
        }
    }

    paths
}