ascent-research 0.4.2

ascent-research — an incremental research workflow CLI for AI agents. Every session resumes; knowledge accretes across runs. Mixes HTTP, browser, and local file ingest into a durable per-session wiki + figure-rich HTML report.
Documentation
//! `research loop <slug>` — run the autonomous research loop.
//!
//! Feature: `autoresearch`. Default builds do not include this command.

use serde_json::json;

#[cfg(feature = "provider-claude")]
use crate::autoresearch::claude::ClaudeProvider;
#[cfg(feature = "provider-codex")]
use crate::autoresearch::codex::CodexProvider;
#[cfg(feature = "provider-opencode-go")]
use crate::autoresearch::opencode_go::OpenCodeGoProvider;
use crate::autoresearch::executor::{self, LoopConfig};
use crate::autoresearch::provider::{AgentProvider, FakeProvider};
use crate::output::Envelope;
use crate::session::{active, config};

const CMD: &str = "research loop";

pub fn run(
    slug_arg: Option<&str>,
    provider_name: &str,
    iterations: Option<u32>,
    max_actions: Option<u32>,
    dry_run: bool,
    fake_responses: Option<Vec<String>>,
) -> Envelope {
    let slug = match slug_arg {
        Some(s) => s.to_string(),
        None => match active::get_active() {
            Some(s) => s,
            None => {
                return Envelope::fail(
                    CMD,
                    "NO_ACTIVE_SESSION",
                    "no active session — pass <slug> or run `research new` first",
                );
            }
        },
    };

    if !config::exists(&slug) {
        return Envelope::fail(CMD, "SESSION_NOT_FOUND", format!("no session '{slug}'"))
            .with_context(json!({ "session": slug }));
    }

    let cfg = LoopConfig {
        iterations: iterations.unwrap_or(executor::DEFAULT_ITERATIONS),
        max_actions: max_actions.unwrap_or(executor::DEFAULT_MAX_ACTIONS),
        dry_run,
    };

    let provider: Box<dyn AgentProvider> = match provider_name {
        "fake" => {
            let responses = fake_responses.unwrap_or_else(|| {
                vec![r#"{"reasoning":"no provider responses provided","actions":[],"done":true,"reason":"fake drained"}"#.to_string()]
            });
            Box::new(FakeProvider::new(responses))
        }
        #[cfg(feature = "provider-claude")]
        "claude" => Box::new(ClaudeProvider::new()),
        #[cfg(not(feature = "provider-claude"))]
        "claude" => {
            return Envelope::fail(
                CMD,
                "PROVIDER_NOT_AVAILABLE",
                "provider 'claude' requires the `provider-claude` feature (build with `--features provider-claude`)",
            )
            .with_context(json!({ "session": slug }));
        }
        #[cfg(feature = "provider-codex")]
        "codex" => Box::new(CodexProvider::new()),
        #[cfg(not(feature = "provider-codex"))]
        "codex" => {
            return Envelope::fail(
                CMD,
                "PROVIDER_NOT_AVAILABLE",
                "provider 'codex' requires the `provider-codex` feature (build with `--features provider-codex`)",
            )
            .with_context(json!({ "session": slug }));
        }
        #[cfg(feature = "provider-opencode-go")]
        "opencode-go" => match OpenCodeGoProvider::from_env() {
            Ok(p) => Box::new(p),
            Err(e) => {
                return Envelope::fail(CMD, "PROVIDER_NOT_AVAILABLE", e.to_string())
                    .with_context(json!({ "session": slug }));
            }
        },
        #[cfg(not(feature = "provider-opencode-go"))]
        "opencode-go" => {
            return Envelope::fail(
                CMD,
                "PROVIDER_NOT_AVAILABLE",
                "provider 'opencode-go' requires the `provider-opencode-go` feature (build with `--features provider-opencode-go`)",
            )
            .with_context(json!({ "session": slug }));
        }
        other => {
            return Envelope::fail(
                CMD,
                "PROVIDER_NOT_AVAILABLE",
                format!(
                    "unknown provider '{other}'; expected one of: fake, claude, codex, opencode-go"
                ),
            )
            .with_context(json!({ "session": slug }));
        }
    };

    let bin = std::env::current_exe().unwrap_or_else(|_| "research".into());
    let rt = match tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
    {
        Ok(rt) => rt,
        Err(e) => {
            return Envelope::fail(CMD, "IO_ERROR", format!("build tokio runtime: {e}"))
                .with_context(json!({ "session": slug }));
        }
    };
    let report = rt.block_on(executor::run(&*provider, &slug, cfg, &bin));

    Envelope::ok(
        CMD,
        json!({
            "provider": report.provider,
            "iterations_run": report.iterations_run,
            "actions_executed": report.actions_executed,
            "actions_rejected": report.actions_rejected,
            "termination_reason": report.termination_reason.as_str(),
            "final_coverage": report.final_coverage,
            "report_ready": report.final_coverage.get("report_ready").cloned().unwrap_or_default(),
            "duration_ms": report.duration_ms,
            "warnings": report.warnings,
        }),
    )
    .with_context(json!({ "session": slug }))
}