Skip to main content

ralph/contracts/config/
agent.rs

1//! Agent runner defaults configuration.
2//!
3//! Responsibilities:
4//! - Define AgentConfig struct and merge behavior for runner defaults.
5//! - Model CI gate execution using explicit argv settings.
6//!
7//! Not handled here:
8//! - Runner-specific configuration (see `crate::contracts::runner`).
9//! - Actual runner invocation (see `crate::runner` module).
10
11use crate::contracts::config::{
12    GitPublishMode, GitRevertMode, NotificationConfig, PhaseOverrides, RunnerRetryConfig,
13    ScanPromptVersion, WebhookConfig,
14};
15use crate::contracts::model::{Model, ReasoningEffort};
16use crate::contracts::runner::{
17    ClaudePermissionMode, Runner, RunnerApprovalMode, RunnerCliConfigRoot, RunnerCliOptionsPatch,
18};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22
23/// Structured CI gate execution settings.
24#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq, Eq)]
25#[serde(default, deny_unknown_fields)]
26pub struct CiGateConfig {
27    /// Enable or disable the CI gate entirely.
28    pub enabled: Option<bool>,
29
30    /// Direct argv execution. The first item is the program and remaining items are arguments.
31    pub argv: Option<Vec<String>>,
32}
33
34impl CiGateConfig {
35    pub fn is_enabled(&self) -> bool {
36        self.enabled.unwrap_or(true)
37    }
38
39    pub fn display_string(&self) -> String {
40        if !self.is_enabled() {
41            return "disabled".to_string();
42        }
43
44        if let Some(argv) = &self.argv {
45            return format_argv(argv);
46        }
47
48        "<unset>".to_string()
49    }
50
51    pub fn merge_from(&mut self, other: Self) {
52        if other.enabled.is_some() {
53            self.enabled = other.enabled;
54        }
55        if other.argv.is_some() {
56            self.argv = other.argv;
57        }
58    }
59}
60
61fn format_argv(argv: &[String]) -> String {
62    argv.iter()
63        .map(|part| {
64            if part.is_empty() {
65                "\"\"".to_string()
66            } else if part
67                .chars()
68                .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\'))
69            {
70                format!("{part:?}")
71            } else {
72                part.clone()
73            }
74        })
75        .collect::<Vec<_>>()
76        .join(" ")
77}
78
79/// Agent runner defaults. Built-in runner IDs: codex, opencode, gemini, claude, cursor, kimi, pi. Plugin runner IDs are also supported as non-empty strings.
80#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
81#[serde(default, deny_unknown_fields)]
82pub struct AgentConfig {
83    /// Which harness to use by default.
84    pub runner: Option<Runner>,
85
86    /// Default model.
87    pub model: Option<Model>,
88
89    /// Default reasoning effort (only meaningful for Codex models).
90    pub reasoning_effort: Option<ReasoningEffort>,
91
92    /// Number of iterations to run for each task (default: 1).
93    #[schemars(range(min = 1))]
94    pub iterations: Option<u8>,
95
96    /// Reasoning effort override for follow-up iterations (iterations > 1).
97    /// Only meaningful for Codex models.
98    pub followup_reasoning_effort: Option<ReasoningEffort>,
99
100    /// Override the codex executable name/path (default is "codex" if None).
101    pub codex_bin: Option<String>,
102
103    /// Override the opencode executable name/path (default is "opencode" if None).
104    pub opencode_bin: Option<String>,
105
106    /// Override the gemini executable name/path (default is "gemini" if None).
107    pub gemini_bin: Option<String>,
108
109    /// Override the claude executable name/path (default is "claude" if None).
110    pub claude_bin: Option<String>,
111
112    /// Override the cursor agent executable name/path (default is "agent" if None).
113    ///
114    /// NOTE: Cursor's runner binary name is `agent` (not `cursor`).
115    pub cursor_bin: Option<String>,
116
117    /// Override the kimi executable name/path (default is "kimi" if None).
118    pub kimi_bin: Option<String>,
119
120    /// Override the pi executable name/path (default is "pi" if None).
121    pub pi_bin: Option<String>,
122
123    /// Claude permission mode for tool and edit approval.
124    /// AcceptEdits: auto-approves file edits only
125    /// BypassPermissions: skip all permission prompts (YOLO mode)
126    pub claude_permission_mode: Option<ClaudePermissionMode>,
127
128    /// Normalized runner CLI behavior overrides (output/approval/sandbox/etc).
129    ///
130    /// This is additive: existing runner-specific fields remain supported.
131    pub runner_cli: Option<RunnerCliConfigRoot>,
132
133    /// Per-phase overrides for runner, model, and reasoning effort.
134    ///
135    /// Allows specifying different settings for each phase (1, 2, 3).
136    /// Phase-specific values override the global agent settings.
137    pub phase_overrides: Option<PhaseOverrides>,
138
139    /// Additional instruction files to inject at the top of every prompt sent to runner CLIs.
140    ///
141    /// Paths may be absolute, `~/`-prefixed, or repo-root relative. Each list entry must be a
142    /// non-empty path; blank or whitespace-only strings are rejected during config validation.
143    /// Missing files are treated as configuration errors. To include repo-local AGENTS.md, add
144    /// `"AGENTS.md"` to this list.
145    pub instruction_files: Option<Vec<PathBuf>>,
146
147    /// Require RepoPrompt usage during planning (inject context_builder instructions).
148    pub repoprompt_plan_required: Option<bool>,
149
150    /// Inject RepoPrompt tooling reminder block into prompts.
151    pub repoprompt_tool_injection: Option<bool>,
152
153    /// Structured CI gate execution settings.
154    pub ci_gate: Option<CiGateConfig>,
155
156    /// Controls automatic git revert behavior when runner or supervision errors occur.
157    pub git_revert_mode: Option<GitRevertMode>,
158
159    /// Post-run git publication behavior after successful runs.
160    pub git_publish_mode: Option<GitPublishMode>,
161
162    /// Number of execution phases (1, 2, or 3).
163    /// 1 = single-pass, 2 = plan+implement, 3 = plan+implement+review.
164    #[schemars(range(min = 1, max = 3))]
165    pub phases: Option<u8>,
166
167    /// Desktop notification configuration for task completion.
168    pub notification: NotificationConfig,
169
170    /// Webhook configuration for HTTP task event notifications.
171    pub webhook: WebhookConfig,
172
173    /// Session timeout in hours for crash recovery (default: 24).
174    /// Sessions older than this threshold are considered stale and require
175    /// explicit user confirmation to resume.
176    #[schemars(range(min = 1))]
177    pub session_timeout_hours: Option<u64>,
178
179    /// Scan prompt version to use (v1 or v2, default: v2).
180    pub scan_prompt_version: Option<ScanPromptVersion>,
181
182    /// Runner invocation retry/backoff configuration.
183    pub runner_retry: RunnerRetryConfig,
184}
185
186impl AgentConfig {
187    pub fn effective_git_publish_mode(&self) -> Option<GitPublishMode> {
188        self.git_publish_mode
189    }
190
191    pub fn effective_runner_cli_patch_for_runner(&self, runner: &Runner) -> RunnerCliOptionsPatch {
192        let mut patch = self
193            .runner_cli
194            .as_ref()
195            .map(|root| root.defaults.clone())
196            .unwrap_or_default();
197        if let Some(root) = &self.runner_cli
198            && let Some(runner_patch) = root.runners.get(runner)
199        {
200            patch.merge_from(runner_patch.clone());
201        }
202        patch
203    }
204
205    pub fn effective_approval_mode(&self) -> Option<RunnerApprovalMode> {
206        let runner = self.runner.clone().unwrap_or(Runner::Codex);
207        self.effective_runner_cli_patch_for_runner(&runner)
208            .approval_mode
209    }
210
211    pub fn ci_gate_enabled(&self) -> bool {
212        self.ci_gate
213            .as_ref()
214            .map(CiGateConfig::is_enabled)
215            .unwrap_or(true)
216    }
217
218    pub fn ci_gate_display_string(&self) -> String {
219        self.ci_gate
220            .as_ref()
221            .map(CiGateConfig::display_string)
222            .unwrap_or_else(|| "make ci".to_string())
223    }
224
225    pub fn merge_from(&mut self, other: Self) {
226        if other.runner.is_some() {
227            self.runner = other.runner;
228        }
229        if other.model.is_some() {
230            self.model = other.model;
231        }
232        if other.reasoning_effort.is_some() {
233            self.reasoning_effort = other.reasoning_effort;
234        }
235        if other.iterations.is_some() {
236            self.iterations = other.iterations;
237        }
238        if other.followup_reasoning_effort.is_some() {
239            self.followup_reasoning_effort = other.followup_reasoning_effort;
240        }
241        if other.codex_bin.is_some() {
242            self.codex_bin = other.codex_bin;
243        }
244        if other.opencode_bin.is_some() {
245            self.opencode_bin = other.opencode_bin;
246        }
247        if other.gemini_bin.is_some() {
248            self.gemini_bin = other.gemini_bin;
249        }
250        if other.claude_bin.is_some() {
251            self.claude_bin = other.claude_bin;
252        }
253        if other.cursor_bin.is_some() {
254            self.cursor_bin = other.cursor_bin;
255        }
256        if other.kimi_bin.is_some() {
257            self.kimi_bin = other.kimi_bin;
258        }
259        if other.pi_bin.is_some() {
260            self.pi_bin = other.pi_bin;
261        }
262        if other.phases.is_some() {
263            self.phases = other.phases;
264        }
265        if other.claude_permission_mode.is_some() {
266            self.claude_permission_mode = other.claude_permission_mode;
267        }
268        if let Some(other_runner_cli) = other.runner_cli {
269            match &mut self.runner_cli {
270                Some(existing) => existing.merge_from(other_runner_cli),
271                None => self.runner_cli = Some(other_runner_cli),
272            }
273        }
274        if let Some(other_phase_overrides) = other.phase_overrides {
275            match &mut self.phase_overrides {
276                Some(existing) => existing.merge_from(other_phase_overrides),
277                None => self.phase_overrides = Some(other_phase_overrides),
278            }
279        }
280        if other.instruction_files.is_some() {
281            self.instruction_files = other.instruction_files;
282        }
283        if other.repoprompt_plan_required.is_some() {
284            self.repoprompt_plan_required = other.repoprompt_plan_required;
285        }
286        if other.repoprompt_tool_injection.is_some() {
287            self.repoprompt_tool_injection = other.repoprompt_tool_injection;
288        }
289        if let Some(other_ci_gate) = other.ci_gate {
290            match &mut self.ci_gate {
291                Some(existing) => existing.merge_from(other_ci_gate),
292                None => self.ci_gate = Some(other_ci_gate),
293            }
294        }
295        if other.git_revert_mode.is_some() {
296            self.git_revert_mode = other.git_revert_mode;
297        }
298        if other.git_publish_mode.is_some() {
299            self.git_publish_mode = other.git_publish_mode;
300        }
301        self.notification.merge_from(other.notification);
302        self.webhook.merge_from(other.webhook);
303        if other.session_timeout_hours.is_some() {
304            self.session_timeout_hours = other.session_timeout_hours;
305        }
306        if other.scan_prompt_version.is_some() {
307            self.scan_prompt_version = other.scan_prompt_version;
308        }
309        self.runner_retry.merge_from(other.runner_retry);
310    }
311}