Files
g3/crates/g3-planner/src/llm.rs
Jochen 1a13fc5345 Add explicit flush to append_entry and strengthen commit ordering docs
Add file.flush() call in append_entry() to ensure planner history
entries are written to disk before git commits execute. While the
file handle drop should flush, explicit flush simplifies reasoning
about the ordering invariant.

Extend code comments in stage_and_commit() to document that the
write_git_commit-before-git::commit ordering has regressed multiple
times and must be preserved in any refactoring.

Requirements: completed_requirements_2025-12-11_10-05-08.md
2025-12-11 10:05:39 +11:00

414 lines
14 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! LLM integration for planning mode
//!
//! This module provides LLM-based functionality for:
//! - Requirements refinement
//! - Generating requirements summaries
//! - Generating git commit messages
use anyhow::{anyhow, Context, Result};
use std::io::Write;
use g3_config::Config;
use g3_core::project::Project;
use g3_core::Agent;
use g3_core::error_handling::{classify_error, ErrorType};
use g3_providers::{CompletionRequest, LLMProvider, Message, MessageRole};
use crate::prompts;
/// Create an LLM provider for the planner based on config
pub async fn create_planner_provider(
config_path: Option<&str>,
) -> Result<Box<dyn LLMProvider>> {
// Load configuration
let config = Config::load(config_path)
.context("Failed to load configuration")?;
// Get planner provider reference (or default)
let provider_ref = config.get_planner_provider();
// If no explicit planner provider, notify user about fallback
if config.providers.planner.is_none() {
let msg = "Note: No 'planner' provider specified in config. Using default_provider '{provider}' for planning mode."
.replace("{provider}", provider_ref);
println!(" {}", msg);
}
// Parse the provider reference
let (provider_type, config_name) = Config::parse_provider_reference(provider_ref)?;
// Create the appropriate provider
match provider_type.as_str() {
"anthropic" => {
let anthropic_config = config
.get_anthropic_config(&config_name)
.ok_or_else(|| anyhow!("Anthropic config '{}' not found", config_name))?;
let provider = g3_providers::AnthropicProvider::new_with_name(
format!("anthropic.{}", config_name),
anthropic_config.api_key.clone(),
Some(anthropic_config.model.clone()),
anthropic_config.max_tokens,
anthropic_config.temperature,
anthropic_config.cache_config.clone(),
anthropic_config.enable_1m_context,
anthropic_config.thinking_budget_tokens,
)?;
Ok(Box::new(provider))
}
"openai" => {
let openai_config = config
.get_openai_config(&config_name)
.ok_or_else(|| anyhow!("OpenAI config '{}' not found", config_name))?;
let provider = g3_providers::OpenAIProvider::new_with_name(
format!("openai.{}", config_name),
openai_config.api_key.clone(),
Some(openai_config.model.clone()),
openai_config.base_url.clone(),
openai_config.max_tokens,
openai_config.temperature,
)?;
Ok(Box::new(provider))
}
"databricks" => {
let databricks_config = config
.get_databricks_config(&config_name)
.ok_or_else(|| anyhow!("Databricks config '{}' not found", config_name))?;
let provider = if let Some(token) = &databricks_config.token {
g3_providers::DatabricksProvider::from_token_with_name(
format!("databricks.{}", config_name),
databricks_config.host.clone(),
token.clone(),
databricks_config.model.clone(),
databricks_config.max_tokens,
databricks_config.temperature,
)?
} else {
g3_providers::DatabricksProvider::from_oauth_with_name(
format!("databricks.{}", config_name),
databricks_config.host.clone(),
databricks_config.model.clone(),
databricks_config.max_tokens,
databricks_config.temperature,
)
.await?
};
Ok(Box::new(provider))
}
_ => {
Err(anyhow!(
"Unsupported provider type '{}' for planner. Supported: anthropic, openai, databricks",
provider_type
))
}
}
}
/// Generate a summary of requirements for planner_history.txt
///
/// Uses the planner LLM to generate a concise summary of the requirements.
/// The summary is at most 5 lines, each at most 120 characters.
pub async fn generate_requirements_summary(
provider: &dyn LLMProvider,
requirements: &str,
) -> Result<String> {
let prompt = prompts::GENERATE_REQUIREMENTS_SUMMARY_PROMPT
.replace("{requirements}", requirements);
let messages = vec![Message::new(MessageRole::User, prompt)];
let request = CompletionRequest {
messages,
max_tokens: Some(500), // Summary should be short
temperature: Some(0.3), // Low temperature for consistent output
stream: false,
tools: None,
disable_thinking: false,
};
let response = provider
.complete(request)
.await
.context("Failed to generate requirements summary")?;
// Clean up the response - ensure max 5 lines, each max 120 chars
let summary = response
.content
.lines()
.take(5)
.map(|line| {
if line.len() > 120 {
format!("{}...", &line[..117])
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
Ok(summary)
}
/// Generate a git commit message based on the requirements
///
/// Uses the planner LLM to generate a commit summary and description.
/// Returns (summary, description) tuple.
pub async fn generate_commit_message(
provider: &dyn LLMProvider,
requirements: &str,
requirements_file: &str,
todo_file: &str,
) -> Result<(String, String)> {
let prompt = prompts::GENERATE_COMMIT_MESSAGE_PROMPT
.replace("{requirements}", requirements)
.replace("{requirements_file}", requirements_file)
.replace("{todo_file}", todo_file);
let messages = vec![Message::new(MessageRole::User, prompt)];
let request = CompletionRequest {
messages,
max_tokens: Some(1000),
temperature: Some(0.3),
stream: false,
tools: None,
disable_thinking: false,
};
let response = provider
.complete(request)
.await
.context("Failed to generate commit message")?;
// Parse the response using the existing parse_commit_message function
Ok(crate::planner::parse_commit_message(&response.content))
}
/// A simple UiWriter implementation for planner output
/// Uses single-line status updates during LLM processing
#[derive(Clone)]
pub struct PlannerUiWriter {
tool_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl Default for PlannerUiWriter {
fn default() -> Self {
Self::new()
}
}
impl PlannerUiWriter {
pub fn new() -> Self {
Self {
tool_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
/// Clear the current line and print a status message
fn print_status_line(&self, message: &str) {
// Print status message without overwriting previous content
// Use println to ensure each status is on its own line
println!("{:.80}", message);
}
}
impl g3_core::ui_writer::UiWriter for PlannerUiWriter {
fn print(&self, message: &str) {
println!("{}", message);
}
fn println(&self, message: &str) {
println!("{}", message);
}
fn print_inline(&self, message: &str) {
print!("{}", message);
}
fn print_system_prompt(&self, _prompt: &str) {}
fn print_context_status(&self, message: &str) {
println!("📊 {}", message);
}
fn print_context_thinning(&self, message: &str) {
println!("🗜️ {}", message);
}
fn print_tool_header(&self, tool_name: &str, tool_args: Option<&serde_json::Value>) {
let count = self.tool_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
// Format args for display (first 50 chars, must be safe char boundary)
let args_display = if let Some(args) = tool_args {
let args_str = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string());
if args_str.len() > 100 {
// Use char_indices to safely truncate at char boundary
let truncate_idx = args_str.char_indices()
.nth(100)
.map(|(idx, _)| idx)
.unwrap_or(args_str.len());
args_str[..truncate_idx].to_string()
} else {
args_str
}
} else {
"{}".to_string()
};
// Print on EXACTLY one line using ui_writer.println
self.println(&format!("🔧 [{}] \x1b[38;5;240m{} {}\x1b[39m", count, tool_name, args_display));
}
fn print_tool_arg(&self, _key: &str, _value: &str) {}
fn print_tool_output_header(&self) {}
fn update_tool_output_line(&self, _line: &str) {}
fn print_tool_output_line(&self, _line: &str) {}
fn print_tool_output_summary(&self, _hidden_count: usize) {}
fn print_tool_timing(&self, _duration_str: &str) {}
fn print_agent_prompt(&self) {
// No-op - don't add extra blank lines
}
// NOTE: this is a partial response, so don't print newlines. Ideally we'd accumulate the
// message and only then print it.
fn print_agent_response(&self, content: &str) {
// Display non-tool text messages from LLM without adding extra newlines
let trimmed = content.trim_end();
if !trimmed.is_empty() {
// Strip ALL trailing whitespace and DON'T add any back.
// Tool headers already use println!() which adds their own newline.
// Adding newlines here causes cumulative blank lines between tool calls.
print!("{}", trimmed);
std::io::stdout().flush().ok();
}
}
fn notify_sse_received(&self) {
// No-op - we don't want to overwrite previous content
// The "Thinking..." status was causing overwrites
}
fn flush(&self) {
use std::io::Write;
std::io::stdout().flush().ok();
}
fn prompt_user_yes_no(&self, _message: &str) -> bool {
true // Default to yes for automated planner
}
fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize {
0 // Default to first option
}
fn print_final_output(&self, summary: &str) {
println!("\n📝 Final Output:\n{}", summary);
}
}
/// Call LLM to refine requirements using a full Agent with tool execution
pub async fn call_refinement_llm_with_tools(
config: &Config,
codepath: &str,
workspace: &str,
) -> Result<String> {
// Build system message with codepath context
let system_prompt = prompts::REFINE_REQUIREMENTS_SYSTEM_PROMPT
.replace("<codepath>", codepath);
// Build user message
let user_message = build_refinement_user_message(codepath);
// Create agent with planner config
let planner_config = config.for_planner()?;
let ui_writer = PlannerUiWriter::new();
// CRITICAL FIX: Use the actual workspace directory, NOT codepath!
// The workspace is where logs should be written (e.g., /tmp/g3_test_workspace)
// The codepath is where the source code lives (e.g., ~/RustroverProjects/g3)
// Previous bug: was using codepath as workspace, causing logs to go to wrong location
let workspace_path = std::path::PathBuf::from(workspace);
let project = Project::new(workspace_path.clone());
project.ensure_workspace_exists()?;
project.enter_workspace()?;
project.ensure_logs_dir()?;
// Create agent - not autonomous mode, just regular agent with tools
let mut agent = Agent::new_with_readme_and_quiet(
planner_config,
ui_writer,
Some(system_prompt),
false, // not quiet
)
.await?;
// Execute the refinement task
// The agent will have access to tools and execute them
let task = user_message;
let result = match agent
.execute_task_with_timing(&task, None, false, false, false, true, None)
.await
{
Ok(response) => response,
Err(e) => {
// Classify the error
let error_type = classify_error(&e);
// Display user-friendly message based on error type
match error_type {
ErrorType::Recoverable(recoverable) => {
eprintln!("⚠️ Recoverable error: {:?}", recoverable);
eprintln!(" Details: {}", e);
}
ErrorType::NonRecoverable => {
eprintln!("❌ Non-recoverable error: {}", e);
}
}
return Err(e.context("Failed to call refinement LLM"));
}
};
println!("📝 Refinement complete");
Ok(result.response)
}
/// Build the user message for requirements refinement
///
/// This message instructs the LLM to read the codebase and refine requirements.
pub fn build_refinement_user_message(codepath: &str) -> String {
format!(
r#"Please refine the requirements for the codebase at: {codepath}
Before making suggestions, please:
1. Read the codebase structure using shell commands like `ls`, `find`, or `tree`
2. Read `{codepath}/g3-plan/planner_history.txt` to understand past planning activities
3. Read any `{codepath}/g3-plan/completed_requirements_*.md` files to see what was implemented before
4. Read `{codepath}/g3-plan/new_requirements.md` which contains the requirements to refine
After understanding the context, update the `{codepath}/g3-plan/new_requirements.md` file by prepending
your refined requirements under the heading `{{{{CURRENT REQUIREMENTS}}}}`.
Use final_output when you are done to indicate completion."#,
codepath = codepath
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_refinement_user_message() {
let msg = build_refinement_user_message("/test/project");
assert!(msg.contains("/test/project"));
assert!(msg.contains("planner_history.txt"));
assert!(msg.contains("new_requirements.md"));
assert!(msg.contains("{{CURRENT REQUIREMENTS}}"));
}
}