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,
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/// Error returned after an automation run handles an interrupt and completes cleanup.
32#[derive(Debug)]
33pub struct AutomationInterrupted {
34    signal: ShutdownSignal,
35}
36
37impl AutomationInterrupted {
38    fn new(signal: ShutdownSignal) -> Self {
39        Self { signal }
40    }
41
42    /// Returns the conventional process exit code for the received signal.
43    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    // The reporter exists before anything that can fail, so a parent process
111    // watching the output file always sees a terminal state — even when the
112    // run dies during startup (bad auth, broken workspace, ...).
113    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                /* stream_output */ 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        // The answer already streamed above and is in the result file.
140        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
271/// Drains run events with no interactive host attached.
272///
273/// Host input requests cannot be answered headlessly; cancel instead of
274/// leaving the requesting tool suspended until a signal arrives.
275async 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
317/// Maintains the `--output-file` status contract for subagent runs and
318/// streams progress to stdout so a watching pane shows live activity.
319pub(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
328/// Longest a status-file write is deferred while text streams.
329const REPORT_THROTTLE: std::time::Duration = std::time::Duration::from_secs(2);
330/// Keeps the status file fresh while a provider or tool call emits no events.
331const 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
475/// Appends to a rolling tail buffer capped at `max` bytes.
476fn 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;