Implement planning mode

This commit is contained in:
Jochen
2025-12-09 09:59:28 +11:00
parent 4aa84e2144
commit ff8b3e7c7b
24 changed files with 3817 additions and 346 deletions

4
Cargo.lock generated
View File

@@ -1529,9 +1529,13 @@ dependencies = [
"anyhow",
"chrono",
"const_format",
"g3-config",
"g3-core",
"g3-providers",
"serde",
"serde_json",
"shellexpand",
"tempfile",
"tokio",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)]

View File

@@ -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");
}
}

View File

@@ -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(_) => {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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");

View File

@@ -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"

View 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"));
}
}

View 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}", &timestamp);
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}", &timestamp)
.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}", &timestamp);
// 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}", &timestamp);
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}", &timestamp);
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}", &timestamp)
.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}", &timestamp)
.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(':'));
}
}

View File

@@ -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;

View 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}}"));
}
}

View 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, &current_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(&current_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());
}
}

View File

@@ -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`."#;

View 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(&current_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));
}
}

View File

@@ -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 {

View File

@@ -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 {

View 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`

View 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 cant find where the TODO file is written during implementation in planning mode. Please check that its 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 were waiting for the llm to finish tool calls. HOWEVER, DO PRINT to the UI all non-tool comments (text messages) that the llm sends (thats currently not happening).
5.Logs are written to the <codepath> directory. Instead write them to the workspace path.

View 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)