ralph/contracts/config/
agent.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq, Eq)]
25#[serde(default, deny_unknown_fields)]
26pub struct CiGateConfig {
27 pub enabled: Option<bool>,
29
30 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#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
81#[serde(default, deny_unknown_fields)]
82pub struct AgentConfig {
83 pub runner: Option<Runner>,
85
86 pub model: Option<Model>,
88
89 pub reasoning_effort: Option<ReasoningEffort>,
91
92 #[schemars(range(min = 1))]
94 pub iterations: Option<u8>,
95
96 pub followup_reasoning_effort: Option<ReasoningEffort>,
99
100 pub codex_bin: Option<String>,
102
103 pub opencode_bin: Option<String>,
105
106 pub gemini_bin: Option<String>,
108
109 pub claude_bin: Option<String>,
111
112 pub cursor_bin: Option<String>,
116
117 pub kimi_bin: Option<String>,
119
120 pub pi_bin: Option<String>,
122
123 pub claude_permission_mode: Option<ClaudePermissionMode>,
127
128 pub runner_cli: Option<RunnerCliConfigRoot>,
132
133 pub phase_overrides: Option<PhaseOverrides>,
138
139 pub instruction_files: Option<Vec<PathBuf>>,
146
147 pub repoprompt_plan_required: Option<bool>,
149
150 pub repoprompt_tool_injection: Option<bool>,
152
153 pub ci_gate: Option<CiGateConfig>,
155
156 pub git_revert_mode: Option<GitRevertMode>,
158
159 pub git_publish_mode: Option<GitPublishMode>,
161
162 #[schemars(range(min = 1, max = 3))]
165 pub phases: Option<u8>,
166
167 pub notification: NotificationConfig,
169
170 pub webhook: WebhookConfig,
172
173 #[schemars(range(min = 1))]
177 pub session_timeout_hours: Option<u64>,
178
179 pub scan_prompt_version: Option<ScanPromptVersion>,
181
182 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}