1use std::{
2 fmt,
3 io::{self, Read, Write},
4 num::NonZeroUsize,
5 path::PathBuf,
6 sync::Arc,
7 time::Duration,
8};
9
10use rho_sdk::{SessionOptions, UserInput};
11
12use {
13 crate::cli::{Command, OutputFormat},
14 crate::config::Config,
15 crate::credential_store::AppCredentialStore,
16 crate::diagnostics::RuntimeDiagnostics,
17 crate::herdr::{HerdrReporter, HerdrState},
18 crate::subagent::{RunState, RunStatus},
19 crate::tools::agent::BackgroundSubagents,
20 rho_providers::providers::build_automation_provider,
21};
22
23use super::{
24 agent_binding::BoundAgent,
25 automation_protocol::{write_event, JsonlAdapter, TerminalReason, WireEvent},
26 headless_run::{self, HeadlessRunDeps, HostInputResponder},
27 policy::AppPolicy,
28 runtime_builder::{
29 build_runtime_with_max_steps, configured_context_window, RuntimeBuildOptions,
30 },
31 sdk_config::SdkBootstrapOptions,
32 tools_prompt::{assemble_tools_and_prompt, ToolsAndPrompt, ToolsAndPromptOptions},
33};
34
35#[derive(Debug)]
37pub struct AutomationExit {
38 code: u8,
39 reason: TerminalReason,
40 message: String,
41}
42
43impl AutomationExit {
44 pub(super) fn new(code: u8, reason: TerminalReason, message: impl Into<String>) -> Self {
45 Self {
46 code,
47 reason,
48 message: message.into(),
49 }
50 }
51
52 pub fn exit_code(&self) -> u8 {
54 self.code
55 }
56
57 fn reason(&self) -> TerminalReason {
58 self.reason
59 }
60}
61
62impl fmt::Display for AutomationExit {
63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64 formatter.write_str(&self.message)
65 }
66}
67
68impl std::error::Error for AutomationExit {}
69
70#[derive(Debug)]
72pub struct AutomationInterrupted {
73 signal: ShutdownSignal,
74}
75
76impl AutomationInterrupted {
77 fn new(signal: ShutdownSignal) -> Self {
78 Self { signal }
79 }
80
81 pub fn exit_code(&self) -> u8 {
83 self.signal.exit_code()
84 }
85}
86
87impl fmt::Display for AutomationInterrupted {
88 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89 write!(formatter, "rho run interrupted by {}", self.signal)
90 }
91}
92
93impl std::error::Error for AutomationInterrupted {}
94
95#[derive(Clone, Copy, Debug)]
96enum ShutdownSignal {
97 Interrupt,
98 Terminate,
99}
100
101impl ShutdownSignal {
102 fn exit_code(self) -> u8 {
103 match self {
104 Self::Interrupt => 130,
105 Self::Terminate => 143,
106 }
107 }
108}
109
110impl fmt::Display for ShutdownSignal {
111 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::Interrupt => formatter.write_str("SIGINT"),
114 Self::Terminate => formatter.write_str("SIGTERM"),
115 }
116 }
117}
118
119#[derive(Debug)]
120struct SubagentCancelled;
121
122impl fmt::Display for SubagentCancelled {
123 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124 formatter.write_str("subagent cancellation requested")
125 }
126}
127
128impl std::error::Error for SubagentCancelled {}
129
130pub(super) struct Startup<'a> {
131 pub config: &'a Config,
132 pub config_path: PathBuf,
133 pub cwd: PathBuf,
134 pub no_system_prompt: bool,
135 pub no_tools: bool,
136 pub no_subagents: bool,
137 pub usage_purpose: &'static str,
138 pub parent_session_id: Option<rho_sdk::SessionId>,
139 pub agent: BoundAgent,
140 pub output_file: Option<PathBuf>,
141 pub output: OutputFormat,
142 pub max_steps: Option<NonZeroUsize>,
143 pub timeout: Option<Duration>,
144 pub diagnostics: RuntimeDiagnostics,
145 pub herdr: HerdrReporter,
146 pub host_input: Option<Arc<dyn HostInputResponder>>,
147 pub approval_session: Option<rho_sdk::ApprovalSession>,
148 pub hook_host_labels: rho_sdk::hooks::HookHostLabels,
149}
150
151pub(super) fn prompt_for_command(command: &Option<Command>) -> anyhow::Result<Option<String>> {
152 match command {
153 Some(Command::Run { prompt, stdin, .. }) => {
154 prompt_from_stdin(prompt.clone(), *stdin).map(Some)
155 }
156 Some(
157 Command::Attach { .. }
158 | Command::Login { .. }
159 | Command::CredentialStore { .. }
160 | Command::Sessions { .. }
161 | Command::Mcp { .. }
162 | Command::Plugins { .. }
163 | Command::Workflow { .. }
164 | Command::WorkflowPlannerWorker
165 | Command::Update,
166 )
167 | None => Ok(None),
168 }
169}
170
171pub(super) fn emit_startup_failure(message: impl Into<String>) -> anyhow::Result<()> {
172 let mut adapter = JsonlAdapter::new();
173 let event = adapter.failed(TerminalReason::ConfigurationError, message.into(), None);
174 emit(event)
175}
176
177pub(super) async fn run(prompt_text: String, startup: Startup<'_>) -> anyhow::Result<()> {
178 let mut jsonl = (startup.output == OutputFormat::Jsonl).then(JsonlAdapter::new);
179 let deadline = startup
180 .timeout
181 .map(|timeout| tokio::time::Instant::now() + timeout);
182 let reporter_result = startup
185 .output_file
186 .as_ref()
187 .map(|path| {
188 RunReporter::new(
189 path.clone(),
190 RunArtifactIdentity {
191 agent_id: startup.agent.id().to_string(),
192 agent_fingerprint: startup.agent.fingerprint().to_string(),
193 provider: startup.config.provider.clone(),
194 model: startup.config.model.clone(),
195 runtime: crate::agent::AgentRuntime::Rho,
196 },
197 startup.cwd.clone(),
198 &prompt_text,
199 startup.output == OutputFormat::Text,
200 None,
201 )
202 })
203 .transpose();
204 let mut reporter = match reporter_result {
205 Ok(reporter) => reporter,
206 Err(error) => {
207 emit_failure(&mut jsonl, TerminalReason::OutputError, &error)?;
208 return Err(
209 AutomationExit::new(1, TerminalReason::OutputError, error.to_string()).into(),
210 );
211 }
212 };
213
214 let cancellation = rho_tools::cancellation::RunCancellation::default();
215 let (result, timed_out) = if let Some(deadline) = deadline {
216 let future = run_session_with_output(
217 prompt_text,
218 &startup,
219 reporter.as_mut(),
220 Some(cancellation.clone()),
221 jsonl.as_mut(),
222 );
223 tokio::pin!(future);
224 tokio::select! {
225 result = &mut future => (result, false),
226 () = tokio::time::sleep_until(deadline) => {
227 cancellation.cancel();
228 (future.await, true)
229 }
230 }
231 } else {
232 (
233 run_session_with_output(
234 prompt_text,
235 &startup,
236 reporter.as_mut(),
237 None,
238 jsonl.as_mut(),
239 )
240 .await,
241 false,
242 )
243 };
244 let terminal = classify_run_terminal(result, timed_out);
245 if let Some(reporter) = reporter.as_mut() {
246 reporter.finish_terminal(&terminal);
247 }
248 emit_and_exit_terminal(terminal, &mut jsonl, reporter.is_some())
249}
250
251fn write_text_answer(answer: &rho_sdk::RunOutcome, has_reporter: bool) -> anyhow::Result<()> {
252 let result = (|| -> io::Result<()> {
253 let mut stdout = io::stdout().lock();
254 if has_reporter {
255 writeln!(stdout, "\n[subagent run complete]")?;
256 } else {
257 writeln!(stdout, "{}", answer.text())?;
258 }
259 stdout.flush()
260 })();
261 result.map_err(|error| {
262 AutomationExit::new(
263 1,
264 TerminalReason::OutputError,
265 format!("could not write output: {error}"),
266 )
267 .into()
268 })
269}
270
271pub(super) fn emit(event: WireEvent) -> anyhow::Result<()> {
272 let mut stdout = io::stdout().lock();
273 write_event(&mut stdout, &event).map_err(|error| {
274 AutomationExit::new(
275 1,
276 TerminalReason::OutputError,
277 format!("could not write JSONL output: {error}"),
278 )
279 .into()
280 })
281}
282
283fn emit_stopped(adapter: &mut Option<JsonlAdapter>, reason: TerminalReason) -> anyhow::Result<()> {
284 if let Some(adapter) = adapter.as_mut() {
285 let text = adapter.partial_text();
286 let event = adapter.stopped(reason, text);
287 emit(event)?;
288 }
289 Ok(())
290}
291
292fn emit_failure(
293 adapter: &mut Option<JsonlAdapter>,
294 reason: TerminalReason,
295 error: &anyhow::Error,
296) -> anyhow::Result<()> {
297 if let Some(adapter) = adapter.as_mut() {
298 let text = adapter.partial_text();
299 let message = terminal_error_message(reason, error);
300 let event = adapter.failed(reason, message, text);
301 emit(event)?;
302 }
303 Ok(())
304}
305
306const MAX_STEPS_MESSAGE: &str = "rho run reached its model-step limit";
307const TIMEOUT_MESSAGE: &str = "rho run timed out";
308
309enum RunTerminal {
312 Completed(rho_sdk::RunOutcome),
313 MaxSteps(rho_sdk::RunOutcome),
314 Timeout,
315 Failed(anyhow::Error),
316}
317
318fn classify_run_terminal(
319 result: anyhow::Result<rho_sdk::RunOutcome>,
320 timed_out: bool,
321) -> RunTerminal {
322 if timed_out {
323 return RunTerminal::Timeout;
324 }
325 match result {
326 Ok(answer) if answer.stop_reason() == rho_sdk::StopReason::MaxSteps => {
327 RunTerminal::MaxSteps(answer)
328 }
329 Ok(answer) => RunTerminal::Completed(answer),
330 Err(error) => RunTerminal::Failed(error),
331 }
332}
333
334fn emit_and_exit_terminal(
335 terminal: RunTerminal,
336 jsonl: &mut Option<JsonlAdapter>,
337 has_reporter: bool,
338) -> anyhow::Result<()> {
339 match terminal {
340 RunTerminal::Timeout => {
341 emit_stopped(jsonl, TerminalReason::Timeout)?;
342 Err(AutomationExit::new(124, TerminalReason::Timeout, TIMEOUT_MESSAGE).into())
343 }
344 RunTerminal::MaxSteps(answer) => {
345 if let Some(adapter) = jsonl.as_mut() {
346 let text = (!answer.text().is_empty()).then(|| answer.text().into());
347 let event = adapter.stopped(TerminalReason::MaxSteps, text);
348 emit(event)?;
349 } else {
350 write_text_answer(&answer, has_reporter)?;
351 }
352 Err(AutomationExit::new(124, TerminalReason::MaxSteps, MAX_STEPS_MESSAGE).into())
353 }
354 RunTerminal::Completed(answer) => {
355 if let Some(adapter) = jsonl.as_mut() {
356 let event = adapter.completed(answer.text().into());
357 emit(event)?;
358 } else {
359 write_text_answer(&answer, has_reporter)?;
360 }
361 Ok(())
362 }
363 RunTerminal::Failed(error) => {
364 let (reason, code) = classify_error(&error);
365 if reason == TerminalReason::Interrupted {
366 emit_stopped(jsonl, reason)?;
367 } else if reason != TerminalReason::OutputError {
368 emit_failure(jsonl, reason, &error)?;
369 }
370 let message = terminal_error_message(reason, &error);
371 if error.is::<AutomationInterrupted>() {
372 return Err(error);
373 }
374 Err(AutomationExit::new(code, reason, message).into())
375 }
376 }
377}
378
379fn terminal_error_message(reason: TerminalReason, error: &anyhow::Error) -> String {
385 match reason {
386 TerminalReason::Authentication => "authentication failed".to_string(),
387 TerminalReason::ProviderError
388 | TerminalReason::ToolHostError
389 | TerminalReason::ConfigurationError
390 | TerminalReason::OutputError
391 | TerminalReason::OtherError
392 | TerminalReason::Interrupted
393 | TerminalReason::MaxSteps
394 | TerminalReason::Timeout
395 | TerminalReason::Completed => error.to_string(),
396 }
397}
398
399fn classify_error(error: &anyhow::Error) -> (TerminalReason, u8) {
400 if let Some(interrupted) = error.downcast_ref::<AutomationInterrupted>() {
401 return (TerminalReason::Interrupted, interrupted.exit_code());
402 }
403 if let Some(exit) = error.downcast_ref::<AutomationExit>() {
404 return (exit.reason(), exit.exit_code());
405 }
406 for cause in error.chain() {
407 if let Some(error) = cause.downcast_ref::<rho_sdk::Error>() {
408 return match error {
409 rho_sdk::Error::Authentication { .. } => (TerminalReason::Authentication, 1),
410 rho_sdk::Error::Provider(provider)
411 if provider.kind() == rho_sdk::ProviderErrorKind::Authentication =>
412 {
413 (TerminalReason::Authentication, 1)
414 }
415 rho_sdk::Error::Provider(_) => (TerminalReason::ProviderError, 1),
416 rho_sdk::Error::Tool(_) => (TerminalReason::ToolHostError, 1),
417 rho_sdk::Error::InvalidConfiguration { .. } => {
418 (TerminalReason::ConfigurationError, 2)
419 }
420 _ => (TerminalReason::OtherError, 1),
421 };
422 }
423 if let Some(error) = cause.downcast_ref::<rho_providers::model::ModelError>() {
424 use rho_providers::model::ModelError;
425 return match error {
426 ModelError::MissingCredentials(_) | ModelError::Credentials(_) => {
427 (TerminalReason::Authentication, 1)
428 }
429 ModelError::UnsupportedReasoning { .. } | ModelError::UnsupportedProvider(_) => {
430 (TerminalReason::ConfigurationError, 2)
431 }
432 _ => (TerminalReason::ProviderError, 1),
433 };
434 }
435 }
436 (TerminalReason::OtherError, 1)
437}
438
439pub(crate) async fn run_session(
440 prompt_text: String,
441 startup: &Startup<'_>,
442 reporter: Option<&mut RunReporter>,
443 cancellation: Option<rho_tools::cancellation::RunCancellation>,
444) -> anyhow::Result<rho_sdk::RunOutcome> {
445 run_session_with_output(prompt_text, startup, reporter, cancellation, None).await
446}
447
448async fn run_session_with_output(
449 prompt_text: String,
450 startup: &Startup<'_>,
451 reporter: Option<&mut RunReporter>,
452 cancellation: Option<rho_tools::cancellation::RunCancellation>,
453 mut jsonl: Option<&mut JsonlAdapter>,
454) -> anyhow::Result<rho_sdk::RunOutcome> {
455 let sdk_options = SdkBootstrapOptions::from_config(startup.config, &startup.cwd)?;
456 let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
457 Arc::new(AppCredentialStore),
458 );
459 let provider = build_automation_provider(sdk_options.provider, &credentials)?;
460 let workspace_root = sdk_options.workspace.root.clone();
461 let workspace = sdk_options.workspace.build_workspace()?;
462 let ToolsAndPrompt {
463 tools: tool_set,
464 system_prompt,
465 ..
466 } = assemble_tools_and_prompt(ToolsAndPromptOptions {
467 config: startup.config,
468 config_path: startup.config_path.clone(),
469 cwd: &startup.cwd,
470 no_system_prompt: startup.no_system_prompt,
471 no_tools: startup.no_tools,
472 no_subagents: startup.no_subagents,
473 questionnaire_enabled: true,
475 mcp_elicitation: match startup.host_input {
479 Some(_) => crate::tools::mcp::McpElicitationSupport::Available,
480 None => crate::tools::mcp::McpElicitationSupport::Unavailable,
481 },
482 mcp_sampling: crate::app::tools_prompt::McpSamplingSupport::Unavailable,
485 background_subagents: BackgroundSubagents::Disabled,
486 diagnostics: &startup.diagnostics,
487 agent: &startup.agent,
488 })
489 .await?;
490
491 let context_window = configured_context_window(startup.config);
492 let compaction = sdk_options.runtime.compaction.clone();
493 startup.diagnostics.update_compaction_config(&compaction);
494 let usage_recording = crate::usage::default_recording().await;
495 let hooks = crate::hooks::start_for_cwd(&workspace_root);
496 if let Some(hooks) = hooks.as_ref() {
497 startup.diagnostics.attach_hooks(hooks);
498 }
499 let startup_result: anyhow::Result<_> = async {
500 let runtime = build_runtime_with_max_steps(
501 RuntimeBuildOptions {
502 provider,
503 tools: tool_set.tools(),
504 workspace,
505 workspace_policy: AppPolicy::for_mode(startup.config.permission_mode),
506 approval_session: startup.approval_session.clone(),
507 system_prompt,
508 reasoning: sdk_options.runtime.reasoning,
509 service_tier: sdk_options.runtime.service_tier,
510 compaction,
511 context_window,
512 usage_purpose: startup.usage_purpose,
513 usage_parent_session_id: startup.parent_session_id.clone(),
514 usage_recording,
515 hook_host_labels: startup.hook_host_labels.clone(),
516 hooks: hooks.as_ref(),
517 },
518 startup.max_steps,
519 )?;
520 let session = match runtime.session(SessionOptions::default()).await {
521 Ok(session) => session,
522 Err(error) => {
523 runtime.shutdown();
524 return Err(error.into());
525 }
526 };
527 anyhow::Ok((runtime, session))
528 }
529 .await;
530 let (runtime, session) = match startup_result {
531 Ok(startup) => startup,
532 Err(error) => {
533 if let Some(hooks) = hooks {
534 hooks.shutdown(crate::hooks::DRAIN_GRACE).await;
535 }
536 tool_set.shutdown().await;
537 return Err(error);
538 }
539 };
540 if let Some(advisor) = tool_set.advisor() {
541 advisor.bind_session(session.clone());
542 }
543 if let Some(adapter) = jsonl.as_deref_mut() {
544 adapter.set_run_context(session.id(), &workspace_root);
545 }
546 startup
547 .herdr
548 .report_state(HerdrState::Working, None, None)
549 .await;
550 let result = complete_run(
551 &session,
552 prompt_text,
553 HeadlessRunDeps {
554 reporter,
555 external_cancellation: cancellation,
556 jsonl,
557 host_input: startup.host_input.as_deref(),
558 },
559 )
560 .await;
561
562 let session_hooks = runtime.hooks();
563 let session_id = session.id().clone();
564 match &result {
565 Ok(_) => {
566 session_hooks.session_completed(&session_id, 1)
567 }
568 Err(error) => session_hooks.session_failed(
569 &session_id,
570 rho_sdk::hooks::HookSessionFailureKind::RunFailed,
571 &error.to_string(),
572 ),
573 }
574 runtime.shutdown();
575 drop(session);
576 drop(runtime);
577 if let Some(hooks) = hooks {
578 hooks.shutdown(crate::hooks::DRAIN_GRACE).await;
579 }
580 tool_set.shutdown().await;
581 startup
582 .herdr
583 .report_state(HerdrState::Idle, None, None)
584 .await;
585 startup.herdr.release().await;
586
587 result
588}
589
590async fn complete_run(
591 session: &rho_sdk::Session,
592 prompt_text: String,
593 dependencies: HeadlessRunDeps<'_>,
594) -> anyhow::Result<rho_sdk::RunOutcome> {
595 let HeadlessRunDeps {
596 reporter,
597 external_cancellation,
598 jsonl,
599 host_input,
600 } = dependencies;
601 let mut run = session.start(UserInput::text(prompt_text)).await?;
602 let cancellation = run.cancellation_handle();
603 let external_cancellation = external_cancellation.unwrap_or_default();
604 tokio::select! {
605 outcome = headless_run::drive(&mut run, reporter, jsonl, host_input) => outcome,
606 signal = shutdown_signal() => {
607 let signal = signal?;
608 cancellation.cancel();
609 let _ = run.outcome().await;
610 Err(AutomationInterrupted::new(signal).into())
611 }
612 () = external_cancellation.cancelled() => {
613 cancellation.cancel();
614 let _ = run.outcome().await;
615 Err(SubagentCancelled.into())
616 }
617 }
618}
619
620pub(crate) use crate::run_artifacts::RunArtifactIdentity;
621
622pub(crate) struct RunReporter {
625 sink: crate::run_artifacts::RunArtifactSink,
626 adapter: crate::tui::event_adapter::SdkEventAdapter,
627 stream_output: bool,
628}
629
630impl RunReporter {
631 pub(crate) fn new(
632 path: PathBuf,
633 identity: RunArtifactIdentity,
634 cwd: PathBuf,
635 prompt: &str,
636 stream_output: bool,
637 status_tx: Option<tokio::sync::watch::Sender<RunStatus>>,
638 ) -> anyhow::Result<Self> {
639 let sink = crate::run_artifacts::RunArtifactSink::open(path, &identity, prompt, status_tx)?;
640 Ok(Self {
641 sink,
642 adapter: crate::tui::event_adapter::SdkEventAdapter::new(cwd),
643 stream_output,
644 })
645 }
646
647 pub(crate) fn continue_from(
649 path: PathBuf,
650 started_status: RunStatus,
651 cwd: PathBuf,
652 prompt: &str,
653 stream_output: bool,
654 status_tx: Option<tokio::sync::watch::Sender<RunStatus>>,
655 ) -> anyhow::Result<Self> {
656 let sink = crate::run_artifacts::RunArtifactSink::continue_from(
657 path,
658 started_status,
659 prompt,
660 status_tx,
661 )?;
662 Ok(Self {
663 sink,
664 adapter: crate::tui::event_adapter::SdkEventAdapter::new(cwd),
665 stream_output,
666 })
667 }
668
669 pub(super) fn on_event(&mut self, event: &rho_sdk::RunEvent) {
670 use rho_sdk::RunEvent;
671
672 let attachments = crate::tui::translate_run_event(&mut self.adapter, event);
673 for attachment in attachments {
674 if let crate::run_artifacts::AttachmentEvent::AssistantTextDelta(text) = &attachment {
677 if !text.is_empty() {
678 self.sink.append_last_text(text);
679 }
680 }
681 self.sink.record_attachment(attachment);
682 }
683 match event {
684 RunEvent::StepStarted { step, .. } => {
685 self.sink.status.state = RunState::Running;
686 self.sink.status.turns = *step as u64;
687 self.sink.publish();
688 }
689 RunEvent::ToolStarted { name, .. } => {
690 self.sink.status.last_activity = Some(format!("tool: {name}"));
691 self.stream(&format!("\n[tool] {name}\n"));
692 self.sink.publish();
693 }
694 RunEvent::HostInputRequested { request }
695 | RunEvent::ToolHostInputRequested { request, .. } => {
696 self.sink.status.last_activity =
697 Some(format!("waiting for questionnaire: {}", request.title()));
698 self.sink.publish();
699 }
700 RunEvent::AssistantTextDelta { text } => {
701 self.sink.status.last_activity = Some("assistant text".into());
702 self.stream(text);
703 }
705 RunEvent::ProviderStreamReset { .. } => {
706 self.sink.status.last_activity = Some("retrying provider response".into());
707 self.sink.status.last_text = None;
708 self.stream("\n[provider response discarded; retrying]\n");
709 self.sink.publish();
710 }
711 RunEvent::UsageUpdated { usage } => {
712 self.sink.status.input_tokens = usage.total_input_tokens();
713 self.sink.status.output_tokens = usage.output_tokens;
714 }
715 _ => {}
716 }
717 }
718
719 #[cfg(test)]
720 pub(crate) fn status(&self) -> &RunStatus {
721 &self.sink.status
722 }
723
724 pub(super) fn write(&mut self) {
725 self.sink.publish();
726 }
727
728 pub(crate) fn finish(&mut self, result: &anyhow::Result<rho_sdk::RunOutcome>) {
729 match result {
730 Ok(outcome) => {
731 let usage = outcome.usage();
732 self.sink.status.input_tokens = usage.total_input_tokens();
733 self.sink.status.output_tokens = usage.output_tokens;
734 self.sink.finish_ok(Some(outcome.text().to_string()));
735 }
736 Err(error)
737 if error.is::<AutomationInterrupted>()
738 || error.downcast_ref::<AutomationExit>().is_some_and(|exit| {
739 matches!(
740 exit.reason(),
741 TerminalReason::MaxSteps | TerminalReason::Timeout
742 )
743 })
744 || error.is::<SubagentCancelled>() =>
745 {
746 self.sink.finish_stopped("stopped");
747 }
748 Err(error) => {
749 self.sink.finish_error(format!("{error:#}"));
750 }
751 }
752 }
753
754 fn finish_terminal(&mut self, terminal: &RunTerminal) {
755 match terminal {
756 RunTerminal::Completed(outcome) => {
757 let usage = outcome.usage();
758 self.sink.status.input_tokens = usage.total_input_tokens();
759 self.sink.status.output_tokens = usage.output_tokens;
760 self.sink.finish_ok(Some(outcome.text().to_string()));
761 }
762 RunTerminal::MaxSteps(_) | RunTerminal::Timeout => {
763 self.sink.finish_stopped("stopped");
764 }
765 RunTerminal::Failed(error)
766 if error.is::<AutomationInterrupted>() || error.is::<SubagentCancelled>() =>
767 {
768 self.sink.finish_stopped("stopped");
769 }
770 RunTerminal::Failed(error) => {
771 self.sink.finish_error(format!("{error:#}"));
772 }
773 }
774 }
775
776 fn stream(&self, text: &str) {
777 if !self.stream_output {
778 return;
779 }
780 let mut stdout = io::stdout().lock();
781 let _ = stdout.write_all(text.as_bytes());
782 let _ = stdout.flush();
783 }
784}
785
786#[cfg(unix)]
787async fn shutdown_signal() -> io::Result<ShutdownSignal> {
788 use tokio::signal::unix::{signal, SignalKind};
789
790 let mut interrupt = signal(SignalKind::interrupt())?;
791 let mut terminate = signal(SignalKind::terminate())?;
792 tokio::select! {
793 _ = interrupt.recv() => Ok(ShutdownSignal::Interrupt),
794 _ = terminate.recv() => Ok(ShutdownSignal::Terminate),
795 }
796}
797
798#[cfg(not(unix))]
799async fn shutdown_signal() -> io::Result<ShutdownSignal> {
800 tokio::signal::ctrl_c().await?;
801 Ok(ShutdownSignal::Interrupt)
802}
803
804fn prompt_from_stdin(parts: Vec<String>, read_stdin: bool) -> anyhow::Result<String> {
805 if !read_stdin && crate::stdio::stdin_is_redirected() {
806 anyhow::bail!(
807 "stdin is redirected but --stdin was not set; pass --stdin to include piped input"
808 );
809 }
810 prompt_from_reader(parts, read_stdin, &mut io::stdin())
811}
812
813fn prompt_from_reader(
814 parts: Vec<String>,
815 read_stdin: bool,
816 stdin: &mut impl Read,
817) -> anyhow::Result<String> {
818 let mut chunks = Vec::new();
819 let inline = parts.join(" ").trim().to_string();
820 if !inline.is_empty() {
821 chunks.push(inline);
822 }
823 if read_stdin {
824 let mut buffer = String::new();
825 stdin.read_to_string(&mut buffer)?;
826 let buffer = buffer.trim().to_string();
827 if !buffer.is_empty() {
828 chunks.push(buffer);
829 }
830 }
831
832 let prompt = chunks.join("\n\n");
833 if prompt.is_empty() {
834 anyhow::bail!("rho run requires a prompt argument or --stdin");
835 }
836 Ok(prompt)
837}
838
839#[cfg(test)]
840#[path = "automation_tests.rs"]
841mod tests;