Implement planning mode
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -1529,9 +1529,13 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"const_format",
|
||||
"g3-config",
|
||||
"g3-core",
|
||||
"g3-providers",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shellexpand",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -170,6 +170,33 @@ g3 --autonomous
|
||||
g3 --chat
|
||||
```
|
||||
|
||||
### Planning Mode
|
||||
|
||||
Planning mode provides a structured workflow for requirements-driven development with git integration:
|
||||
|
||||
```bash
|
||||
# Start planning mode for a codebase
|
||||
g3 --planning --codepath ~/my-project
|
||||
|
||||
# Without git operations (for repos not yet initialized)
|
||||
g3 --planning --codepath ~/my-project --no-git
|
||||
```
|
||||
|
||||
Planning mode workflow:
|
||||
1. **Refine Requirements**: Write requirements in `<codepath>/g3-plan/new_requirements.md`, then let the LLM suggest improvements
|
||||
2. **Implement**: Once requirements are approved, they're renamed to `current_requirements.md` and the coach/player loop implements them
|
||||
3. **Complete**: After implementation, files are archived with timestamps (e.g., `completed_requirements_2025-01-15_10-30-00.md`)
|
||||
4. **Git Commit**: Staged files are committed with an LLM-generated commit message
|
||||
5. **Repeat**: Return to step 1 for the next iteration
|
||||
|
||||
All planning artifacts are stored in `<codepath>/g3-plan/`:
|
||||
- `planner_history.txt` - Audit log of all planning activities
|
||||
- `new_requirements.md` / `current_requirements.md` - Active requirements
|
||||
- `todo.g3.md` - Implementation TODO list
|
||||
- `completed_*.md` - Archived requirements and todos
|
||||
|
||||
See the configuration section for setting up different providers for the planner role.
|
||||
|
||||
```bash
|
||||
# Build the project
|
||||
cargo build --release
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
# G3 Configuration Example - Coach/Player Mode
|
||||
#
|
||||
# This configuration demonstrates using different providers for coach and player
|
||||
# roles in autonomous mode. The coach reviews code while the player implements.
|
||||
|
||||
[providers]
|
||||
default_provider = "databricks"
|
||||
# Specify different providers for coach and player in autonomous mode
|
||||
coach = "databricks" # Provider for coach (code reviewer) - can be more powerful/expensive
|
||||
player = "anthropic" # Provider for player (code implementer) - can be faster/cheaper
|
||||
# Default provider used when no specific provider is specified
|
||||
default_provider = "anthropic.default"
|
||||
|
||||
[providers.databricks]
|
||||
host = "https://your-workspace.cloud.databricks.com"
|
||||
# token = "your-databricks-token" # Optional - will use OAuth if not provided
|
||||
model = "databricks-claude-sonnet-4"
|
||||
max_tokens = 4096
|
||||
temperature = 0.1
|
||||
use_oauth = true
|
||||
# cache_config = "ephemeral" # Optional: Enable prompt caching for Claude models
|
||||
# Options: "ephemeral", "5minute", "1hour"
|
||||
# Reduces costs and latency for repeated prompts. Uses Anthropic's prompt caching with different TTLs.
|
||||
# The cache control will be automatically applied to:
|
||||
# - The system prompt at the start of each session
|
||||
# - Assistant responses after every 10 tool calls
|
||||
# - 5minute costs $3/mtok, more details below
|
||||
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#pricing
|
||||
# Coach uses a model optimized for code review and analysis
|
||||
coach = "anthropic.coach"
|
||||
|
||||
[providers.anthropic]
|
||||
# Player uses a model optimized for code generation
|
||||
player = "anthropic.player"
|
||||
|
||||
# Optional: Use a specialized model for planning mode
|
||||
# planner = "anthropic.planner"
|
||||
|
||||
# Default Anthropic configuration
|
||||
[providers.anthropic.default]
|
||||
api_key = "your-anthropic-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3 # Slightly higher temperature for more creative implementations
|
||||
# cache_config = "ephemeral" # Optional: Enable prompt caching
|
||||
# Options: "ephemeral", "5minute", "1hour"
|
||||
# Reduces costs and latency for repeated prompts. Uses Anthropic's prompt caching with different TTLs.
|
||||
# enable_1m_context = true # optional, more expensive
|
||||
max_tokens = 64000
|
||||
temperature = 0.2
|
||||
|
||||
# Coach configuration - focused on careful analysis
|
||||
[providers.anthropic.coach]
|
||||
api_key = "your-anthropic-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 32000
|
||||
temperature = 0.1 # Lower temperature for more consistent reviews
|
||||
|
||||
# Player configuration - focused on code generation
|
||||
[providers.anthropic.player]
|
||||
api_key = "your-anthropic-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 64000
|
||||
temperature = 0.3 # Slightly higher for more creative implementations
|
||||
|
||||
# Optional: Planner configuration with extended thinking
|
||||
# [providers.anthropic.planner]
|
||||
# api_key = "your-anthropic-api-key"
|
||||
# model = "claude-opus-4-5"
|
||||
# max_tokens = 64000
|
||||
# thinking_budget_tokens = 16000 # Enable extended thinking for planning
|
||||
|
||||
# Example: Using Databricks for one of the roles
|
||||
# [providers.databricks.default]
|
||||
# host = "https://your-workspace.cloud.databricks.com"
|
||||
# model = "databricks-claude-sonnet-4"
|
||||
# max_tokens = 4096
|
||||
# temperature = 0.1
|
||||
# use_oauth = true
|
||||
|
||||
[agent]
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
allow_multiple_tool_calls = true # Enable multiple tool calls, will usually only work with Anthropic
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
allow_multiple_tool_calls = true
|
||||
|
||||
[computer_control]
|
||||
enabled = false
|
||||
require_confirmation = true
|
||||
max_actions_per_second = 5
|
||||
|
||||
[webdriver]
|
||||
enabled = false
|
||||
safari_port = 4444
|
||||
|
||||
[macax]
|
||||
enabled = false
|
||||
|
||||
@@ -1,35 +1,52 @@
|
||||
[providers]
|
||||
default_provider = "databricks"
|
||||
# Optional: Specify different providers for coach and player in autonomous mode
|
||||
# If not specified, will use default_provider for both
|
||||
# coach = "databricks" # Provider for coach (code reviewer)
|
||||
# player = "anthropic" # Provider for player (code implementer)
|
||||
# Note: Make sure the specified providers are configured below
|
||||
# G3 Configuration Example
|
||||
#
|
||||
# This file demonstrates the new provider configuration format.
|
||||
# Provider references use the format: "<provider_type>.<config_name>"
|
||||
|
||||
[providers.databricks]
|
||||
[providers]
|
||||
# Default provider used when no specific provider is specified
|
||||
default_provider = "anthropic.default"
|
||||
|
||||
# Optional: Specify different providers for each mode
|
||||
# If not specified, these fall back to default_provider
|
||||
# planner = "anthropic.planner" # Provider for planning mode
|
||||
# coach = "anthropic.default" # Provider for coach (code reviewer) in autonomous mode
|
||||
# player = "anthropic.default" # Provider for player (code implementer) in autonomous mode
|
||||
|
||||
# Named Anthropic configurations
|
||||
[providers.anthropic.default]
|
||||
api_key = "your-anthropic-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 64000
|
||||
temperature = 0.3
|
||||
# cache_config = "ephemeral" # Optional: Enable prompt caching
|
||||
# enable_1m_context = true # Optional: Enable 1M context (costs extra)
|
||||
# thinking_budget_tokens = 10000 # Optional: Enable extended thinking mode
|
||||
|
||||
# Example: A separate config for planning mode with a more capable model
|
||||
# [providers.anthropic.planner]
|
||||
# api_key = "your-anthropic-api-key"
|
||||
# model = "claude-opus-4-5"
|
||||
# max_tokens = 64000
|
||||
# thinking_budget_tokens = 16000
|
||||
|
||||
# Named Databricks configurations
|
||||
[providers.databricks.default]
|
||||
host = "https://your-workspace.cloud.databricks.com"
|
||||
# token = "your-databricks-token" # Optional - will use OAuth if not provided
|
||||
model = "databricks-claude-sonnet-4"
|
||||
max_tokens = 4096 # Per-request output limit (how many tokens the model can generate per response)
|
||||
# Note: This is different from max_context_length (total conversation history size)
|
||||
max_tokens = 4096
|
||||
temperature = 0.1
|
||||
use_oauth = true
|
||||
|
||||
[providers.anthropic]
|
||||
api_key = "your-anthropic-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3 # Slightly higher temperature for more creative implementations
|
||||
# cache_config = "ephemeral" # Optional: Enable prompt caching
|
||||
# Options: "ephemeral", "5minute", "1hour"
|
||||
# Reduces costs and latency for repeated prompts. Uses Anthropic's prompt caching with different TTLs.
|
||||
# enable_1m_context = true # optional, more expensive
|
||||
# thinking_budget_tokens = 10000 # Optional: Enable extended thinking mode with token budget
|
||||
# Allows the model to "think" before responding. Useful for complex reasoning tasks.
|
||||
# Named OpenAI configurations
|
||||
# [providers.openai.default]
|
||||
# api_key = "your-openai-api-key"
|
||||
# model = "gpt-4-turbo"
|
||||
# max_tokens = 4096
|
||||
# temperature = 0.1
|
||||
|
||||
|
||||
# Multiple OpenAI-compatible providers can be configured with custom names
|
||||
# Each provider gets its own section under [providers.openai_compatible.<name>]
|
||||
# Multiple OpenAI-compatible providers can be configured
|
||||
# [providers.openai_compatible.openrouter]
|
||||
# api_key = "your-openrouter-api-key"
|
||||
# model = "anthropic/claude-3.5-sonnet"
|
||||
@@ -44,24 +61,25 @@ temperature = 0.3 # Slightly higher temperature for more creative implementatio
|
||||
# max_tokens = 4096
|
||||
# temperature = 0.1
|
||||
|
||||
# To use one of these providers, set default_provider to the name you chose:
|
||||
# default_provider = "openrouter"
|
||||
|
||||
[agent]
|
||||
fallback_default_max_tokens = 8192
|
||||
# max_context_length: Override the context window size for all providers
|
||||
# This is the total size of conversation history, not per-request output limit
|
||||
# Useful for models with large context windows (e.g., Claude with 200k tokens)
|
||||
# If not set, uses provider-specific defaults based on model capabilities
|
||||
# max_context_length = 200000
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
# Retry configuration for recoverable errors (timeouts, rate limits, etc.)
|
||||
max_retry_attempts = 3 # Default mode retry attempts
|
||||
autonomous_max_retry_attempts = 6 # Autonomous mode retry attempts (higher for long-running tasks)
|
||||
allow_multiple_tool_calls = true # Enable multiple tool calls
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
allow_multiple_tool_calls = true
|
||||
|
||||
[computer_control]
|
||||
enabled = false # Set to true to enable computer control (requires OS permissions)
|
||||
require_confirmation = true
|
||||
max_actions_per_second = 5
|
||||
|
||||
[webdriver]
|
||||
enabled = false
|
||||
safari_port = 4444
|
||||
|
||||
[macax]
|
||||
enabled = false
|
||||
|
||||
@@ -315,6 +315,10 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
pub auto: bool,
|
||||
|
||||
/// Enable interactive chat mode (no autonomous runs)
|
||||
#[arg(long)]
|
||||
pub chat: bool,
|
||||
|
||||
/// Enable machine-friendly output mode with JSON markers and stats
|
||||
#[arg(long)]
|
||||
pub machine: bool,
|
||||
@@ -355,6 +359,18 @@ pub struct Cli {
|
||||
#[arg(long, default_value = "5")]
|
||||
pub flock_max_turns: usize,
|
||||
|
||||
/// Enable planning mode for requirements-driven development
|
||||
#[arg(long, conflicts_with_all = ["autonomous", "auto", "chat"])]
|
||||
pub planning: bool,
|
||||
|
||||
/// Path to the codebase to work on (for planning mode)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub codepath: Option<String>,
|
||||
|
||||
/// Disable git operations in planning mode
|
||||
#[arg(long)]
|
||||
pub no_git: bool,
|
||||
|
||||
/// Enable fast codebase discovery before first LLM turn
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub codebase_fast_start: Option<PathBuf>,
|
||||
@@ -376,13 +392,25 @@ pub async fn run() -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if cli.codebase_fast_start.is_some() {
|
||||
print!("codebase_fast_start is temporarily disabled.");
|
||||
exit(1);
|
||||
}
|
||||
// Otherwise, continue with normal mode
|
||||
|
||||
// Check if planning mode is enabled
|
||||
if cli.planning {
|
||||
// Expand ~ in codepath if provided
|
||||
// The expand_codepath function in g3_planner handles tilde expansion
|
||||
let codepath = cli.codepath.clone();
|
||||
return g3_planner::run_planning_mode(
|
||||
codepath,
|
||||
cli.no_git,
|
||||
cli.config.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Only initialize logging if not in retro mode
|
||||
if !cli.machine {
|
||||
// Initialize logging with filtering
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Main configuration structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub providers: ProvidersConfig,
|
||||
@@ -11,18 +13,40 @@ pub struct Config {
|
||||
pub macax: MacAxConfig,
|
||||
}
|
||||
|
||||
/// Provider configuration with named configs per provider type
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvidersConfig {
|
||||
pub openai: Option<OpenAIConfig>,
|
||||
/// Default provider in format "<provider_type>.<config_name>"
|
||||
pub default_provider: String,
|
||||
|
||||
/// Provider for planner mode (optional, falls back to default_provider)
|
||||
pub planner: Option<String>,
|
||||
|
||||
/// Provider for coach in autonomous mode (optional, falls back to default_provider)
|
||||
pub coach: Option<String>,
|
||||
|
||||
/// Provider for player in autonomous mode (optional, falls back to default_provider)
|
||||
pub player: Option<String>,
|
||||
|
||||
/// Named Anthropic provider configs
|
||||
#[serde(default)]
|
||||
pub anthropic: HashMap<String, AnthropicConfig>,
|
||||
|
||||
/// Named OpenAI provider configs
|
||||
#[serde(default)]
|
||||
pub openai: HashMap<String, OpenAIConfig>,
|
||||
|
||||
/// Named Databricks provider configs
|
||||
#[serde(default)]
|
||||
pub databricks: HashMap<String, DatabricksConfig>,
|
||||
|
||||
/// Named embedded provider configs
|
||||
#[serde(default)]
|
||||
pub embedded: HashMap<String, EmbeddedConfig>,
|
||||
|
||||
/// Multiple named OpenAI-compatible providers (e.g., openrouter, groq, etc.)
|
||||
#[serde(default)]
|
||||
pub openai_compatible: std::collections::HashMap<String, OpenAIConfig>,
|
||||
pub anthropic: Option<AnthropicConfig>,
|
||||
pub databricks: Option<DatabricksConfig>,
|
||||
pub embedded: Option<EmbeddedConfig>,
|
||||
pub default_provider: String,
|
||||
pub coach: Option<String>, // Provider to use for coach in autonomous mode
|
||||
pub player: Option<String>, // Provider to use for player in autonomous mode
|
||||
pub openai_compatible: HashMap<String, OpenAIConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -40,30 +64,30 @@ pub struct AnthropicConfig {
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub cache_config: Option<String>, // "ephemeral", "5minute", "1hour", or None to disable
|
||||
pub enable_1m_context: Option<bool>, // Enable 1m context window (costs extra)
|
||||
pub thinking_budget_tokens: Option<u32>, // Budget tokens for extended thinking
|
||||
pub cache_config: Option<String>,
|
||||
pub enable_1m_context: Option<bool>,
|
||||
pub thinking_budget_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabricksConfig {
|
||||
pub host: String,
|
||||
pub token: Option<String>, // Optional - will use OAuth if not provided
|
||||
pub token: Option<String>,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub use_oauth: Option<bool>, // Default to true if token not provided
|
||||
pub use_oauth: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddedConfig {
|
||||
pub model_path: String,
|
||||
pub model_type: String, // e.g., "llama", "mistral", "codellama"
|
||||
pub model_type: String,
|
||||
pub context_length: Option<u32>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub gpu_layers: Option<u32>, // Number of layers to offload to GPU
|
||||
pub threads: Option<u32>, // Number of CPU threads to use
|
||||
pub gpu_layers: Option<u32>,
|
||||
pub threads: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -120,7 +144,7 @@ impl Default for WebDriverConfig {
|
||||
impl Default for ComputerControlConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false, // Disabled by default for safety
|
||||
enabled: false,
|
||||
require_confirmation: true,
|
||||
max_actions_per_second: 5,
|
||||
}
|
||||
@@ -129,23 +153,30 @@ impl Default for ComputerControlConfig {
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let mut databricks_configs = HashMap::new();
|
||||
databricks_configs.insert(
|
||||
"default".to_string(),
|
||||
DatabricksConfig {
|
||||
host: "https://your-workspace.cloud.databricks.com".to_string(),
|
||||
token: None,
|
||||
model: "databricks-claude-sonnet-4".to_string(),
|
||||
max_tokens: Some(4096),
|
||||
temperature: Some(0.1),
|
||||
use_oauth: Some(true),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
providers: ProvidersConfig {
|
||||
openai: None,
|
||||
openai_compatible: std::collections::HashMap::new(),
|
||||
anthropic: None,
|
||||
databricks: Some(DatabricksConfig {
|
||||
host: "https://your-workspace.cloud.databricks.com".to_string(),
|
||||
token: None, // Will use OAuth by default
|
||||
model: "databricks-claude-sonnet-4".to_string(),
|
||||
max_tokens: Some(4096),
|
||||
temperature: Some(0.1),
|
||||
use_oauth: Some(true),
|
||||
}),
|
||||
embedded: None,
|
||||
default_provider: "databricks".to_string(),
|
||||
coach: None, // Will use default_provider if not specified
|
||||
player: None, // Will use default_provider if not specified
|
||||
default_provider: "databricks.default".to_string(),
|
||||
planner: None,
|
||||
coach: None,
|
||||
player: None,
|
||||
anthropic: HashMap::new(),
|
||||
openai: HashMap::new(),
|
||||
databricks: databricks_configs,
|
||||
embedded: HashMap::new(),
|
||||
openai_compatible: HashMap::new(),
|
||||
},
|
||||
agent: AgentConfig {
|
||||
max_context_length: None,
|
||||
@@ -165,26 +196,54 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Error message for old config format
|
||||
const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported.
|
||||
|
||||
Please update your configuration to use the new provider format:
|
||||
|
||||
```toml
|
||||
[providers]
|
||||
default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
|
||||
planner = "anthropic.planner" # Optional: specific provider for planner
|
||||
coach = "anthropic.default" # Optional: specific provider for coach
|
||||
player = "openai.player" # Optional: specific provider for player
|
||||
|
||||
# Named configs per provider type
|
||||
[providers.anthropic.default]
|
||||
api_key = "your-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 64000
|
||||
|
||||
[providers.anthropic.planner]
|
||||
api_key = "your-api-key"
|
||||
model = "claude-opus-4-5"
|
||||
thinking_budget_tokens = 16000
|
||||
|
||||
[providers.openai.player]
|
||||
api_key = "your-api-key"
|
||||
model = "gpt-5"
|
||||
```
|
||||
|
||||
Each mode (planner, coach, player) can specify a full path like "<provider_type>.<config_name>".
|
||||
If not specified, they fall back to `default_provider`."#;
|
||||
|
||||
impl Config {
|
||||
pub fn load(config_path: Option<&str>) -> Result<Self> {
|
||||
// Check if any config file exists
|
||||
let config_exists = if let Some(path) = config_path {
|
||||
Path::new(path).exists()
|
||||
} else {
|
||||
// Check default locations
|
||||
let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
|
||||
|
||||
default_paths.iter().any(|path| {
|
||||
let expanded_path = shellexpand::tilde(path);
|
||||
Path::new(expanded_path.as_ref()).exists()
|
||||
})
|
||||
};
|
||||
|
||||
// If no config exists, create and save a default Databricks config
|
||||
// If no config exists, create and save a default config
|
||||
if !config_exists {
|
||||
let databricks_config = Self::default();
|
||||
let default_config = Self::default();
|
||||
|
||||
// Save to default location
|
||||
let config_dir = dirs::home_dir()
|
||||
.map(|mut path| {
|
||||
path.push(".config");
|
||||
@@ -193,89 +252,171 @@ impl Config {
|
||||
})
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
std::fs::create_dir_all(&config_dir).ok();
|
||||
|
||||
let config_file = config_dir.join("config.toml");
|
||||
if let Err(e) = databricks_config.save(config_file.to_str().unwrap()) {
|
||||
if let Err(e) = default_config.save(config_file.to_str().unwrap()) {
|
||||
eprintln!("Warning: Could not save default config: {}", e);
|
||||
} else {
|
||||
println!(
|
||||
"Created default Databricks configuration at: {}",
|
||||
"Created default configuration at: {}",
|
||||
config_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(databricks_config);
|
||||
return Ok(default_config);
|
||||
}
|
||||
|
||||
// Existing config loading logic
|
||||
let mut settings = config::Config::builder();
|
||||
|
||||
// Load default configuration
|
||||
settings = settings.add_source(config::Config::try_from(&Config::default())?);
|
||||
|
||||
// Load from config file if provided
|
||||
if let Some(path) = config_path {
|
||||
if Path::new(path).exists() {
|
||||
settings = settings.add_source(config::File::with_name(path));
|
||||
}
|
||||
// Load config from file
|
||||
let config_path_to_load = if let Some(path) = config_path {
|
||||
Some(path.to_string())
|
||||
} else {
|
||||
// Try to load from default locations
|
||||
let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
|
||||
|
||||
for path in &default_paths {
|
||||
default_paths.iter().find_map(|path| {
|
||||
let expanded_path = shellexpand::tilde(path);
|
||||
if Path::new(expanded_path.as_ref()).exists() {
|
||||
settings = settings.add_source(config::File::with_name(expanded_path.as_ref()));
|
||||
break;
|
||||
Some(expanded_path.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(path) = config_path_to_load {
|
||||
// Read and parse the config file
|
||||
let config_content = std::fs::read_to_string(&path)?;
|
||||
|
||||
// Check for old format (direct provider config without named configs)
|
||||
if Self::is_old_format(&config_content) {
|
||||
anyhow::bail!("{}", OLD_CONFIG_FORMAT_ERROR);
|
||||
}
|
||||
|
||||
let config: Config = toml::from_str(&config_content)?;
|
||||
|
||||
// Validate the default_provider format
|
||||
config.validate_provider_reference(&config.providers.default_provider)?;
|
||||
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
Ok(Self::default())
|
||||
}
|
||||
|
||||
/// Check if the config content uses the old format
|
||||
fn is_old_format(content: &str) -> bool {
|
||||
// Old format has [providers.anthropic] with api_key directly
|
||||
// New format has [providers.anthropic.<name>] with api_key
|
||||
|
||||
// Parse as TOML value to inspect structure
|
||||
if let Ok(value) = content.parse::<toml::Value>() {
|
||||
if let Some(providers) = value.get("providers") {
|
||||
if let Some(providers_table) = providers.as_table() {
|
||||
// Check anthropic section
|
||||
if let Some(anthropic) = providers_table.get("anthropic") {
|
||||
if let Some(anthropic_table) = anthropic.as_table() {
|
||||
// If anthropic has api_key directly, it's old format
|
||||
if anthropic_table.contains_key("api_key") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check databricks section
|
||||
if let Some(databricks) = providers_table.get("databricks") {
|
||||
if let Some(databricks_table) = databricks.as_table() {
|
||||
// If databricks has host directly, it's old format
|
||||
if databricks_table.contains_key("host") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check openai section
|
||||
if let Some(openai) = providers_table.get("openai") {
|
||||
if let Some(openai_table) = openai.as_table() {
|
||||
// If openai has api_key directly, it's old format
|
||||
if openai_table.contains_key("api_key") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Validate a provider reference (format: "<provider_type>.<config_name>")
|
||||
fn validate_provider_reference(&self, reference: &str) -> Result<()> {
|
||||
let parts: Vec<&str> = reference.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
anyhow::bail!(
|
||||
"Invalid provider reference '{}'. Expected format: '<provider_type>.<config_name>'",
|
||||
reference
|
||||
);
|
||||
}
|
||||
|
||||
let (provider_type, config_name) = (parts[0], parts[1]);
|
||||
|
||||
match provider_type {
|
||||
"anthropic" => {
|
||||
if !self.providers.anthropic.contains_key(config_name) {
|
||||
anyhow::bail!(
|
||||
"Provider config 'anthropic.{}' not found. Available: {:?}",
|
||||
config_name,
|
||||
self.providers.anthropic.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
"openai" => {
|
||||
if !self.providers.openai.contains_key(config_name) {
|
||||
anyhow::bail!(
|
||||
"Provider config 'openai.{}' not found. Available: {:?}",
|
||||
config_name,
|
||||
self.providers.openai.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
"databricks" => {
|
||||
if !self.providers.databricks.contains_key(config_name) {
|
||||
anyhow::bail!(
|
||||
"Provider config 'databricks.{}' not found. Available: {:?}",
|
||||
config_name,
|
||||
self.providers.databricks.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
"embedded" => {
|
||||
if !self.providers.embedded.contains_key(config_name) {
|
||||
anyhow::bail!(
|
||||
"Provider config 'embedded.{}' not found. Available: {:?}",
|
||||
config_name,
|
||||
self.providers.embedded.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Check openai_compatible providers
|
||||
if !self.providers.openai_compatible.contains_key(provider_type) {
|
||||
anyhow::bail!(
|
||||
"Unknown provider type '{}'. Valid types: anthropic, openai, databricks, embedded, or openai_compatible names",
|
||||
provider_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
settings = settings.add_source(config::Environment::with_prefix("G3").separator("_"));
|
||||
|
||||
let config = settings.build()?.try_deserialize()?;
|
||||
Ok(config)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn default_qwen_config() -> Self {
|
||||
Self {
|
||||
providers: ProvidersConfig {
|
||||
openai: None,
|
||||
openai_compatible: std::collections::HashMap::new(),
|
||||
anthropic: None,
|
||||
databricks: None,
|
||||
embedded: Some(EmbeddedConfig {
|
||||
model_path: "~/.cache/g3/models/qwen2.5-7b-instruct-q3_k_m.gguf".to_string(),
|
||||
model_type: "qwen".to_string(),
|
||||
context_length: Some(32768), // Qwen2.5 supports 32k context
|
||||
max_tokens: Some(2048),
|
||||
temperature: Some(0.1),
|
||||
gpu_layers: Some(32),
|
||||
threads: Some(8),
|
||||
}),
|
||||
default_provider: "embedded".to_string(),
|
||||
coach: None, // Will use default_provider if not specified
|
||||
player: None, // Will use default_provider if not specified
|
||||
},
|
||||
agent: AgentConfig {
|
||||
max_context_length: None,
|
||||
fallback_default_max_tokens: 8192,
|
||||
enable_streaming: true,
|
||||
allow_multiple_tool_calls: false,
|
||||
timeout_seconds: 60,
|
||||
auto_compact: true,
|
||||
max_retry_attempts: 3,
|
||||
autonomous_max_retry_attempts: 6,
|
||||
check_todo_staleness: true,
|
||||
},
|
||||
computer_control: ComputerControlConfig::default(),
|
||||
webdriver: WebDriverConfig::default(),
|
||||
macax: MacAxConfig::default(),
|
||||
/// Parse a provider reference into (provider_type, config_name)
|
||||
pub fn parse_provider_reference(reference: &str) -> Result<(String, String)> {
|
||||
let parts: Vec<&str> = reference.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
anyhow::bail!(
|
||||
"Invalid provider reference '{}'. Expected format: '<provider_type>.<config_name>'",
|
||||
reference
|
||||
);
|
||||
}
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &str) -> Result<()> {
|
||||
@@ -289,58 +430,72 @@ impl Config {
|
||||
provider_override: Option<String>,
|
||||
model_override: Option<String>,
|
||||
) -> Result<Self> {
|
||||
// Load the base configuration
|
||||
let mut config = Self::load(config_path)?;
|
||||
|
||||
// Apply provider override
|
||||
if let Some(provider) = provider_override {
|
||||
// Validate the override
|
||||
config.validate_provider_reference(&provider)?;
|
||||
config.providers.default_provider = provider;
|
||||
}
|
||||
|
||||
// Apply model override to the active provider
|
||||
if let Some(model) = model_override {
|
||||
match config.providers.default_provider.as_str() {
|
||||
let (provider_type, config_name) = Self::parse_provider_reference(
|
||||
&config.providers.default_provider
|
||||
)?;
|
||||
|
||||
match provider_type.as_str() {
|
||||
"anthropic" => {
|
||||
if let Some(ref mut anthropic) = config.providers.anthropic {
|
||||
anthropic.model = model;
|
||||
if let Some(ref mut anthropic_config) = config.providers.anthropic.get_mut(&config_name) {
|
||||
anthropic_config.model = model;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider 'anthropic' is not configured. Please add anthropic configuration to your config file."
|
||||
"Provider config 'anthropic.{}' not found.",
|
||||
config_name
|
||||
));
|
||||
}
|
||||
}
|
||||
"databricks" => {
|
||||
if let Some(ref mut databricks) = config.providers.databricks {
|
||||
databricks.model = model;
|
||||
if let Some(ref mut databricks_config) = config.providers.databricks.get_mut(&config_name) {
|
||||
databricks_config.model = model;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider 'databricks' is not configured. Please add databricks configuration to your config file."
|
||||
"Provider config 'databricks.{}' not found.",
|
||||
config_name
|
||||
));
|
||||
}
|
||||
}
|
||||
"embedded" => {
|
||||
if let Some(ref mut embedded) = config.providers.embedded {
|
||||
embedded.model_path = model;
|
||||
if let Some(ref mut embedded_config) = config.providers.embedded.get_mut(&config_name) {
|
||||
embedded_config.model_path = model;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider 'embedded' is not configured. Please add embedded configuration to your config file."
|
||||
"Provider config 'embedded.{}' not found.",
|
||||
config_name
|
||||
));
|
||||
}
|
||||
}
|
||||
"openai" => {
|
||||
if let Some(ref mut openai) = config.providers.openai {
|
||||
openai.model = model;
|
||||
if let Some(ref mut openai_config) = config.providers.openai.get_mut(&config_name) {
|
||||
openai_config.model = model;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider 'openai' is not configured. Please add openai configuration to your config file."
|
||||
"Provider config 'openai.{}' not found.",
|
||||
config_name
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unknown provider: {}",
|
||||
config.providers.default_provider
|
||||
))
|
||||
// Check openai_compatible
|
||||
if let Some(ref mut compat_config) = config.providers.openai_compatible.get_mut(&provider_type) {
|
||||
compat_config.model = model;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unknown provider type: {}",
|
||||
provider_type
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +503,15 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get the provider to use for coach mode in autonomous execution
|
||||
/// Get the provider reference for planner mode
|
||||
pub fn get_planner_provider(&self) -> &str {
|
||||
self.providers
|
||||
.planner
|
||||
.as_deref()
|
||||
.unwrap_or(&self.providers.default_provider)
|
||||
}
|
||||
|
||||
/// Get the provider reference for coach mode in autonomous execution
|
||||
pub fn get_coach_provider(&self) -> &str {
|
||||
self.providers
|
||||
.coach
|
||||
@@ -356,7 +519,7 @@ impl Config {
|
||||
.unwrap_or(&self.providers.default_provider)
|
||||
}
|
||||
|
||||
/// Get the provider to use for player mode in autonomous execution
|
||||
/// Get the provider reference for player mode in autonomous execution
|
||||
pub fn get_player_provider(&self) -> &str {
|
||||
self.providers
|
||||
.player
|
||||
@@ -365,41 +528,20 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Create a copy of the config with a different default provider
|
||||
pub fn with_provider_override(&self, provider: &str) -> Result<Self> {
|
||||
pub fn with_provider_override(&self, provider_ref: &str) -> Result<Self> {
|
||||
// Validate that the provider is configured
|
||||
match provider {
|
||||
"anthropic" if self.providers.anthropic.is_none() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
|
||||
provider, provider
|
||||
));
|
||||
}
|
||||
"databricks" if self.providers.databricks.is_none() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
|
||||
provider, provider
|
||||
));
|
||||
}
|
||||
"embedded" if self.providers.embedded.is_none() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
|
||||
provider, provider
|
||||
));
|
||||
}
|
||||
"openai" if self.providers.openai.is_none() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider '{}' is specified but not configured. Please add {} configuration to your config file.",
|
||||
provider, provider
|
||||
));
|
||||
}
|
||||
_ => {} // Provider is configured or unknown (will be caught later)
|
||||
}
|
||||
self.validate_provider_reference(provider_ref)?;
|
||||
|
||||
let mut config = self.clone();
|
||||
config.providers.default_provider = provider.to_string();
|
||||
config.providers.default_provider = provider_ref.to_string();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Create a copy of the config for planner mode
|
||||
pub fn for_planner(&self) -> Result<Self> {
|
||||
self.with_provider_override(self.get_planner_provider())
|
||||
}
|
||||
|
||||
/// Create a copy of the config for coach mode in autonomous execution
|
||||
pub fn for_coach(&self) -> Result<Self> {
|
||||
self.with_provider_override(self.get_coach_provider())
|
||||
@@ -409,6 +551,71 @@ impl Config {
|
||||
pub fn for_player(&self) -> Result<Self> {
|
||||
self.with_provider_override(self.get_player_provider())
|
||||
}
|
||||
|
||||
/// Get Anthropic config by name
|
||||
pub fn get_anthropic_config(&self, name: &str) -> Option<&AnthropicConfig> {
|
||||
self.providers.anthropic.get(name)
|
||||
}
|
||||
|
||||
/// Get OpenAI config by name
|
||||
pub fn get_openai_config(&self, name: &str) -> Option<&OpenAIConfig> {
|
||||
self.providers.openai.get(name)
|
||||
}
|
||||
|
||||
/// Get Databricks config by name
|
||||
pub fn get_databricks_config(&self, name: &str) -> Option<&DatabricksConfig> {
|
||||
self.providers.databricks.get(name)
|
||||
}
|
||||
|
||||
/// Get Embedded config by name
|
||||
pub fn get_embedded_config(&self, name: &str) -> Option<&EmbeddedConfig> {
|
||||
self.providers.embedded.get(name)
|
||||
}
|
||||
|
||||
/// Get the current default provider's config
|
||||
pub fn get_default_provider_config(&self) -> Result<ProviderConfigRef<'_>> {
|
||||
let (provider_type, config_name) = Self::parse_provider_reference(
|
||||
&self.providers.default_provider
|
||||
)?;
|
||||
|
||||
match provider_type.as_str() {
|
||||
"anthropic" => {
|
||||
self.providers.anthropic.get(&config_name)
|
||||
.map(ProviderConfigRef::Anthropic)
|
||||
.ok_or_else(|| anyhow::anyhow!("Anthropic config '{}' not found", config_name))
|
||||
}
|
||||
"openai" => {
|
||||
self.providers.openai.get(&config_name)
|
||||
.map(ProviderConfigRef::OpenAI)
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenAI config '{}' not found", config_name))
|
||||
}
|
||||
"databricks" => {
|
||||
self.providers.databricks.get(&config_name)
|
||||
.map(ProviderConfigRef::Databricks)
|
||||
.ok_or_else(|| anyhow::anyhow!("Databricks config '{}' not found", config_name))
|
||||
}
|
||||
"embedded" => {
|
||||
self.providers.embedded.get(&config_name)
|
||||
.map(ProviderConfigRef::Embedded)
|
||||
.ok_or_else(|| anyhow::anyhow!("Embedded config '{}' not found", config_name))
|
||||
}
|
||||
_ => {
|
||||
self.providers.openai_compatible.get(&provider_type)
|
||||
.map(ProviderConfigRef::OpenAICompatible)
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenAI compatible config '{}' not found", provider_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference to a provider configuration
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderConfigRef<'a> {
|
||||
Anthropic(&'a AnthropicConfig),
|
||||
OpenAI(&'a OpenAIConfig),
|
||||
Databricks(&'a DatabricksConfig),
|
||||
Embedded(&'a EmbeddedConfig),
|
||||
OpenAICompatible(&'a OpenAIConfig),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,29 +4,45 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_config_footer() -> &'static str {
|
||||
r#"
|
||||
[computer_control]
|
||||
enabled = false
|
||||
require_confirmation = true
|
||||
max_actions_per_second = 10
|
||||
|
||||
[webdriver]
|
||||
enabled = false
|
||||
safari_port = 4444
|
||||
|
||||
[macax]
|
||||
enabled = false
|
||||
"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coach_player_providers() {
|
||||
// Create a temporary directory for the test config
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test configuration with coach and player providers
|
||||
let config_content = r#"
|
||||
// Write a test configuration with coach and player providers (new format)
|
||||
let config_content = format!(r#"
|
||||
[providers]
|
||||
default_provider = "databricks"
|
||||
coach = "anthropic"
|
||||
player = "embedded"
|
||||
default_provider = "databricks.default"
|
||||
coach = "anthropic.default"
|
||||
player = "embedded.local"
|
||||
|
||||
[providers.databricks]
|
||||
[providers.databricks.default]
|
||||
host = "https://test.databricks.com"
|
||||
token = "test-token"
|
||||
model = "test-model"
|
||||
|
||||
[providers.anthropic]
|
||||
[providers.anthropic.default]
|
||||
api_key = "test-key"
|
||||
model = "claude-3"
|
||||
|
||||
[providers.embedded]
|
||||
[providers.embedded.local]
|
||||
model_path = "test.gguf"
|
||||
model_type = "llama"
|
||||
|
||||
@@ -34,7 +50,11 @@ model_type = "llama"
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
"#;
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
{}"#, test_config_footer());
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
@@ -42,17 +62,17 @@ timeout_seconds = 60
|
||||
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
|
||||
|
||||
// Test that the providers are correctly identified
|
||||
assert_eq!(config.providers.default_provider, "databricks");
|
||||
assert_eq!(config.get_coach_provider(), "anthropic");
|
||||
assert_eq!(config.get_player_provider(), "embedded");
|
||||
assert_eq!(config.providers.default_provider, "databricks.default");
|
||||
assert_eq!(config.get_coach_provider(), "anthropic.default");
|
||||
assert_eq!(config.get_player_provider(), "embedded.local");
|
||||
|
||||
// Test creating coach config
|
||||
let coach_config = config.for_coach().unwrap();
|
||||
assert_eq!(coach_config.providers.default_provider, "anthropic");
|
||||
assert_eq!(coach_config.providers.default_provider, "anthropic.default");
|
||||
|
||||
// Test creating player config
|
||||
let player_config = config.for_player().unwrap();
|
||||
assert_eq!(player_config.providers.default_provider, "embedded");
|
||||
assert_eq!(player_config.providers.default_provider, "embedded.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -61,12 +81,12 @@ timeout_seconds = 60
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test configuration WITHOUT coach and player providers
|
||||
let config_content = r#"
|
||||
// Write a test configuration WITHOUT coach and player providers (new format)
|
||||
let config_content = format!(r#"
|
||||
[providers]
|
||||
default_provider = "databricks"
|
||||
default_provider = "databricks.default"
|
||||
|
||||
[providers.databricks]
|
||||
[providers.databricks.default]
|
||||
host = "https://test.databricks.com"
|
||||
token = "test-token"
|
||||
model = "test-model"
|
||||
@@ -75,7 +95,11 @@ model = "test-model"
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
"#;
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
{}"#, test_config_footer());
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
@@ -83,16 +107,16 @@ timeout_seconds = 60
|
||||
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
|
||||
|
||||
// Test that coach and player fall back to default provider
|
||||
assert_eq!(config.get_coach_provider(), "databricks");
|
||||
assert_eq!(config.get_player_provider(), "databricks");
|
||||
assert_eq!(config.get_coach_provider(), "databricks.default");
|
||||
assert_eq!(config.get_player_provider(), "databricks.default");
|
||||
|
||||
// Test creating coach config (should use default)
|
||||
let coach_config = config.for_coach().unwrap();
|
||||
assert_eq!(coach_config.providers.default_provider, "databricks");
|
||||
assert_eq!(coach_config.providers.default_provider, "databricks.default");
|
||||
|
||||
// Test creating player config (should use default)
|
||||
let player_config = config.for_player().unwrap();
|
||||
assert_eq!(player_config.providers.default_provider, "databricks");
|
||||
assert_eq!(player_config.providers.default_provider, "databricks.default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -101,13 +125,13 @@ timeout_seconds = 60
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test configuration with an unconfigured provider
|
||||
let config_content = r#"
|
||||
// Write a test configuration with an unconfigured provider (new format)
|
||||
let config_content = format!(r#"
|
||||
[providers]
|
||||
default_provider = "databricks"
|
||||
coach = "openai" # OpenAI is not configured
|
||||
default_provider = "databricks.default"
|
||||
coach = "openai.default" # OpenAI default is not configured
|
||||
|
||||
[providers.databricks]
|
||||
[providers.databricks.default]
|
||||
host = "https://test.databricks.com"
|
||||
token = "test-token"
|
||||
model = "test-model"
|
||||
@@ -116,7 +140,11 @@ model = "test-model"
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
"#;
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
{}"#, test_config_footer());
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
@@ -126,6 +154,123 @@ timeout_seconds = 60
|
||||
// Test that trying to create a coach config with unconfigured provider fails
|
||||
let result = config.for_coach();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not configured"));
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("not found") || err_msg.contains("not configured"),
|
||||
"Expected error message to contain 'not found' or 'not configured', got: {}", err_msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_format_detection() {
|
||||
// Create a temporary directory for the test config
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test configuration with OLD format (api_key directly under [providers.anthropic])
|
||||
let config_content = format!(r#"
|
||||
[providers]
|
||||
default_provider = "anthropic"
|
||||
|
||||
[providers.anthropic]
|
||||
api_key = "test-key"
|
||||
model = "claude-3"
|
||||
|
||||
[agent]
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
{}"#, test_config_footer());
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
// Loading should fail with old format error
|
||||
let result = Config::load(Some(config_path.to_str().unwrap()));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("old format") || err_msg.contains("no longer supported"),
|
||||
"Expected error about old format, got: {}", err_msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_planner_provider() {
|
||||
// Create a temporary directory for the test config
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test configuration with planner provider (new format)
|
||||
let config_content = format!(r#"
|
||||
[providers]
|
||||
default_provider = "databricks.default"
|
||||
planner = "anthropic.planner"
|
||||
|
||||
[providers.databricks.default]
|
||||
host = "https://test.databricks.com"
|
||||
token = "test-token"
|
||||
model = "test-model"
|
||||
|
||||
[providers.anthropic.planner]
|
||||
api_key = "test-key"
|
||||
model = "claude-opus"
|
||||
thinking_budget_tokens = 16000
|
||||
|
||||
[agent]
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
{}"#, test_config_footer());
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
// Load the configuration
|
||||
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
|
||||
|
||||
// Test that the planner provider is correctly identified
|
||||
assert_eq!(config.get_planner_provider(), "anthropic.planner");
|
||||
|
||||
// Test creating planner config
|
||||
let planner_config = config.for_planner().unwrap();
|
||||
assert_eq!(planner_config.providers.default_provider, "anthropic.planner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_planner_fallback_to_default() {
|
||||
// Create a temporary directory for the test config
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("test_config.toml");
|
||||
|
||||
// Write a test configuration WITHOUT planner provider
|
||||
let config_content = format!(r#"
|
||||
[providers]
|
||||
default_provider = "databricks.default"
|
||||
|
||||
[providers.databricks.default]
|
||||
host = "https://test.databricks.com"
|
||||
token = "test-token"
|
||||
model = "test-model"
|
||||
|
||||
[agent]
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
{}"#, test_config_footer());
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
// Load the configuration
|
||||
let config = Config::load(Some(config_path.to_str().unwrap())).unwrap();
|
||||
|
||||
// Test that planner falls back to default provider
|
||||
assert_eq!(config.get_planner_provider(), "databricks.default");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,18 @@ use std::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Get the path to the todo.g3.md file.
|
||||
///
|
||||
/// Checks for G3_TODO_PATH environment variable first (used by planning mode),
|
||||
/// then falls back to todo.g3.md in the current directory.
|
||||
fn get_todo_path() -> std::path::PathBuf {
|
||||
if let Ok(custom_path) = std::env::var("G3_TODO_PATH") {
|
||||
std::path::PathBuf::from(custom_path)
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join("todo.g3.md")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub tool: String,
|
||||
@@ -1119,12 +1131,18 @@ impl<W: UiWriter> Agent<W> {
|
||||
vec![config.providers.default_provider.clone()]
|
||||
};
|
||||
|
||||
// Only register providers that are configured AND selected as the default provider
|
||||
// Only register providers that are configured AND selected
|
||||
// This prevents unnecessary initialization of heavy providers like embedded models
|
||||
|
||||
// Register embedded provider if configured AND it's the default provider
|
||||
if let Some(embedded_config) = &config.providers.embedded {
|
||||
if providers_to_register.contains(&"embedded".to_string()) {
|
||||
// Helper to check if a provider ref should be registered
|
||||
let should_register = |provider_type: &str, config_name: &str| -> bool {
|
||||
let full_ref = format!("{}.{}", provider_type, config_name);
|
||||
providers_to_register.iter().any(|p| p == &full_ref || p.starts_with(&format!("{}.", provider_type)))
|
||||
};
|
||||
|
||||
// Register embedded providers from HashMap
|
||||
for (name, embedded_config) in &config.providers.embedded {
|
||||
if should_register("embedded", name) {
|
||||
let embedded_provider = g3_providers::EmbeddedProvider::new(
|
||||
embedded_config.model_path.clone(),
|
||||
embedded_config.model_type.clone(),
|
||||
@@ -1138,10 +1156,11 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
}
|
||||
|
||||
// Register OpenAI provider if configured AND it's the default provider
|
||||
if let Some(openai_config) = &config.providers.openai {
|
||||
if providers_to_register.contains(&"openai".to_string()) {
|
||||
let openai_provider = g3_providers::OpenAIProvider::new(
|
||||
// Register OpenAI providers from HashMap
|
||||
for (name, openai_config) in &config.providers.openai {
|
||||
if should_register("openai", name) {
|
||||
let openai_provider = g3_providers::OpenAIProvider::new_with_name(
|
||||
format!("openai.{}", name),
|
||||
openai_config.api_key.clone(),
|
||||
Some(openai_config.model.clone()),
|
||||
openai_config.base_url.clone(),
|
||||
@@ -1154,7 +1173,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// Register OpenAI-compatible providers (e.g., OpenRouter, Groq, etc.)
|
||||
for (name, openai_config) in &config.providers.openai_compatible {
|
||||
if providers_to_register.contains(name) {
|
||||
if should_register(name, "default") {
|
||||
let openai_provider = g3_providers::OpenAIProvider::new_with_name(
|
||||
name.clone(),
|
||||
openai_config.api_key.clone(),
|
||||
@@ -1167,10 +1186,11 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
}
|
||||
|
||||
// Register Anthropic provider if configured AND it's the default provider
|
||||
if let Some(anthropic_config) = &config.providers.anthropic {
|
||||
if providers_to_register.contains(&"anthropic".to_string()) {
|
||||
let anthropic_provider = g3_providers::AnthropicProvider::new(
|
||||
// Register Anthropic providers from HashMap
|
||||
for (name, anthropic_config) in &config.providers.anthropic {
|
||||
if should_register("anthropic", name) {
|
||||
let anthropic_provider = g3_providers::AnthropicProvider::new_with_name(
|
||||
format!("anthropic.{}", name),
|
||||
anthropic_config.api_key.clone(),
|
||||
Some(anthropic_config.model.clone()),
|
||||
anthropic_config.max_tokens,
|
||||
@@ -1183,12 +1203,13 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
}
|
||||
|
||||
// Register Databricks provider if configured AND it's the default provider
|
||||
if let Some(databricks_config) = &config.providers.databricks {
|
||||
if providers_to_register.contains(&"databricks".to_string()) {
|
||||
// Register Databricks providers from HashMap
|
||||
for (name, databricks_config) in &config.providers.databricks {
|
||||
if should_register("databricks", name) {
|
||||
let databricks_provider = if let Some(token) = &databricks_config.token {
|
||||
// Use token-based authentication
|
||||
g3_providers::DatabricksProvider::from_token(
|
||||
g3_providers::DatabricksProvider::from_token_with_name(
|
||||
format!("databricks.{}", name),
|
||||
databricks_config.host.clone(),
|
||||
token.clone(),
|
||||
databricks_config.model.clone(),
|
||||
@@ -1197,7 +1218,8 @@ impl<W: UiWriter> Agent<W> {
|
||||
)?
|
||||
} else {
|
||||
// Use OAuth authentication
|
||||
g3_providers::DatabricksProvider::from_oauth(
|
||||
g3_providers::DatabricksProvider::from_oauth_with_name(
|
||||
format!("databricks.{}", name),
|
||||
databricks_config.host.clone(),
|
||||
databricks_config.model.clone(),
|
||||
databricks_config.max_tokens,
|
||||
@@ -1253,13 +1275,9 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
|
||||
// Load existing TODO list if present (after system prompt and README)
|
||||
let todo_path = std::env::current_dir().ok().map(|p| p.join("todo.g3.md"));
|
||||
let initial_todo_content = if let Some(ref path) = todo_path {
|
||||
if path.exists() {
|
||||
std::fs::read_to_string(path).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let todo_path = get_todo_path();
|
||||
let initial_todo_content = if todo_path.exists() {
|
||||
std::fs::read_to_string(&todo_path).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1304,13 +1322,8 @@ impl<W: UiWriter> Agent<W> {
|
||||
ui_writer,
|
||||
todo_content: std::sync::Arc::new(tokio::sync::RwLock::new({
|
||||
// Initialize from TODO.md file if it exists
|
||||
let todo_path = std::env::current_dir().ok().map(|p| p.join("todo.g3.md"));
|
||||
|
||||
if let Some(path) = todo_path {
|
||||
std::fs::read_to_string(&path).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
let todo_path = get_todo_path();
|
||||
std::fs::read_to_string(&todo_path).unwrap_or_default()
|
||||
})),
|
||||
is_autonomous,
|
||||
quiet,
|
||||
@@ -1386,22 +1399,40 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
/// Get the configured max_tokens for a provider from top-level config
|
||||
fn provider_max_tokens(config: &Config, provider_name: &str) -> Option<u32> {
|
||||
match provider_name {
|
||||
"anthropic" => config.providers.anthropic.as_ref()?.max_tokens,
|
||||
"openai" => config.providers.openai.as_ref()?.max_tokens,
|
||||
"databricks" => config.providers.databricks.as_ref()?.max_tokens,
|
||||
"embedded" => config.providers.embedded.as_ref()?.max_tokens,
|
||||
// Parse provider reference (format: "provider_type.config_name")
|
||||
let parts: Vec<&str> = provider_name.split('.').collect();
|
||||
let (provider_type, config_name) = if parts.len() == 2 {
|
||||
(parts[0], parts[1])
|
||||
} else {
|
||||
// Fallback for simple provider names - assume "default" config
|
||||
(provider_name, "default")
|
||||
};
|
||||
|
||||
match provider_type {
|
||||
"anthropic" => config.providers.anthropic.get(config_name)?.max_tokens,
|
||||
"openai" => config.providers.openai.get(config_name)?.max_tokens,
|
||||
"databricks" => config.providers.databricks.get(config_name)?.max_tokens,
|
||||
"embedded" => config.providers.embedded.get(config_name)?.max_tokens,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the configured temperature for a provider from top-level config
|
||||
fn provider_temperature(config: &Config, provider_name: &str) -> Option<f32> {
|
||||
match provider_name {
|
||||
"anthropic" => config.providers.anthropic.as_ref()?.temperature,
|
||||
"openai" => config.providers.openai.as_ref()?.temperature,
|
||||
"databricks" => config.providers.databricks.as_ref()?.temperature,
|
||||
"embedded" => config.providers.embedded.as_ref()?.temperature,
|
||||
// Parse provider reference (format: "provider_type.config_name")
|
||||
let parts: Vec<&str> = provider_name.split('.').collect();
|
||||
let (provider_type, config_name) = if parts.len() == 2 {
|
||||
(parts[0], parts[1])
|
||||
} else {
|
||||
// Fallback for simple provider names - assume "default" config
|
||||
(provider_name, "default")
|
||||
};
|
||||
|
||||
match provider_type {
|
||||
"anthropic" => config.providers.anthropic.get(config_name)?.temperature,
|
||||
"openai" => config.providers.openai.get(config_name)?.temperature,
|
||||
"databricks" => config.providers.databricks.get(config_name)?.temperature,
|
||||
"embedded" => config.providers.embedded.get(config_name)?.temperature,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1430,11 +1461,23 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
|
||||
/// Get the thinking budget tokens for Anthropic provider, if configured
|
||||
fn get_thinking_budget_tokens(&self) -> Option<u32> {
|
||||
self.config
|
||||
.providers
|
||||
.anthropic
|
||||
.as_ref()
|
||||
fn get_thinking_budget_tokens(&self, provider_name: &str) -> Option<u32> {
|
||||
// Parse provider reference (format: "provider_type.config_name")
|
||||
let parts: Vec<&str> = provider_name.split('.').collect();
|
||||
let (provider_type, config_name) = if parts.len() == 2 {
|
||||
(parts[0], parts[1])
|
||||
} else {
|
||||
// Fallback for simple provider names - assume "default" config
|
||||
(provider_name, "default")
|
||||
};
|
||||
|
||||
// Only Anthropic has thinking_budget_tokens
|
||||
if provider_type != "anthropic" {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.config.providers.anthropic
|
||||
.get(config_name)
|
||||
.and_then(|c| c.thinking_budget_tokens)
|
||||
}
|
||||
|
||||
@@ -1448,12 +1491,15 @@ impl<W: UiWriter> Agent<W> {
|
||||
provider_name: &str,
|
||||
proposed_max_tokens: u32,
|
||||
) -> (u32, bool) {
|
||||
// Only applies to Anthropic provider with thinking enabled
|
||||
if provider_name != "anthropic" {
|
||||
// Parse provider type from provider_name (format: "provider_type.config_name")
|
||||
let provider_type = provider_name.split('.').next().unwrap_or(provider_name);
|
||||
|
||||
// Only applies to Anthropic provider
|
||||
if provider_type != "anthropic" {
|
||||
return (proposed_max_tokens, false);
|
||||
}
|
||||
|
||||
let budget_tokens = match self.get_thinking_budget_tokens() {
|
||||
let budget_tokens = match self.get_thinking_budget_tokens(provider_name) {
|
||||
Some(budget) => budget,
|
||||
None => return (proposed_max_tokens, false), // No thinking enabled
|
||||
};
|
||||
@@ -1702,14 +1748,23 @@ impl<W: UiWriter> Agent<W> {
|
||||
let provider_name = provider.name();
|
||||
let model_name = provider.model();
|
||||
|
||||
// Use provider-specific context length if available, otherwise fall back to agent config
|
||||
let context_length = match provider_name {
|
||||
"embedded" => {
|
||||
// Parse provider name to get type and config name
|
||||
let parts: Vec<&str> = provider_name.split('.').collect();
|
||||
let (provider_type, config_name) = if parts.len() == 2 {
|
||||
(parts[0], parts[1])
|
||||
} else {
|
||||
// Fallback for simple provider names
|
||||
(provider_name, "default")
|
||||
};
|
||||
|
||||
// Use provider-specific context length if available
|
||||
let context_length = match provider_type {
|
||||
"embedded" | "embedded." => {
|
||||
// For embedded models, use the configured context_length or model-specific defaults
|
||||
if let Some(embedded_config) = &config.providers.embedded {
|
||||
if let Some(embedded_config) = config.providers.embedded.get(config_name) {
|
||||
embedded_config.context_length.unwrap_or_else(|| {
|
||||
// Model-specific defaults for embedded models
|
||||
match embedded_config.model_type.to_lowercase().as_str() {
|
||||
match &embedded_config.model_type.to_lowercase()[..] {
|
||||
"codellama" => 16384, // CodeLlama supports 16k context
|
||||
"llama" => 4096, // Base Llama models
|
||||
"mistral" => 8192, // Mistral models
|
||||
@@ -1722,11 +1777,11 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
}
|
||||
"openai" => {
|
||||
// gpt-5 has 400k window
|
||||
if let Some(max_tokens) = Self::provider_max_tokens(config, "openai") {
|
||||
// OpenAI models have varying context windows
|
||||
if let Some(max_tokens) = Self::provider_max_tokens(config, provider_name) {
|
||||
warnings.push(format!(
|
||||
"Context length falling back to max_tokens ({}) for provider=openai",
|
||||
max_tokens
|
||||
"Context length falling back to max_tokens ({}) for provider={}",
|
||||
max_tokens, provider_name
|
||||
));
|
||||
max_tokens
|
||||
} else {
|
||||
@@ -1735,11 +1790,10 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
"anthropic" => {
|
||||
// Claude models have large context windows
|
||||
// Use configured max_tokens or fall back to default
|
||||
if let Some(max_tokens) = Self::provider_max_tokens(config, "anthropic") {
|
||||
if let Some(max_tokens) = Self::provider_max_tokens(config, provider_name) {
|
||||
warnings.push(format!(
|
||||
"Context length falling back to max_tokens ({}) for provider=anthropic",
|
||||
max_tokens
|
||||
"Context length falling back to max_tokens ({}) for provider={}",
|
||||
max_tokens, provider_name
|
||||
));
|
||||
max_tokens
|
||||
} else {
|
||||
@@ -1748,11 +1802,10 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
"databricks" => {
|
||||
// Databricks models have varying context windows depending on the model
|
||||
// Use configured max_tokens or fall back to model-specific defaults
|
||||
if let Some(max_tokens) = Self::provider_max_tokens(config, "databricks") {
|
||||
if let Some(max_tokens) = Self::provider_max_tokens(config, provider_name) {
|
||||
warnings.push(format!(
|
||||
"Context length falling back to max_tokens ({}) for provider=databricks",
|
||||
max_tokens
|
||||
"Context length falling back to max_tokens ({}) for provider={}",
|
||||
max_tokens, provider_name
|
||||
));
|
||||
max_tokens
|
||||
} else if model_name.contains("claude") {
|
||||
@@ -1948,14 +2001,18 @@ impl<W: UiWriter> Agent<W> {
|
||||
// Check if we should use cache control (every 10 tool calls)
|
||||
// But only if we haven't already added 4 cache_control annotations
|
||||
let provider = self.providers.get(None)?;
|
||||
if let Some(cache_config) = match provider.name() {
|
||||
"anthropic" => self
|
||||
.config
|
||||
.providers
|
||||
.anthropic
|
||||
.as_ref()
|
||||
.and_then(|c| c.cache_config.as_ref())
|
||||
.and_then(|config| Self::parse_cache_control(config)),
|
||||
let provider_name = provider.name();
|
||||
let provider_type = provider_name.split('.').next().unwrap_or("");
|
||||
let config_name = provider_name.split('.').nth(1).unwrap_or("default");
|
||||
if let Some(cache_config) = match provider_type {
|
||||
"anthropic" => {
|
||||
self.config
|
||||
.providers
|
||||
.anthropic
|
||||
.get(config_name)
|
||||
.and_then(|c| c.cache_config.as_ref())
|
||||
.and_then(|config| Self::parse_cache_control(config))
|
||||
}
|
||||
_ => None,
|
||||
} {
|
||||
Message::with_cache_control_validated(
|
||||
@@ -2451,7 +2508,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
// Apply provider-specific caps
|
||||
// For Anthropic with thinking enabled, we need max_tokens > thinking.budget_tokens
|
||||
// So we set a higher cap when thinking is configured
|
||||
let anthropic_cap = match self.get_thinking_budget_tokens() {
|
||||
let anthropic_cap = match self.get_thinking_budget_tokens(&provider_name) {
|
||||
Some(budget) => (budget + 2000).max(10_000), // At least budget + 2000 for response
|
||||
None => 10_000,
|
||||
};
|
||||
@@ -3485,7 +3542,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
// Apply provider-specific caps
|
||||
// For Anthropic with thinking enabled, we need max_tokens > thinking.budget_tokens
|
||||
// So we set a higher cap when thinking is configured
|
||||
let anthropic_cap = match self.get_thinking_budget_tokens() {
|
||||
let anthropic_cap = match self.get_thinking_budget_tokens(&provider_name) {
|
||||
Some(budget) => (budget + 2000).max(10_000), // At least budget + 2000 for response
|
||||
None => 10_000,
|
||||
};
|
||||
@@ -4078,14 +4135,18 @@ impl<W: UiWriter> Agent<W> {
|
||||
&& self.count_cache_controls_in_history() < 4
|
||||
{
|
||||
let provider = self.providers.get(None)?;
|
||||
if let Some(cache_config) = match provider.name() {
|
||||
"anthropic" => self
|
||||
.config
|
||||
.providers
|
||||
.anthropic
|
||||
.as_ref()
|
||||
.and_then(|c| c.cache_config.as_ref())
|
||||
.and_then(|config| Self::parse_cache_control(config)),
|
||||
let provider_name = provider.name();
|
||||
let provider_type = provider_name.split('.').next().unwrap_or("");
|
||||
let config_name = provider_name.split('.').nth(1).unwrap_or("default");
|
||||
if let Some(cache_config) = match provider_type {
|
||||
"anthropic" => {
|
||||
self.config
|
||||
.providers
|
||||
.anthropic
|
||||
.get(config_name)
|
||||
.and_then(|c| c.cache_config.as_ref())
|
||||
.and_then(|config| Self::parse_cache_control(config))
|
||||
}
|
||||
_ => None,
|
||||
} {
|
||||
Message::with_cache_control_validated(
|
||||
@@ -5118,8 +5179,8 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
"todo_read" => {
|
||||
debug!("Processing todo_read tool call");
|
||||
// Read from todo.g3.md file in current workspace directory
|
||||
let todo_path = std::env::current_dir()?.join("todo.g3.md");
|
||||
// Read from todo.g3.md file (uses G3_TODO_PATH env var if set, else current dir)
|
||||
let todo_path = get_todo_path();
|
||||
|
||||
if !todo_path.exists() {
|
||||
// Also update in-memory content to stay in sync
|
||||
@@ -5233,7 +5294,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
|
||||
// If all todos are complete, delete the file instead of writing
|
||||
if !has_incomplete && (content_str.contains("- [x]") || content_str.contains("- [X]")) {
|
||||
let todo_path = std::env::current_dir()?.join("todo.g3.md");
|
||||
let todo_path = get_todo_path();
|
||||
if todo_path.exists() {
|
||||
match std::fs::remove_file(&todo_path) {
|
||||
Ok(_) => {
|
||||
@@ -5253,8 +5314,8 @@ impl<W: UiWriter> Agent<W> {
|
||||
}
|
||||
}
|
||||
|
||||
// Write to todo.g3.md file in current workspace directory
|
||||
let todo_path = std::env::current_dir()?.join("todo.g3.md");
|
||||
// Write to todo.g3.md file (uses G3_TODO_PATH env var if set, else current dir)
|
||||
let todo_path = get_todo_path();
|
||||
|
||||
match std::fs::write(&todo_path, content_str) {
|
||||
Ok(_) => {
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
|
||||
use g3_config::Config;
|
||||
use g3_core::ContextWindow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Helper function to create a minimal config for testing
|
||||
fn create_test_config_with_thinking(thinking_budget: Option<u32>) -> Config {
|
||||
let mut config = Config::default();
|
||||
|
||||
// Set up Anthropic provider with optional thinking budget
|
||||
config.providers.anthropic = Some(g3_config::AnthropicConfig {
|
||||
// Set up Anthropic provider with optional thinking budget using new HashMap format
|
||||
let mut anthropic_configs = HashMap::new();
|
||||
anthropic_configs.insert("default".to_string(), g3_config::AnthropicConfig {
|
||||
api_key: "test-key".to_string(),
|
||||
model: "claude-sonnet-4-5".to_string(),
|
||||
max_tokens: Some(16000),
|
||||
@@ -20,8 +22,9 @@ fn create_test_config_with_thinking(thinking_budget: Option<u32>) -> Config {
|
||||
enable_1m_context: None,
|
||||
thinking_budget_tokens: thinking_budget,
|
||||
});
|
||||
config.providers.anthropic = anthropic_configs;
|
||||
|
||||
config.providers.default_provider = "anthropic".to_string();
|
||||
config.providers.default_provider = "anthropic.default".to_string();
|
||||
config
|
||||
}
|
||||
|
||||
@@ -35,14 +38,14 @@ fn test_no_thinking_budget_passes_through() {
|
||||
|
||||
// The constraint check would return (proposed_max, false)
|
||||
// since there's no thinking_budget_tokens configured
|
||||
assert!(config.providers.anthropic.as_ref().unwrap().thinking_budget_tokens.is_none());
|
||||
assert!(config.providers.anthropic.get("default").unwrap().thinking_budget_tokens.is_none());
|
||||
}
|
||||
|
||||
/// Test that when max_tokens > budget_tokens + buffer, no reduction is needed
|
||||
#[test]
|
||||
fn test_sufficient_max_tokens_no_reduction_needed() {
|
||||
let config = create_test_config_with_thinking(Some(10000));
|
||||
let budget_tokens = config.providers.anthropic.as_ref().unwrap().thinking_budget_tokens.unwrap();
|
||||
let budget_tokens = config.providers.anthropic.get("default").unwrap().thinking_budget_tokens.unwrap();
|
||||
|
||||
// minimum_required = budget_tokens + 1024 = 11024
|
||||
let minimum_required = budget_tokens + 1024;
|
||||
@@ -56,7 +59,7 @@ fn test_sufficient_max_tokens_no_reduction_needed() {
|
||||
#[test]
|
||||
fn test_insufficient_max_tokens_needs_reduction() {
|
||||
let config = create_test_config_with_thinking(Some(10000));
|
||||
let budget_tokens = config.providers.anthropic.as_ref().unwrap().thinking_budget_tokens.unwrap();
|
||||
let budget_tokens = config.providers.anthropic.get("default").unwrap().thinking_budget_tokens.unwrap();
|
||||
|
||||
// minimum_required = budget_tokens + 1024 = 11024
|
||||
let minimum_required = budget_tokens + 1024;
|
||||
|
||||
@@ -67,7 +67,55 @@ impl FlockConfig {
|
||||
}
|
||||
|
||||
// Load default config
|
||||
let g3_config = Config::load(None)?;
|
||||
let g3_config = Config::load(None).or_else(|_| {
|
||||
// If no config file exists, return an error with helpful message
|
||||
anyhow::bail!("No G3 configuration found. Please create a .g3.toml file.")
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
project_dir,
|
||||
flock_workspace,
|
||||
num_segments,
|
||||
max_turns: 5, // Default
|
||||
g3_config,
|
||||
g3_binary: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new flock configuration with a specified config path
|
||||
pub fn new_with_config(
|
||||
project_dir: PathBuf,
|
||||
flock_workspace: PathBuf,
|
||||
num_segments: usize,
|
||||
config_path: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// Validate project directory
|
||||
if !project_dir.exists() {
|
||||
anyhow::bail!(
|
||||
"Project directory does not exist: {}",
|
||||
project_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a git repo
|
||||
if !project_dir.join(".git").exists() {
|
||||
anyhow::bail!(
|
||||
"Project directory must be a git repository: {}",
|
||||
project_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Check for flock-requirements.md
|
||||
let requirements_path = project_dir.join("flock-requirements.md");
|
||||
if !requirements_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Project directory must contain flock-requirements.md: {}",
|
||||
project_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Load config from specified path
|
||||
let g3_config = Config::load(config_path)?;
|
||||
|
||||
Ok(Self {
|
||||
project_dir,
|
||||
|
||||
@@ -6,6 +6,43 @@ use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a test config file with the new format
|
||||
fn create_test_config(temp_dir: &TempDir) -> PathBuf {
|
||||
let config_path = temp_dir.path().join(".g3.toml");
|
||||
let config_content = r#"
|
||||
[providers]
|
||||
default_provider = "databricks.default"
|
||||
|
||||
[providers.databricks.default]
|
||||
host = "https://test.databricks.com"
|
||||
token = "test-token"
|
||||
model = "test-model"
|
||||
|
||||
[agent]
|
||||
fallback_default_max_tokens = 8192
|
||||
enable_streaming = true
|
||||
timeout_seconds = 60
|
||||
auto_compact = true
|
||||
allow_multiple_tool_calls = false
|
||||
max_retry_attempts = 3
|
||||
autonomous_max_retry_attempts = 6
|
||||
|
||||
[computer_control]
|
||||
enabled = false
|
||||
require_confirmation = true
|
||||
max_actions_per_second = 10
|
||||
|
||||
[webdriver]
|
||||
enabled = false
|
||||
safari_port = 4444
|
||||
|
||||
[macax]
|
||||
enabled = false
|
||||
"#;
|
||||
fs::write(&config_path, config_content).expect("Failed to write config");
|
||||
config_path
|
||||
}
|
||||
|
||||
/// Helper to create a test git repository with flock-requirements.md
|
||||
fn create_test_project(name: &str) -> TempDir {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
@@ -73,11 +110,14 @@ fn create_test_project(name: &str) -> TempDir {
|
||||
#[test]
|
||||
fn test_flock_config_validation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = create_test_config(&temp_dir);
|
||||
let project_path = temp_dir.path().to_path_buf();
|
||||
let workspace_path = temp_dir.path().join("workspace");
|
||||
|
||||
// Should fail - not a git repo
|
||||
let result = FlockConfig::new(project_path.clone(), workspace_path.clone(), 2);
|
||||
let result = FlockConfig::new_with_config(
|
||||
project_path.clone(), workspace_path.clone(), 2,
|
||||
Some(config_path.to_str().unwrap()));
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
@@ -92,7 +132,9 @@ fn test_flock_config_validation() {
|
||||
.expect("Failed to run git init");
|
||||
|
||||
// Should fail - no flock-requirements.md
|
||||
let result = FlockConfig::new(project_path.clone(), workspace_path.clone(), 2);
|
||||
let result = FlockConfig::new_with_config(
|
||||
project_path.clone(), workspace_path.clone(), 2,
|
||||
Some(config_path.to_str().unwrap()));
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
@@ -104,7 +146,9 @@ fn test_flock_config_validation() {
|
||||
.expect("Failed to write requirements");
|
||||
|
||||
// Should succeed now
|
||||
let result = FlockConfig::new(project_path, workspace_path, 2);
|
||||
let result = FlockConfig::new_with_config(
|
||||
project_path, workspace_path, 2,
|
||||
Some(config_path.to_str().unwrap()));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
@@ -112,11 +156,13 @@ fn test_flock_config_validation() {
|
||||
fn test_flock_config_builder() {
|
||||
let project_dir = create_test_project("builder-test");
|
||||
let workspace_dir = TempDir::new().unwrap();
|
||||
let config_path = create_test_config(&workspace_dir);
|
||||
|
||||
let config = FlockConfig::new(
|
||||
let config = FlockConfig::new_with_config(
|
||||
project_dir.path().to_path_buf(),
|
||||
workspace_dir.path().to_path_buf(),
|
||||
2,
|
||||
Some(config_path.to_str().unwrap()),
|
||||
)
|
||||
.expect("Failed to create config")
|
||||
.with_max_turns(15)
|
||||
@@ -131,11 +177,13 @@ fn test_flock_config_builder() {
|
||||
fn test_workspace_creation() {
|
||||
let project_dir = create_test_project("workspace-test");
|
||||
let workspace_dir = TempDir::new().unwrap();
|
||||
let config_path = create_test_config(&workspace_dir);
|
||||
|
||||
let config = FlockConfig::new(
|
||||
let config = FlockConfig::new_with_config(
|
||||
project_dir.path().to_path_buf(),
|
||||
workspace_dir.path().to_path_buf(),
|
||||
2,
|
||||
Some(config_path.to_str().unwrap()),
|
||||
)
|
||||
.expect("Failed to create config");
|
||||
|
||||
|
||||
@@ -6,9 +6,15 @@ description = "Fast-discovery planner for G3 AI coding agent"
|
||||
|
||||
[dependencies]
|
||||
g3-providers = { path = "../g3-providers" }
|
||||
g3-core = { path = "../g3-core" }
|
||||
g3-config = { path = "../g3-config" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
const_format = "0.2"
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
shellexpand = "3.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
396
crates/g3-planner/src/git.rs
Normal file
396
crates/g3-planner/src/git.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
//! Git operations for planning mode
|
||||
//!
|
||||
//! This module provides git functionality for the planner:
|
||||
//! - Repository detection
|
||||
//! - Branch information
|
||||
//! - Dirty file detection
|
||||
//! - Staging and committing
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Files and directories to exclude from staging
|
||||
const EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"target/",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"*.log",
|
||||
"*.tmp",
|
||||
"*.bak",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"*.pyc",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
".pytest_cache/",
|
||||
".mypy_cache/",
|
||||
".ruff_cache/",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*~",
|
||||
];
|
||||
|
||||
/// Check if the given path is within a git repository
|
||||
pub fn check_git_repo(codepath: &Path) -> Result<bool> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to execute git command")?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Get the root directory of the git repository
|
||||
pub fn get_repo_root(codepath: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get git repo root")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Not in a git repository");
|
||||
}
|
||||
|
||||
let root = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Get the current git branch name
|
||||
pub fn get_current_branch(codepath: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get current git branch")?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Might be in detached HEAD state
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to get branch name: {}", stderr);
|
||||
}
|
||||
|
||||
let branch = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if branch.is_empty() {
|
||||
// Detached HEAD state - get short SHA instead
|
||||
let sha_output = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get HEAD SHA")?;
|
||||
|
||||
let sha = String::from_utf8(sha_output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(format!("(detached HEAD at {})", sha))
|
||||
} else {
|
||||
Ok(branch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current HEAD SHA
|
||||
pub fn get_head_sha(codepath: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get HEAD SHA")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to get HEAD SHA: {}", stderr);
|
||||
}
|
||||
|
||||
let sha = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
/// Information about dirty/untracked files
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DirtyFiles {
|
||||
pub modified: Vec<String>,
|
||||
pub untracked: Vec<String>,
|
||||
pub staged: Vec<String>,
|
||||
}
|
||||
|
||||
impl DirtyFiles {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.modified.is_empty() && self.untracked.is_empty() && self.staged.is_empty()
|
||||
}
|
||||
|
||||
pub fn to_display_string(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if !self.staged.is_empty() {
|
||||
lines.push("Staged:".to_string());
|
||||
for f in &self.staged {
|
||||
lines.push(format!(" {}", f));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.modified.is_empty() {
|
||||
lines.push("Modified:".to_string());
|
||||
for f in &self.modified {
|
||||
lines.push(format!(" {}", f));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.untracked.is_empty() {
|
||||
lines.push("Untracked:".to_string());
|
||||
for f in &self.untracked {
|
||||
lines.push(format!(" {}", f));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for untracked, uncommitted, or dirty files
|
||||
/// Optionally ignores files matching a given path pattern
|
||||
pub fn check_dirty_files(codepath: &Path, ignore_pattern: Option<&str>) -> Result<DirtyFiles> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to check git status")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to check git status: {}", stderr);
|
||||
}
|
||||
|
||||
let status_output = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?;
|
||||
|
||||
let mut result = DirtyFiles::default();
|
||||
|
||||
for line in status_output.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = &line[0..2];
|
||||
let file = line[3..].trim();
|
||||
|
||||
// Check if this file should be ignored
|
||||
if let Some(pattern) = ignore_pattern {
|
||||
if file.contains(pattern) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match status {
|
||||
"??" => result.untracked.push(file.to_string()),
|
||||
" M" | "MM" | "AM" => result.modified.push(file.to_string()),
|
||||
"M " | "A " | "D " | "R " => result.staged.push(file.to_string()),
|
||||
_ => {
|
||||
// Other statuses (deleted, renamed, etc.)
|
||||
if status.starts_with(' ') {
|
||||
result.modified.push(file.to_string());
|
||||
} else {
|
||||
result.staged.push(file.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Check if a file should be excluded from staging based on patterns
|
||||
fn should_exclude(path: &str) -> bool {
|
||||
for pattern in EXCLUDE_PATTERNS {
|
||||
if pattern.ends_with('/') {
|
||||
// Directory pattern
|
||||
let dir_name = pattern.trim_end_matches('/');
|
||||
if path.contains(&format!("/{}/", dir_name)) || path.starts_with(&format!("{}/", dir_name)) {
|
||||
return true;
|
||||
}
|
||||
} else if pattern.starts_with('*') {
|
||||
// Wildcard pattern
|
||||
let suffix = pattern.trim_start_matches('*');
|
||||
if path.ends_with(suffix) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if path == *pattern || path.ends_with(&format!("/{}", pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Stage files for commit, excluding temporary/artifact files
|
||||
/// Stages all files in the specified directory plus any modified/new code files
|
||||
pub fn stage_files(codepath: &Path, plan_dir: &Path) -> Result<StagingResult> {
|
||||
let mut result = StagingResult::default();
|
||||
|
||||
// First, stage all files in the g3-plan directory
|
||||
let plan_dir_str = plan_dir.to_string_lossy();
|
||||
let add_plan_output = Command::new("git")
|
||||
.args(["add", &plan_dir_str])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to stage g3-plan directory")?;
|
||||
|
||||
if !add_plan_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&add_plan_output.stderr);
|
||||
// Don't fail if directory doesn't exist yet
|
||||
if !stderr.contains("did not match any files") {
|
||||
anyhow::bail!("Failed to stage g3-plan directory: {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of all changed files
|
||||
let status_output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to get git status")?;
|
||||
|
||||
let status_str = String::from_utf8(status_output.stdout)
|
||||
.context("Invalid UTF-8 in git output")?;
|
||||
|
||||
// Stage files that aren't excluded
|
||||
for line in status_str.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = &line[0..2];
|
||||
let file = line[3..].trim();
|
||||
|
||||
// Skip already staged files
|
||||
if !status.starts_with(' ') && status != "??" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this file should be excluded
|
||||
if should_exclude(file) {
|
||||
result.excluded.push(file.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stage the file
|
||||
let add_output = Command::new("git")
|
||||
.args(["add", file])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context(format!("Failed to stage file: {}", file))?;
|
||||
|
||||
if add_output.status.success() {
|
||||
result.staged.push(file.to_string());
|
||||
} else {
|
||||
result.failed.push(file.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Result of staging operation
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StagingResult {
|
||||
pub staged: Vec<String>,
|
||||
pub excluded: Vec<String>,
|
||||
pub failed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Make a git commit with the given summary and description
|
||||
pub fn commit(codepath: &Path, summary: &str, description: &str) -> Result<String> {
|
||||
// Combine summary and description into full commit message
|
||||
let full_message = if description.is_empty() {
|
||||
summary.to_string()
|
||||
} else {
|
||||
format!("{}\n\n{}", summary, description)
|
||||
};
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["commit", "-m", &full_message])
|
||||
.current_dir(codepath)
|
||||
.output()
|
||||
.context("Failed to make git commit")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Git commit failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Get the commit SHA
|
||||
get_head_sha(codepath)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_target() {
|
||||
assert!(should_exclude("target/debug/something"));
|
||||
assert!(should_exclude("some/path/target/release/bin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_node_modules() {
|
||||
assert!(should_exclude("node_modules/package/index.js"));
|
||||
assert!(should_exclude("frontend/node_modules/react/index.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_log_files() {
|
||||
assert!(should_exclude("app.log"));
|
||||
assert!(should_exclude("logs/debug.log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_exclude_temp_files() {
|
||||
assert!(should_exclude("file.tmp"));
|
||||
assert!(should_exclude("file.bak"));
|
||||
assert!(should_exclude("file.swp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_not_exclude_normal_files() {
|
||||
assert!(!should_exclude("src/main.rs"));
|
||||
assert!(!should_exclude("Cargo.toml"));
|
||||
assert!(!should_exclude("README.md"));
|
||||
assert!(!should_exclude("package.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirty_files_display() {
|
||||
let dirty = DirtyFiles {
|
||||
modified: vec!["src/main.rs".to_string()],
|
||||
untracked: vec!["new_file.txt".to_string()],
|
||||
staged: vec!["Cargo.toml".to_string()],
|
||||
};
|
||||
|
||||
let display = dirty.to_display_string();
|
||||
assert!(display.contains("Modified:"));
|
||||
assert!(display.contains("src/main.rs"));
|
||||
assert!(display.contains("Untracked:"));
|
||||
assert!(display.contains("new_file.txt"));
|
||||
assert!(display.contains("Staged:"));
|
||||
assert!(display.contains("Cargo.toml"));
|
||||
}
|
||||
}
|
||||
234
crates/g3-planner/src/history.rs
Normal file
234
crates/g3-planner/src/history.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Planner history management
|
||||
//!
|
||||
//! This module manages the planner_history.txt file which serves as:
|
||||
//! - An audit log of planning steps
|
||||
//! - A comprehensive reference of historic requirements and implementations
|
||||
//! - A file that requires merging/resolution if updated on separate git branches
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::prompts;
|
||||
|
||||
/// Format a timestamp for planner_history.txt entries
|
||||
/// Format: YYYY-MM-DD HH:MM:SS (ISO 8601 for readability)
|
||||
pub fn format_timestamp() -> String {
|
||||
Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Format a timestamp for filenames
|
||||
/// Format: YYYY-MM-DD_HH-MM-SS (filesystem-safe)
|
||||
pub fn format_timestamp_for_filename() -> String {
|
||||
Local::now().format("%Y-%m-%d_%H-%M-%S").to_string()
|
||||
}
|
||||
|
||||
/// Ensure the planner_history.txt file exists, creating it if necessary
|
||||
pub fn ensure_history_file(plan_dir: &Path) -> Result<()> {
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
|
||||
if !history_path.exists() {
|
||||
fs::write(&history_path, "")
|
||||
.context("Failed to create planner_history.txt")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append an entry to planner_history.txt
|
||||
fn append_entry(plan_dir: &Path, entry: &str) -> Result<()> {
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&history_path)
|
||||
.context("Failed to open planner_history.txt for appending")?;
|
||||
|
||||
writeln!(file, "{}", entry)
|
||||
.context("Failed to write to planner_history.txt")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a "REFINING REQUIREMENTS" entry
|
||||
pub fn write_refining_requirements(plan_dir: &Path) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - REFINING REQUIREMENTS (new_requirements.md)"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "GIT HEAD" entry with the current SHA
|
||||
pub fn write_git_head(plan_dir: &Path, sha: &str) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - GIT HEAD ({sha})"
|
||||
.replace("{timestamp}", ×tamp)
|
||||
.replace("{sha}", sha);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "START IMPLEMENTING" entry with a summary block
|
||||
pub fn write_start_implementing(plan_dir: &Path, summary: &str) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - START IMPLEMENTING (current_requirements.md)"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
|
||||
// Format the summary with proper indentation
|
||||
let indented_summary = summary
|
||||
.lines()
|
||||
.map(|line| format!(" {}", line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let summary_block = "<<\n{summary}\n>>"
|
||||
.replace("{summary}", &indented_summary);
|
||||
|
||||
append_entry(plan_dir, &entry)?;
|
||||
append_entry(plan_dir, &summary_block)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write an "ATTEMPTING RECOVERY" entry
|
||||
pub fn write_attempting_recovery(plan_dir: &Path) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} ATTEMPTING RECOVERY"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "USER SKIPPED RECOVERY" entry
|
||||
pub fn write_skipped_recovery(plan_dir: &Path) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} USER SKIPPED RECOVERY"
|
||||
.replace("{timestamp}", ×tamp);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "COMPLETED REQUIREMENTS" entry
|
||||
pub fn write_completed_requirements(
|
||||
plan_dir: &Path,
|
||||
requirements_file: &str,
|
||||
todo_file: &str,
|
||||
) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
let entry = "{timestamp} - COMPLETED REQUIREMENTS ({requirements_file}, {todo_file})"
|
||||
.replace("{timestamp}", ×tamp)
|
||||
.replace("{requirements_file}", requirements_file)
|
||||
.replace("{todo_file}", todo_file);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Write a "GIT COMMIT" entry
|
||||
pub fn write_git_commit(plan_dir: &Path, message: &str) -> Result<()> {
|
||||
let timestamp = format_timestamp();
|
||||
// Truncate message if too long for a single line
|
||||
let truncated_message = if message.len() > 72 {
|
||||
format!("{}...", &message[..69])
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
let entry = "{timestamp} - GIT COMMIT ({message})"
|
||||
.replace("{timestamp}", ×tamp)
|
||||
.replace("{message}", &truncated_message);
|
||||
append_entry(plan_dir, &entry)
|
||||
}
|
||||
|
||||
/// Generate the completed requirements filename
|
||||
pub fn completed_requirements_filename() -> String {
|
||||
format!("completed_requirements_{}.md", format_timestamp_for_filename())
|
||||
}
|
||||
|
||||
/// Generate the completed todo filename
|
||||
pub fn completed_todo_filename() -> String {
|
||||
format!("completed_todo_{}.md", format_timestamp_for_filename())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp() {
|
||||
let ts = format_timestamp();
|
||||
// Should be in format YYYY-MM-DD HH:MM:SS
|
||||
assert_eq!(ts.len(), 19);
|
||||
assert_eq!(&ts[4..5], "-");
|
||||
assert_eq!(&ts[7..8], "-");
|
||||
assert_eq!(&ts[10..11], " ");
|
||||
assert_eq!(&ts[13..14], ":");
|
||||
assert_eq!(&ts[16..17], ":");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp_for_filename() {
|
||||
let ts = format_timestamp_for_filename();
|
||||
// Should be in format YYYY-MM-DD_HH-MM-SS
|
||||
assert_eq!(ts.len(), 19);
|
||||
assert_eq!(&ts[4..5], "-");
|
||||
assert_eq!(&ts[7..8], "-");
|
||||
assert_eq!(&ts[10..11], "_");
|
||||
assert_eq!(&ts[13..14], "-");
|
||||
assert_eq!(&ts[16..17], "-");
|
||||
// Should not contain colons (filesystem-safe)
|
||||
assert!(!ts.contains(':'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_history_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plan_dir = temp_dir.path();
|
||||
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
assert!(!history_path.exists());
|
||||
|
||||
ensure_history_file(plan_dir).unwrap();
|
||||
|
||||
assert!(history_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_entries() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plan_dir = temp_dir.path();
|
||||
|
||||
ensure_history_file(plan_dir).unwrap();
|
||||
|
||||
write_refining_requirements(plan_dir).unwrap();
|
||||
write_git_head(plan_dir, "abc123def456").unwrap();
|
||||
write_start_implementing(plan_dir, "Test summary line 1\nTest summary line 2").unwrap();
|
||||
write_attempting_recovery(plan_dir).unwrap();
|
||||
write_completed_requirements(plan_dir, "completed_requirements_2025-01-01_12-00-00.md", "completed_todo_2025-01-01_12-00-00.md").unwrap();
|
||||
write_git_commit(plan_dir, "Add feature X").unwrap();
|
||||
|
||||
let history_path = plan_dir.join("planner_history.txt");
|
||||
let content = fs::read_to_string(history_path).unwrap();
|
||||
|
||||
assert!(content.contains("REFINING REQUIREMENTS"));
|
||||
assert!(content.contains("GIT HEAD (abc123def456)"));
|
||||
assert!(content.contains("START IMPLEMENTING"));
|
||||
assert!(content.contains("Test summary line 1"));
|
||||
assert!(content.contains("ATTEMPTING RECOVERY"));
|
||||
assert!(content.contains("COMPLETED REQUIREMENTS"));
|
||||
assert!(content.contains("GIT COMMIT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completed_filenames() {
|
||||
let req_file = completed_requirements_filename();
|
||||
let todo_file = completed_todo_filename();
|
||||
|
||||
assert!(req_file.starts_with("completed_requirements_"));
|
||||
assert!(req_file.ends_with(".md"));
|
||||
assert!(todo_file.starts_with("completed_todo_"));
|
||||
assert!(todo_file.ends_with(".md"));
|
||||
|
||||
// Should not contain colons
|
||||
assert!(!req_file.contains(':'));
|
||||
assert!(!todo_file.contains(':'));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
//! g3-planner: Fast-discovery planner for G3 AI coding agent
|
||||
//! g3-planner: Planning mode and fast-discovery planner for G3 AI coding agent
|
||||
//!
|
||||
//! This crate provides functionality to generate initial discovery tool calls
|
||||
//! that are injected into the conversation before the first LLM turn.
|
||||
//! This crate provides:
|
||||
//! - Planning mode state machine and orchestration
|
||||
//! - Requirements refinement workflow
|
||||
//! - Git integration for planning commits
|
||||
//! - Planner history management
|
||||
//! - Fast-discovery functionality for codebase exploration
|
||||
|
||||
mod code_explore;
|
||||
pub mod git;
|
||||
pub mod history;
|
||||
pub mod llm;
|
||||
pub mod planner;
|
||||
pub mod prompts;
|
||||
pub mod state;
|
||||
|
||||
pub use code_explore::explore_codebase;
|
||||
pub use planner::{expand_codepath, PlannerConfig, PlannerResult};
|
||||
pub use state::{PlannerState, RecoveryInfo};
|
||||
pub use planner::run_planning_mode;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
|
||||
321
crates/g3-planner/src/llm.rs
Normal file
321
crates/g3-planner/src/llm.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
//! 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 g3_config::Config;
|
||||
use g3_core::project::Project;
|
||||
use g3_core::Agent;
|
||||
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,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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
|
||||
#[derive(Clone)]
|
||||
pub struct PlannerUiWriter;
|
||||
|
||||
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) {
|
||||
println!("🔧 {}", tool_name);
|
||||
}
|
||||
|
||||
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) {}
|
||||
fn print_agent_response(&self, _content: &str) {}
|
||||
fn notify_sse_received(&self) {}
|
||||
|
||||
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,
|
||||
) -> 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;
|
||||
|
||||
// Create project pointing to codepath as workspace
|
||||
let workspace = std::path::PathBuf::from(codepath);
|
||||
let project = Project::new(workspace.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 = agent
|
||||
.execute_task_with_timing(&task, None, false, false, false, true, None)
|
||||
.await
|
||||
.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}}"));
|
||||
}
|
||||
}
|
||||
989
crates/g3-planner/src/planner.rs
Normal file
989
crates/g3-planner/src/planner.rs
Normal file
@@ -0,0 +1,989 @@
|
||||
//! Main planning mode orchestration
|
||||
//!
|
||||
//! This module contains the main logic for running planning mode,
|
||||
//! including the state machine transitions and user interactions.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::git;
|
||||
use crate::history;
|
||||
use crate::llm;
|
||||
use crate::prompts;
|
||||
use crate::state::{
|
||||
ApprovalChoice, BranchConfirmChoice, CompletionChoice, DirtyFilesChoice,
|
||||
PlannerState, RecoveryChoice, RecoveryInfo,
|
||||
};
|
||||
|
||||
/// Configuration for planning mode
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlannerConfig {
|
||||
/// The codepath to work in
|
||||
pub codepath: PathBuf,
|
||||
/// Whether git operations are disabled
|
||||
pub no_git: bool,
|
||||
/// Maximum turns for coach/player loop
|
||||
pub max_turns: usize,
|
||||
/// Whether to run in quiet mode
|
||||
pub quiet: bool,
|
||||
/// Path to config file
|
||||
pub config_path: Option<String>,
|
||||
}
|
||||
|
||||
impl PlannerConfig {
|
||||
/// Get the g3-plan directory path
|
||||
pub fn plan_dir(&self) -> PathBuf {
|
||||
self.codepath.join("g3-plan")
|
||||
}
|
||||
|
||||
/// Get the path to new_requirements.md
|
||||
pub fn new_requirements_path(&self) -> PathBuf {
|
||||
self.plan_dir().join("new_requirements.md")
|
||||
}
|
||||
|
||||
/// Get the path to current_requirements.md
|
||||
pub fn current_requirements_path(&self) -> PathBuf {
|
||||
self.plan_dir().join("current_requirements.md")
|
||||
}
|
||||
|
||||
/// Get the path to todo.g3.md
|
||||
pub fn todo_path(&self) -> PathBuf {
|
||||
self.plan_dir().join("todo.g3.md")
|
||||
}
|
||||
|
||||
/// Get the path to planner_history.txt
|
||||
pub fn history_path(&self) -> PathBuf {
|
||||
self.plan_dir().join("planner_history.txt")
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of running planning mode
|
||||
#[derive(Debug)]
|
||||
pub enum PlannerResult {
|
||||
/// User quit normally
|
||||
Quit,
|
||||
/// Completed a planning cycle
|
||||
Completed,
|
||||
/// Error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Expand tilde in path to home directory
|
||||
pub fn expand_codepath(path: &str) -> Result<PathBuf> {
|
||||
let expanded = shellexpand::tilde(path);
|
||||
let path = PathBuf::from(expanded.as_ref());
|
||||
|
||||
// Resolve to absolute path
|
||||
let resolved = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
std::env::current_dir()?.join(path)
|
||||
};
|
||||
|
||||
// Canonicalize if path exists, otherwise just return resolved
|
||||
if resolved.exists() {
|
||||
Ok(resolved.canonicalize()?)
|
||||
} else {
|
||||
Ok(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt user for codepath if not provided
|
||||
pub fn prompt_for_codepath() -> Result<PathBuf> {
|
||||
print!("Enter codepath (path to your project): ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let input = input.trim();
|
||||
|
||||
if input.is_empty() || input == "quit" || input == "q" {
|
||||
anyhow::bail!("User quit during codepath prompt");
|
||||
}
|
||||
|
||||
expand_codepath(input)
|
||||
}
|
||||
|
||||
/// Read a line of user input
|
||||
fn read_line() -> Result<String> {
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Print a message to stdout
|
||||
fn print_msg(msg: &str) {
|
||||
println!("{}", msg);
|
||||
}
|
||||
|
||||
/// Print a message and flush stdout (for prompts)
|
||||
fn print_prompt(msg: &str) {
|
||||
print!("{}", msg);
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
/// Initialize the planning directory structure
|
||||
pub fn initialize_plan_dir(config: &PlannerConfig) -> Result<()> {
|
||||
let plan_dir = config.plan_dir();
|
||||
|
||||
// Create plan directory if it doesn't exist
|
||||
if !plan_dir.exists() {
|
||||
fs::create_dir_all(&plan_dir)
|
||||
.context("Failed to create g3-plan directory")?;
|
||||
print_msg(&format!("📁 Created {}", plan_dir.display()));
|
||||
}
|
||||
|
||||
// Ensure history file exists
|
||||
history::ensure_history_file(&plan_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check git repository status (if git is enabled)
|
||||
pub fn check_git_status(config: &PlannerConfig) -> Result<()> {
|
||||
if config.no_git {
|
||||
print_msg("⚠️ Git operations disabled (--no-git flag)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if we're in a git repo
|
||||
if !git::check_git_repo(&config.codepath)? {
|
||||
print_msg("No git repository found for the codepath. Please initialize a git repo and try again.");
|
||||
anyhow::bail!("No git repository found");
|
||||
}
|
||||
|
||||
// Get and display current branch
|
||||
let branch = git::get_current_branch(&config.codepath)?;
|
||||
let prompt = "Current git branch: {branch}\nIs this the correct branch to work on? [Y/n]".replace("{branch}", &branch);
|
||||
print_prompt(&format!("{} ", prompt));
|
||||
|
||||
let input = read_line()?;
|
||||
match BranchConfirmChoice::from_input(&input) {
|
||||
Some(BranchConfirmChoice::Confirm) => {},
|
||||
Some(BranchConfirmChoice::Quit) | None => {
|
||||
print_msg("Exiting - please switch to the correct branch and restart.");
|
||||
anyhow::bail!("User declined branch confirmation");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dirty/untracked files (ignore new_requirements.md)
|
||||
let ignore_pattern = "g3-plan/new_requirements.md";
|
||||
let dirty_files = git::check_dirty_files(&config.codepath, Some(ignore_pattern))?;
|
||||
|
||||
if !dirty_files.is_empty() {
|
||||
let warning = r#"Warning: There are uncommitted changes in the git repository:
|
||||
{files}
|
||||
|
||||
This may be expected if resuming from a previous session.
|
||||
Do you want to proceed anyway? [Y/n]"#
|
||||
.replace("{files}", &dirty_files.to_display_string());
|
||||
print_msg(&warning);
|
||||
print_prompt("[Y/n] ");
|
||||
|
||||
let input = read_line()?;
|
||||
match DirtyFilesChoice::from_input(&input) {
|
||||
Some(DirtyFilesChoice::Proceed) => {},
|
||||
Some(DirtyFilesChoice::Quit) | None => {
|
||||
print_msg("Exiting - please commit or stash your changes and restart.");
|
||||
anyhow::bail!("User declined to proceed with dirty files");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check startup state and determine if recovery is needed
|
||||
pub fn check_startup_state(config: &PlannerConfig) -> PlannerState {
|
||||
let plan_dir = config.plan_dir();
|
||||
|
||||
// Check for recovery situation
|
||||
if let Some(recovery_info) = RecoveryInfo::detect(&plan_dir) {
|
||||
return PlannerState::Recovery(recovery_info);
|
||||
}
|
||||
|
||||
PlannerState::PromptForRequirements
|
||||
}
|
||||
|
||||
/// Handle recovery situation
|
||||
pub fn handle_recovery(config: &PlannerConfig, info: &RecoveryInfo) -> Result<PlannerState> {
|
||||
// Build the recovery prompt
|
||||
let datetime = info.requirements_modified.as_deref().unwrap_or("unknown time");
|
||||
let todo_info = if let Some(ref contents) = info.todo_contents {
|
||||
"- todo.g3.md contents:\n{contents}".replace("{contents}", contents)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let prompt = r#"The last run didn't complete successfully. Found:
|
||||
- current_requirements.md from {datetime}
|
||||
{todo_info}
|
||||
|
||||
Would you like to resume the previous implementation?
|
||||
[Y] Yes - Attempt to resume
|
||||
[N] No - Mark as complete and proceed to review new_requirements.md
|
||||
[Q] Quit - Exit and investigate manually"#
|
||||
.replace("{datetime}", datetime)
|
||||
.replace("{todo_info}", &todo_info);
|
||||
|
||||
print_msg(&prompt);
|
||||
print_prompt("Choice: ");
|
||||
|
||||
loop {
|
||||
let input = read_line()?;
|
||||
match RecoveryChoice::from_input(&input) {
|
||||
Some(RecoveryChoice::Resume) => {
|
||||
// Log recovery attempt
|
||||
history::write_attempting_recovery(&config.plan_dir())?;
|
||||
return Ok(PlannerState::ImplementRequirements);
|
||||
}
|
||||
Some(RecoveryChoice::MarkComplete) => {
|
||||
// Log skipped recovery
|
||||
history::write_skipped_recovery(&config.plan_dir())?;
|
||||
return Ok(PlannerState::ImplementationComplete);
|
||||
}
|
||||
Some(RecoveryChoice::Quit) => {
|
||||
return Ok(PlannerState::Quit);
|
||||
}
|
||||
None => {
|
||||
print_prompt("Invalid choice. Please enter Y, N, or Q: ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for new requirements
|
||||
pub fn prompt_for_new_requirements(config: &PlannerConfig) -> Result<PlannerState> {
|
||||
// Delete existing todo file since we're starting fresh
|
||||
let todo_path = config.todo_path();
|
||||
if todo_path.exists() {
|
||||
fs::remove_file(&todo_path)
|
||||
.context("Failed to delete old todo.g3.md")?;
|
||||
}
|
||||
|
||||
// Display prompt
|
||||
let prompt = r#"I will help you refine the current requirements of your project.
|
||||
Please write or edit your requirements in `{codepath}/g3-plan/new_requirements.md`.
|
||||
Hit enter for me to start a review of that file."#
|
||||
.replace("{codepath}", &config.codepath.display().to_string());
|
||||
print_msg(&prompt);
|
||||
print_prompt("Press Enter when ready: ");
|
||||
|
||||
let input = read_line()?;
|
||||
if input.to_lowercase() == "quit" || input.to_lowercase() == "q" {
|
||||
return Ok(PlannerState::Quit);
|
||||
}
|
||||
|
||||
// Check if new_requirements.md exists
|
||||
let new_req_path = config.new_requirements_path();
|
||||
if !new_req_path.exists() {
|
||||
let error_msg = "File not found: {path}/g3-plan/new_requirements.md"
|
||||
.replace("{path}", &config.codepath.display().to_string());
|
||||
print_msg(&format!("❌ {}", error_msg));
|
||||
print_msg("Please create the file and try again.");
|
||||
return Ok(PlannerState::PromptForRequirements);
|
||||
}
|
||||
|
||||
// Ensure the file has the ORIGINAL_REQUIREMENTS tag
|
||||
ensure_original_requirements_tag(&new_req_path)?;
|
||||
|
||||
// Log that we're refining requirements
|
||||
history::write_refining_requirements(&config.plan_dir())?;
|
||||
|
||||
Ok(PlannerState::RefineRequirements)
|
||||
}
|
||||
|
||||
/// Ensure the new_requirements.md file has the ORIGINAL_REQUIREMENTS tag
|
||||
fn ensure_original_requirements_tag(path: &Path) -> Result<()> {
|
||||
let content = fs::read_to_string(path)
|
||||
.context("Failed to read new_requirements.md")?;
|
||||
|
||||
// Check if either tag is already present
|
||||
if content.contains("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}")
|
||||
|| content.contains("{{CURRENT REQUIREMENTS}}") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Prepend the ORIGINAL_REQUIREMENTS tag
|
||||
let new_content = format!("{}\n\n{}", "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}", content);
|
||||
fs::write(path, new_content)
|
||||
.context("Failed to update new_requirements.md with ORIGINAL_REQUIREMENTS tag")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if requirements have CURRENT REQUIREMENTS tag after LLM refinement
|
||||
pub fn check_current_requirements_tag(config: &PlannerConfig) -> Result<bool> {
|
||||
let new_req_path = config.new_requirements_path();
|
||||
let content = fs::read_to_string(&new_req_path)
|
||||
.context("Failed to read new_requirements.md")?;
|
||||
|
||||
Ok(content.contains("{{CURRENT REQUIREMENTS}}"))
|
||||
}
|
||||
|
||||
/// Prompt user to approve refined requirements
|
||||
pub fn prompt_for_approval(config: &PlannerConfig) -> Result<ApprovalChoice> {
|
||||
let prompt = r#"The LLM has updated `{codepath}/g3-plan/new_requirements.md`.
|
||||
Please review the file. If it's acceptable, type 'yes' to proceed with implementation.
|
||||
Type 'no' to continue refining, or 'quit' to exit."#
|
||||
.replace("{codepath}", &config.codepath.display().to_string());
|
||||
print_msg(&prompt);
|
||||
print_prompt("Choice: ");
|
||||
|
||||
loop {
|
||||
let input = read_line()?;
|
||||
match ApprovalChoice::from_input(&input) {
|
||||
Some(choice) => return Ok(choice),
|
||||
None => {
|
||||
print_prompt("Invalid choice. Please enter 'yes', 'no', or 'quit': ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move new_requirements.md to current_requirements.md
|
||||
pub fn promote_requirements(config: &PlannerConfig) -> Result<()> {
|
||||
let new_req_path = config.new_requirements_path();
|
||||
let current_req_path = config.current_requirements_path();
|
||||
|
||||
fs::rename(&new_req_path, ¤t_req_path)
|
||||
.context("Failed to rename new_requirements.md to current_requirements.md")?;
|
||||
|
||||
print_msg(&format!(
|
||||
"📄 Renamed new_requirements.md to current_requirements.md"
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read current requirements content
|
||||
pub fn read_current_requirements(config: &PlannerConfig) -> Result<String> {
|
||||
let path = config.current_requirements_path();
|
||||
fs::read_to_string(&path)
|
||||
.context("Failed to read current_requirements.md")
|
||||
}
|
||||
|
||||
/// Read todo file content
|
||||
pub fn read_todo(config: &PlannerConfig) -> Result<Option<String>> {
|
||||
let path = config.todo_path();
|
||||
if path.exists() {
|
||||
Ok(Some(fs::read_to_string(&path)
|
||||
.context("Failed to read todo.g3.md")?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if all todos are complete
|
||||
pub fn check_todos_complete(todo_contents: &str) -> bool {
|
||||
// Check if there are any incomplete items (- [ ])
|
||||
!todo_contents.contains("- [ ]")
|
||||
}
|
||||
|
||||
/// Prompt user to confirm implementation completion
|
||||
pub fn prompt_for_completion(config: &PlannerConfig) -> Result<CompletionChoice> {
|
||||
let todo_contents = read_todo(config)?.unwrap_or_else(|| "(no todo file)".to_string());
|
||||
|
||||
let prompt = r#"The coach/player loop has completed.
|
||||
|
||||
Todo file contents:
|
||||
{todo_contents}
|
||||
|
||||
Do you consider the todos and requirements completed? [Y/n]
|
||||
If not, we'll return to the coach/player loop."#
|
||||
.replace("{todo_contents}", &todo_contents);
|
||||
print_msg(&prompt);
|
||||
print_prompt("Choice: ");
|
||||
|
||||
loop {
|
||||
let input = read_line()?;
|
||||
match CompletionChoice::from_input(&input) {
|
||||
Some(choice) => return Ok(choice),
|
||||
None => {
|
||||
print_prompt("Invalid choice. Please enter Y, N, or Q: ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete the implementation - rename files and prepare for commit
|
||||
pub fn complete_implementation(config: &PlannerConfig) -> Result<(String, String)> {
|
||||
let plan_dir = config.plan_dir();
|
||||
|
||||
// Generate timestamped filenames
|
||||
let req_filename = history::completed_requirements_filename();
|
||||
let todo_filename = history::completed_todo_filename();
|
||||
|
||||
// Rename current_requirements.md
|
||||
let current_req = config.current_requirements_path();
|
||||
let completed_req = plan_dir.join(&req_filename);
|
||||
if current_req.exists() {
|
||||
fs::rename(¤t_req, &completed_req)
|
||||
.context("Failed to rename current_requirements.md")?;
|
||||
print_msg(&format!("📄 Renamed to {}", req_filename));
|
||||
}
|
||||
|
||||
// Rename todo.g3.md
|
||||
let todo_path = config.todo_path();
|
||||
let completed_todo = plan_dir.join(&todo_filename);
|
||||
if todo_path.exists() {
|
||||
fs::rename(&todo_path, &completed_todo)
|
||||
.context("Failed to rename todo.g3.md")?;
|
||||
print_msg(&format!("📄 Renamed to {}", todo_filename));
|
||||
}
|
||||
|
||||
// Log completion
|
||||
history::write_completed_requirements(&plan_dir, &req_filename, &todo_filename)?;
|
||||
|
||||
Ok((req_filename, todo_filename))
|
||||
}
|
||||
|
||||
/// Stage files and make git commit
|
||||
pub fn stage_and_commit(
|
||||
config: &PlannerConfig,
|
||||
summary: &str,
|
||||
description: &str,
|
||||
) -> Result<()> {
|
||||
if config.no_git {
|
||||
print_msg("⚠️ Skipping git commit (--no-git flag)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Stage files
|
||||
print_msg("📦 Staging files...");
|
||||
let staging_result = git::stage_files(&config.codepath, &config.plan_dir())?;
|
||||
|
||||
if !staging_result.staged.is_empty() {
|
||||
print_msg(&format!(" Staged {} files", staging_result.staged.len()));
|
||||
}
|
||||
if !staging_result.excluded.is_empty() {
|
||||
print_msg(&format!(" Excluded {} files (temporary/artifacts)", staging_result.excluded.len()));
|
||||
}
|
||||
|
||||
// Show pre-commit message
|
||||
let pre_commit = r#"Ready to make a git commit with the following message:
|
||||
|
||||
Summary: {summary}
|
||||
|
||||
Description:
|
||||
{description}
|
||||
|
||||
Please review the currently staged files (use `git status` in another terminal).
|
||||
Press Enter to continue with the commit, or type 'quit' to exit without committing."#
|
||||
.replace("{summary}", summary)
|
||||
.replace("{description}", description);
|
||||
print_msg(&pre_commit);
|
||||
|
||||
let input = read_line()?;
|
||||
if input.to_lowercase() == "quit" || input.to_lowercase() == "q" {
|
||||
print_msg("Skipping commit. Files remain staged.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Make commit
|
||||
print_msg("📝 Making git commit...");
|
||||
let _commit_sha = git::commit(&config.codepath, summary, description)?;
|
||||
print_msg("✅ Commit successful");
|
||||
|
||||
// Log commit to history
|
||||
history::write_git_commit(&config.plan_dir(), summary)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse commit message from LLM response
|
||||
pub fn parse_commit_message(response: &str) -> (String, String) {
|
||||
let mut summary = String::new();
|
||||
let mut description = String::new();
|
||||
|
||||
if let Some(summary_start) = response.find("{{COMMIT_SUMMARY}}") {
|
||||
let after_tag = &response[summary_start + "{{COMMIT_SUMMARY}}".len()..];
|
||||
if let Some(end) = after_tag.find("{{COMMIT_DESCRIPTION}}") {
|
||||
summary = after_tag[..end].trim().to_string();
|
||||
} else {
|
||||
summary = after_tag.lines().next().unwrap_or("").trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(desc_start) = response.find("{{COMMIT_DESCRIPTION}}") {
|
||||
let after_tag = &response[desc_start + "{{COMMIT_DESCRIPTION}}".len()..];
|
||||
description = after_tag.trim().to_string();
|
||||
}
|
||||
|
||||
// Ensure summary is max 72 chars
|
||||
if summary.len() > 72 {
|
||||
summary = format!("{}...", &summary[..69]);
|
||||
}
|
||||
|
||||
// Ensure description lines are max 72 chars
|
||||
let wrapped_desc: Vec<String> = description
|
||||
.lines()
|
||||
.take(10) // Max 10 lines
|
||||
.map(|line| {
|
||||
if line.len() > 72 {
|
||||
format!("{}...", &line[..69])
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
description = wrapped_desc.join("\n");
|
||||
|
||||
// Fallback if parsing failed
|
||||
if summary.is_empty() {
|
||||
summary = "Implement requirements".to_string();
|
||||
}
|
||||
|
||||
(summary, description)
|
||||
}
|
||||
|
||||
/// Tools available to the planner agent
|
||||
pub fn get_planner_tools() -> Vec<&'static str> {
|
||||
vec![
|
||||
"read_file",
|
||||
"write_file",
|
||||
"shell",
|
||||
"code_search",
|
||||
"str_replace",
|
||||
"final_output",
|
||||
]
|
||||
}
|
||||
|
||||
/// Tools NOT available to the planner agent
|
||||
pub fn get_excluded_planner_tools() -> Vec<&'static str> {
|
||||
vec![
|
||||
"todo_write", // Planner should not write todos during refinement
|
||||
]
|
||||
}
|
||||
|
||||
/// Run the coach/player implementation loop
|
||||
///
|
||||
/// This function runs the actual implementation phase using g3-core's Agent
|
||||
/// in a coach/player feedback loop similar to autonomous mode.
|
||||
pub async fn run_coach_player_loop(
|
||||
planner_config: &PlannerConfig,
|
||||
g3_config: &g3_config::Config,
|
||||
requirements_content: &str,
|
||||
) -> Result<()> {
|
||||
use g3_core::project::Project;
|
||||
use g3_core::Agent;
|
||||
|
||||
let max_turns = planner_config.max_turns;
|
||||
|
||||
// Create project with custom requirements path
|
||||
let project = Project::new_autonomous_with_requirements(
|
||||
planner_config.codepath.clone(),
|
||||
requirements_content.to_string(),
|
||||
)?;
|
||||
|
||||
// Enter the workspace
|
||||
project.ensure_workspace_exists()?;
|
||||
project.enter_workspace()?;
|
||||
|
||||
print_msg(&format!("📁 Working in: {}", planner_config.codepath.display()));
|
||||
print_msg(&format!("🔄 Max turns: {}", max_turns));
|
||||
|
||||
// Set environment variable for custom todo path
|
||||
std::env::set_var("G3_TODO_PATH", planner_config.todo_path().display().to_string());
|
||||
|
||||
let mut turn = 1;
|
||||
let mut coach_feedback = String::new();
|
||||
|
||||
while turn <= max_turns {
|
||||
print_msg(&format!("\n=== Turn {}/{} ===", turn, max_turns));
|
||||
|
||||
// Player phase - implement requirements
|
||||
print_msg("🎯 Player: Implementing requirements...");
|
||||
|
||||
let player_config = g3_config.for_player()?;
|
||||
let ui_writer = llm::PlannerUiWriter;
|
||||
let mut player_agent = Agent::new_autonomous_with_readme_and_quiet(
|
||||
player_config,
|
||||
ui_writer,
|
||||
None,
|
||||
planner_config.quiet,
|
||||
).await?;
|
||||
|
||||
let player_prompt = if coach_feedback.is_empty() {
|
||||
format!(
|
||||
"You are G3 in implementation mode. Read and implement the following requirements:\n\n{}\n\nImplement this step by step. Write the todo list to: {}\n\nCreate all necessary files and code.",
|
||||
requirements_content,
|
||||
planner_config.todo_path().display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"You are G3 in implementation mode. Address the following coach feedback:\n\n{}\n\nContext requirements:\n{}\n\nFix the issues mentioned above.",
|
||||
coach_feedback,
|
||||
requirements_content
|
||||
)
|
||||
};
|
||||
|
||||
let player_result = player_agent
|
||||
.execute_task_with_timing(&player_prompt, None, false, false, false, true, None)
|
||||
.await;
|
||||
|
||||
match player_result {
|
||||
Ok(result) => print_msg(&format!("✅ Player completed: {} chars response", result.response.len())),
|
||||
Err(e) => print_msg(&format!("⚠️ Player error: {}", e)),
|
||||
}
|
||||
|
||||
// Coach phase - review implementation
|
||||
print_msg("🎓 Coach: Reviewing implementation...");
|
||||
|
||||
let coach_config = g3_config.for_coach()?;
|
||||
let coach_ui_writer = llm::PlannerUiWriter;
|
||||
let mut coach_agent = Agent::new_autonomous_with_readme_and_quiet(
|
||||
coach_config,
|
||||
coach_ui_writer,
|
||||
None,
|
||||
planner_config.quiet,
|
||||
).await?;
|
||||
|
||||
let coach_prompt = format!(
|
||||
"You are G3 in coach mode. Review the implementation against these requirements:\n\n{}\n\nCheck:\n1. Are requirements implemented correctly?\n2. Does the code compile?\n3. What's missing?\n\nIf COMPLETE, respond with 'IMPLEMENTATION_APPROVED'.\nOtherwise, provide specific feedback for the player to fix.",
|
||||
requirements_content
|
||||
);
|
||||
|
||||
let coach_result = coach_agent
|
||||
.execute_task_with_timing(&coach_prompt, None, false, false, false, true, None)
|
||||
.await;
|
||||
|
||||
match coach_result {
|
||||
Ok(result) => {
|
||||
if result.response.contains("IMPLEMENTATION_APPROVED") || result.is_approved() {
|
||||
print_msg("✅ Coach approved implementation!");
|
||||
return Ok(());
|
||||
}
|
||||
coach_feedback = result.response;
|
||||
print_msg(&format!("📝 Coach feedback: {} chars", coach_feedback.len()));
|
||||
}
|
||||
Err(e) => {
|
||||
print_msg(&format!("⚠️ Coach error: {}", e));
|
||||
coach_feedback = "Please review and fix any issues.".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
turn += 1;
|
||||
}
|
||||
|
||||
print_msg(&format!("⏰ Reached max turns ({})", max_turns));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Main entry point for planning mode
|
||||
///
|
||||
/// This function orchestrates the entire planning workflow:
|
||||
/// 1. Initialize the planning directory
|
||||
/// 2. Check git status (if enabled)
|
||||
/// 3. Detect and handle recovery situations
|
||||
/// 4. Run the refinement and implementation loop
|
||||
pub async fn run_planning_mode(
|
||||
codepath: Option<String>,
|
||||
no_git: bool,
|
||||
config_path: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
print_msg("\n🎯 G3 Planning Mode");
|
||||
print_msg("==================\n");
|
||||
|
||||
// Create the LLM provider for planning
|
||||
print_msg("🔧 Initializing planner provider...");
|
||||
let provider = match llm::create_planner_provider(config_path).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
print_msg(&format!("❌ Failed to initialize provider: {}", e));
|
||||
print_msg("Please check your configuration file.");
|
||||
anyhow::bail!("Provider initialization failed: {}", e);
|
||||
}
|
||||
};
|
||||
print_msg(&format!("✅ Provider initialized: {}", provider.name()));
|
||||
|
||||
// Get codepath from argument or prompt user
|
||||
let codepath = match codepath {
|
||||
Some(path) => {
|
||||
let expanded = expand_codepath(&path)?;
|
||||
print_msg(&format!("📁 Codepath: {}", expanded.display()));
|
||||
expanded
|
||||
}
|
||||
None => {
|
||||
let path = prompt_for_codepath()?;
|
||||
print_msg(&format!("📁 Codepath: {}", path.display()));
|
||||
path
|
||||
}
|
||||
};
|
||||
|
||||
// Verify codepath exists
|
||||
if !codepath.exists() {
|
||||
anyhow::bail!("Codepath does not exist: {}", codepath.display());
|
||||
}
|
||||
|
||||
// Create configuration
|
||||
let config = PlannerConfig {
|
||||
codepath: codepath.clone(),
|
||||
no_git,
|
||||
max_turns: 5, // Default, could be made configurable
|
||||
quiet: false,
|
||||
config_path: config_path.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
// Initialize plan directory
|
||||
initialize_plan_dir(&config)?;
|
||||
|
||||
// Check git status
|
||||
check_git_status(&config)?;
|
||||
|
||||
// Main planning loop
|
||||
let mut state = check_startup_state(&config);
|
||||
|
||||
loop {
|
||||
state = match state {
|
||||
PlannerState::Startup => {
|
||||
// Startup state transitions to checking for recovery
|
||||
check_startup_state(&config)
|
||||
}
|
||||
PlannerState::Recovery(info) => {
|
||||
handle_recovery(&config, &info)?
|
||||
}
|
||||
PlannerState::PromptForRequirements => {
|
||||
prompt_for_new_requirements(&config)?
|
||||
}
|
||||
PlannerState::RefineRequirements => {
|
||||
// Call LLM for refinement with full tool execution
|
||||
print_msg("\n🔄 Refinement phase - calling LLM...");
|
||||
|
||||
let codepath_str = config.codepath.display().to_string();
|
||||
|
||||
// Load config and call LLM with full tool execution capability
|
||||
let g3_config = g3_config::Config::load(config.config_path.as_deref())?;
|
||||
let response = llm::call_refinement_llm_with_tools(
|
||||
&g3_config,
|
||||
&codepath_str,
|
||||
).await;
|
||||
|
||||
match response {
|
||||
Ok(_) => print_msg("✅ LLM refinement complete."),
|
||||
Err(e) => print_msg(&format!("⚠️ LLM refinement error: {}", e)),
|
||||
}
|
||||
|
||||
if check_current_requirements_tag(&config)? {
|
||||
match prompt_for_approval(&config)? {
|
||||
ApprovalChoice::Approve => PlannerState::ImplementRequirements,
|
||||
ApprovalChoice::Refine => PlannerState::PromptForRequirements,
|
||||
ApprovalChoice::Quit => PlannerState::Quit,
|
||||
}
|
||||
} else {
|
||||
print_msg(&format!("❌ {}", "The LLM didn't update the requirements file with {{CURRENT REQUIREMENTS}}. Please restart the app."));
|
||||
PlannerState::Quit
|
||||
}
|
||||
}
|
||||
PlannerState::ImplementRequirements => {
|
||||
// Promote requirements and run coach/player
|
||||
if config.new_requirements_path().exists() {
|
||||
promote_requirements(&config)?;
|
||||
}
|
||||
|
||||
// Write git HEAD to history before implementation
|
||||
if !config.no_git {
|
||||
let head_sha = git::get_head_sha(&config.codepath)?;
|
||||
history::write_git_head(&config.plan_dir(), &head_sha)?;
|
||||
print_msg(&format!("📝 Recorded git HEAD: {}", &head_sha[..12.min(head_sha.len())]));
|
||||
}
|
||||
|
||||
// Read requirements and generate summary
|
||||
let requirements_content = read_current_requirements(&config)?;
|
||||
|
||||
print_msg("📝 Generating requirements summary...");
|
||||
let summary = match llm::generate_requirements_summary(
|
||||
provider.as_ref(),
|
||||
&requirements_content,
|
||||
).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
print_msg(&format!("⚠️ Summary generation failed: {}", e));
|
||||
"Requirements implementation in progress".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// Write start implementing entry with summary
|
||||
history::write_start_implementing(&config.plan_dir(), &summary)?;
|
||||
print_msg("📝 Recorded implementation start in history");
|
||||
|
||||
// Run the actual coach/player loop
|
||||
print_msg("\n🚀 Starting coach/player implementation loop...");
|
||||
|
||||
let g3_config = g3_config::Config::load(config.config_path.as_deref())?;
|
||||
let implementation_result = run_coach_player_loop(
|
||||
&config,
|
||||
&g3_config,
|
||||
&requirements_content,
|
||||
).await;
|
||||
|
||||
match implementation_result {
|
||||
Ok(_) => print_msg("✅ Coach/player loop completed"),
|
||||
Err(e) => {
|
||||
print_msg(&format!("⚠️ Implementation error: {}", e));
|
||||
print_msg("You can try to resume or mark as complete.");
|
||||
}
|
||||
}
|
||||
|
||||
PlannerState::ImplementationComplete
|
||||
}
|
||||
PlannerState::ImplementationComplete => {
|
||||
// Check completion and commit
|
||||
match prompt_for_completion(&config)? {
|
||||
CompletionChoice::Complete => {
|
||||
let (req_file, todo_file) = complete_implementation(&config)?;
|
||||
|
||||
// Read requirements for LLM context
|
||||
let requirements_content = if config.plan_dir().join(&req_file).exists() {
|
||||
std::fs::read_to_string(config.plan_dir().join(&req_file))
|
||||
.unwrap_or_else(|_| "Requirements unavailable".to_string())
|
||||
} else {
|
||||
"Requirements unavailable".to_string()
|
||||
};
|
||||
|
||||
// Generate commit message using LLM
|
||||
print_msg("📝 Generating commit message...");
|
||||
let (summary, description) = match llm::generate_commit_message(
|
||||
provider.as_ref(),
|
||||
&requirements_content,
|
||||
&req_file,
|
||||
&todo_file,
|
||||
).await {
|
||||
Ok((s, d)) => (s, d),
|
||||
Err(e) => {
|
||||
print_msg(&format!("⚠️ Commit message generation failed: {}", e));
|
||||
("Implement planning requirements".to_string(),
|
||||
format!("Requirements: {}\nTodo: {}", req_file, todo_file))
|
||||
}
|
||||
};
|
||||
|
||||
stage_and_commit(&config, &summary, &description)?;
|
||||
PlannerState::PromptForRequirements
|
||||
}
|
||||
CompletionChoice::Continue => PlannerState::ImplementRequirements,
|
||||
CompletionChoice::Quit => PlannerState::Quit,
|
||||
}
|
||||
}
|
||||
PlannerState::Quit => {
|
||||
print_msg("\n👋 Exiting planning mode.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_expand_codepath_tilde() {
|
||||
let result = expand_codepath("~/test/path").unwrap();
|
||||
assert!(result.to_string_lossy().contains("test/path"));
|
||||
assert!(!result.to_string_lossy().contains('~'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_planner_config_paths() {
|
||||
let config = PlannerConfig {
|
||||
codepath: PathBuf::from("/test/project"),
|
||||
no_git: false,
|
||||
max_turns: 5,
|
||||
quiet: false,
|
||||
config_path: None,
|
||||
};
|
||||
|
||||
assert_eq!(config.plan_dir(), PathBuf::from("/test/project/g3-plan"));
|
||||
assert_eq!(config.new_requirements_path(), PathBuf::from("/test/project/g3-plan/new_requirements.md"));
|
||||
assert_eq!(config.current_requirements_path(), PathBuf::from("/test/project/g3-plan/current_requirements.md"));
|
||||
assert_eq!(config.todo_path(), PathBuf::from("/test/project/g3-plan/todo.g3.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_todos_complete() {
|
||||
assert!(check_todos_complete("- [x] Task 1\n- [x] Task 2"));
|
||||
assert!(!check_todos_complete("- [x] Task 1\n- [ ] Task 2"));
|
||||
assert!(!check_todos_complete("- [ ] Task 1"));
|
||||
assert!(check_todos_complete("No tasks here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_commit_message() {
|
||||
let response = r#"Some preamble
|
||||
{{COMMIT_SUMMARY}}
|
||||
Add planning mode with state machine
|
||||
{{COMMIT_DESCRIPTION}}
|
||||
Implements the planning workflow including:
|
||||
- Requirements refinement
|
||||
- Git integration
|
||||
- History tracking"#;
|
||||
|
||||
let (summary, desc) = parse_commit_message(response);
|
||||
assert_eq!(summary, "Add planning mode with state machine");
|
||||
assert!(desc.contains("Implements the planning workflow"));
|
||||
assert!(desc.contains("Requirements refinement"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_commit_message_truncation() {
|
||||
let long_summary = "A".repeat(100);
|
||||
let response = format!("{{{{COMMIT_SUMMARY}}}}\n{}\n{{{{COMMIT_DESCRIPTION}}}}\nDesc", long_summary);
|
||||
|
||||
let (summary, _) = parse_commit_message(&response);
|
||||
assert!(summary.len() <= 72);
|
||||
assert!(summary.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_original_requirements_tag() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("new_requirements.md");
|
||||
|
||||
// Write content without tag
|
||||
fs::write(&path, "Some requirements").unwrap();
|
||||
|
||||
ensure_original_requirements_tag(&path).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}"));
|
||||
assert!(content.contains("Some requirements"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_original_requirements_tag_already_present() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("new_requirements.md");
|
||||
|
||||
// Write content with tag already
|
||||
let content_with_tag = format!("{}\n\nSome requirements", "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}");
|
||||
fs::write(&path, &content_with_tag).unwrap();
|
||||
|
||||
ensure_original_requirements_tag(&path).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
// Should not duplicate the tag
|
||||
assert_eq!(content.matches("{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_plan_dir() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config = PlannerConfig {
|
||||
codepath: temp_dir.path().to_path_buf(),
|
||||
no_git: true,
|
||||
max_turns: 5,
|
||||
quiet: false,
|
||||
config_path: None,
|
||||
};
|
||||
|
||||
initialize_plan_dir(&config).unwrap();
|
||||
|
||||
assert!(config.plan_dir().exists());
|
||||
assert!(config.history_path().exists());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
//! Prompts used for discovery phase
|
||||
//! Prompts used for planning mode and discovery phase
|
||||
//!
|
||||
//! This module contains all LLM prompts used in the planner crate.
|
||||
//! All prompts are defined as constants to ensure consistency and maintainability.
|
||||
|
||||
// =============================================================================
|
||||
// DISCOVERY PHASE PROMPTS (existing)
|
||||
// =============================================================================
|
||||
|
||||
/// System prompt for discovery mode - instructs the LLM to analyze codebase and generate exploration commands
|
||||
pub const DISCOVERY_SYSTEM_PROMPT: &str = r#"You are an expert code analyst. Your task is to analyze a codebase structure and generate shell commands to explore it further.
|
||||
@@ -35,3 +42,101 @@ Your output MUST include:
|
||||
- Mark the beginning and end of the commands with "```".
|
||||
|
||||
DO NOT ADD ANY COMMENTS OR OTHER EXPLANATION IN THE COMMANDS SECTION, JUST INCLUDE THE SHELL COMMANDS."#;
|
||||
|
||||
// =============================================================================
|
||||
// PLANNING MODE PROMPTS
|
||||
// =============================================================================
|
||||
|
||||
/// System prompt for requirements refinement phase
|
||||
pub const REFINE_REQUIREMENTS_SYSTEM_PROMPT: &str = r#"You're an experienced software engineering architect. Please help me to ideate and refine
|
||||
REQUIREMENTS for an implementation (or changes to the existing implementation), at the specified codepath.
|
||||
The requirements will later be used by an LLM.
|
||||
|
||||
IMPORTANT: Before suggesting changes, you MUST:
|
||||
1. Read and understand the existing codebase at the specified codepath using read_file, shell commands, and code_search
|
||||
2. Read the `<codepath>/g3-plan/` directory to understand past requirements and implementation history
|
||||
- Pay particular attention to `planner_history.txt` which contains a chronological record of all planning activities
|
||||
- Review any `completed_requirements_*.md` files to understand what has been implemented before
|
||||
3. Use this context to ensure your suggestions are consistent with the existing codebase architecture
|
||||
|
||||
I wish to have a compact specification, and DO NOT ATTEMPT TO IMPLEMENT OR BUILD ANYTHING.
|
||||
At this point ONLY suggest improvements to the requirements. Do not implement anything.
|
||||
DO NOT DO A RE-WRITE, UNLESS THE USER EXPLICITLY ASKS FOR THAT.
|
||||
If you think the requirements are totally incoherent and unusable, write constructive feedback on
|
||||
why that is, and suggest (very briefly) that you could rewrite it if explicitly asked to do so.
|
||||
If the requirements are usable, make some edits/changes/additions as you deem necessary, and
|
||||
PREPEND them under the heading `{{CURRENT REQUIREMENTS}}` to the `<codepath>/g3-plan/new_requirements.md` file.
|
||||
|
||||
The codepath will be provided in the user message."#;
|
||||
|
||||
/// System prompt for generating requirements summary for planner_history.txt
|
||||
pub const GENERATE_REQUIREMENTS_SUMMARY_PROMPT: &str = r#"Generate a short summary of the following requirements.
|
||||
Take care that the most important elements of the requirements are reflected.
|
||||
Do not go into deep detail. Make the summary at most 5 lines long.
|
||||
Each line should be at most 120 characters long.
|
||||
Output ONLY the summary text, no headers or formatting.
|
||||
|
||||
Requirements:
|
||||
{requirements}"#;
|
||||
|
||||
/// System prompt for generating git commit message
|
||||
pub const GENERATE_COMMIT_MESSAGE_PROMPT: &str = r#"Generate a git commit message for the following implementation.
|
||||
|
||||
REQUIREMENTS THAT WERE IMPLEMENTED:
|
||||
{requirements}
|
||||
|
||||
COMPLETED FILES:
|
||||
- Requirements: {requirements_file}
|
||||
- Todo: {todo_file}
|
||||
|
||||
Generate a commit message with:
|
||||
1. A summary line (max 72 characters, imperative mood, e.g., "Add planning mode with...")
|
||||
2. A blank line
|
||||
3. A description (max 10 lines, each max 72 characters, wrapped properly)
|
||||
|
||||
The description should:
|
||||
- Describe the implementation concisely
|
||||
- Include only the most important and salient details
|
||||
- Mention the completed_requirements and completed_todo filenames
|
||||
|
||||
Output format:
|
||||
{{COMMIT_SUMMARY}}
|
||||
<summary line here>
|
||||
{{COMMIT_DESCRIPTION}}
|
||||
<description here>"#;
|
||||
|
||||
// =============================================================================
|
||||
// CONFIG ERROR MESSAGES
|
||||
// =============================================================================
|
||||
|
||||
/// Error message for old config format
|
||||
pub const OLD_CONFIG_FORMAT_ERROR: &str = r#"Your configuration file uses an old format that is no longer supported.
|
||||
|
||||
Please update your configuration to use the new provider format:
|
||||
|
||||
```toml
|
||||
[providers]
|
||||
default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
|
||||
planner = "anthropic.planner" # Optional: specific provider for planner
|
||||
coach = "anthropic.default" # Optional: specific provider for coach
|
||||
player = "openai.player" # Optional: specific provider for player
|
||||
|
||||
# Named configs per provider type
|
||||
[providers.anthropic.default]
|
||||
api_key = "your-api-key"
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 64000
|
||||
|
||||
[providers.anthropic.planner]
|
||||
api_key = "your-api-key"
|
||||
model = "claude-opus-4-5"
|
||||
thinking_budget_tokens = 16000
|
||||
|
||||
[providers.openai.player]
|
||||
api_key = "your-api-key"
|
||||
model = "gpt-5"
|
||||
```
|
||||
|
||||
Each mode (planner, coach, player) can specify a full path like "<provider_type>.<config_name>".
|
||||
If not specified, they fall back to `default_provider`."#;
|
||||
|
||||
|
||||
289
crates/g3-planner/src/state.rs
Normal file
289
crates/g3-planner/src/state.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
//! Planner state machine
|
||||
//!
|
||||
//! This module defines the state machine for the planning mode:
|
||||
//!
|
||||
//! ```text
|
||||
//! +------------- RECOVERY (Resume) ---------------------+
|
||||
//! | |
|
||||
//! | +---------- RECOVERY (Mark Complete) ----+ |
|
||||
//! | | | |
|
||||
//! ^ ^ v v
|
||||
//! STARTUP -> PROMPT FOR NEW REQUIREMENTS -> REFINE REQUIREMENTS -> IMPLEMENT REQUIREMENTS -> IMPLEMENTATION COMPLETE +
|
||||
//! ^ v
|
||||
//! | |
|
||||
//! +---------------------------------------------------------------------------------------------------------+
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// The state of the planning mode
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlannerState {
|
||||
/// Initial startup state
|
||||
Startup,
|
||||
/// Recovery needed - found incomplete previous run
|
||||
Recovery(RecoveryInfo),
|
||||
/// Prompting user for new requirements
|
||||
PromptForRequirements,
|
||||
/// Refining requirements with LLM
|
||||
RefineRequirements,
|
||||
/// Implementing requirements (coach/player loop)
|
||||
ImplementRequirements,
|
||||
/// Implementation completed successfully
|
||||
ImplementationComplete,
|
||||
/// User quit the application
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Information about a recovery situation
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecoveryInfo {
|
||||
/// Whether current_requirements.md exists
|
||||
pub has_current_requirements: bool,
|
||||
/// Timestamp of current_requirements.md if it exists
|
||||
pub requirements_modified: Option<String>,
|
||||
/// Whether todo.g3.md exists
|
||||
pub has_todo: bool,
|
||||
/// Contents of todo.g3.md if it exists
|
||||
pub todo_contents: Option<String>,
|
||||
}
|
||||
|
||||
impl RecoveryInfo {
|
||||
/// Create recovery info by checking file existence
|
||||
pub fn detect(plan_dir: &Path) -> Option<Self> {
|
||||
let current_req_path = plan_dir.join("current_requirements.md");
|
||||
let todo_path = plan_dir.join("todo.g3.md");
|
||||
|
||||
let has_current_requirements = current_req_path.exists();
|
||||
let has_todo = todo_path.exists();
|
||||
|
||||
// If neither file exists, no recovery needed
|
||||
if !has_current_requirements && !has_todo {
|
||||
return None;
|
||||
}
|
||||
|
||||
let requirements_modified = if has_current_requirements {
|
||||
get_file_modified_time(¤t_req_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let todo_contents = if has_todo {
|
||||
std::fs::read_to_string(&todo_path).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(RecoveryInfo {
|
||||
has_current_requirements,
|
||||
requirements_modified,
|
||||
has_todo,
|
||||
todo_contents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the modified time of a file as a formatted string
|
||||
fn get_file_modified_time(path: &Path) -> Option<String> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
let modified = metadata.modified().ok()?;
|
||||
let datetime: DateTime<Local> = modified.into();
|
||||
Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
}
|
||||
|
||||
/// User's choice when presented with recovery options
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecoveryChoice {
|
||||
/// Resume the previous implementation
|
||||
Resume,
|
||||
/// Mark as complete and proceed to new requirements
|
||||
MarkComplete,
|
||||
/// Quit and investigate manually
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl RecoveryChoice {
|
||||
/// Parse user input into a recovery choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" => Some(RecoveryChoice::Resume),
|
||||
"n" | "no" => Some(RecoveryChoice::MarkComplete),
|
||||
"q" | "quit" => Some(RecoveryChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when asked to approve requirements
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalChoice {
|
||||
/// Approve and proceed to implementation
|
||||
Approve,
|
||||
/// Continue refining
|
||||
Refine,
|
||||
/// Quit the application
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl ApprovalChoice {
|
||||
/// Parse user input into an approval choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" => Some(ApprovalChoice::Approve),
|
||||
"n" | "no" => Some(ApprovalChoice::Refine),
|
||||
"q" | "quit" => Some(ApprovalChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when asked if implementation is complete
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CompletionChoice {
|
||||
/// Yes, implementation is complete
|
||||
Complete,
|
||||
/// No, continue with coach/player loop
|
||||
Continue,
|
||||
/// Quit the application
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl CompletionChoice {
|
||||
/// Parse user input into a completion choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" | "" => Some(CompletionChoice::Complete),
|
||||
"n" | "no" => Some(CompletionChoice::Continue),
|
||||
"q" | "quit" => Some(CompletionChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when asked to confirm git branch
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BranchConfirmChoice {
|
||||
/// Yes, correct branch
|
||||
Confirm,
|
||||
/// No, wrong branch - quit
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl BranchConfirmChoice {
|
||||
/// Parse user input into a branch confirmation choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" | "" => Some(BranchConfirmChoice::Confirm),
|
||||
"n" | "no" | "q" | "quit" => Some(BranchConfirmChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User's choice when warned about dirty files
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DirtyFilesChoice {
|
||||
/// Proceed anyway
|
||||
Proceed,
|
||||
/// Quit and handle manually
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl DirtyFilesChoice {
|
||||
/// Parse user input into a dirty files choice
|
||||
pub fn from_input(input: &str) -> Option<Self> {
|
||||
let input = input.trim().to_lowercase();
|
||||
match input.as_str() {
|
||||
"y" | "yes" | "" => Some(DirtyFilesChoice::Proceed),
|
||||
"n" | "no" | "q" | "quit" => Some(DirtyFilesChoice::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_recovery_info_no_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let result = RecoveryInfo::detect(temp_dir.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_info_with_current_requirements() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let req_path = temp_dir.path().join("current_requirements.md");
|
||||
std::fs::write(&req_path, "test requirements").unwrap();
|
||||
|
||||
let result = RecoveryInfo::detect(temp_dir.path());
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert!(info.has_current_requirements);
|
||||
assert!(info.requirements_modified.is_some());
|
||||
assert!(!info.has_todo);
|
||||
assert!(info.todo_contents.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_info_with_todo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let todo_path = temp_dir.path().join("todo.g3.md");
|
||||
std::fs::write(&todo_path, "- [ ] Test task").unwrap();
|
||||
|
||||
let result = RecoveryInfo::detect(temp_dir.path());
|
||||
assert!(result.is_some());
|
||||
let info = result.unwrap();
|
||||
assert!(!info.has_current_requirements);
|
||||
assert!(info.has_todo);
|
||||
assert_eq!(info.todo_contents, Some("- [ ] Test task".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_choice_parsing() {
|
||||
assert_eq!(RecoveryChoice::from_input("y"), Some(RecoveryChoice::Resume));
|
||||
assert_eq!(RecoveryChoice::from_input("YES"), Some(RecoveryChoice::Resume));
|
||||
assert_eq!(RecoveryChoice::from_input("n"), Some(RecoveryChoice::MarkComplete));
|
||||
assert_eq!(RecoveryChoice::from_input("No"), Some(RecoveryChoice::MarkComplete));
|
||||
assert_eq!(RecoveryChoice::from_input("q"), Some(RecoveryChoice::Quit));
|
||||
assert_eq!(RecoveryChoice::from_input("quit"), Some(RecoveryChoice::Quit));
|
||||
assert_eq!(RecoveryChoice::from_input("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_choice_parsing() {
|
||||
assert_eq!(ApprovalChoice::from_input("yes"), Some(ApprovalChoice::Approve));
|
||||
assert_eq!(ApprovalChoice::from_input("no"), Some(ApprovalChoice::Refine));
|
||||
assert_eq!(ApprovalChoice::from_input("quit"), Some(ApprovalChoice::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_choice_parsing() {
|
||||
assert_eq!(CompletionChoice::from_input("y"), Some(CompletionChoice::Complete));
|
||||
assert_eq!(CompletionChoice::from_input(""), Some(CompletionChoice::Complete)); // Default
|
||||
assert_eq!(CompletionChoice::from_input("n"), Some(CompletionChoice::Continue));
|
||||
assert_eq!(CompletionChoice::from_input("quit"), Some(CompletionChoice::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branch_confirm_parsing() {
|
||||
assert_eq!(BranchConfirmChoice::from_input("y"), Some(BranchConfirmChoice::Confirm));
|
||||
assert_eq!(BranchConfirmChoice::from_input(""), Some(BranchConfirmChoice::Confirm)); // Default
|
||||
assert_eq!(BranchConfirmChoice::from_input("n"), Some(BranchConfirmChoice::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirty_files_choice_parsing() {
|
||||
assert_eq!(DirtyFilesChoice::from_input("y"), Some(DirtyFilesChoice::Proceed));
|
||||
assert_eq!(DirtyFilesChoice::from_input(""), Some(DirtyFilesChoice::Proceed)); // Default
|
||||
assert_eq!(DirtyFilesChoice::from_input("n"), Some(DirtyFilesChoice::Quit));
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnthropicProvider {
|
||||
client: Client,
|
||||
name: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
@@ -150,6 +151,40 @@ impl AnthropicProvider {
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
name: "anthropic".to_string(),
|
||||
api_key,
|
||||
model,
|
||||
max_tokens: max_tokens.unwrap_or(4096),
|
||||
temperature: temperature.unwrap_or(0.1),
|
||||
cache_config,
|
||||
enable_1m_context: enable_1m_context.unwrap_or(false),
|
||||
thinking_budget_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new AnthropicProvider with a custom name (e.g., "anthropic.default")
|
||||
pub fn new_with_name(
|
||||
name: String,
|
||||
api_key: String,
|
||||
model: Option<String>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
cache_config: Option<String>,
|
||||
enable_1m_context: Option<bool>,
|
||||
thinking_budget_tokens: Option<u32>,
|
||||
) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let model = model.unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string());
|
||||
|
||||
debug!("Initialized Anthropic provider '{}' with model: {}", name, model);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
name,
|
||||
api_key,
|
||||
model,
|
||||
max_tokens: max_tokens.unwrap_or(4096),
|
||||
@@ -787,7 +822,7 @@ impl LLMProvider for AnthropicProvider {
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"anthropic"
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
|
||||
@@ -145,6 +145,7 @@ impl DatabricksAuth {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatabricksProvider {
|
||||
client: Client,
|
||||
name: String,
|
||||
host: String,
|
||||
auth: DatabricksAuth,
|
||||
model: String,
|
||||
@@ -172,6 +173,34 @@ impl DatabricksProvider {
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
name: "databricks".to_string(),
|
||||
host: host.trim_end_matches('/').to_string(),
|
||||
auth: DatabricksAuth::token(token),
|
||||
model,
|
||||
max_tokens: max_tokens.unwrap_or(50000),
|
||||
temperature: temperature.unwrap_or(0.1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a DatabricksProvider with token auth and a custom name (e.g., "databricks.default")
|
||||
pub fn from_token_with_name(
|
||||
name: String,
|
||||
host: String,
|
||||
token: String,
|
||||
model: String,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
info!("Initialized Databricks provider '{}' with model: {} on host: {}", name, model, host);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
name,
|
||||
host: host.trim_end_matches('/').to_string(),
|
||||
auth: DatabricksAuth::token(token),
|
||||
model,
|
||||
@@ -198,6 +227,33 @@ impl DatabricksProvider {
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
name: "databricks".to_string(),
|
||||
host: host.trim_end_matches('/').to_string(),
|
||||
auth: DatabricksAuth::oauth(host.clone()),
|
||||
model,
|
||||
max_tokens: max_tokens.unwrap_or(50000),
|
||||
temperature: temperature.unwrap_or(0.1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a DatabricksProvider with OAuth auth and a custom name (e.g., "databricks.default")
|
||||
pub async fn from_oauth_with_name(
|
||||
name: String,
|
||||
host: String,
|
||||
model: String,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
info!("Initialized Databricks provider '{}' with OAuth for model: {} on host: {}", name, model, host);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
name,
|
||||
host: host.trim_end_matches('/').to_string(),
|
||||
auth: DatabricksAuth::oauth(host.clone()),
|
||||
model,
|
||||
@@ -1045,7 +1101,7 @@ impl LLMProvider for DatabricksProvider {
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"databricks"
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
|
||||
376
g3-plan/completed_requirements_2025-12-08_18-30-00.md
Normal file
376
g3-plan/completed_requirements_2025-12-08_18-30-00.md
Normal file
@@ -0,0 +1,376 @@
|
||||
This is for the g3 app in `~/src/g3`.
|
||||
|
||||
*OVERVIEW*
|
||||
|
||||
I wish to add a planning mode in g3 that operates in the following manner:
|
||||
|
||||
1. Review new requirements (`new_requirements.md`), and suggest improvements to the user (if they want them).
|
||||
2. Once approved by the user, rename the new requirements to `current_requirements.md`.
|
||||
3. Implement the requirements. When done, rename it to `completed_requirements_<timestamp>.md` (see spec below)
|
||||
4. goto 1.
|
||||
|
||||
The new workflow also includes git operations.
|
||||
|
||||
State machine:
|
||||
|
||||
|
||||
+------------- RECOVERY (Resume) ---------------------+
|
||||
| |
|
||||
| +---------- RECOVERY (Mark Complete) ----+ |
|
||||
| | | |
|
||||
^ ^ v v
|
||||
STARTUP -> PROMPT FOR NEW REQUIREMENTS -> REFINE REQUIREMENTS -> IMPLEMENT REQUIREMENTS -> IMPLEMENTATION COMPLETE +
|
||||
^ v
|
||||
| |
|
||||
+---------------------------------------------------------------------------------------------------------+
|
||||
|
||||
|
||||
*DETAILED DESCRIPTION*
|
||||
|
||||
Put as much of the new code for implementing this mode into to the g3-planner crate (i.e. crates/g3-planner/src/...).
|
||||
Where you need to change the start-up logic (e.g. in controller.rs or g3-cli/src/lib.rs), do so of course, but keep changes to a minimum.
|
||||
I want the bulk of planner code in the g3-planner crate.
|
||||
|
||||
Create a new planning mode as peer to autonomous mode. (see controller.rs or g3-cli/src/lib.rs: to start in that mode, use "--planning" commandline flag).
|
||||
|
||||
|
||||
Change the toplevel config structure (.g3.toml)
|
||||
-----------------------------------------------
|
||||
|
||||
There is a new config for planner, similar to coach and player.
|
||||
Change how coach and player providers are specified, and also use the new pattern for planner.
|
||||
Do keep the `default_provider`.
|
||||
|
||||
The different providers must be specified differently to what it was in the past. (The old style
|
||||
config should no longer work, no migration is needed. If g3 encounters the old format, it should give an example for how
|
||||
to use the new format. Also update the examples in the g3 folder and the README)
|
||||
|
||||
Implement the code to match the following logic:
|
||||
Each mode must specify the full path of the provider config, and there can be different configs
|
||||
for any given provider:
|
||||
```toml
|
||||
[providers]
|
||||
default_provider = "anthropic.default" # Format: "<provider_type>.<config_name>"
|
||||
planner = "anthropic.planner"
|
||||
coach = "anthropic.default"
|
||||
player = "openai.player"
|
||||
|
||||
# Named configs per provider type
|
||||
[providers.anthropic.default]
|
||||
api_key = "..."
|
||||
model = "claude-sonnet-4-5"
|
||||
max_tokens = 64000
|
||||
|
||||
[providers.anthropic.planner]
|
||||
api_key = "..."
|
||||
model = "claude-opus-4-5"
|
||||
thinking_budget_tokens = 16000
|
||||
|
||||
[providers.openai.player]
|
||||
api_key = "..."
|
||||
model = "gpt-5"
|
||||
```
|
||||
|
||||
If `planner` is not specified in [providers], fall back to `default_provider` when in planning mode. (Make SURE to
|
||||
tell the user this)
|
||||
If default_provider also doesn't resolve, exit with error showing example config.
|
||||
|
||||
Change the existing hardcoded locations of todo
|
||||
-----------------------------------------------
|
||||
Allow the planning mode to specify that the todo file written by the LLM is at `<codepath>/g3-plan/todo.g3.md`,
|
||||
and not just the default todo location. Use that location whenever in planning mode.
|
||||
|
||||
Change the existing hardcoded locations of requirements
|
||||
------------------------------------------------------
|
||||
|
||||
Allow the planning mode to specify that project requirements are at `<codepath>/g3-plan/current_requirements.md`,
|
||||
instead of the default `requirements.md` location in the workspace. Always use the requirements path for planning
|
||||
mode.
|
||||
|
||||
Adding git functionality
|
||||
------------------------
|
||||
|
||||
Add a commandline arg '--no-git' to g3. It's only useful in planning mode. If no-git is specified, all git
|
||||
functionality described in these requirements must be disabled.
|
||||
|
||||
When starting the application, ensure there is a git repo that `<codepath>` sits under. If not, notify user that
|
||||
they should create one, and quit.
|
||||
|
||||
When starting the application, print the current git branch name, and confirm with the user whether it's the correct
|
||||
branch to start work on. If they say 'No' or quit (or CTRL-C), simply exit the app.
|
||||
|
||||
When starting the application, check that there are no untracked, uncommitted or dirty files on the current git branch
|
||||
(ignore `<codepath>/g3-plan/new_requirements.md`)
|
||||
of the repo that `<codepath>` sits in. If there are, notify the user and ask whether
|
||||
to proceed (e.g. if this is a recovery, there WILL be uncommitted files etc..).
|
||||
If they quit, simply exit the application. Otherwise proceed.
|
||||
|
||||
Generating summaries
|
||||
--------------------
|
||||
|
||||
Use the planner agent LLM to generate summaries
|
||||
- The requirements summary for planner_history.txt
|
||||
- The git commit summary and description
|
||||
|
||||
Provide the current_requirements.md content as context for generation.
|
||||
|
||||
(The prompts to be sent to the LLM in this specification are the authoritative text.
|
||||
Implement them as constants in `prompts.rs`. The implementation
|
||||
should use these constants, not inline strings.
|
||||
Put ALL prompts that will be sent to the LLM into `~/src/g3/crates/g3-planner/src/prompts.rs`. DO NOT inline them
|
||||
with all the rest of the code).
|
||||
|
||||
|
||||
Startup
|
||||
-------
|
||||
|
||||
When starting up, enter planning mode.
|
||||
Try to determine which codebase needs to be worked in:
|
||||
If there's a commandline `--codepath=<path>` parameter, use that and print it to the UI, otherwise
|
||||
prompt the user for the codepath.
|
||||
|
||||
(make sure the codepath argument resolves, also make sure that '~' will expand to user's home dir)
|
||||
|
||||
The argument `--planning` is mutually exclusive with `--autonomous`, `--auto` and `--chat`, throw an error if more
|
||||
than one is present. (`--task` is ignored in planning mode).
|
||||
|
||||
On startup in planning mode:
|
||||
|
||||
If not present, create a top-level directory called: `<codepath>/g3-plan`, and a blank file `<codepath>/g3-plan/planner_history.txt`.
|
||||
|
||||
check for these files:
|
||||
`<codepath>/g3-plan/current_requirements.md`
|
||||
`<codepath>/g3-plan/todo.g3.md`
|
||||
|
||||
If there is a todo file and/or current_requirements, something went wrong in the last g3 implementation loop.
|
||||
Prompt the user saying there is a `<codepath>/g3_plan/current_requirements.md` file from <SHOW DATE AND TIME OF THE FILE>,
|
||||
and/or `<codepath>/g3_plan/todo.g3.md`. Print the todo file if present.
|
||||
"""The last run didn't complete successfully. Found:
|
||||
- current_requirements.md from <DATE AND TIME>
|
||||
- todo.g3.md <IF PRESENT, SHOW CONTENTS>
|
||||
|
||||
Would you like to resume the previous implementation?
|
||||
[Y] Yes - Attempt to resume
|
||||
[N] No - Mark as complete and proceed to review new_requirements.md
|
||||
[Q] Quit - Exit and investigate manually
|
||||
"""
|
||||
If attempting a recovery, go to "implementation recovery" in the "Implement current requirements" step below.
|
||||
(update the planner_history.txt by saying "2025-12-08 14:31:00 ATTEMPTING RECOVERY")
|
||||
|
||||
If "[N] No - Mark as complete" chosen, go to "Implementation recovery skipped" step.
|
||||
|
||||
Refine requirements
|
||||
-------------------
|
||||
|
||||
Delete `<codepath>/g3-plan/todo.g3.md` because we're starting with fresh requirements.
|
||||
|
||||
Enter into an interactive prompt (similar to accumulation mode).
|
||||
|
||||
Prompts:
|
||||
"""I will help you refine the current requirements of your project.
|
||||
Please write or edit your requirements in `<codepath>/g3-plan/new_requirements.md`.
|
||||
Hit enter for me to start a review of that file."""
|
||||
|
||||
If `new_requirements.md` does not exist when user hits Enter:
|
||||
- Display error: "File not found: <path>/g3-plan/new_requirements.md"
|
||||
- Prompt user to create the file and try again
|
||||
- Do NOT create an empty file automatically
|
||||
|
||||
|
||||
There is a tag called ORIGINAL_REQUIREMENTS, it literally should read: "{{ORIGINAL USER REQUIREMENTS -- THIS SECTION WILL BE IGNORED BY THE IMPLEMENTATION}}"
|
||||
|
||||
If the file does not contain the tags ORIGINAL_REQUIREMENTS or `{{CURRENT REQUIREMENTS}}`,
|
||||
PREPEND ORIGINAL_REQUIREMENTS to `<codepath>/g3-plan/new_requirements.md`.
|
||||
|
||||
|
||||
For g3 add a config for "planner", pattern it on 'coach' and 'player' i.e. Have a top-level config in `providers` called
|
||||
`planner`,
|
||||
Use the provider spec for planner to create a new agent instance.
|
||||
Add a system prompt (the prompt literal (ONLY) MUST be stored in `~/src/g3/crates/g3-planner/src/prompts.rs`)
|
||||
|
||||
"""
|
||||
You're an experienced software engineering architect. Please help me to ideate and refine
|
||||
REQUIREMENTS for an implementation (or changes to the existing implementation), at <codepath>.
|
||||
The requirements will later be used by an LLM.
|
||||
I wish to have a compact specification, and DO NOT ATTEMPT TO IMPLEMENT OR BUILD ANYTHING.
|
||||
At this point ONLY suggest improvements to the requirements. Do not implement anything.
|
||||
DO NOT DO A RE-WRITE, UNLESS THE USER EXPLICITLY ASKS FOR THAT.
|
||||
If you think the requirements are totally incoherent and unusable, write constructive feedback on
|
||||
why that is, and suggest (very briefly) that you could rewrite it if explicitly asked to do so.
|
||||
If the requirements are usable, make some edits/changes/additions as you deem necessary, and
|
||||
PREPEND them under the heading `{{CURRENT REQUIREMENTS}}` to `<codepath>/g3-plan/new_requirements.md`.
|
||||
"""
|
||||
|
||||
Send this to the LLM, allow it to use tools, use the existing functionality in g3-core or g3-cli to parse
|
||||
and execute the task.
|
||||
|
||||
The planner agent should have access to:
|
||||
- read_file
|
||||
- write_file
|
||||
- shell
|
||||
- code_search
|
||||
- str_replace
|
||||
- final_output
|
||||
|
||||
|
||||
The planner should NOT have access to:
|
||||
- todo_write
|
||||
|
||||
Once the task is done, check that there is a `{{CURRENT REQUIREMENTS}}` heading in `<codepath>/g3-plan/new_requirements.md` file. If not,
|
||||
log an error saying the llm didn't respond, tell the user that they need to restart the app and quit.
|
||||
|
||||
Tell the user that the LLM has updated `<codepath>/g3-plan/new_requirements.md`. Ask them to go and read that file, and if it's acceptable,
|
||||
to say 'yes', if so, go to "Implement current requirements" step. If not, go to "Refine requirements" step.
|
||||
|
||||
|
||||
|
||||
planner_history.txt purpose
|
||||
---------------------------
|
||||
|
||||
The file `<codepath>/g3-plan/planner_history.txt` is a summary of planning steps and acts as the comprehensive reference
|
||||
of historic requirements and implementations undertaken in the code at `<codepath>` and in that git repo.
|
||||
|
||||
This file serves as an audit log, also to provide strict ordering information. It is also
|
||||
the file that will require merging/resolution if updated on separate git branches.
|
||||
|
||||
At the start of each step update the planner_history file. See the format below.
|
||||
Before starting the implementation, write the SHA of the current git HEAD.
|
||||
At the beginning of the implementation
|
||||
step, generate a short summary of the requirements. Take care that the most important elements
|
||||
of the requirements are reflected. Do not go into deep detail. Make the summary at most 5 lines long.
|
||||
Each line should be at most 120 characters long.
|
||||
|
||||
In the completion step ("Implementation is complete"), a git commit is made. Show the commit message (unfortunately
|
||||
we don't have the SHA since deriving it is a circular reference)
|
||||
|
||||
GIT HEAD entries should be written:
|
||||
- At start of implementation (records starting point for potential rollback)
|
||||
|
||||
|
||||
Format:
|
||||
"""
|
||||
2025-12-08 14:31:00 - REFINING REQUIREMENTS (new_requirements.md)
|
||||
2025-12-08 17:24:05 - GIT HEAD (<SHA>)
|
||||
2025-12-08 17:25:31 - START IMPLEMENTING (current_requirements.md)
|
||||
<<
|
||||
This is an example of a short summary of what's in the requirements.
|
||||
Keep it indented like this. Generate only a short summary, taking care that the most important elements
|
||||
of the requirements are reflected. Do not go into deep detail. Make the summary at most 5 lines long.
|
||||
Each line should be at most 120 characters long.
|
||||
>>
|
||||
2025-12-08 18:20:00 ATTEMPTING RECOVERY
|
||||
2025-12-08 18:30:00 - COMPLETED REQUIREMENTS (completed_requirements_2025-12-08_18-30-00.md, completed_todo_2025-12-08_18-30-00.md)
|
||||
2025-12-08 18:30:00 - GIT COMMIT (<MESSAGE>)
|
||||
2025-12-08 20:33:14 - REFINING REQUIREMENTS (new_requirements.md)
|
||||
2025-12-09 17:25:05 - GIT HEAD (<SHA>)
|
||||
2025-12-09 17:25:31 - START IMPLEMENTING (current_requirements.md)
|
||||
<<
|
||||
Lorem ipsum
|
||||
>>
|
||||
2025-12-09 17:20:12 - COMPLETED REQUIREMENTS (completed_requirements_2025-12-09_12-20-12.md, completed_todo_2025-12-09_12-20-12.md)
|
||||
2025-12-09 17:20:30 - GIT COMMIT (<MESSAGE>)
|
||||
......
|
||||
"""
|
||||
|
||||
Implementation recovery skipped
|
||||
-------------------------------
|
||||
|
||||
Append to planner_history.txt:
|
||||
"2025-12-08 14:31:00 USER SKIPPED RECOVERY"
|
||||
|
||||
go to "Implementation is complete" step.
|
||||
|
||||
Implement current requirements
|
||||
------------------------------
|
||||
|
||||
Rename `<codepath>/g3-plan/new_requirements.md` to `<codepath>/g3-plan/current_requirements.md`
|
||||
|
||||
("recovery point" -- do not rename new_requirements file in step above, instead use whatever `<codepath>/g3-plan/current_requirements.md` is there..)
|
||||
|
||||
Update `planner_history.txt` with a summary of requirements etc.. see format above.
|
||||
|
||||
Proceed to the coach/player loop, making sure it uses `<codepath>/g3-plan/current_requirements.md`.
|
||||
|
||||
Wait for the coach/player loop to complete.
|
||||
|
||||
|
||||
Implementation is complete
|
||||
---------------------------
|
||||
|
||||
When the coach/player loop has completed (or in recovery mode), make sure the todos are done (check the todo file). If not, prompt the user, and ask whether they consider
|
||||
the todos and the requirements completed. If the user thinks it's not completed, go back to the coach/player loop.
|
||||
If they agree, then rename `<codepath>/g3-plan/current_requirements.md` to `completed_requirements_<DATE AND TIME>.md` (see example below).
|
||||
also rename the todo file to `completed_todo_<DATE AND TIME>.md`.
|
||||
|
||||
Stage all changed/new files in `<codepath>/g3-plan` directory.
|
||||
|
||||
Stage all new & modified code, configuration and other files in the git repo. Make a special note of file that appear to be
|
||||
temporary artifacts produced by code execution, or during testing, log files and other temporary detritus, and do not
|
||||
stage them.
|
||||
|
||||
(for example Do NOT stage:
|
||||
- target/, node_modules/, __pycache__/, .venv/
|
||||
- *.log, *.tmp, *.bak
|
||||
- .DS_Store, Thumbs.db
|
||||
- .pyc
|
||||
- Files in tmp/ or temp/ directories
|
||||
- **/__pycache__/
|
||||
and any similar files, use your discretion)
|
||||
|
||||
Using the planning agent LLM, generate a short summary line for a git commit and well as a description for the
|
||||
commit (at most 10 lines). Use
|
||||
the current_requirements and describe the implementation. Take care that only the most important and salient
|
||||
details are included in the description. ALSO include in the description what the `completed_requirements_<DATE AND TIME>.md`
|
||||
and `completed_todo_<DATE AND TIME>.md` filenames are.
|
||||
|
||||
Print to the UI that g3 is ready to make a git commit. Print the summary and description generated for the git commit.
|
||||
|
||||
Tell the user to review the currently staged files, and prompt them to hit continue when they're done. (They may choose
|
||||
to quit, in which case do nothing (i.e. no git commit, no updates to the planner_history file, and just quit)
|
||||
|
||||
Make the git commit with the summary and description generated above.
|
||||
|
||||
Go back to "Refine requirements" step.
|
||||
|
||||
|
||||
Exiting Planning Mode
|
||||
---------------------
|
||||
User can exit at these points:
|
||||
- During codepath prompt: Ctrl+C or type "quit"
|
||||
- During refinement loop: type Ctrl+C "quit" instead of "yes"/"no"
|
||||
- During implementation: Ctrl+C (state preserved for resume)
|
||||
- After implementation complete: type "quit" or Ctrl+C when prompted for new requirements
|
||||
|
||||
When user quits, do NOT rename incomplete files. Leave state for potential resume.
|
||||
|
||||
Git Commit Format
|
||||
-----------------
|
||||
Summary line: Max 72 characters, imperative mood (e.g., "Add planning mode with...")
|
||||
Description: Max 10 lines, each max 72 characters, wrapped properly
|
||||
|
||||
Example:
|
||||
Add user authentication with OAuth2 support
|
||||
|
||||
Implements OAuth2 flow for Google and GitHub providers.
|
||||
Includes token refresh logic and secure storage.
|
||||
|
||||
Requirements: completed_requirements_2025-12-08_17-25-31.md
|
||||
Todo: completed_todo_2025-12-08_17-25-31.md
|
||||
|
||||
Timestamp Formats
|
||||
-----------------
|
||||
- For filenames: `YYYY-MM-DD_HH-MM-SS` (all hyphens, filesystem-safe)
|
||||
Example: completed_requirements_2025-12-08_17-25-31.md
|
||||
|
||||
- For planner_history.txt: `YYYY-MM-DD HH:MM:SS` (ISO 8601 for readability)
|
||||
Example: 2025-12-08 18:30:00 - COMPLETED REQUIREMENTS
|
||||
|
||||
*EXAMPLE FILES*
|
||||
|
||||
Example files in `<codepath>/g3-plan`:
|
||||
`planner_history.txt`
|
||||
`new_requirements.md` or `current_requirements.md`
|
||||
`todo.g3.md`
|
||||
`completed_todo_2025-12-08_17-25-31.md`
|
||||
`completed_requirements_2025-12-08_17-25-31.md`
|
||||
`completed_requirements_2025-12-08_17-20-12.md`
|
||||
19
g3-plan/new_requirements.md
Normal file
19
g3-plan/new_requirements.md
Normal file
@@ -0,0 +1,19 @@
|
||||
1.
|
||||
|
||||
In planner.rs Show coach feedback: up to 25 lines
|
||||
|
||||
coach_feedback = result.response;
|
||||
print_msg(&format!("📝 Coach feedback: {} chars", coach_feedback.len()));
|
||||
|
||||
2.
|
||||
|
||||
I can’t find where the TODO file is written during implementation in planning mode. Please check that it’s written to the g3-plan directory.
|
||||
It looks like there are explicit instructions to delete the TODO file when complete, potentially in player mode. DO NOT ALLOW it to be deleted when in planner mode since we want to copy it for history.
|
||||
|
||||
3.
|
||||
Make sure to write the “GIT COMMIT (<message>)” to the planner_history.txt file *immediately before* doing the actual commit (not after, like the current implementation does).
|
||||
|
||||
4.
In planner mode, do not write a new line in UI writer for each tool call. Instead keep a single line that says “thinking....” While the llm is working. Keep each update on a single line (use backspace or something to erase the last update?) and show the context window size and that we’re waiting for the llm to finish tool calls. HOWEVER, DO PRINT to the UI all non-tool comments (text messages) that the llm sends (that’s currently not happening).
|
||||
|
||||
5.
Logs are written to the <codepath> directory. Instead write them to the workspace path.
|
||||
|
||||
8
g3-plan/planner_history.txt
Normal file
8
g3-plan/planner_history.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
2025-12-08 14:31:00 - REFINING REQUIREMENTS (new_requirements.md)
|
||||
2025-12-08 17:24:05 - GIT HEAD (fb2cf6f898d81d6556840d60057fc3f41855788f)
|
||||
2025-12-08 17:25:31 - START IMPLEMENTING (current_requirements.md)
|
||||
<<
|
||||
Implement planning mode.
|
||||
>>
|
||||
2025-12-08 18:30:00 - COMPLETED REQUIREMENTS (completed_requirements_2025-12-08_18-30-00.md)
|
||||
2025-12-08 18:30:01 - GIT COMMIT (Implement planning mode)
|
||||
Reference in New Issue
Block a user