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#[derive(Debug)]
31pub struct AutomationInterrupted {
32 signal: ShutdownSignal,
33}
34
35impl AutomationInterrupted {
36 fn new(signal: ShutdownSignal) -> Self {
37 Self { signal }
38 }
39
40 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
170async 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;