Skip to main content

rho_coding_agent/app/
automation.rs

1use std::{
2    fmt,
3    io::{self, Read, Write},
4    path::PathBuf,
5    sync::Arc,
6};
7
8use rho_sdk::{
9    CapabilityRequest, PolicyDecision, SessionOptions, SystemPrompt, UserInput, Workspace,
10    WorkspacePolicy,
11};
12
13use crate::{
14    cli::Command,
15    config::Config,
16    credentials::OsCredentialStore,
17    diagnostics::RuntimeDiagnostics,
18    herdr::{HerdrReporter, HerdrState},
19    prompt,
20    providers::build_automation_provider,
21    tools::sdk_registry::{AppToolSet, ToolSetOptions},
22};
23
24use super::{
25    runtime_builder::{build_runtime, configured_context_window, RuntimeBuildOptions},
26    sdk_config::SdkBootstrapOptions,
27};
28
29/// Error returned after an automation run handles an interrupt and completes cleanup.
30#[derive(Debug)]
31pub struct AutomationInterrupted {
32    signal: ShutdownSignal,
33}
34
35impl AutomationInterrupted {
36    fn new(signal: ShutdownSignal) -> Self {
37        Self { signal }
38    }
39
40    /// Returns the conventional process exit code for the received signal.
41    pub fn exit_code(&self) -> u8 {
42        match self.signal {
43            ShutdownSignal::Interrupt => 130,
44            ShutdownSignal::Terminate => 143,
45        }
46    }
47}
48
49impl fmt::Display for AutomationInterrupted {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(formatter, "rho run interrupted by {}", self.signal)
52    }
53}
54
55impl std::error::Error for AutomationInterrupted {}
56
57#[derive(Clone, Copy, Debug)]
58enum ShutdownSignal {
59    Interrupt,
60    Terminate,
61}
62
63impl fmt::Display for ShutdownSignal {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Interrupt => formatter.write_str("SIGINT"),
67            Self::Terminate => formatter.write_str("SIGTERM"),
68        }
69    }
70}
71
72pub(super) struct Startup<'a> {
73    pub config: &'a Config,
74    pub cwd: PathBuf,
75    pub no_system_prompt: bool,
76    pub no_tools: bool,
77    pub diagnostics: RuntimeDiagnostics,
78    pub herdr: HerdrReporter,
79}
80
81pub(super) fn prompt_for_command(command: &Option<Command>) -> anyhow::Result<Option<String>> {
82    match command {
83        Some(Command::Run { prompt, stdin }) => prompt_from_stdin(prompt.clone(), *stdin).map(Some),
84        Some(Command::Login { .. }) | Some(Command::Update) | None => Ok(None),
85    }
86}
87
88pub(super) async fn run(prompt_text: String, startup: Startup<'_>) -> anyhow::Result<()> {
89    let sdk_options = SdkBootstrapOptions::from_config(startup.config, &startup.cwd)?;
90    let credentials = crate::auth::provider_credentials::ApplicationCredentialSource::new(
91        Arc::new(OsCredentialStore),
92    );
93    let provider = build_automation_provider(sdk_options.provider, &credentials)?;
94    let tool_set = if startup.no_tools {
95        AppToolSet::disabled()
96    } else {
97        AppToolSet::new(
98            startup.config,
99            startup.diagnostics.clone(),
100            ToolSetOptions::default(),
101        )
102    };
103    let tool_specs = tool_set.specs();
104    let system_prompt = if startup.no_system_prompt {
105        startup.diagnostics.update_prompt_sources(Vec::new());
106        SystemPrompt::None
107    } else {
108        let system_prompt = prompt::system_prompt(&tool_specs, &startup.cwd);
109        startup
110            .diagnostics
111            .update_prompt_sources(system_prompt.sources);
112        SystemPrompt::Custom(system_prompt.text)
113    };
114    startup.diagnostics.update_tools(&tool_specs);
115
116    let workspace = Workspace::new(&sdk_options.workspace.root)?;
117    let context_window = configured_context_window(startup.config);
118    let compaction = sdk_options.runtime.compaction.clone();
119    startup.diagnostics.update_compaction_config(&compaction);
120    let runtime = build_runtime(RuntimeBuildOptions {
121        provider,
122        tools: tool_set.tools(),
123        workspace,
124        workspace_policy: AutomationWorkspacePolicy,
125        system_prompt,
126        reasoning: sdk_options.runtime.reasoning,
127        compaction,
128        context_window,
129    })?;
130    let session = runtime.session(SessionOptions::default()).await?;
131
132    startup
133        .herdr
134        .report_state(HerdrState::Working, None, None)
135        .await;
136    let result = complete_run(&session, prompt_text).await;
137
138    runtime.shutdown();
139    tool_set.shutdown().await;
140    startup
141        .herdr
142        .report_state(HerdrState::Idle, None, None)
143        .await;
144    startup.herdr.release().await;
145
146    let answer = result?;
147    let mut stdout = io::stdout().lock();
148    writeln!(stdout, "{}", answer.text())?;
149    stdout.flush()?;
150    Ok(())
151}
152
153async fn complete_run(
154    session: &rho_sdk::Session,
155    prompt_text: String,
156) -> anyhow::Result<rho_sdk::RunOutcome> {
157    let mut run = session.start(UserInput::text(prompt_text)).await?;
158    let cancellation = run.cancellation_handle();
159    tokio::select! {
160        outcome = drive_headless_run(&mut run) => outcome,
161        signal = shutdown_signal() => {
162            let signal = signal?;
163            cancellation.cancel();
164            let _ = run.outcome().await;
165            Err(AutomationInterrupted::new(signal).into())
166        }
167    }
168}
169
170/// Drains run events with no interactive host attached.
171///
172/// Host input requests cannot be answered headlessly; cancel instead of
173/// leaving the requesting tool suspended until a signal arrives.
174async fn drive_headless_run(run: &mut rho_sdk::Run) -> anyhow::Result<rho_sdk::RunOutcome> {
175    while let Some(event) = run.next_event().await {
176        if let rho_sdk::RunEvent::HostInputRequested { request } = event {
177            run.cancel();
178            let _ = run.outcome().await;
179            anyhow::bail!(
180                "rho run cannot answer host input request '{}' ({}); run without tools that require interactive input",
181                request.id(),
182                request.title(),
183            );
184        }
185    }
186    Ok(run.outcome().await?)
187}
188
189#[cfg(unix)]
190async fn shutdown_signal() -> io::Result<ShutdownSignal> {
191    use tokio::signal::unix::{signal, SignalKind};
192
193    let mut interrupt = signal(SignalKind::interrupt())?;
194    let mut terminate = signal(SignalKind::terminate())?;
195    tokio::select! {
196        _ = interrupt.recv() => Ok(ShutdownSignal::Interrupt),
197        _ = terminate.recv() => Ok(ShutdownSignal::Terminate),
198    }
199}
200
201#[cfg(not(unix))]
202async fn shutdown_signal() -> io::Result<ShutdownSignal> {
203    tokio::signal::ctrl_c().await?;
204    Ok(ShutdownSignal::Interrupt)
205}
206
207#[derive(Clone, Copy, Debug)]
208struct AutomationWorkspacePolicy;
209
210impl WorkspacePolicy for AutomationWorkspacePolicy {
211    fn evaluate(&self, _request: &CapabilityRequest) -> PolicyDecision {
212        PolicyDecision::Allow
213    }
214}
215
216fn prompt_from_stdin(parts: Vec<String>, read_stdin: bool) -> anyhow::Result<String> {
217    prompt_from_reader(parts, read_stdin, &mut io::stdin())
218}
219
220fn prompt_from_reader(
221    parts: Vec<String>,
222    read_stdin: bool,
223    stdin: &mut impl Read,
224) -> anyhow::Result<String> {
225    let mut chunks = Vec::new();
226    let inline = parts.join(" ").trim().to_string();
227    if !inline.is_empty() {
228        chunks.push(inline);
229    }
230    if read_stdin {
231        let mut buffer = String::new();
232        stdin.read_to_string(&mut buffer)?;
233        let buffer = buffer.trim().to_string();
234        if !buffer.is_empty() {
235            chunks.push(buffer);
236        }
237    }
238
239    let prompt = chunks.join("\n\n");
240    if prompt.is_empty() {
241        anyhow::bail!("rho run requires a prompt argument or --stdin");
242    }
243    Ok(prompt)
244}
245
246#[cfg(test)]
247#[path = "automation_tests.rs"]
248mod tests;