Skip to main content

rho_coding_agent/app/
bootstrap.rs

1use std::{
2    io::{self, IsTerminal},
3    sync::Arc,
4};
5
6use {
7    crate::cli::{Cli, Command, CredentialStoreCommand, OutputFormat},
8    crate::credential_store::AppCredentialStore,
9    crate::diagnostics::RuntimeDiagnostics,
10    crate::herdr::HerdrReporter,
11    crate::update,
12    rho_providers::model::ModelError,
13};
14
15use super::{
16    agent_binding::{AgentBinder, AgentInvocation, AgentRole},
17    automation, automation_protocol, cli_config,
18    config_repository::ConfigRepository,
19    interactive, login,
20    sdk_config::SdkBootstrapOptions,
21};
22
23pub async fn run(cli: Cli) -> anyhow::Result<()> {
24    let run_output = match &cli.command {
25        Some(Command::Run { output, .. }) => Some(*output),
26        _ => None,
27    };
28    let result = run_inner(cli).await;
29    let Err(error) = result else {
30        return Ok(());
31    };
32    if error.downcast_ref::<automation::AutomationExit>().is_some()
33        || error
34            .downcast_ref::<automation::AutomationInterrupted>()
35            .is_some()
36    {
37        return Err(error);
38    }
39    if run_output == Some(OutputFormat::Jsonl) {
40        automation::emit_startup_failure()?;
41        return Err(automation::AutomationExit::new(
42            2,
43            automation_protocol::TerminalReason::ConfigurationError,
44            "configuration failed",
45        )
46        .into());
47    }
48    if run_output.is_some() {
49        return Err(automation::AutomationExit::new(
50            2,
51            automation_protocol::TerminalReason::ConfigurationError,
52            error.to_string(),
53        )
54        .into());
55    }
56    Err(error)
57}
58
59async fn run_inner(cli: Cli) -> anyhow::Result<()> {
60    cli_config::validate(&cli)?;
61    if let Some(Command::CredentialStore { command }) = &cli.command {
62        return run_credential_store_command(command);
63    }
64    if let Some(Command::Attach { id }) = &cli.command {
65        return crate::tui::run_attachment(id, HerdrReporter::from_env()).await;
66    }
67    if matches!(cli.command, Some(Command::Update)) {
68        return update::run_update(env!("CARGO_PKG_VERSION")).await;
69    }
70    if let Some(Command::Login {
71        provider,
72        device_auth,
73    }) = &cli.command
74    {
75        crate::credential_store::initialize()?;
76        return login::run(provider, *device_auth).await;
77    }
78
79    let config_path = cli.config.clone();
80    let config_repository = ConfigRepository::new(config_path.clone());
81    let mut config = config_repository.load()?;
82    let cwd = std::env::current_dir()?;
83    let automation_prompt = automation::prompt_for_command(&cli.command)?;
84    let (output_file, output, max_steps, timeout) = match &cli.command {
85        Some(Command::Run {
86            output_file,
87            output,
88            max_steps,
89            timeout,
90            ..
91        }) => (output_file.clone(), *output, *max_steps, *timeout),
92        _ => (None, OutputFormat::Text, None, None),
93    };
94    let catalog = crate::agent::AgentCatalog::discover(&cwd)?;
95    let selected_agent = cli.agent.as_deref().unwrap_or("default");
96    let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
97
98    let store = AppCredentialStore;
99    let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
100    let mut save_config = cli_config::apply_overrides(&mut config, &cli)?;
101    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
102    save_config |= cli_config::normalize_reasoning_for_cli(
103        &mut config,
104        if cli.reasoning.is_some() {
105            rho_providers::model::ReasoningRequestSource::Explicit
106        } else {
107            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
108        },
109    )?;
110    if save_config {
111        config_repository.save(&config)?;
112    }
113    let reasoning_before_binding = config.reasoning;
114    let role = if automation_prompt.is_some() {
115        AgentRole::AutomationRoot
116    } else {
117        AgentRole::InteractiveRoot
118    };
119    let bound_agent = AgentBinder::bind(
120        definition,
121        AgentInvocation {
122            role,
123            available_tools: host_capabilities(&cli, &config, role),
124        },
125        &config,
126    )?;
127    config = bound_agent.config().clone();
128
129    validate_terminal_mode(&cli)?;
130    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
131    let bound_reasoning_source =
132        if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
133            rho_providers::model::ReasoningRequestSource::Explicit
134        } else {
135            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
136        };
137    cli_config::normalize_reasoning_for_cli(&mut config, bound_reasoning_source)?;
138    let herdr = HerdrReporter::from_env();
139    if let Some(prompt) = automation_prompt {
140        let diagnostics = RuntimeDiagnostics::new(&config);
141        diagnostics.update_agent(
142            bound_agent.id().as_str(),
143            &bound_agent.fingerprint().to_string(),
144        );
145        return automation::run(
146            prompt,
147            automation::Startup {
148                config: &config,
149                config_path: absolute_config_path(&config_repository)?,
150                cwd,
151                no_system_prompt: cli.no_system_prompt,
152                no_tools: cli.no_tools,
153                no_subagents: cli.no_subagents,
154                usage_purpose: "agent",
155                parent_session_id: None,
156                agent: bound_agent,
157                output_file,
158                output,
159                max_steps,
160                timeout,
161                diagnostics,
162                herdr,
163            },
164        )
165        .await;
166    }
167    let diagnostics = RuntimeDiagnostics::new(&config);
168    diagnostics.update_agent(
169        bound_agent.id().as_str(),
170        &bound_agent.fingerprint().to_string(),
171    );
172
173    let pending_update_notice = config
174        .check_for_updates
175        .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
176
177    let sdk_options = SdkBootstrapOptions::from_config(&config, &cwd)?;
178    let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
179        Arc::new(AppCredentialStore),
180    );
181    let provider_result = rho_providers::providers::build_sdk_provider_with_source(
182        sdk_options.provider,
183        &credentials,
184    );
185    let (missing_auth_error, missing_auth_model_error) = match provider_result {
186        Ok(_) => (None, None),
187        Err(error) if is_interactive_startup_unavailable_error(&error) => {
188            (Some(error.to_string()), Some(error))
189        }
190        Err(error) => return Err(error.into()),
191    };
192    let result = interactive::run(interactive::Startup {
193        cli: &cli,
194        config,
195        config_path: absolute_config_path(&config_repository)?,
196        config_repository,
197        cwd,
198        missing_auth_error,
199        missing_auth_model_error,
200        pending_update_notice,
201        diagnostics,
202        herdr,
203        agent: bound_agent,
204        reasoning_source: bound_reasoning_source,
205    })
206    .await;
207    result
208}
209
210fn run_credential_store_command(command: &CredentialStoreCommand) -> anyhow::Result<()> {
211    match command {
212        CredentialStoreCommand::Probe { backend } => {
213            let result = crate::credential_store::probe(*backend);
214            if result.available {
215                println!("available: {}", result.detail);
216                Ok(())
217            } else {
218                anyhow::bail!(result.detail)
219            }
220        }
221        CredentialStoreCommand::Status => {
222            // Installer contract: saved policy only (ignore RHO_CREDENTIAL_STORE).
223            match crate::credential_store::saved_policy_backend()? {
224                None => println!("unset"),
225                Some(backend) => println!("{}", backend.as_str()),
226            }
227            Ok(())
228        }
229        CredentialStoreCommand::Set { backend } => {
230            let path = crate::credential_store::set_backend(*backend)?;
231            println!(
232                "credential store set to {} in {}",
233                backend.as_str(),
234                path.display()
235            );
236            Ok(())
237        }
238    }
239}
240
241fn host_capabilities(
242    cli: &Cli,
243    config: &crate::config::Config,
244    role: AgentRole,
245) -> crate::agent::AgentCapabilities {
246    use crate::agent::ToolCapability;
247
248    if cli.no_tools {
249        return crate::agent::AgentCapabilities::default();
250    }
251    let mut tools = crate::agent::AgentCapabilities::all_host_tools();
252    if !crate::tools::web::access_tools(config).is_available() {
253        tools.remove(&ToolCapability::WebSearch);
254    }
255    #[cfg(windows)]
256    tools.remove(&ToolCapability::Bash);
257    #[cfg(not(windows))]
258    tools.remove(&ToolCapability::Powershell);
259    if cli.no_subagents || !config.enable_subagents {
260        tools.remove(&ToolCapability::Agent);
261        tools.remove(&ToolCapability::Agents);
262    }
263    if role != AgentRole::InteractiveRoot {
264        tools.remove(&ToolCapability::Questionnaire);
265    }
266    #[cfg(debug_assertions)]
267    if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
268        tools.insert(ToolCapability::Extension(
269            crate::tools::tui_fixture::NAME.into(),
270        ));
271    }
272    tools
273}
274
275fn absolute_config_path(repository: &ConfigRepository) -> anyhow::Result<std::path::PathBuf> {
276    let path = repository.configured_path()?;
277    if path.is_absolute() {
278        Ok(path)
279    } else {
280        Ok(std::env::current_dir()?.join(path))
281    }
282}
283
284fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
285    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
286        anyhow::bail!(
287            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
288        );
289    }
290    Ok(())
291}
292
293fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
294    matches!(
295        error,
296        ModelError::MissingApiKey
297            | ModelError::MissingCodexAuth
298            | ModelError::MissingAnthropicApiKey
299            | ModelError::MissingGithubCopilotAuth
300            | ModelError::MissingXaiApiKey
301            | ModelError::MissingXaiAuth
302            | ModelError::Credentials(_)
303            | ModelError::UnsupportedProvider(_)
304    )
305}
306
307#[cfg(test)]
308#[path = "bootstrap_tests.rs"]
309mod tests;