Skip to main content

ralph_workflow/phases/commit/
runner.rs

1/// Result of commit message generation.
2pub struct CommitMessageResult {
3    /// The generated commit message
4    pub message: String,
5    /// Whether the generation was successful
6    pub success: bool,
7    /// Path to the agent log file for debugging (currently unused)
8    pub _log_path: String,
9    /// Prompts that were generated during this commit generation (key -> prompt)
10    pub generated_prompts: HashMap<String, String>,
11}
12
13/// Outcome from a single commit attempt.
14pub struct CommitAttemptResult {
15    pub had_error: bool,
16    pub output_valid: bool,
17    pub message: Option<String>,
18    pub validation_detail: String,
19    pub auth_failure: bool,
20}
21
22/// Run a single commit generation attempt with explicit agent and prompt.
23///
24/// This does **not** perform in-session XSD retries. If validation fails, the
25/// caller should emit a MessageValidationFailed event and let the reducer decide
26/// retry/fallback behavior.
27///
28/// **IMPORTANT:** The `model_safe_diff` parameter must be pre-truncated to the
29/// effective model budget. Use the reducer's `MaterializeCommitInputs` effect
30/// to truncate the diff before calling this function. The reducer writes the
31/// model-safe diff to `.agent/tmp/commit_diff.model_safe.txt`.
32pub fn run_commit_attempt(
33    ctx: &mut PhaseContext<'_>,
34    attempt: u32,
35    model_safe_diff: &str,
36    commit_agent: &str,
37) -> anyhow::Result<CommitAttemptResult> {
38    // NOTE: Truncation is now handled by materialize_commit_inputs in the reducer.
39    // The diff passed here is already truncated to the effective model budget.
40    // See: reducer/handler/commit.rs::materialize_commit_inputs
41
42    let prompt_key = format!("commit_message_attempt_{attempt}");
43    let (prompt, was_replayed) = build_commit_prompt(
44        &prompt_key,
45        ctx.template_context,
46        model_safe_diff,
47        ctx.workspace,
48        &ctx.prompt_history,
49    );
50
51    // Enforce that the rendered prompt does not contain unresolved template placeholders.
52    // This must happen before any agent invocation.
53    if let Err(err) = crate::prompts::validate_no_unresolved_placeholders_with_ignored_content(
54        &prompt,
55        &[model_safe_diff],
56    ) {
57        return Err(crate::prompts::TemplateVariablesInvalidError {
58            template_name: "commit_message_xml".to_string(),
59            missing_variables: Vec::new(),
60            unresolved_placeholders: err.unresolved_placeholders,
61        }
62        .into());
63    }
64
65    if !was_replayed {
66        ctx.capture_prompt(&prompt_key, &prompt);
67    }
68
69    let mut runtime = PipelineRuntime {
70        timer: ctx.timer,
71        logger: ctx.logger,
72        colors: ctx.colors,
73        config: ctx.config,
74        executor: ctx.executor,
75        executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
76        workspace: ctx.workspace,
77    };
78
79    let log_dir = Path::new(".agent/logs/commit_generation");
80    let mut session = CommitLogSession::new(log_dir.to_str().unwrap(), ctx.workspace)
81        .unwrap_or_else(|_| CommitLogSession::noop());
82    let mut attempt_log = session.new_attempt(commit_agent, "single");
83    attempt_log.set_prompt_size(prompt.len());
84    // The diff passed here is already model-safe. However, for accurate debugging we still want
85    // to record whether truncation happened upstream. We infer truncation from the marker text
86    // emitted by `truncate_diff_to_model_budget`.
87    let diff_was_truncated =
88        model_safe_diff.contains("[Truncated:") || model_safe_diff.contains("[truncated...]");
89    attempt_log.set_diff_info(model_safe_diff.len(), diff_was_truncated);
90
91    let agent_config = ctx
92        .registry
93        .resolve_config(commit_agent)
94        .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", commit_agent))?;
95    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
96
97    let log_prefix = ".agent/logs/commit_generation/commit_generation";
98    let model_index = 0usize;
99    let agent_for_log = commit_agent.to_lowercase();
100    let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
101        log_prefix,
102        &agent_for_log,
103        model_index,
104        attempt,
105    );
106    let prompt_cmd = PromptCommand {
107        label: commit_agent,
108        display_name: commit_agent,
109        cmd_str: &cmd_str,
110        prompt: &prompt,
111        log_prefix,
112        model_index: Some(model_index),
113        attempt: Some(attempt),
114        logfile: &logfile,
115        parser_type: agent_config.json_parser,
116        env_vars: &agent_config.env_vars,
117    };
118
119    let result = run_with_prompt(&prompt_cmd, &mut runtime)?;
120    let had_error = result.exit_code != 0;
121    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
122    attempt_log.set_raw_output(&result.stderr);
123
124    if auth_failure {
125        attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
126            "Authentication error detected".to_string(),
127        ));
128        if !session.is_noop() {
129            let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
130            let _ = session.write_summary(1, "AUTHENTICATION_FAILURE", ctx.workspace);
131        }
132        return Ok(CommitAttemptResult {
133            had_error,
134            output_valid: false,
135            message: None,
136            validation_detail: "Authentication error detected".to_string(),
137            auth_failure: true,
138        });
139    }
140
141    let extraction = extract_commit_message_from_file_with_workspace(ctx.workspace);
142    let (outcome, detail, extraction_result) = match extraction {
143        CommitExtractionOutcome::Valid(result) => (
144            AttemptOutcome::Success(result.clone().into_message()),
145            "Valid commit message extracted".to_string(),
146            Some(result),
147        ),
148        CommitExtractionOutcome::InvalidXml(detail) => (
149            AttemptOutcome::XsdValidationFailed(detail.clone()),
150            detail,
151            None,
152        ),
153        CommitExtractionOutcome::MissingFile(detail) => (
154            AttemptOutcome::ExtractionFailed(detail.clone()),
155            detail,
156            None,
157        ),
158    };
159    attempt_log.add_extraction_attempt(match &extraction_result {
160        Some(_) => ExtractionAttempt::success("XML", detail.clone()),
161        None => ExtractionAttempt::failure("XML", detail.clone()),
162    });
163    attempt_log.set_outcome(outcome.clone());
164
165    if !session.is_noop() {
166        let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
167        let final_outcome = format!("{outcome}");
168        let _ = session.write_summary(1, &final_outcome, ctx.workspace);
169    }
170
171    if let Some(result) = extraction_result {
172        let message = result.into_message();
173        return Ok(CommitAttemptResult {
174            had_error,
175            output_valid: true,
176            message: Some(message),
177            validation_detail: detail,
178            auth_failure: false,
179        });
180    }
181
182    Ok(CommitAttemptResult {
183        had_error,
184        output_valid: false,
185        message: None,
186        validation_detail: detail,
187        auth_failure: false,
188    })
189}
190
191/// Generate a commit message using a single agent attempt.
192///
193/// Returns an error if XML validation fails or the agent output is missing.
194///
195/// # Truncation Behavior (CLI vs Reducer)
196///
197/// **IMPORTANT:** This function uses **single-agent budget** for truncation, which
198/// differs from the reducer-driven path that uses **chain-minimum budget**.
199///
200/// | Path | Budget Calculation | When Used |
201/// |------|-------------------|-----------|
202/// | CLI (`--generate-commit-msg`) | `model_budget_bytes_for_agent_name(agent)` | Single agent, no fallback chain |
203/// | Reducer (`MaterializeCommitInputs`) | `effective_model_budget_bytes(&agents)` | Agent chain with potential fallbacks |
204///
205/// **Why this is acceptable:**
206/// - CLI plumbing commands (`--generate-commit-msg`) invoke a single, explicitly-specified
207///   agent with no fallback chain. There's no need to compute min budget across agents.
208/// - The reducer path handles multi-agent chains where the diff must fit the smallest
209///   agent's context window to ensure fallback attempts can succeed.
210///
211/// **Implication:** A diff that works via CLI might fail via reducer if the chain
212/// includes an agent with a smaller budget. This is by design - the CLI user
213/// explicitly chose the agent and accepts its budget constraints.
214pub fn generate_commit_message(
215    diff: &str,
216    registry: &AgentRegistry,
217    runtime: &mut PipelineRuntime,
218    commit_agent: &str,
219    template_context: &TemplateContext,
220    workspace: &dyn Workspace,
221    prompt_history: &HashMap<String, String>,
222) -> anyhow::Result<CommitMessageResult> {
223    // For CLI plumbing, we truncate to the single agent's budget.
224    // This is different from the reducer path which uses min budget across the chain.
225    let model_budget = model_budget_bytes_for_agent_name(commit_agent);
226    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
227    if truncated {
228        runtime.logger.warn(&format!(
229            "Diff size ({} KB) exceeds agent limit ({} KB). Truncated to {} KB.",
230            diff.len() / 1024,
231            model_budget / 1024,
232            model_safe_diff.len() / 1024
233        ));
234    }
235
236    let prompt_key = "commit_message_attempt_1";
237    let (prompt, was_replayed) = build_commit_prompt(
238        prompt_key,
239        template_context,
240        &model_safe_diff,
241        workspace,
242        prompt_history,
243    );
244
245    let mut generated_prompts = HashMap::new();
246    if !was_replayed {
247        generated_prompts.insert(prompt_key.to_string(), prompt.clone());
248    }
249
250    let agent_config = registry
251        .resolve_config(commit_agent)
252        .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", commit_agent))?;
253    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
254
255    let log_prefix = ".agent/logs/commit_generation/commit_generation";
256    let model_index = 0usize;
257    let attempt = 1u32;
258    let agent_for_log = commit_agent.to_lowercase();
259    let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
260        log_prefix,
261        &agent_for_log,
262        model_index,
263        attempt,
264    );
265    let prompt_cmd = PromptCommand {
266        label: commit_agent,
267        display_name: commit_agent,
268        cmd_str: &cmd_str,
269        prompt: &prompt,
270        log_prefix,
271        model_index: Some(model_index),
272        attempt: Some(attempt),
273        logfile: &logfile,
274        parser_type: agent_config.json_parser,
275        env_vars: &agent_config.env_vars,
276    };
277
278    let result = run_with_prompt(&prompt_cmd, runtime)?;
279    let had_error = result.exit_code != 0;
280    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
281    if auth_failure {
282        anyhow::bail!("Authentication error detected");
283    }
284
285    let extraction = extract_commit_message_from_file_with_workspace(workspace);
286    let result = match extraction {
287        CommitExtractionOutcome::Valid(result) => result,
288        CommitExtractionOutcome::InvalidXml(detail)
289        | CommitExtractionOutcome::MissingFile(detail) => anyhow::bail!(detail),
290    };
291
292    archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
293
294    Ok(CommitMessageResult {
295        message: result.into_message(),
296        success: true,
297        _log_path: String::new(),
298        generated_prompts,
299    })
300}