Skip to main content

rho_coding_agent/app/
automation.rs

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/// Error returned after an automation run handles an interrupt and completes cleanup.
35#[derive(Debug)]
36pub struct AutomationInterrupted {
37    signal: ShutdownSignal,
38}
39
40impl AutomationInterrupted {
41    fn new(signal: ShutdownSignal) -> Self {
42        Self { signal }
43    }
44
45    /// Returns the conventional process exit code for the received signal.
46    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    // The reporter exists before anything that can fail, so a parent process
114    // watching the output file always sees a terminal state — even when the
115    // run dies during startup (bad auth, broken workspace, ...).
116    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                /* stream_output */ 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        // The answer already streamed above and is in the result file.
143        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
277/// Drains run events with no interactive host attached.
278///
279/// Host input requests cannot be answered headlessly; cancel instead of
280/// leaving the requesting tool suspended until a signal arrives.
281async 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        let request = match event {
304            rho_sdk::RunEvent::HostInputRequested { request }
305            | rho_sdk::RunEvent::ToolHostInputRequested { request, .. } => Some(request),
306            _ => None,
307        };
308        if let Some(request) = request {
309            run.cancel();
310            let _ = run.outcome().await;
311            anyhow::bail!(
312                "rho run cannot answer host input request '{}' ({}); run without tools that require interactive input",
313                request.id(),
314                request.title(),
315            );
316        }
317    }
318    Ok(run.outcome().await?)
319}
320
321pub(crate) struct RunArtifactIdentity {
322    pub(crate) agent_id: String,
323    pub(crate) agent_fingerprint: String,
324    pub(crate) provider: String,
325    pub(crate) model: String,
326}
327
328/// Maintains the `--output-file` status contract for subagent runs and
329/// streams progress to stdout so a watching pane shows live activity.
330pub(crate) struct RunReporter {
331    path: PathBuf,
332    status: RunStatus,
333    attachment: Option<AttachmentWriter>,
334    stream_output: bool,
335    status_tx: Option<tokio::sync::watch::Sender<RunStatus>>,
336    last_write: std::time::Instant,
337}
338
339/// Longest a status-file write is deferred while text streams.
340const REPORT_THROTTLE: std::time::Duration = std::time::Duration::from_secs(2);
341/// Keeps the status file fresh while a provider or tool call emits no events.
342const REPORT_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(10);
343const LAST_TEXT_BYTES: usize = 400;
344
345impl RunReporter {
346    pub(crate) fn new(
347        path: PathBuf,
348        identity: RunArtifactIdentity,
349        cwd: PathBuf,
350        prompt: &str,
351        stream_output: bool,
352        status_tx: Option<tokio::sync::watch::Sender<RunStatus>>,
353    ) -> anyhow::Result<Self> {
354        let status = RunStatus {
355            state: RunState::Starting,
356            agent_id: Some(identity.agent_id),
357            agent_fingerprint: Some(identity.agent_fingerprint),
358            provider: Some(identity.provider),
359            model: Some(identity.model),
360            ..RunStatus::default()
361        };
362        subagent::write_status(&path, &status)?;
363        let attachment = match AttachmentWriter::new(&path, cwd, prompt) {
364            Ok(attachment) => Some(attachment),
365            Err(error) => {
366                let mut status = status;
367                status.attachment_error = Some(format!("could not record attach output: {error}"));
368                subagent::write_status(&path, &status)?;
369                return Ok(Self {
370                    path,
371                    status,
372                    attachment: None,
373                    stream_output,
374                    status_tx,
375                    last_write: std::time::Instant::now(),
376                });
377            }
378        };
379        Ok(Self {
380            path,
381            status,
382            attachment,
383            stream_output,
384            status_tx,
385            last_write: std::time::Instant::now(),
386        })
387    }
388
389    fn on_event(&mut self, event: &rho_sdk::RunEvent) {
390        use rho_sdk::RunEvent;
391
392        if let Some(attachment) = self.attachment.as_mut() {
393            if let Err(error) = attachment.on_event(event) {
394                self.status.attachment_error =
395                    Some(format!("could not record attach output: {error}"));
396                self.attachment = None;
397                self.write();
398            }
399        }
400        match event {
401            RunEvent::StepStarted { step } => {
402                self.status.state = RunState::Running;
403                self.status.turns = *step as u64;
404                self.write();
405            }
406            RunEvent::ToolStarted { name, .. } => {
407                self.status.last_activity = Some(format!("tool: {name}"));
408                self.stream(&format!("\n[tool] {name}\n"));
409                self.write();
410            }
411            RunEvent::AssistantTextDelta { text } => {
412                self.status.last_activity = Some("assistant text".into());
413                append_tail(
414                    self.status.last_text.get_or_insert_with(String::new),
415                    text,
416                    LAST_TEXT_BYTES,
417                );
418                self.stream(text);
419                self.write_throttled();
420            }
421            RunEvent::ProviderStreamReset { .. } => {
422                self.status.last_activity = Some("retrying provider response".into());
423                self.status.last_text = None;
424                self.stream("\n[provider response discarded; retrying]\n");
425                self.write();
426            }
427            RunEvent::UsageUpdated { usage } => {
428                self.status.input_tokens = usage.total_input_tokens().unwrap_or(0);
429                self.status.output_tokens = usage.output_tokens.unwrap_or(0);
430            }
431            _ => {}
432        }
433    }
434
435    pub(crate) fn finish(&mut self, result: &anyhow::Result<rho_sdk::RunOutcome>) {
436        match result {
437            Ok(outcome) => {
438                self.status.state = RunState::Ok;
439                self.status.result = Some(outcome.text().to_string());
440                let usage = outcome.usage();
441                self.status.input_tokens = usage.total_input_tokens().unwrap_or(0);
442                self.status.output_tokens = usage.output_tokens.unwrap_or(0);
443            }
444            Err(error)
445                if error.is::<AutomationInterrupted>() || error.is::<SubagentCancelled>() =>
446            {
447                self.status.state = RunState::Stopped;
448                self.status.result = self
449                    .status
450                    .last_text
451                    .as_ref()
452                    .map(|text| format!("(partial, stopped before finishing)\n{text}"));
453            }
454            Err(error) => {
455                self.status.state = RunState::Error;
456                self.status.error = Some(format!("{error:#}"));
457            }
458        }
459        self.write();
460    }
461
462    fn stream(&self, text: &str) {
463        if !self.stream_output {
464            return;
465        }
466        let mut stdout = io::stdout().lock();
467        let _ = stdout.write_all(text.as_bytes());
468        let _ = stdout.flush();
469    }
470
471    fn write_throttled(&mut self) {
472        if self.last_write.elapsed() >= REPORT_THROTTLE {
473            self.write();
474        }
475    }
476
477    fn write(&mut self) {
478        self.last_write = std::time::Instant::now();
479        if let Some(status_tx) = &self.status_tx {
480            status_tx.send_replace(self.status.clone());
481        }
482        let _ = subagent::write_status(&self.path, &self.status);
483    }
484}
485
486/// Appends to a rolling tail buffer capped at `max` bytes.
487fn append_tail(buffer: &mut String, text: &str, max: usize) {
488    buffer.push_str(text);
489    if buffer.len() > max {
490        let cut = buffer.len() - max;
491        let boundary = (cut..buffer.len())
492            .find(|index| buffer.is_char_boundary(*index))
493            .unwrap_or(buffer.len());
494        buffer.drain(..boundary);
495    }
496}
497
498#[cfg(unix)]
499async fn shutdown_signal() -> io::Result<ShutdownSignal> {
500    use tokio::signal::unix::{signal, SignalKind};
501
502    let mut interrupt = signal(SignalKind::interrupt())?;
503    let mut terminate = signal(SignalKind::terminate())?;
504    tokio::select! {
505        _ = interrupt.recv() => Ok(ShutdownSignal::Interrupt),
506        _ = terminate.recv() => Ok(ShutdownSignal::Terminate),
507    }
508}
509
510#[cfg(not(unix))]
511async fn shutdown_signal() -> io::Result<ShutdownSignal> {
512    tokio::signal::ctrl_c().await?;
513    Ok(ShutdownSignal::Interrupt)
514}
515
516fn prompt_from_stdin(parts: Vec<String>, read_stdin: bool) -> anyhow::Result<String> {
517    prompt_from_reader(parts, read_stdin, &mut io::stdin())
518}
519
520fn prompt_from_reader(
521    parts: Vec<String>,
522    read_stdin: bool,
523    stdin: &mut impl Read,
524) -> anyhow::Result<String> {
525    let mut chunks = Vec::new();
526    let inline = parts.join(" ").trim().to_string();
527    if !inline.is_empty() {
528        chunks.push(inline);
529    }
530    if read_stdin {
531        let mut buffer = String::new();
532        stdin.read_to_string(&mut buffer)?;
533        let buffer = buffer.trim().to_string();
534        if !buffer.is_empty() {
535            chunks.push(buffer);
536        }
537    }
538
539    let prompt = chunks.join("\n\n");
540    if prompt.is_empty() {
541        anyhow::bail!("rho run requires a prompt argument or --stdin");
542    }
543    Ok(prompt)
544}
545
546#[cfg(test)]
547#[path = "automation_tests.rs"]
548mod tests;