1use std::{
2 fmt,
3 io::{self, Read, Write},
4 path::PathBuf,
5 sync::Arc,
6};
7
8use rho_sdk::{SessionOptions, SystemPrompt, UserInput, Workspace};
9
10use {
11 crate::agent::{PromptPolicy, ToolCapability},
12 crate::cli::Command,
13 crate::config::Config,
14 crate::diagnostics::RuntimeDiagnostics,
15 crate::herdr::{HerdrReporter, HerdrState},
16 crate::prompt,
17 crate::subagent::{self, RunState, RunStatus},
18 crate::tools::{
19 agent::BackgroundSubagents,
20 sdk_registry::{AppToolSet, DelegationConfig, ToolSetOptions},
21 },
22 crate::tui::AttachmentWriter,
23 rho_providers::credentials::OsCredentialStore,
24 rho_providers::providers::build_automation_provider,
25};
26
27use super::{
28 agent_binding::BoundAgent,
29 policy::AppPolicy,
30 runtime_builder::{build_runtime, configured_context_window, RuntimeBuildOptions},
31 sdk_config::SdkBootstrapOptions,
32};
33
34#[derive(Debug)]
36pub struct AutomationInterrupted {
37 signal: ShutdownSignal,
38}
39
40impl AutomationInterrupted {
41 fn new(signal: ShutdownSignal) -> Self {
42 Self { signal }
43 }
44
45 pub fn exit_code(&self) -> u8 {
47 match self.signal {
48 ShutdownSignal::Interrupt => 130,
49 ShutdownSignal::Terminate => 143,
50 }
51 }
52}
53
54impl fmt::Display for AutomationInterrupted {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(formatter, "rho run interrupted by {}", self.signal)
57 }
58}
59
60impl std::error::Error for AutomationInterrupted {}
61
62#[derive(Clone, Copy, Debug)]
63enum ShutdownSignal {
64 Interrupt,
65 Terminate,
66}
67
68impl fmt::Display for ShutdownSignal {
69 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match self {
71 Self::Interrupt => formatter.write_str("SIGINT"),
72 Self::Terminate => formatter.write_str("SIGTERM"),
73 }
74 }
75}
76
77#[derive(Debug)]
78struct SubagentCancelled;
79
80impl fmt::Display for SubagentCancelled {
81 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82 formatter.write_str("subagent cancellation requested")
83 }
84}
85
86impl std::error::Error for SubagentCancelled {}
87
88pub(super) struct Startup<'a> {
89 pub config: &'a Config,
90 pub config_path: PathBuf,
91 pub cwd: PathBuf,
92 pub no_system_prompt: bool,
93 pub no_tools: bool,
94 pub no_subagents: bool,
95 pub usage_purpose: &'static str,
96 pub parent_session_id: Option<rho_sdk::SessionId>,
97 pub agent: BoundAgent,
98 pub output_file: Option<PathBuf>,
99 pub diagnostics: RuntimeDiagnostics,
100 pub herdr: HerdrReporter,
101}
102
103pub(super) fn prompt_for_command(command: &Option<Command>) -> anyhow::Result<Option<String>> {
104 match command {
105 Some(Command::Run { prompt, stdin, .. }) => {
106 prompt_from_stdin(prompt.clone(), *stdin).map(Some)
107 }
108 Some(Command::Attach { .. } | Command::Login { .. } | Command::Update) | None => Ok(None),
109 }
110}
111
112pub(super) async fn run(prompt_text: String, startup: Startup<'_>) -> anyhow::Result<()> {
113 let mut reporter = startup
117 .output_file
118 .as_ref()
119 .map(|path| {
120 RunReporter::new(
121 path.clone(),
122 RunArtifactIdentity {
123 agent_id: startup.agent.id().to_string(),
124 agent_fingerprint: startup.agent.fingerprint().to_string(),
125 provider: startup.config.provider.clone(),
126 model: startup.config.model.clone(),
127 },
128 startup.cwd.clone(),
129 &prompt_text,
130 true,
131 None,
132 )
133 })
134 .transpose()?;
135 let result = run_session(prompt_text, &startup, reporter.as_mut(), None).await;
136 if let Some(reporter) = reporter.as_mut() {
137 reporter.finish(&result);
138 }
139 let answer = result?;
140 let mut stdout = io::stdout().lock();
141 if reporter.is_some() {
142 writeln!(stdout, "\n[subagent run complete]")?;
144 } else {
145 writeln!(stdout, "{}", answer.text())?;
146 }
147 stdout.flush()?;
148 Ok(())
149}
150
151pub(crate) async fn run_session(
152 prompt_text: String,
153 startup: &Startup<'_>,
154 reporter: Option<&mut RunReporter>,
155 cancellation: Option<rho_tools::cancellation::RunCancellation>,
156) -> anyhow::Result<rho_sdk::RunOutcome> {
157 let sdk_options = SdkBootstrapOptions::from_config(startup.config, &startup.cwd)?;
158 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
159 Arc::new(OsCredentialStore),
160 );
161 let provider = build_automation_provider(sdk_options.provider, &credentials)?;
162 let mut capabilities = startup.agent.capabilities().clone();
163 if startup.no_subagents {
164 capabilities.remove(&ToolCapability::Agent);
165 capabilities.remove(&ToolCapability::Agents);
166 }
167 let launch_delegation_enabled = capabilities.contains(&ToolCapability::Agent);
168 let delegation_enabled =
169 launch_delegation_enabled || capabilities.contains(&ToolCapability::Agents);
170 let tool_set = if startup.no_tools {
171 AppToolSet::disabled()
172 } else {
173 let mut options = ToolSetOptions::new(capabilities);
174 if delegation_enabled {
175 options = options.delegation(DelegationConfig::new(
176 startup.cwd.clone(),
177 startup.config_path.clone(),
178 BackgroundSubagents::Disabled,
179 ));
180 }
181 AppToolSet::new(startup.config, startup.diagnostics.clone(), options)
182 };
183 let tool_specs = tool_set.specs();
184 let system_prompt = if startup.no_system_prompt {
185 startup.diagnostics.update_prompt_sources(Vec::new());
186 SystemPrompt::None
187 } else {
188 let mut text = match startup.agent.prompt() {
189 PromptPolicy::Replace(text) => text.clone(),
190 PromptPolicy::Extend(extra) => {
191 let built = prompt::system_prompt(&tool_specs, &startup.cwd);
192 startup.diagnostics.update_prompt_sources(built.sources);
193 let mut text = built.text;
194 if !launch_delegation_enabled {
195 prompt::append_subagents_disabled_instruction(&mut text);
196 }
197 if !extra.is_empty() {
198 text.push_str("\n\n# Agent instructions\n\n");
199 text.push_str(extra);
200 }
201 text
202 }
203 };
204 if text.is_empty() {
205 text = "You are a coding agent.".into();
206 }
207 SystemPrompt::Custom(text)
208 };
209 startup.diagnostics.update_tools(&tool_specs);
210
211 let workspace = Workspace::new(&sdk_options.workspace.root)?;
212 let context_window = configured_context_window(startup.config);
213 let compaction = sdk_options.runtime.compaction.clone();
214 startup.diagnostics.update_compaction_config(&compaction);
215 let usage_recording = crate::usage::default_recording().await;
216 let runtime = build_runtime(RuntimeBuildOptions {
217 provider,
218 tools: tool_set.tools(),
219 workspace,
220 workspace_policy: AppPolicy::for_mode(startup.config.permission_mode),
221 approval_handler: None,
222 system_prompt,
223 reasoning: sdk_options.runtime.reasoning,
224 compaction,
225 context_window,
226 usage_purpose: startup.usage_purpose,
227 usage_parent_session_id: startup.parent_session_id.clone(),
228 usage_recording,
229 })?;
230 let session = runtime.session(SessionOptions::default()).await?;
231 if let Some(manager) = tool_set.subagents() {
232 manager.set_session(session.id().to_string());
233 }
234
235 startup
236 .herdr
237 .report_state(HerdrState::Working, None, None)
238 .await;
239 let result = complete_run(&session, prompt_text, reporter, cancellation).await;
240
241 runtime.shutdown();
242 tool_set.shutdown().await;
243 startup
244 .herdr
245 .report_state(HerdrState::Idle, None, None)
246 .await;
247 startup.herdr.release().await;
248
249 result
250}
251
252async fn complete_run(
253 session: &rho_sdk::Session,
254 prompt_text: String,
255 reporter: Option<&mut RunReporter>,
256 external_cancellation: Option<rho_tools::cancellation::RunCancellation>,
257) -> anyhow::Result<rho_sdk::RunOutcome> {
258 let mut run = session.start(UserInput::text(prompt_text)).await?;
259 let cancellation = run.cancellation_handle();
260 let external_cancellation = external_cancellation.unwrap_or_default();
261 tokio::select! {
262 outcome = drive_headless_run(&mut run, reporter) => outcome,
263 signal = shutdown_signal() => {
264 let signal = signal?;
265 cancellation.cancel();
266 let _ = run.outcome().await;
267 Err(AutomationInterrupted::new(signal).into())
268 }
269 () = external_cancellation.cancelled() => {
270 cancellation.cancel();
271 let _ = run.outcome().await;
272 Err(SubagentCancelled.into())
273 }
274 }
275}
276
277async fn drive_headless_run(
282 run: &mut rho_sdk::Run,
283 mut reporter: Option<&mut RunReporter>,
284) -> anyhow::Result<rho_sdk::RunOutcome> {
285 let mut heartbeat = tokio::time::interval(REPORT_HEARTBEAT);
286 heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
287 loop {
288 let event = tokio::select! {
289 event = run.next_event() => event,
290 _ = heartbeat.tick(), if reporter.is_some() => {
291 if let Some(reporter) = reporter.as_deref_mut() {
292 reporter.write();
293 }
294 continue;
295 }
296 };
297 let Some(event) = event else {
298 break;
299 };
300 if let Some(reporter) = reporter.as_deref_mut() {
301 reporter.on_event(&event);
302 }
303 if let rho_sdk::RunEvent::HostInputRequested { request } = event {
304 run.cancel();
305 let _ = run.outcome().await;
306 anyhow::bail!(
307 "rho run cannot answer host input request '{}' ({}); run without tools that require interactive input",
308 request.id(),
309 request.title(),
310 );
311 }
312 }
313 Ok(run.outcome().await?)
314}
315
316pub(crate) struct RunArtifactIdentity {
317 pub(crate) agent_id: String,
318 pub(crate) agent_fingerprint: String,
319 pub(crate) provider: String,
320 pub(crate) model: String,
321}
322
323pub(crate) struct RunReporter {
326 path: PathBuf,
327 status: RunStatus,
328 attachment: Option<AttachmentWriter>,
329 stream_output: bool,
330 status_tx: Option<tokio::sync::watch::Sender<RunStatus>>,
331 last_write: std::time::Instant,
332}
333
334const REPORT_THROTTLE: std::time::Duration = std::time::Duration::from_secs(2);
336const REPORT_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(10);
338const LAST_TEXT_BYTES: usize = 400;
339
340impl RunReporter {
341 pub(crate) fn new(
342 path: PathBuf,
343 identity: RunArtifactIdentity,
344 cwd: PathBuf,
345 prompt: &str,
346 stream_output: bool,
347 status_tx: Option<tokio::sync::watch::Sender<RunStatus>>,
348 ) -> anyhow::Result<Self> {
349 let status = RunStatus {
350 state: RunState::Starting,
351 agent_id: Some(identity.agent_id),
352 agent_fingerprint: Some(identity.agent_fingerprint),
353 provider: Some(identity.provider),
354 model: Some(identity.model),
355 ..RunStatus::default()
356 };
357 subagent::write_status(&path, &status)?;
358 let attachment = match AttachmentWriter::new(&path, cwd, prompt) {
359 Ok(attachment) => Some(attachment),
360 Err(error) => {
361 let mut status = status;
362 status.attachment_error = Some(format!("could not record attach output: {error}"));
363 subagent::write_status(&path, &status)?;
364 return Ok(Self {
365 path,
366 status,
367 attachment: None,
368 stream_output,
369 status_tx,
370 last_write: std::time::Instant::now(),
371 });
372 }
373 };
374 Ok(Self {
375 path,
376 status,
377 attachment,
378 stream_output,
379 status_tx,
380 last_write: std::time::Instant::now(),
381 })
382 }
383
384 fn on_event(&mut self, event: &rho_sdk::RunEvent) {
385 use rho_sdk::RunEvent;
386
387 if let Some(attachment) = self.attachment.as_mut() {
388 if let Err(error) = attachment.on_event(event) {
389 self.status.attachment_error =
390 Some(format!("could not record attach output: {error}"));
391 self.attachment = None;
392 self.write();
393 }
394 }
395 match event {
396 RunEvent::StepStarted { step } => {
397 self.status.state = RunState::Running;
398 self.status.turns = *step as u64;
399 self.write();
400 }
401 RunEvent::ToolStarted { name, .. } => {
402 self.status.last_activity = Some(format!("tool: {name}"));
403 self.stream(&format!("\n[tool] {name}\n"));
404 self.write();
405 }
406 RunEvent::AssistantTextDelta { text } => {
407 self.status.last_activity = Some("assistant text".into());
408 append_tail(
409 self.status.last_text.get_or_insert_with(String::new),
410 text,
411 LAST_TEXT_BYTES,
412 );
413 self.stream(text);
414 self.write_throttled();
415 }
416 RunEvent::ProviderStreamReset { .. } => {
417 self.status.last_activity = Some("retrying provider response".into());
418 self.status.last_text = None;
419 self.stream("\n[provider response discarded; retrying]\n");
420 self.write();
421 }
422 RunEvent::UsageUpdated { usage } => {
423 self.status.input_tokens = usage.total_input_tokens().unwrap_or(0);
424 self.status.output_tokens = usage.output_tokens.unwrap_or(0);
425 }
426 _ => {}
427 }
428 }
429
430 pub(crate) fn finish(&mut self, result: &anyhow::Result<rho_sdk::RunOutcome>) {
431 match result {
432 Ok(outcome) => {
433 self.status.state = RunState::Ok;
434 self.status.result = Some(outcome.text().to_string());
435 let usage = outcome.usage();
436 self.status.input_tokens = usage.total_input_tokens().unwrap_or(0);
437 self.status.output_tokens = usage.output_tokens.unwrap_or(0);
438 }
439 Err(error)
440 if error.is::<AutomationInterrupted>() || error.is::<SubagentCancelled>() =>
441 {
442 self.status.state = RunState::Stopped;
443 self.status.result = self
444 .status
445 .last_text
446 .as_ref()
447 .map(|text| format!("(partial, stopped before finishing)\n{text}"));
448 }
449 Err(error) => {
450 self.status.state = RunState::Error;
451 self.status.error = Some(format!("{error:#}"));
452 }
453 }
454 self.write();
455 }
456
457 fn stream(&self, text: &str) {
458 if !self.stream_output {
459 return;
460 }
461 let mut stdout = io::stdout().lock();
462 let _ = stdout.write_all(text.as_bytes());
463 let _ = stdout.flush();
464 }
465
466 fn write_throttled(&mut self) {
467 if self.last_write.elapsed() >= REPORT_THROTTLE {
468 self.write();
469 }
470 }
471
472 fn write(&mut self) {
473 self.last_write = std::time::Instant::now();
474 if let Some(status_tx) = &self.status_tx {
475 status_tx.send_replace(self.status.clone());
476 }
477 let _ = subagent::write_status(&self.path, &self.status);
478 }
479}
480
481fn append_tail(buffer: &mut String, text: &str, max: usize) {
483 buffer.push_str(text);
484 if buffer.len() > max {
485 let cut = buffer.len() - max;
486 let boundary = (cut..buffer.len())
487 .find(|index| buffer.is_char_boundary(*index))
488 .unwrap_or(buffer.len());
489 buffer.drain(..boundary);
490 }
491}
492
493#[cfg(unix)]
494async fn shutdown_signal() -> io::Result<ShutdownSignal> {
495 use tokio::signal::unix::{signal, SignalKind};
496
497 let mut interrupt = signal(SignalKind::interrupt())?;
498 let mut terminate = signal(SignalKind::terminate())?;
499 tokio::select! {
500 _ = interrupt.recv() => Ok(ShutdownSignal::Interrupt),
501 _ = terminate.recv() => Ok(ShutdownSignal::Terminate),
502 }
503}
504
505#[cfg(not(unix))]
506async fn shutdown_signal() -> io::Result<ShutdownSignal> {
507 tokio::signal::ctrl_c().await?;
508 Ok(ShutdownSignal::Interrupt)
509}
510
511fn prompt_from_stdin(parts: Vec<String>, read_stdin: bool) -> anyhow::Result<String> {
512 prompt_from_reader(parts, read_stdin, &mut io::stdin())
513}
514
515fn prompt_from_reader(
516 parts: Vec<String>,
517 read_stdin: bool,
518 stdin: &mut impl Read,
519) -> anyhow::Result<String> {
520 let mut chunks = Vec::new();
521 let inline = parts.join(" ").trim().to_string();
522 if !inline.is_empty() {
523 chunks.push(inline);
524 }
525 if read_stdin {
526 let mut buffer = String::new();
527 stdin.read_to_string(&mut buffer)?;
528 let buffer = buffer.trim().to_string();
529 if !buffer.is_empty() {
530 chunks.push(buffer);
531 }
532 }
533
534 let prompt = chunks.join("\n\n");
535 if prompt.is_empty() {
536 anyhow::bail!("rho run requires a prompt argument or --stdin");
537 }
538 Ok(prompt)
539}
540
541#[cfg(test)]
542#[path = "automation_tests.rs"]
543mod tests;