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>>,
144
145 pub repoprompt_plan_required: Option<bool>,
147
148 pub repoprompt_tool_injection: Option<bool>,
150
151 pub ci_gate: Option<CiGateConfig>,
153
154 pub git_revert_mode: Option<GitRevertMode>,
156
157 pub git_publish_mode: Option<GitPublishMode>,
159
160 #[schemars(range(min = 1, max = 3))]
163 pub phases: Option<u8>,
164
165 pub notification: NotificationConfig,
167
168 pub webhook: WebhookConfig,
170
171 #[schemars(range(min = 1))]
175 pub session_timeout_hours: Option<u64>,
176
177 pub scan_prompt_version: Option<ScanPromptVersion>,
179
180 pub runner_retry: RunnerRetryConfig,
182}
183
184impl AgentConfig {
185 pub fn effective_git_publish_mode(&self) -> Option<GitPublishMode> {
186 self.git_publish_mode
187 }
188
189 pub fn effective_runner_cli_patch_for_runner(&self, runner: &Runner) -> RunnerCliOptionsPatch {
190 let mut patch = self
191 .runner_cli
192 .as_ref()
193 .map(|root| root.defaults.clone())
194 .unwrap_or_default();
195 if let Some(root) = &self.runner_cli
196 && let Some(runner_patch) = root.runners.get(runner)
197 {
198 patch.merge_from(runner_patch.clone());
199 }
200 patch
201 }
202
203 pub fn effective_approval_mode(&self) -> Option<RunnerApprovalMode> {
204 let runner = self.runner.clone().unwrap_or(Runner::Codex);
205 self.effective_runner_cli_patch_for_runner(&runner)
206 .approval_mode
207 }
208
209 pub fn ci_gate_enabled(&self) -> bool {
210 self.ci_gate
211 .as_ref()
212 .map(CiGateConfig::is_enabled)
213 .unwrap_or(true)
214 }
215
216 pub fn ci_gate_display_string(&self) -> String {
217 self.ci_gate
218 .as_ref()
219 .map(CiGateConfig::display_string)
220 .unwrap_or_else(|| "make ci".to_string())
221 }
222
223 pub fn merge_from(&mut self, other: Self) {
224 if other.runner.is_some() {
225 self.runner = other.runner;
226 }
227 if other.model.is_some() {
228 self.model = other.model;
229 }
230 if other.reasoning_effort.is_some() {
231 self.reasoning_effort = other.reasoning_effort;
232 }
233 if other.iterations.is_some() {
234 self.iterations = other.iterations;
235 }
236 if other.followup_reasoning_effort.is_some() {
237 self.followup_reasoning_effort = other.followup_reasoning_effort;
238 }
239 if other.codex_bin.is_some() {
240 self.codex_bin = other.codex_bin;
241 }
242 if other.opencode_bin.is_some() {
243 self.opencode_bin = other.opencode_bin;
244 }
245 if other.gemini_bin.is_some() {
246 self.gemini_bin = other.gemini_bin;
247 }
248 if other.claude_bin.is_some() {
249 self.claude_bin = other.claude_bin;
250 }
251 if other.cursor_bin.is_some() {
252 self.cursor_bin = other.cursor_bin;
253 }
254 if other.kimi_bin.is_some() {
255 self.kimi_bin = other.kimi_bin;
256 }
257 if other.pi_bin.is_some() {
258 self.pi_bin = other.pi_bin;
259 }
260 if other.phases.is_some() {
261 self.phases = other.phases;
262 }
263 if other.claude_permission_mode.is_some() {
264 self.claude_permission_mode = other.claude_permission_mode;
265 }
266 if let Some(other_runner_cli) = other.runner_cli {
267 match &mut self.runner_cli {
268 Some(existing) => existing.merge_from(other_runner_cli),
269 None => self.runner_cli = Some(other_runner_cli),
270 }
271 }
272 if let Some(other_phase_overrides) = other.phase_overrides {
273 match &mut self.phase_overrides {
274 Some(existing) => existing.merge_from(other_phase_overrides),
275 None => self.phase_overrides = Some(other_phase_overrides),
276 }
277 }
278 if other.instruction_files.is_some() {
279 self.instruction_files = other.instruction_files;
280 }
281 if other.repoprompt_plan_required.is_some() {
282 self.repoprompt_plan_required = other.repoprompt_plan_required;
283 }
284 if other.repoprompt_tool_injection.is_some() {
285 self.repoprompt_tool_injection = other.repoprompt_tool_injection;
286 }
287 if let Some(other_ci_gate) = other.ci_gate {
288 match &mut self.ci_gate {
289 Some(existing) => existing.merge_from(other_ci_gate),
290 None => self.ci_gate = Some(other_ci_gate),
291 }
292 }
293 if other.git_revert_mode.is_some() {
294 self.git_revert_mode = other.git_revert_mode;
295 }
296 if other.git_publish_mode.is_some() {
297 self.git_publish_mode = other.git_publish_mode;
298 }
299 self.notification.merge_from(other.notification);
300 self.webhook.merge_from(other.webhook);
301 if other.session_timeout_hours.is_some() {
302 self.session_timeout_hours = other.session_timeout_hours;
303 }
304 if other.scan_prompt_version.is_some() {
305 self.scan_prompt_version = other.scan_prompt_version;
306 }
307 self.runner_retry.merge_from(other.runner_retry);
308 }
309}