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