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