Skip to main content

ralph_workflow/phases/commit/
runner.rs

1/// Result of commit message generation.
2#[derive(Debug)]
3pub struct CommitMessageResult {
4    /// The generated commit message
5    pub message: String,
6    /// Whether the generation was successful
7    pub success: bool,
8    /// Path to the agent log file for debugging (currently unused)
9    pub _log_path: String,
10    /// Prompts that were generated during this commit generation (key -> prompt)
11    pub generated_prompts: HashMap<String, String>,
12}
13
14/// Outcome from a single commit attempt.
15pub struct CommitAttemptResult {
16    pub had_error: bool,
17    pub output_valid: bool,
18    pub message: Option<String>,
19    pub validation_detail: String,
20    pub auth_failure: bool,
21}
22
23/// Run a single commit generation attempt with explicit agent and prompt.
24///
25/// This does **not** perform in-session XSD retries. If validation fails, the
26/// caller should emit a MessageValidationFailed event and let the reducer decide
27/// retry/fallback behavior.
28///
29/// **IMPORTANT:** The `model_safe_diff` parameter must be pre-truncated to the
30/// effective model budget. Use the reducer's `MaterializeCommitInputs` effect
31/// to truncate the diff before calling this function. The reducer writes the
32/// model-safe diff to `.agent/tmp/commit_diff.model_safe.txt`.
33pub fn run_commit_attempt(
34    ctx: &mut PhaseContext<'_>,
35    attempt: u32,
36    model_safe_diff: &str,
37    commit_agent: &str,
38) -> anyhow::Result<CommitAttemptResult> {
39    // NOTE: Truncation is now handled by materialize_commit_inputs in the reducer.
40    // The diff passed here is already truncated to the effective model budget.
41    // See: reducer/handler/commit.rs::materialize_commit_inputs
42
43    let prompt_key = format!("commit_message_attempt_{attempt}");
44    let (prompt, was_replayed) = build_commit_prompt(
45        &prompt_key,
46        ctx.template_context,
47        model_safe_diff,
48        ctx.workspace,
49        &ctx.prompt_history,
50    );
51
52    // Enforce that the rendered prompt does not contain unresolved template placeholders.
53    // This must happen before any agent invocation.
54    if let Err(err) = crate::prompts::validate_no_unresolved_placeholders_with_ignored_content(
55        &prompt,
56        &[model_safe_diff],
57    ) {
58        return Err(crate::prompts::TemplateVariablesInvalidError {
59            template_name: "commit_message_xml".to_string(),
60            missing_variables: Vec::new(),
61            unresolved_placeholders: err.unresolved_placeholders,
62        }
63        .into());
64    }
65
66    if !was_replayed {
67        ctx.capture_prompt(&prompt_key, &prompt);
68    }
69
70    let mut runtime = PipelineRuntime {
71        timer: ctx.timer,
72        logger: ctx.logger,
73        colors: ctx.colors,
74        config: ctx.config,
75        executor: ctx.executor,
76        executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
77        workspace: ctx.workspace,
78    };
79
80    let log_dir = Path::new(".agent/logs/commit_generation");
81    let mut session = CommitLogSession::new(log_dir.to_str().unwrap(), ctx.workspace)
82        .unwrap_or_else(|_| CommitLogSession::noop());
83    let mut attempt_log = session.new_attempt(commit_agent, "single");
84    attempt_log.set_prompt_size(prompt.len());
85    // The diff passed here is already model-safe. However, for accurate debugging we still want
86    // to record whether truncation happened upstream. We infer truncation from the marker text
87    // emitted by `truncate_diff_to_model_budget`.
88    let diff_was_truncated =
89        model_safe_diff.contains("[Truncated:") || model_safe_diff.contains("[truncated...]");
90    attempt_log.set_diff_info(model_safe_diff.len(), diff_was_truncated);
91
92    let agent_config = ctx
93        .registry
94        .resolve_config(commit_agent)
95        .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", commit_agent))?;
96    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
97
98    // Use per-run log directory with simplified naming
99    let base_log_path = ctx.run_log_context.agent_log("commit", attempt, None);
100    let log_attempt = crate::pipeline::logfile::next_simplified_logfile_attempt_index(
101        &base_log_path,
102        ctx.workspace,
103    );
104    let logfile = if log_attempt == 0 {
105        base_log_path.to_str().unwrap().to_string()
106    } else {
107        ctx.run_log_context
108            .agent_log("commit", attempt, Some(log_attempt))
109            .to_str()
110            .unwrap()
111            .to_string()
112    };
113
114    // Write log file header with agent metadata
115    // Use append_bytes to avoid overwriting if file exists (defense-in-depth)
116    let log_header = format!(
117        "# Ralph Agent Invocation Log\n\
118         # Role: Commit\n\
119         # Agent: {}\n\
120         # Model Index: 0\n\
121         # Attempt: {}\n\
122         # Phase: CommitMessage\n\
123         # Timestamp: {}\n\n",
124        commit_agent,
125        log_attempt,
126        chrono::Utc::now().to_rfc3339()
127    );
128    ctx.workspace
129        .append_bytes(std::path::Path::new(&logfile), log_header.as_bytes())
130        .context("Failed to write agent log header - log would be incomplete without metadata")?;
131
132    let log_prefix = format!("commit_{attempt}"); // For attribution only
133    let model_index = 0usize; // Default model index for attribution
134    let prompt_cmd = PromptCommand {
135        label: commit_agent,
136        display_name: commit_agent,
137        cmd_str: &cmd_str,
138        prompt: &prompt,
139        log_prefix: &log_prefix,
140        model_index: Some(model_index),
141        attempt: Some(log_attempt),
142        logfile: &logfile,
143        parser_type: agent_config.json_parser,
144        env_vars: &agent_config.env_vars,
145    };
146
147    let result = run_with_prompt(&prompt_cmd, &mut runtime)?;
148    let had_error = result.exit_code != 0;
149    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
150    attempt_log.set_raw_output(&result.stderr);
151
152    if auth_failure {
153        attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
154            "Authentication error detected".to_string(),
155        ));
156        if !session.is_noop() {
157            let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
158            let _ = session.write_summary(1, "AUTHENTICATION_FAILURE", ctx.workspace);
159        }
160        return Ok(CommitAttemptResult {
161            had_error,
162            output_valid: false,
163            message: None,
164            validation_detail: "Authentication error detected".to_string(),
165            auth_failure: true,
166        });
167    }
168
169    let extraction = extract_commit_message_from_file_with_workspace(ctx.workspace);
170    let (outcome, detail, extraction_result) = match extraction {
171        CommitExtractionOutcome::Valid(result) => (
172            AttemptOutcome::Success(result.clone().into_message()),
173            "Valid commit message extracted".to_string(),
174            Some(result),
175        ),
176        CommitExtractionOutcome::InvalidXml(detail) => (
177            AttemptOutcome::XsdValidationFailed(detail.clone()),
178            detail,
179            None,
180        ),
181        CommitExtractionOutcome::MissingFile(detail) => (
182            AttemptOutcome::ExtractionFailed(detail.clone()),
183            detail,
184            None,
185        ),
186    };
187    attempt_log.add_extraction_attempt(match &extraction_result {
188        Some(_) => ExtractionAttempt::success("XML", detail.clone()),
189        None => ExtractionAttempt::failure("XML", detail.clone()),
190    });
191    attempt_log.set_outcome(outcome.clone());
192
193    if !session.is_noop() {
194        let _ = attempt_log.write_to_workspace(session.run_dir(), ctx.workspace);
195        let final_outcome = format!("{outcome}");
196        let _ = session.write_summary(1, &final_outcome, ctx.workspace);
197    }
198
199    if let Some(result) = extraction_result {
200        let message = result.into_message();
201        return Ok(CommitAttemptResult {
202            had_error,
203            output_valid: true,
204            message: Some(message),
205            validation_detail: detail,
206            auth_failure: false,
207        });
208    }
209
210    Ok(CommitAttemptResult {
211        had_error,
212        output_valid: false,
213        message: None,
214        validation_detail: detail,
215        auth_failure: false,
216    })
217}
218
219/// Generate a commit message using a single agent attempt.
220///
221/// Returns an error if XML validation fails or the agent output is missing.
222///
223/// # Truncation Behavior (CLI vs Reducer)
224///
225/// **IMPORTANT:** This function uses **single-agent budget** for truncation, which
226/// differs from the reducer-driven path that uses **chain-minimum budget**.
227///
228/// | Path | Budget Calculation | When Used |
229/// |------|-------------------|-----------|
230/// | CLI (`--generate-commit-msg`) | `model_budget_bytes_for_agent_name(agent)` | Single agent, no fallback chain |
231/// | Reducer (`MaterializeCommitInputs`) | `effective_model_budget_bytes(&agents)` | Agent chain with potential fallbacks |
232///
233/// **Why this is acceptable:**
234/// - CLI plumbing commands (`--generate-commit-msg`) invoke a single, explicitly-specified
235///   agent with no fallback chain. There's no need to compute min budget across agents.
236/// - The reducer path handles multi-agent chains where the diff must fit the smallest
237///   agent's context window to ensure fallback attempts can succeed.
238///
239/// **Implication:** A diff that works via CLI might fail via reducer if the chain
240/// includes an agent with a smaller budget. This is by design - the CLI user
241/// explicitly chose the agent and accepts its budget constraints.
242pub fn generate_commit_message(
243    diff: &str,
244    registry: &AgentRegistry,
245    runtime: &mut PipelineRuntime,
246    commit_agent: &str,
247    template_context: &TemplateContext,
248    workspace: &dyn Workspace,
249    prompt_history: &HashMap<String, String>,
250) -> anyhow::Result<CommitMessageResult> {
251    // For CLI plumbing, we truncate to the single agent's budget.
252    // This is different from the reducer path which uses min budget across the chain.
253    let model_budget = model_budget_bytes_for_agent_name(commit_agent);
254    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
255    if truncated {
256        runtime.logger.warn(&format!(
257            "Diff size ({} KB) exceeds agent limit ({} KB). Truncated to {} KB.",
258            diff.len() / 1024,
259            model_budget / 1024,
260            model_safe_diff.len() / 1024
261        ));
262    }
263
264    let prompt_key = "commit_message_attempt_1";
265    let (prompt, was_replayed) = build_commit_prompt(
266        prompt_key,
267        template_context,
268        &model_safe_diff,
269        workspace,
270        prompt_history,
271    );
272
273    let mut generated_prompts = HashMap::new();
274    if !was_replayed {
275        generated_prompts.insert(prompt_key.to_string(), prompt.clone());
276    }
277
278    let agent_config = registry
279        .resolve_config(commit_agent)
280        .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", commit_agent))?;
281    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
282
283    let log_prefix = ".agent/logs/commit_generation/commit_generation";
284    let model_index = 0usize;
285    let attempt = 1u32;
286    let agent_for_log = commit_agent.to_lowercase();
287    let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
288        log_prefix,
289        &agent_for_log,
290        model_index,
291        attempt,
292    );
293    let prompt_cmd = PromptCommand {
294        label: commit_agent,
295        display_name: commit_agent,
296        cmd_str: &cmd_str,
297        prompt: &prompt,
298        log_prefix,
299        model_index: Some(model_index),
300        attempt: Some(attempt),
301        logfile: &logfile,
302        parser_type: agent_config.json_parser,
303        env_vars: &agent_config.env_vars,
304    };
305
306    let result = run_with_prompt(&prompt_cmd, runtime)?;
307    let had_error = result.exit_code != 0;
308    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
309    if auth_failure {
310        anyhow::bail!("Authentication error detected");
311    }
312
313    let extraction = extract_commit_message_from_file_with_workspace(workspace);
314    let result = match extraction {
315        CommitExtractionOutcome::Valid(result) => result,
316        CommitExtractionOutcome::InvalidXml(detail)
317        | CommitExtractionOutcome::MissingFile(detail) => anyhow::bail!(detail),
318    };
319
320    archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
321
322    Ok(CommitMessageResult {
323        message: result.into_message(),
324        success: true,
325        _log_path: String::new(),
326        generated_prompts,
327    })
328}
329
330/// Generate a commit message with fallback chain support.
331///
332/// Tries each agent in the chain sequentially until one succeeds.
333/// Uses the minimum budget across all agents in the chain for truncation
334/// to ensure the diff fits all potential fallback agents.
335///
336/// # Arguments
337/// * `diff` - The diff to generate a commit message for
338/// * `registry` - Agent registry for resolving agent configs
339/// * `runtime` - Pipeline runtime for execution
340/// * `agents` - Chain of agents to try in order (first agent tried first)
341/// * `template_context` - Template context for prompt generation
342/// * `workspace` - Workspace for file operations
343/// * `prompt_history` - History of prompts for replay detection
344///
345/// # Returns
346/// * `Ok(CommitMessageResult)` - If any agent in the chain succeeds
347/// * `Err` - If all agents in the chain fail
348pub fn generate_commit_message_with_chain(
349    diff: &str,
350    registry: &AgentRegistry,
351    runtime: &mut PipelineRuntime,
352    agents: &[String],
353    template_context: &TemplateContext,
354    workspace: &dyn Workspace,
355    prompt_history: &HashMap<String, String>,
356) -> anyhow::Result<CommitMessageResult> {
357    if agents.is_empty() {
358        anyhow::bail!("No agents provided in commit chain");
359    }
360
361    // Use minimum budget across all agents in the chain
362    let model_budget = effective_model_budget_bytes(agents);
363    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
364    if truncated {
365        runtime.logger.warn(&format!(
366            "Diff size ({} KB) exceeds chain limit ({} KB). Truncated to {} KB.",
367            diff.len() / 1024,
368            model_budget / 1024,
369            model_safe_diff.len() / 1024
370        ));
371    }
372
373    let mut last_error: Option<anyhow::Error> = None;
374
375    for (agent_index, commit_agent) in agents.iter().enumerate() {
376        let prompt_key = format!("commit_message_chain_attempt_{}", agent_index + 1);
377        let (prompt, was_replayed) = build_commit_prompt(
378            &prompt_key,
379            template_context,
380            &model_safe_diff,
381            workspace,
382            prompt_history,
383        );
384
385        let mut generated_prompts = HashMap::new();
386        if !was_replayed {
387            generated_prompts.insert(prompt_key.clone(), prompt.clone());
388        }
389
390        let agent_config = match registry.resolve_config(commit_agent) {
391            Some(config) => config,
392            None => {
393                last_error = Some(anyhow::anyhow!("Agent not found: {}", commit_agent));
394                continue;
395            }
396        };
397        let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
398
399        let log_prefix = ".agent/logs/commit_generation/commit_generation";
400        let model_index = agent_index;
401        let attempt = 1u32;
402        let agent_for_log = commit_agent.to_lowercase();
403        let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
404            log_prefix,
405            &agent_for_log,
406            model_index,
407            attempt,
408        );
409        let prompt_cmd = PromptCommand {
410            label: commit_agent,
411            display_name: commit_agent,
412            cmd_str: &cmd_str,
413            prompt: &prompt,
414            log_prefix,
415            model_index: Some(model_index),
416            attempt: Some(attempt),
417            logfile: &logfile,
418            parser_type: agent_config.json_parser,
419            env_vars: &agent_config.env_vars,
420        };
421
422        let result = match run_with_prompt(&prompt_cmd, runtime) {
423            Ok(r) => r,
424            Err(e) => {
425                last_error = Some(e.into());
426                continue;
427            }
428        };
429
430        let had_error = result.exit_code != 0;
431        let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
432
433        if auth_failure {
434            last_error = Some(anyhow::anyhow!("Authentication error detected"));
435            continue;
436        }
437
438        if had_error {
439            last_error = Some(anyhow::anyhow!(
440                "Agent {} failed with exit code {}",
441                commit_agent,
442                result.exit_code
443            ));
444            continue;
445        }
446
447        let extraction = extract_commit_message_from_file_with_workspace(workspace);
448        match extraction {
449            CommitExtractionOutcome::Valid(extracted) => {
450                archive_xml_file_with_workspace(
451                    workspace,
452                    Path::new(xml_paths::COMMIT_MESSAGE_XML),
453                );
454                return Ok(CommitMessageResult {
455                    message: extracted.into_message(),
456                    success: true,
457                    _log_path: String::new(),
458                    generated_prompts,
459                });
460            }
461            CommitExtractionOutcome::InvalidXml(detail)
462            | CommitExtractionOutcome::MissingFile(detail) => {
463                last_error = Some(anyhow::anyhow!(detail));
464                continue;
465            }
466        }
467    }
468
469    // All agents failed
470    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("All agents in commit chain failed")))
471}