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/// Result of trying a single agent in the commit chain.
118enum TryAgentResult {
119    Success(CommitMessageResult),
120    Skip(Option<anyhow::Error>),
121}
122
123fn try_single_commit_agent(
124    agent_index: usize,
125    commit_agent: &str,
126    template_context: &TemplateContext,
127    model_safe_diff: &str,
128    registry: &AgentRegistry,
129    runtime: &mut PipelineRuntime<'_>,
130    workspace: &dyn Workspace,
131) -> TryAgentResult {
132    let (prompt, substitution_log) =
133        build_commit_prompt(template_context, model_safe_diff, workspace);
134    if !substitution_log.is_complete() {
135        return TryAgentResult::Skip(Some(anyhow::anyhow!(
136            "Commit prompt has unresolved placeholders: {:?}",
137            substitution_log.unsubstituted
138        )));
139    }
140
141    let Some(agent_config) = registry.resolve_config(commit_agent) else {
142        return TryAgentResult::Skip(Some(anyhow::anyhow!("Agent not found: {commit_agent}")));
143    };
144    let cmd_str = agent_config.build_cmd_with_model(true, true, true, None);
145
146    let log_prefix = ".agent/logs/commit_generation/commit_generation";
147    let model_index = agent_index;
148    let attempt = 1u32;
149    let agent_for_log = commit_agent.to_lowercase();
150    let logfile = crate::pipeline::logfile::build_logfile_path_with_attempt(
151        log_prefix,
152        &agent_for_log,
153        model_index,
154        attempt,
155    );
156    let prompt_cmd = PromptCommand {
157        label: commit_agent,
158        display_name: commit_agent,
159        cmd_str: &cmd_str,
160        prompt: &prompt,
161        log_prefix,
162        model_index: Some(model_index),
163        attempt: Some(attempt),
164        logfile: &logfile,
165        parser_type: agent_config.json_parser,
166        env_vars: &agent_config.env_vars,
167    };
168
169    let result = match run_with_prompt(&prompt_cmd, runtime) {
170        Ok(r) => r,
171        Err(e) => return TryAgentResult::Skip(Some(e.into())),
172    };
173
174    let had_error = result.exit_code != 0;
175    let auth_failure = had_error && stderr_contains_auth_error(&result.stderr);
176
177    if auth_failure {
178        return TryAgentResult::Skip(Some(anyhow::anyhow!("Authentication error detected")));
179    }
180
181    if had_error {
182        return TryAgentResult::Skip(Some(anyhow::anyhow!(
183            "Agent {} failed with exit code {}",
184            commit_agent,
185            result.exit_code
186        )));
187    }
188
189    let extraction = extract_commit_message_from_file_with_workspace(workspace);
190    match extraction {
191        CommitExtractionOutcome::Valid {
192            extracted,
193            files: _,
194            ..
195        } => {
196            archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
197            TryAgentResult::Success(CommitMessageResult {
198                outcome: CommitMessageOutcome::Message(extracted.into_message()),
199            })
200        }
201        CommitExtractionOutcome::Skipped(reason) => {
202            archive_xml_file_with_workspace(workspace, Path::new(xml_paths::COMMIT_MESSAGE_XML));
203            TryAgentResult::Success(CommitMessageResult {
204                outcome: CommitMessageOutcome::Skipped { reason },
205            })
206        }
207        CommitExtractionOutcome::InvalidXml(detail)
208        | CommitExtractionOutcome::MissingFile(detail) => {
209            TryAgentResult::Skip(Some(anyhow::anyhow!(detail)))
210        }
211    }
212}
213
214/// Generate a commit message with fallback chain support.
215///
216/// Tries each agent in the chain sequentially until one succeeds.
217/// Uses the minimum budget across all agents in the chain for truncation
218/// to ensure the diff fits all potential fallback agents.
219///
220/// # Arguments
221/// * `diff` - The diff to generate a commit message for
222/// * `registry` - Agent registry for resolving agent configs
223/// * `runtime` - Pipeline runtime for execution
224/// * `agents` - Chain of agents to try in order (first agent tried first)
225/// * `template_context` - Template context for prompt generation
226/// * `workspace` - Workspace for file operations
227/// # Returns
228/// * `Ok(CommitMessageResult)` - If any agent in the chain succeeds
229/// * `Err` - If all agents in the chain fail
230///
231/// # Errors
232///
233/// Returns error if the operation fails.
234pub fn generate_commit_message_with_chain(
235    diff: &str,
236    registry: &AgentRegistry,
237    runtime: &mut PipelineRuntime<'_>,
238    agents: &[String],
239    template_context: &TemplateContext,
240    workspace: &dyn Workspace,
241) -> anyhow::Result<CommitMessageResult> {
242    if agents.is_empty() {
243        anyhow::bail!("No agents provided in commit chain");
244    }
245
246    // Use minimum budget across all agents in the chain
247    let model_budget = effective_model_budget_bytes(agents);
248    let (model_safe_diff, truncated) = truncate_diff_to_model_budget(diff, model_budget);
249    if truncated {
250        runtime.logger.warn(&format!(
251            "Diff size ({} KB) exceeds chain limit ({} KB). Truncated to {} KB.",
252            diff.len() / 1024,
253            model_budget / 1024,
254            model_safe_diff.len() / 1024
255        ));
256    }
257
258    let last_error =
259        agents
260            .iter()
261            .enumerate()
262            .try_fold(
263                None,
264                |last_err, (agent_index, commit_agent)| match try_single_commit_agent(
265                    agent_index,
266                    commit_agent,
267                    template_context,
268                    &model_safe_diff,
269                    registry,
270                    runtime,
271                    workspace,
272                ) {
273                    TryAgentResult::Success(result) => Err(result),
274                    TryAgentResult::Skip(opt_err) => Ok(opt_err.or(last_err)),
275                },
276            );
277
278    match last_error {
279        Ok(last_err) => {
280            Err(last_err.unwrap_or_else(|| anyhow::anyhow!("All agents in commit chain failed")))
281        }
282        Err(result) => Ok(result),
283    }
284}