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