This change removes the legacy logs/ directory and consolidates all session data, error logs, and discovery files under the .g3/ directory. New directory structure: - .g3/sessions/<session_id>/session.json - session logs - .g3/errors/ - error logs (was logs/errors/) - .g3/background_processes/ - background process logs - .g3/discovery/ - planner discovery files (was workspace/logs/) Changes: - paths.rs: Remove get_logs_dir()/logs_dir(), add get_errors_dir(), get_background_processes_dir(), get_discovery_dir() - session.rs: Anonymous sessions now use .g3/sessions/anonymous_<ts>/ - error_handling.rs: Errors now saved to .g3/errors/ - project.rs: Remove logs_dir() and ensure_logs_dir() methods - feedback_extraction.rs: Remove logs_dir field and fallback logic - planner: Use .g3/ for workspace data and .g3/discovery/ for reports - flock.rs: Look for session metrics in .g3/sessions/ - coach_feedback.rs: Remove fallback to logs/ path - Update all tests to use new paths - Update README.md and .gitignore
410 lines
14 KiB
Rust
410 lines
14 KiB
Rust
//! 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
|
||
#[allow(dead_code)]
|
||
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, _tokens_delta: u32, _context_percentage: f32) {}
|
||
|
||
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
|
||
}
|
||
|
||
}
|
||
|
||
/// 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 session data should be written (e.g., /tmp/g3_test_workspace)
|
||
// The codepath is where the source code lives (e.g., ~/RustroverProjects/g3)
|
||
let workspace_path = std::path::PathBuf::from(workspace);
|
||
let project = Project::new(workspace_path.clone());
|
||
project.ensure_workspace_exists()?;
|
||
project.enter_workspace()?;
|
||
|
||
// 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}}}}`.
|
||
|
||
When you are done, provide a brief summary of the refinements you made."#,
|
||
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}}"));
|
||
}
|
||
}
|