Skip to main content

ralph/
runner.rs

1//! Runner orchestration for executing tasks across supported CLIs and parsing outputs.
2//!
3//! Responsibilities:
4//! - Expose the runner orchestration API (`run_prompt`, `resume_session`) and shared types.
5//! - Delegate execution details to `runner/execution/*`.
6//! - Re-export cohesive submodules for errors, models, and settings.
7//!
8//! Does not handle:
9//! - Runner subprocess command assembly (see `runner/execution/*`).
10//! - Queue persistence or task selection.
11//!
12//! Assumptions/invariants:
13//! - Runner output is redacted before display/logging where required.
14
15mod error;
16mod execution;
17mod invoke;
18mod model;
19mod plugin_dispatch;
20mod settings;
21
22#[cfg(test)]
23mod tests;
24
25pub use error::RunnerError;
26pub(crate) use error::{
27    RetryableReason, RunnerFailureClass, runner_execution_error, runner_execution_error_with_source,
28};
29
30pub(crate) use execution::{
31    BuiltInRunnerPlugin, ResolvedRunnerCliOptions, RunnerPlugin, ctrlc_state,
32};
33
34pub(crate) use model::{
35    default_model_for_runner, parse_model, parse_reasoning_effort, resolve_model_for_runner,
36    validate_model_for_runner,
37};
38
39pub(crate) use settings::{
40    AgentSettings, PhaseSettingsMatrix, ResolvedPhaseSettings, resolve_agent_settings,
41    resolve_phase_settings_matrix,
42};
43
44// Prevent clippy --fix from removing this re-export (used by commands/run/tests.rs)
45#[allow(unused)]
46const _: () = {
47    fn _use_resolved_phase_settings(_: ResolvedPhaseSettings) {}
48};
49
50use crate::commands::run::PhaseType;
51use crate::contracts::{ClaudePermissionMode, Model, ReasoningEffort, Runner};
52use crate::plugins::registry::PluginRegistry;
53use crate::redaction::redact_text;
54use anyhow::Result;
55use std::fmt;
56use std::path::Path;
57use std::process::ExitStatus;
58use std::sync::Arc;
59use std::time::Duration;
60
61/// Callback type for streaming runner output to consumers (e.g., the macOS app).
62/// Called with each chunk of output as it's received from the runner process.
63pub type OutputHandler = Arc<Box<dyn Fn(&str) + Send + Sync>>;
64
65/// Controls whether runner output is streamed directly to the terminal.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum OutputStream {
68    /// Stream runner output to stdout/stderr as well as any output handler.
69    Terminal,
70    /// Suppress direct terminal output and only deliver output to the handler.
71    HandlerOnly,
72}
73
74impl OutputStream {
75    /// Returns true when output should be streamed to stdout/stderr.
76    pub fn streams_to_terminal(self) -> bool {
77        matches!(self, OutputStream::Terminal)
78    }
79}
80
81pub(crate) struct RunnerOutput {
82    pub status: ExitStatus,
83    pub stdout: String,
84    pub stderr: String,
85    pub session_id: Option<String>,
86}
87
88impl fmt::Display for RunnerOutput {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(
91            f,
92            "status: {}\nstdout: {}\nstderr: {}",
93            self.status,
94            redact_text(&self.stdout),
95            redact_text(&self.stderr)
96        )
97    }
98}
99
100impl fmt::Debug for RunnerOutput {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.debug_struct("RunnerOutput")
103            .field("status", &self.status)
104            .field("stdout", &redact_text(&self.stdout))
105            .field("stderr", &redact_text(&self.stderr))
106            .field("session_id", &self.session_id.as_deref())
107            .finish()
108    }
109}
110
111#[derive(Clone, Copy)]
112pub struct RunnerBinaries<'a> {
113    pub codex: &'a str,
114    pub opencode: &'a str,
115    pub gemini: &'a str,
116    pub claude: &'a str,
117    pub cursor: &'a str,
118    pub kimi: &'a str,
119    pub pi: &'a str,
120}
121
122pub(crate) fn resolve_binaries(agent: &crate::contracts::AgentConfig) -> RunnerBinaries<'_> {
123    let codex = agent.codex_bin.as_deref().unwrap_or("codex");
124    let opencode = agent.opencode_bin.as_deref().unwrap_or("opencode");
125    let gemini = agent.gemini_bin.as_deref().unwrap_or("gemini");
126    let claude = agent.claude_bin.as_deref().unwrap_or("claude");
127    let cursor = agent.cursor_bin.as_deref().unwrap_or("agent");
128    let kimi = agent.kimi_bin.as_deref().unwrap_or("kimi");
129    let pi = agent.pi_bin.as_deref().unwrap_or("pi");
130    RunnerBinaries {
131        codex,
132        opencode,
133        gemini,
134        claude,
135        cursor,
136        kimi,
137        pi,
138    }
139}
140
141pub(crate) fn extract_final_assistant_response(stdout: &str) -> Option<String> {
142    execution::extract_final_assistant_response(stdout)
143}
144
145fn runner_label(runner: Runner) -> String {
146    runner.id().to_string()
147}
148
149#[allow(clippy::too_many_arguments)]
150pub(crate) fn run_prompt(
151    runner: Runner,
152    work_dir: &Path,
153    bins: RunnerBinaries<'_>,
154    model: Model,
155    reasoning_effort: Option<ReasoningEffort>,
156    runner_cli: execution::ResolvedRunnerCliOptions,
157    prompt: &str,
158    timeout: Option<Duration>,
159    permission_mode: Option<ClaudePermissionMode>,
160    output_handler: Option<OutputHandler>,
161    output_stream: OutputStream,
162    phase_type: PhaseType,
163    session_id: Option<String>,
164    plugins: Option<&PluginRegistry>,
165) -> Result<RunnerOutput, RunnerError> {
166    invoke::dispatch(
167        invoke::RunnerDispatchContext {
168            runner,
169            work_dir,
170            bins,
171            model,
172            reasoning_effort,
173            runner_cli,
174            timeout,
175            permission_mode,
176            output_handler,
177            output_stream,
178            phase_type,
179            plugins,
180        },
181        invoke::RunnerInvocation::Prompt { prompt, session_id },
182    )
183}
184
185#[allow(clippy::too_many_arguments)]
186pub(crate) fn resume_session(
187    runner: Runner,
188    work_dir: &Path,
189    bins: RunnerBinaries<'_>,
190    model: Model,
191    reasoning_effort: Option<ReasoningEffort>,
192    runner_cli: execution::ResolvedRunnerCliOptions,
193    session_id: &str,
194    message: &str,
195    permission_mode: Option<ClaudePermissionMode>,
196    timeout: Option<Duration>,
197    output_handler: Option<OutputHandler>,
198    output_stream: OutputStream,
199    phase_type: PhaseType,
200    plugins: Option<&PluginRegistry>,
201) -> Result<RunnerOutput, RunnerError> {
202    invoke::dispatch(
203        invoke::RunnerDispatchContext {
204            runner,
205            work_dir,
206            bins,
207            model,
208            reasoning_effort,
209            runner_cli,
210            timeout,
211            permission_mode,
212            output_handler,
213            output_stream,
214            phase_type,
215            plugins,
216        },
217        invoke::RunnerInvocation::Resume {
218            session_id,
219            message,
220        },
221    )
222}