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