Skip to main content

ralph_workflow/phases/commit/runner/
chain.rs

1/// Generate a commit message using a single agent attempt.
2///
3/// Returns an error if XML validation fails or the agent output is missing.
4///
5/// # Truncation Behavior (CLI vs Reducer)
6///
7/// **IMPORTANT:** This function uses **single-agent budget** for truncation, which
8/// differs from the reducer-driven path that uses **chain-minimum budget**.
9///
10/// | Path | Budget Calculation | When Used |
11/// |------|-------------------|-----------|
12/// | CLI (`--generate-commit-msg`) | `model_budget_bytes_for_agent_name(agent)` | Single agent, no fallback chain |
13/// | Reducer (`MaterializeCommitInputs`) | `effective_model_budget_bytes(&agents)` | Agent chain with potential fallbacks |
14///
15/// **Why this is acceptable:**
16/// - CLI plumbing commands (`--generate-commit-msg`) invoke a single, explicitly-specified
17///   agent with no fallback chain. There's no need to compute min budget across agents.
18/// - The reducer path handles multi-agent chains where the diff must fit the smallest
19///   agent's context window to ensure fallback attempts can succeed.
20///
21/// **Implication:** A diff that works via CLI might fail via reducer if the chain
22/// includes an agent with a smaller budget. This is by design - the CLI user
23/// explicitly chose the agent and accepts its budget constraints.
24///
25/// # Errors
26///
27/// Returns error if the operation fails.
28pub fn generate_commit_message(
29    diff: &str,
30    registry: &AgentRegistry,
31    runtime: &mut PipelineRuntime<'_>,
32    commit_agent: &str,
33    template_context: &TemplateContext,
34    workspace: &dyn Workspace,
35) -> anyhow::Result<CommitMessageResult> {
36    // For CLI plumbing, we truncate to the single agent's budget.
37    // This is different from the reducer path which uses min budget across the chain.
38    let model_budget = model_budget_bytes_for_agent_name(commit_agent);
39    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
40    if truncated {
41        runtime.logger.warn(&format!(
42            "Diff size ({} KB) exceeds agent limit ({} KB). Truncated to {} KB.",
43            diff.len() / 1024,
44            model_budget / 1024,
45            model_safe_diff.len() / 1024
46        ));
47    }
48
49    let (prompt, substitution_log) =
50        build_commit_prompt(template_context, &model_safe_diff, workspace);
51    if !substitution_log.is_complete() {
52        return Err(anyhow::anyhow!(
53            "Commit prompt has unresolved placeholders: {:?}",
54            substitution_log.unsubstituted
55        ));
56    }
57
58    let agent_config = registry
59        .resolve_config(commit_agent)
60        .ok_or_else(|| anyhow::anyhow!("Agent not found: {commit_agent}"))?;
61    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
62
63    let log_prefix = ".agent/logs/commit_generation/commit_generation";
64    let model_index = 0usize;
65    let attempt = 1u32;
66    let agent_for_log = commit_agent.to_lowercase();
67    let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
68        log_prefix,
69        &agent_for_log,
70        model_index,
71        attempt,
72    );
73    let prompt_cmd = PromptCommand {
74        label: commit_agent,
75        display_name: commit_agent,
76        cmd_str: &cmd_str,
77        prompt: &prompt,
78        log_prefix,
79        model_index: Some(model_index),
80        attempt: Some(attempt),
81        logfile: &logfile,
82        parser_type: agent_config.json_parser,
83        env_vars: &agent_config.env_vars,
84    };
85
86    let result = run_with_prompt(&prompt_cmd, runtime)?;
87    let had_error = result.exit_code != 0;
88    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
89    if auth_failure {
90        anyhow::bail!("Authentication error detected");
91    }
92
93    let extraction = extract_commit_message_from_file_with_workspace(workspace);
94    let result = match extraction {
95        CommitExtractionOutcome::Valid {
96            extracted: result,
97            files: _,
98            ..
99        } => result,
100        CommitExtractionOutcome::InvalidXml(detail)
101        | CommitExtractionOutcome::MissingFile(detail) => anyhow::bail!(detail),
102        CommitExtractionOutcome::Skipped(reason) => {
103            archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
104            return Ok(CommitMessageResult {
105                outcome: CommitMessageOutcome::Skipped { reason },
106            });
107        }
108    };
109
110    archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
111
112    Ok(CommitMessageResult {
113        outcome: CommitMessageOutcome::Message(result.into_message()),
114    })
115}
116
117/// Generate a commit message with fallback chain support.
118///
119/// Tries each agent in the chain sequentially until one succeeds.
120/// Uses the minimum budget across all agents in the chain for truncation
121/// to ensure the diff fits all potential fallback agents.
122///
123/// # Arguments
124/// * `diff` - The diff to generate a commit message for
125/// * `registry` - Agent registry for resolving agent configs
126/// * `runtime` - Pipeline runtime for execution
127/// * `agents` - Chain of agents to try in order (first agent tried first)
128/// * `template_context` - Template context for prompt generation
129/// * `workspace` - Workspace for file operations
130/// # Returns
131/// * `Ok(CommitMessageResult)` - If any agent in the chain succeeds
132/// * `Err` - If all agents in the chain fail
133///
134/// # Errors
135///
136/// Returns error if the operation fails.
137pub fn generate_commit_message_with_chain(
138    diff: &str,
139    registry: &AgentRegistry,
140    runtime: &mut PipelineRuntime<'_>,
141    agents: &[String],
142    template_context: &TemplateContext,
143    workspace: &dyn Workspace,
144) -> anyhow::Result<CommitMessageResult> {
145    if agents.is_empty() {
146        anyhow::bail!("No agents provided in commit chain");
147    }
148
149    // Use minimum budget across all agents in the chain
150    let model_budget = effective_model_budget_bytes(agents);
151    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
152    if truncated {
153        runtime.logger.warn(&format!(
154            "Diff size ({} KB) exceeds chain limit ({} KB). Truncated to {} KB.",
155            diff.len() / 1024,
156            model_budget / 1024,
157            model_safe_diff.len() / 1024
158        ));
159    }
160
161    let mut last_error: Option<anyhow::Error> = None;
162
163    for (agent_index, commit_agent) in agents.iter().enumerate() {
164        let (prompt, substitution_log) =
165            build_commit_prompt(template_context, &model_safe_diff, workspace);
166        if !substitution_log.is_complete() {
167            return Err(anyhow::anyhow!(
168                "Commit prompt has unresolved placeholders: {:?}",
169                substitution_log.unsubstituted
170            ));
171        }
172
173        let Some(agent_config) = registry.resolve_config(commit_agent) else {
174            last_error = Some(anyhow::anyhow!("Agent not found: {commit_agent}"));
175            continue;
176        };
177        let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
178
179        let log_prefix = ".agent/logs/commit_generation/commit_generation";
180        let model_index = agent_index;
181        let attempt = 1u32;
182        let agent_for_log = commit_agent.to_lowercase();
183        let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
184            log_prefix,
185            &agent_for_log,
186            model_index,
187            attempt,
188        );
189        let prompt_cmd = PromptCommand {
190            label: commit_agent,
191            display_name: commit_agent,
192            cmd_str: &cmd_str,
193            prompt: &prompt,
194            log_prefix,
195            model_index: Some(model_index),
196            attempt: Some(attempt),
197            logfile: &logfile,
198            parser_type: agent_config.json_parser,
199            env_vars: &agent_config.env_vars,
200        };
201
202        let result = match run_with_prompt(&prompt_cmd, runtime) {
203            Ok(r) => r,
204            Err(e) => {
205                last_error = Some(e.into());
206                continue;
207            }
208        };
209
210        let had_error = result.exit_code != 0;
211        let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
212
213        if auth_failure {
214            last_error = Some(anyhow::anyhow!("Authentication error detected"));
215            continue;
216        }
217
218        if had_error {
219            last_error = Some(anyhow::anyhow!(
220                "Agent {} failed with exit code {}",
221                commit_agent,
222                result.exit_code
223            ));
224            continue;
225        }
226
227        let extraction = extract_commit_message_from_file_with_workspace(workspace);
228        match extraction {
229            CommitExtractionOutcome::Valid {
230                extracted,
231                files: _,
232                ..
233            } => {
234                archive_xml_file_with_workspace(
235                    workspace,
236                    Path::new(xml_paths::COMMIT_MESSAGE_XML),
237                );
238                return Ok(CommitMessageResult {
239                    outcome: CommitMessageOutcome::Message(extracted.into_message()),
240                });
241            }
242            CommitExtractionOutcome::Skipped(reason) => {
243                archive_xml_file_with_workspace(
244                    workspace,
245                    Path::new(xml_paths::COMMIT_MESSAGE_XML),
246                );
247                return Ok(CommitMessageResult {
248                    outcome: CommitMessageOutcome::Skipped { reason },
249                });
250            }
251            CommitExtractionOutcome::InvalidXml(detail)
252            | CommitExtractionOutcome::MissingFile(detail) => {
253                last_error = Some(anyhow::anyhow!(detail));
254            }
255        }
256    }
257
258    // All agents failed
259    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("All agents in commit chain failed")))
260}