Skip to main content

rho_coding_agent/app/
bootstrap.rs

1use std::{
2    io::{self, IsTerminal},
3    num::NonZeroUsize,
4    sync::Arc,
5    time::Duration,
6};
7
8use {
9    crate::cli::{Cli, Command, CredentialStoreCommand, OutputFormat},
10    crate::credential_store::AppCredentialStore,
11    crate::diagnostics::RuntimeDiagnostics,
12    crate::herdr::HerdrReporter,
13    crate::update,
14    rho_providers::model::ModelError,
15};
16
17use super::{
18    agent_binding::{AgentBinder, AgentInvocation, AgentRole},
19    automation, automation_protocol, cli_config,
20    config_repository::ConfigRepository,
21    interactive, login,
22    sdk_config::SdkBootstrapOptions,
23    sessions_cli, workflow_cli,
24};
25
26pub async fn run(cli: Cli) -> anyhow::Result<()> {
27    if workflow_cli::planner_worker_requested(&cli) {
28        return workflow_cli::run_planner_worker().await;
29    }
30    let run_output = match &cli.command {
31        Some(Command::Run { output, .. }) => Some(*output),
32        _ => None,
33    };
34    let result = run_inner(cli).await;
35    let Err(error) = result else {
36        return Ok(());
37    };
38    if error.downcast_ref::<automation::AutomationExit>().is_some()
39        || error
40            .downcast_ref::<automation::AutomationInterrupted>()
41            .is_some()
42    {
43        return Err(error);
44    }
45    if run_output == Some(OutputFormat::Jsonl) {
46        automation::emit_startup_failure()?;
47        return Err(automation::AutomationExit::new(
48            2,
49            automation_protocol::TerminalReason::ConfigurationError,
50            "configuration failed",
51        )
52        .into());
53    }
54    if run_output.is_some() {
55        return Err(automation::AutomationExit::new(
56            2,
57            automation_protocol::TerminalReason::ConfigurationError,
58            error.to_string(),
59        )
60        .into());
61    }
62    Err(error)
63}
64
65async fn run_inner(cli: Cli) -> anyhow::Result<()> {
66    cli_config::validate(&cli)?;
67    if let EarlyDispatch::Handled(result) = dispatch_early_command(&cli).await? {
68        return result;
69    }
70
71    let PreparedStartup {
72        cli,
73        mut config,
74        config_repository,
75        cwd,
76        automation_prompt,
77        output_file,
78        output,
79        max_steps,
80        timeout,
81        bound_agent,
82        bound_reasoning_source,
83        provider_refresh,
84        store,
85    } = prepare_startup(cli).await?;
86
87    validate_terminal_mode(&cli)?;
88    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
89    cli_config::normalize_reasoning_for_cli(&mut config, bound_reasoning_source)?;
90    let herdr = HerdrReporter::from_env();
91    if let Some(prompt) = automation_prompt {
92        return run_automation_startup(AutomationStartup {
93            prompt,
94            config: &config,
95            config_repository: &config_repository,
96            cwd,
97            cli: &cli,
98            bound_agent,
99            output_file,
100            output,
101            max_steps,
102            timeout,
103            herdr,
104        })
105        .await;
106    }
107    run_interactive_startup(InteractiveStartup {
108        cli: &cli,
109        config,
110        config_repository,
111        cwd,
112        bound_agent,
113        bound_reasoning_source,
114        herdr,
115    })
116    .await
117}
118
119enum EarlyDispatch {
120    Handled(anyhow::Result<()>),
121    Continue,
122}
123
124async fn dispatch_early_command(cli: &Cli) -> anyhow::Result<EarlyDispatch> {
125    if let Some(Command::Workflow { command }) = &cli.command {
126        return Ok(EarlyDispatch::Handled(
127            workflow_cli::run(command, cli).await,
128        ));
129    }
130    if let Some(Command::CredentialStore { command }) = &cli.command {
131        return Ok(EarlyDispatch::Handled(run_credential_store_command(
132            command,
133            cli.config.clone(),
134        )));
135    }
136    if let Some(Command::Sessions { command }) = &cli.command {
137        return Ok(EarlyDispatch::Handled(sessions_cli::run(command)));
138    }
139    if let Some(Command::Attach { id }) = &cli.command {
140        return Ok(EarlyDispatch::Handled(
141            crate::tui::run_attachment(id, HerdrReporter::from_env()).await,
142        ));
143    }
144    if matches!(cli.command, Some(Command::Update)) {
145        return Ok(EarlyDispatch::Handled(
146            update::run_update(env!("CARGO_PKG_VERSION")).await,
147        ));
148    }
149    if let Some(Command::Login {
150        provider,
151        device_auth,
152    }) = &cli.command
153    {
154        let config_repository = ConfigRepository::new(cli.config.clone());
155        let mut config = config_repository.load()?;
156        let config_path = absolute_config_path(&config_repository)?;
157        ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
158        crate::credential_store::initialize_from_config(&mut config, &config_path)?;
159        return Ok(EarlyDispatch::Handled(
160            login::run(provider, *device_auth).await,
161        ));
162    }
163    Ok(EarlyDispatch::Continue)
164}
165
166struct PreparedStartup {
167    cli: Cli,
168    config: crate::config::Config,
169    config_repository: ConfigRepository,
170    cwd: std::path::PathBuf,
171    automation_prompt: Option<String>,
172    output_file: Option<std::path::PathBuf>,
173    output: OutputFormat,
174    max_steps: Option<NonZeroUsize>,
175    timeout: Option<Duration>,
176    bound_agent: super::agent_binding::BoundAgent,
177    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
178    provider_refresh: cli_config::ProviderRefreshStatus,
179    store: AppCredentialStore,
180}
181
182async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
183    let config_path = cli.config.clone();
184    let config_repository = ConfigRepository::new(config_path.clone());
185    let mut config = config_repository.load()?;
186    let absolute_config = absolute_config_path(&config_repository)?;
187    crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
188    let cwd = std::env::current_dir()?;
189    let automation_prompt = automation::prompt_for_command(&cli.command)?;
190    let (output_file, output, max_steps, timeout) = match &cli.command {
191        Some(Command::Run {
192            output_file,
193            output,
194            max_steps,
195            timeout,
196            ..
197        }) => (output_file.clone(), *output, *max_steps, *timeout),
198        _ => (None, OutputFormat::Text, None, None),
199    };
200    let catalog = crate::agent::AgentCatalog::discover(&cwd)?;
201    let selected_agent = cli.agent.as_deref().unwrap_or("default");
202    let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
203
204    let store = AppCredentialStore;
205    let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
206    let mut save_config = cli_config::apply_overrides(&mut config, &cli)?;
207    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
208    save_config |= cli_config::normalize_reasoning_for_cli(
209        &mut config,
210        if cli.reasoning.is_some() {
211            rho_providers::model::ReasoningRequestSource::Explicit
212        } else {
213            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
214        },
215    )?;
216    if save_config {
217        config_repository.save(&config)?;
218    }
219    let reasoning_before_binding = config.reasoning;
220    let role = if automation_prompt.is_some() {
221        AgentRole::AutomationRoot
222    } else {
223        AgentRole::InteractiveRoot
224    };
225    let bound_agent = AgentBinder::bind(
226        definition,
227        AgentInvocation {
228            role,
229            available_tools: host_capabilities(&cli, &config, role),
230        },
231        &config,
232    )?;
233    config = bound_agent.rho_config().cloned().unwrap_or(config);
234    let bound_reasoning_source =
235        if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
236            rho_providers::model::ReasoningRequestSource::Explicit
237        } else {
238            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
239        };
240
241    Ok(PreparedStartup {
242        cli,
243        config,
244        config_repository,
245        cwd,
246        automation_prompt,
247        output_file,
248        output,
249        max_steps,
250        timeout,
251        bound_agent,
252        bound_reasoning_source,
253        provider_refresh,
254        store,
255    })
256}
257
258struct AutomationStartup<'a> {
259    prompt: String,
260    config: &'a crate::config::Config,
261    config_repository: &'a ConfigRepository,
262    cwd: std::path::PathBuf,
263    cli: &'a Cli,
264    bound_agent: super::agent_binding::BoundAgent,
265    output_file: Option<std::path::PathBuf>,
266    output: OutputFormat,
267    max_steps: Option<NonZeroUsize>,
268    timeout: Option<Duration>,
269    herdr: HerdrReporter,
270}
271
272async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
273    let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
274    automation::run(
275        startup.prompt,
276        automation::Startup {
277            config: startup.config,
278            config_path: absolute_config_path(startup.config_repository)?,
279            cwd: startup.cwd,
280            no_system_prompt: startup.cli.no_system_prompt,
281            no_tools: startup.cli.no_tools,
282            no_subagents: startup.cli.no_subagents,
283            usage_purpose: "agent",
284            parent_session_id: None,
285            agent: startup.bound_agent,
286            output_file: startup.output_file,
287            output: startup.output,
288            max_steps: startup.max_steps,
289            timeout: startup.timeout,
290            diagnostics,
291            herdr: startup.herdr,
292            host_input: None,
293            approval_session: None,
294            hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
295        },
296    )
297    .await
298}
299
300struct InteractiveStartup<'a> {
301    cli: &'a Cli,
302    config: crate::config::Config,
303    config_repository: ConfigRepository,
304    cwd: std::path::PathBuf,
305    bound_agent: super::agent_binding::BoundAgent,
306    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
307    herdr: HerdrReporter,
308}
309
310async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
311    let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
312
313    let pending_update_notice = startup
314        .config
315        .check_for_updates
316        .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
317
318    let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
319    let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
320        Arc::new(AppCredentialStore),
321    );
322    let provider_result = rho_providers::providers::build_sdk_provider_with_source(
323        sdk_options.provider,
324        &credentials,
325    );
326    let (missing_auth_error, missing_auth_model_error) = match provider_result {
327        Ok(_) => (None, None),
328        Err(error) if is_interactive_startup_unavailable_error(&error) => {
329            (Some(error.to_string()), Some(error))
330        }
331        Err(error) => return Err(error.into()),
332    };
333    interactive::run(interactive::Startup {
334        cli: startup.cli,
335        config: startup.config,
336        config_path: absolute_config_path(&startup.config_repository)?,
337        config_repository: startup.config_repository,
338        cwd: startup.cwd,
339        missing_auth_error,
340        missing_auth_model_error,
341        pending_update_notice,
342        diagnostics,
343        herdr: startup.herdr,
344        agent: startup.bound_agent,
345        reasoning_source: startup.bound_reasoning_source,
346    })
347    .await
348}
349
350fn bind_agent_diagnostics(
351    config: &crate::config::Config,
352    agent: &super::agent_binding::BoundAgent,
353) -> RuntimeDiagnostics {
354    let diagnostics = RuntimeDiagnostics::new(config);
355    diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
356    diagnostics
357}
358
359fn ensure_cli_credential_store_choice(
360    config: &mut crate::config::Config,
361    config_path: Option<std::path::PathBuf>,
362) -> anyhow::Result<()> {
363    use rho_providers::credentials::CredentialStoreBackend;
364    use std::io::{self, IsTerminal, Write};
365
366    let Some(request) = crate::credential_store::choice_request(config) else {
367        return Ok(());
368    };
369
370    if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
371        anyhow::bail!(
372            "credential store is unset; set it before non-interactive login with \
373`rho credential-store set os|file`, behavior.credential_store in config.toml, \
374or RHO_CREDENTIAL_STORE=os|file"
375        );
376    }
377
378    let backends = request.available_backends();
379    if backends.is_empty() {
380        anyhow::bail!(
381            "no credential store backend is available (os: {}; file: {})",
382            request.os.detail,
383            request.file.detail
384        );
385    }
386
387    eprintln!("Choose where Rho stores provider credentials:");
388    eprintln!("This is saved to config and used for future logins on this machine.");
389    if request.os.available {
390        eprintln!("  [1] OS credential store (recommended)");
391    } else {
392        eprintln!(
393            "  [1] OS credential store (unavailable: {})",
394            request.os.detail
395        );
396    }
397    if request.file.available {
398        eprintln!("  [2] Local file under ~/.rho/credentials (not encrypted at rest)");
399    } else {
400        eprintln!("  [2] Local file (unavailable: {})", request.file.detail);
401    }
402    let default_backend = request
403        .default_backend()
404        .unwrap_or(CredentialStoreBackend::Os);
405    let default_hint = match default_backend {
406        CredentialStoreBackend::Os => "1",
407        CredentialStoreBackend::File => "2",
408    };
409    eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
410    io::stderr().flush()?;
411
412    let mut answer = String::new();
413    io::stdin().read_line(&mut answer)?;
414    let backend = match answer.trim() {
415        "" => default_backend,
416        "1" | "os" | "OS" => CredentialStoreBackend::Os,
417        "2" | "file" | "FILE" => CredentialStoreBackend::File,
418        other => {
419            anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
420        }
421    };
422    if !backends.contains(&backend) {
423        let detail = request.detail_for(backend);
424        anyhow::bail!(
425            "{} credential store is unavailable: {detail}",
426            backend.as_str()
427        );
428    }
429
430    let path = crate::credential_store::set_backend(backend, config_path)?;
431    config.credential_store = Some(backend);
432    eprintln!(
433        "credential store set to {} in {}",
434        backend.as_str(),
435        path.display()
436    );
437    Ok(())
438}
439
440fn run_credential_store_command(
441    command: &CredentialStoreCommand,
442    config_path: Option<std::path::PathBuf>,
443) -> anyhow::Result<()> {
444    match command {
445        CredentialStoreCommand::Probe { backend } => {
446            let result = crate::credential_store::probe(*backend);
447            if result.available {
448                println!("available: {}", result.detail);
449                Ok(())
450            } else {
451                anyhow::bail!(result.detail)
452            }
453        }
454        CredentialStoreCommand::Status => {
455            // Saved config policy only (ignore RHO_CREDENTIAL_STORE).
456            match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
457                None => println!("unset"),
458                Some(backend) => println!("{}", backend.as_str()),
459            }
460            Ok(())
461        }
462        CredentialStoreCommand::Set { backend } => {
463            let path = crate::credential_store::set_backend(*backend, config_path)?;
464            println!(
465                "credential store set to {} in {}",
466                backend.as_str(),
467                path.display()
468            );
469            Ok(())
470        }
471    }
472}
473
474pub(super) fn host_capabilities(
475    cli: &Cli,
476    config: &crate::config::Config,
477    role: AgentRole,
478) -> crate::agent::AgentCapabilities {
479    use crate::agent::ToolCapability;
480
481    if cli.no_tools {
482        return crate::agent::AgentCapabilities::default();
483    }
484    let mut tools = crate::agent::AgentCapabilities::all_host_tools();
485    // web_search is gated after bind against the resolved provider/model.
486    #[cfg(windows)]
487    tools.remove(&ToolCapability::Bash);
488    #[cfg(not(windows))]
489    tools.remove(&ToolCapability::Powershell);
490    if cli.no_subagents || !config.enable_subagents {
491        tools.remove(&ToolCapability::Agent);
492        tools.remove(&ToolCapability::Agents);
493    }
494    if role != AgentRole::InteractiveRoot {
495        tools.remove(&ToolCapability::Questionnaire);
496    }
497    #[cfg(debug_assertions)]
498    if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
499        tools.insert(ToolCapability::Extension(
500            crate::tools::tui_fixture::NAME.into(),
501        ));
502    }
503    tools
504}
505
506pub(super) fn absolute_config_path(
507    repository: &ConfigRepository,
508) -> anyhow::Result<std::path::PathBuf> {
509    let path = repository.configured_path()?;
510    if path.is_absolute() {
511        Ok(path)
512    } else {
513        Ok(std::env::current_dir()?.join(path))
514    }
515}
516
517fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
518    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
519        anyhow::bail!(
520            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
521        );
522    }
523    Ok(())
524}
525
526fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
527    matches!(
528        error,
529        ModelError::MissingCredentials(_)
530            | ModelError::Credentials(_)
531            | ModelError::UnsupportedProvider(_)
532    )
533}
534
535#[cfg(test)]
536#[path = "bootstrap_tests.rs"]
537mod tests;