refactor(g3-cli): break lib.rs into focused modules

Extract 7 modules from the 2966-line lib.rs:
- cli_args.rs (133 lines): CLI argument parsing with clap
- autonomous.rs (785 lines): coach-player feedback loop
- agent_mode.rs (284 lines): specialized agent execution
- accumulative.rs (343 lines): iterative requirements mode
- interactive.rs (851 lines): REPL with command handling
- task_execution.rs (212 lines): unified retry logic
- utils.rs (91 lines): display and workspace helpers

Key improvements:
- lib.rs reduced from 2966 to 415 lines (86% reduction)
- Eliminated duplicate retry logic between execute_task and execute_task_machine
- Each module has a single responsibility
- Easier to reason about and maintain

Agent: fowler
This commit is contained in:
Dhanji R. Prasanna
2026-01-12 05:35:08 +05:30
parent 14cc28d9ba
commit b9cdb99557
8 changed files with 2945 additions and 2797 deletions

View File

@@ -0,0 +1,343 @@
//! Accumulative autonomous mode for G3 CLI.
use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::path::PathBuf;
use tracing::error;
use g3_config::Config;
use g3_core::project::Project;
use g3_core::Agent;
use crate::autonomous::run_autonomous;
use crate::cli_args::Cli;
use crate::interactive::run_interactive;
use crate::simple_output::SimpleOutput;
use crate::ui_writer_impl::ConsoleUiWriter;
/// Run accumulative autonomous mode - accumulates requirements from user input
/// and runs autonomous mode after each input.
pub async fn run_accumulative_mode(
workspace_dir: PathBuf,
cli: Cli,
combined_content: Option<String>,
) -> Result<()> {
let output = SimpleOutput::new();
output.print("");
output.print("g3 programming agent - autonomous mode");
output.print(" >> describe what you want, I'll build it iteratively");
output.print("");
print!(
"{}workspace: {}{}\n",
SetForegroundColor(Color::DarkGrey),
workspace_dir.display(),
ResetColor
);
output.print("");
output.print("💡 Each input you provide will be added to requirements");
output.print(" and I'll automatically work on implementing them. You can");
output.print(" interrupt at any time (Ctrl+C) to add clarifications or more requirements.");
output.print("");
output.print(" Type '/help' for commands, 'exit' or 'quit' to stop, Ctrl+D to finish");
output.print("");
// Initialize rustyline editor with history
let mut rl = DefaultEditor::new()?;
let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_accumulative_history");
path
});
if let Some(ref history_path) = history_file {
let _ = rl.load_history(history_path);
}
// Accumulated requirements stored in memory
let mut accumulated_requirements = Vec::new();
let mut turn_number = 0;
loop {
output.print(&format!("\n{}", "=".repeat(60)));
if accumulated_requirements.is_empty() {
output.print("📝 What would you like me to build? (describe your requirements)");
} else {
output.print(&format!(
"📝 Turn {} - What's next? (add more requirements or refinements)",
turn_number + 1
));
}
output.print(&format!("{}", "=".repeat(60)));
let readline = rl.readline("requirement> ");
match readline {
Ok(line) => {
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
output.print("\n👋 Goodbye!");
break;
}
// Check for slash commands
if input.starts_with('/') {
match handle_command(
&input,
&output,
&accumulated_requirements,
&cli,
&combined_content,
&workspace_dir,
)
.await?
{
CommandResult::Continue => continue,
CommandResult::Exit => break,
CommandResult::Unknown => {
output.print(&format!(
"❌ Unknown command: {}. Type /help for available commands.",
input
));
continue;
}
}
}
// Add to history
rl.add_history_entry(&input)?;
// Add this requirement to accumulated list
turn_number += 1;
accumulated_requirements.push(format!("{}. {}", turn_number, input));
// Build the complete requirements document
let requirements_doc = format!(
"# Project Requirements\n\n\
## Current Instructions and Requirements:\n\n\
{}\n\n\
## Latest Requirement (Turn {}):\n\n\
{}",
accumulated_requirements.join("\n"),
turn_number,
input
);
output.print("");
output.print(&format!(
"📋 Current instructions and requirements (Turn {}):",
turn_number
));
output.print(&format!(" {}", input));
output.print("");
output.print("🚀 Starting autonomous implementation...");
output.print("");
// Create a project with the accumulated requirements
let project = Project::new_autonomous_with_requirements(
workspace_dir.clone(),
requirements_doc.clone(),
)?;
// Ensure workspace exists and enter it
project.ensure_workspace_exists()?;
project.enter_workspace()?;
// Load configuration with CLI overrides
let config = load_config_with_cli_overrides(&cli)?;
// Create agent for this autonomous run
let ui_writer = ConsoleUiWriter::new();
let agent = Agent::new_autonomous_with_readme_and_quiet(
config.clone(),
ui_writer,
combined_content.clone(),
cli.quiet,
)
.await?;
// Run autonomous mode with the accumulated requirements
let autonomous_result = tokio::select! {
result = run_autonomous(
agent,
project,
cli.show_prompt,
cli.show_code,
cli.max_turns,
cli.quiet,
cli.codebase_fast_start.clone(),
) => result.map(Some),
_ = tokio::signal::ctrl_c() => {
output.print("\n⚠️ Autonomous run cancelled by user (Ctrl+C)");
Ok(None)
}
};
match autonomous_result {
Ok(Some(_returned_agent)) => {
output.print("");
output.print("✅ Autonomous run completed");
}
Ok(None) => {
output.print(" (session continuation not saved due to cancellation)");
}
Err(e) => {
output.print("");
output.print(&format!("❌ Autonomous run failed: {}", e));
output.print(" You can provide more requirements to continue.");
}
}
}
Err(ReadlineError::Interrupted) => {
output.print("\n👋 Interrupted. Goodbye!");
break;
}
Err(ReadlineError::Eof) => {
output.print("\n👋 Goodbye!");
break;
}
Err(err) => {
error!("Error: {:?}", err);
break;
}
}
}
// Save history before exiting
if let Some(ref history_path) = history_file {
let _ = rl.save_history(history_path);
}
Ok(())
}
enum CommandResult {
Continue,
Exit,
Unknown,
}
async fn handle_command(
input: &str,
output: &SimpleOutput,
accumulated_requirements: &[String],
cli: &Cli,
combined_content: &Option<String>,
workspace_dir: &PathBuf,
) -> Result<CommandResult> {
match input {
"/help" => {
output.print("");
output.print("📖 Available Commands:");
output.print(" /requirements - Show all accumulated requirements");
output.print(" /chat - Switch to interactive chat mode");
output.print(" /help - Show this help message");
output.print(" exit/quit - Exit the session");
output.print("");
Ok(CommandResult::Continue)
}
"/requirements" => {
output.print("");
if accumulated_requirements.is_empty() {
output.print("📋 No requirements accumulated yet");
} else {
output.print("📋 Accumulated Requirements:");
output.print("");
for req in accumulated_requirements {
output.print(&format!(" {}", req));
}
}
output.print("");
Ok(CommandResult::Continue)
}
"/chat" => {
output.print("");
output.print("🔄 Switching to interactive chat mode...");
output.print("");
// Build context message with accumulated requirements
let requirements_context = if accumulated_requirements.is_empty() {
None
} else {
Some(format!(
"📋 Context from Accumulative Mode:\n\n\
We were working on these requirements. There may be unstaged or in-progress changes or recent changes to this branch. This is for your information.\n\n\
Requirements:\n{}\n",
accumulated_requirements.join("\n")
))
};
// Combine with existing content (README/AGENTS.md)
let chat_combined_content = match (requirements_context, combined_content.clone()) {
(Some(req_ctx), Some(existing)) => Some(format!("{}\n\n{}", req_ctx, existing)),
(Some(req_ctx), None) => Some(req_ctx),
(None, existing) => existing,
};
// Load configuration
let config = load_config_with_cli_overrides(cli)?;
// Create agent for interactive mode with requirements context
let ui_writer = ConsoleUiWriter::new();
let agent = Agent::new_with_readme_and_quiet(
config,
ui_writer,
chat_combined_content.clone(),
cli.quiet,
)
.await?;
// Run interactive mode
run_interactive(
agent,
cli.show_prompt,
cli.show_code,
chat_combined_content,
workspace_dir,
)
.await?;
// After returning from interactive mode, exit
output.print("\n👋 Goodbye!");
Ok(CommandResult::Exit)
}
_ => Ok(CommandResult::Unknown),
}
}
fn load_config_with_cli_overrides(cli: &Cli) -> Result<Config> {
let mut config = Config::load_with_overrides(
cli.config.as_deref(),
cli.provider.clone(),
cli.model.clone(),
)?;
// Apply webdriver flag override
if cli.webdriver {
config.webdriver.enabled = true;
}
// Apply chrome-headless flag override
if cli.chrome_headless {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::ChromeHeadless;
}
// Apply safari flag override
if cli.safari {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::Safari;
}
// Apply no-auto-compact flag override
if cli.manual_compact {
config.agent.auto_compact = false;
}
Ok(config)
}

View File

@@ -0,0 +1,284 @@
//! Agent mode for G3 CLI - runs specialized agents with custom prompts.
use anyhow::Result;
use std::path::PathBuf;
use tracing::debug;
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use crate::project_files::{read_agents_config, read_project_memory, read_project_readme};
use crate::simple_output::SimpleOutput;
use crate::ui_writer_impl::ConsoleUiWriter;
/// Run agent mode - loads a specialized agent prompt and executes a single task.
pub async fn run_agent_mode(
agent_name: &str,
workspace: Option<PathBuf>,
config_path: Option<&str>,
_quiet: bool,
new_session: bool,
task: Option<String>,
chrome_headless: bool,
safari: bool,
) -> Result<()> {
use g3_core::find_incomplete_agent_session;
use g3_core::get_agent_system_prompt;
// Initialize logging
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
let filter = EnvFilter::from_default_env()
.add_directive("g3_core=info".parse().unwrap())
.add_directive("g3_cli=info".parse().unwrap())
.add_directive("llama_cpp=off".parse().unwrap())
.add_directive("llama=off".parse().unwrap());
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(filter)
.init();
let output = SimpleOutput::new();
// Determine workspace directory (current dir if not specified)
let workspace_dir = workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
// Change to the workspace directory first so session scanning works correctly
std::env::set_current_dir(&workspace_dir)?;
// Check for incomplete agent sessions before starting a new one (unless --new-session is set)
let resuming_session = if new_session {
output.print("\n🆕 Starting new session (--new-session flag set)");
output.print("");
None
} else {
find_incomplete_agent_session(agent_name).ok().flatten()
};
if let Some(ref incomplete_session) = resuming_session {
output.print(&format!(
"\n🔄 Found incomplete session for agent '{}'",
agent_name
));
output.print(&format!(" Session: {}", incomplete_session.session_id));
output.print(&format!(" Created: {}", incomplete_session.created_at));
if let Some(ref todo) = incomplete_session.todo_snapshot {
// Show first few lines of TODO
let preview: String = todo.lines().take(5).collect::<Vec<_>>().join("\n");
output.print(&format!(" TODO preview:\n{}", preview));
}
output.print("");
output.print(" Resuming incomplete session...");
output.print("");
}
// Load agent prompt from agents/<name>.md
let agent_prompt_path = workspace_dir
.join("agents")
.join(format!("{}.md", agent_name));
// Also check in the g3 installation directory
let agent_prompt = if agent_prompt_path.exists() {
std::fs::read_to_string(&agent_prompt_path).map_err(|e| {
anyhow::anyhow!(
"Failed to read agent prompt from {:?}: {}",
agent_prompt_path,
e
)
})?
} else {
// Try to find agents/ relative to the executable or in common locations
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
let possible_paths = [
exe_dir
.as_ref()
.map(|d| d.join("agents").join(format!("{}.md", agent_name))),
Some(PathBuf::from(format!("agents/{}.md", agent_name))),
];
let mut found_prompt = None;
for path_opt in possible_paths.iter().flatten() {
if path_opt.exists() {
found_prompt = Some(std::fs::read_to_string(path_opt).map_err(|e| {
anyhow::anyhow!("Failed to read agent prompt from {:?}: {}", path_opt, e)
})?);
break;
}
}
found_prompt.ok_or_else(|| {
anyhow::anyhow!(
"Agent prompt not found: agents/{}.md\nSearched in: {:?} and current directory",
agent_name,
agent_prompt_path
)
})?
};
output.print(&format!(">> agent mode | {}", agent_name));
// Format workspace path, replacing home dir with ~
let workspace_display = {
let path_str = workspace_dir.display().to_string();
dirs::home_dir()
.and_then(|home| {
path_str
.strip_prefix(&home.display().to_string())
.map(|s| format!("~{}", s))
})
.unwrap_or(path_str)
};
output.print(&format!("-> {}", workspace_display));
// Load config
let mut config = g3_config::Config::load(config_path)?;
// Apply chrome-headless flag override
if chrome_headless {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::ChromeHeadless;
}
// Apply safari flag override
if safari {
config.webdriver.enabled = true;
config.webdriver.browser = g3_config::WebDriverBrowser::Safari;
}
// Generate the combined system prompt (agent prompt + tool instructions)
// Note: allow_multiple_tool_calls parameter is deprecated but kept for API compatibility
let system_prompt = get_agent_system_prompt(&agent_prompt, true);
// Load AGENTS.md, README, and memory - same as normal mode
let agents_content_opt = read_agents_config(&workspace_dir);
let readme_content_opt = read_project_readme(&workspace_dir);
let memory_content_opt = read_project_memory(&workspace_dir);
// Show what was loaded
let readme_status = if readme_content_opt.is_some() {
""
} else {
"·"
};
let agents_status = if agents_content_opt.is_some() {
""
} else {
"·"
};
let memory_status = if memory_content_opt.is_some() {
""
} else {
"·"
};
output.print(&format!(
" {} README | {} AGENTS.md | {} Memory",
readme_status, agents_status, memory_status
));
// Combine all content for the agent's context
let combined_content = {
let mut parts = Vec::new();
if let Some(agents) = agents_content_opt {
parts.push(agents);
}
if let Some(readme) = readme_content_opt {
parts.push(readme);
}
if let Some(memory) = memory_content_opt {
parts.push(memory);
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
};
// Create agent with custom system prompt
let ui_writer = ConsoleUiWriter::new();
// Set agent mode on UI writer for visual differentiation (light gray tool names)
ui_writer.set_agent_mode(true);
let mut agent =
Agent::new_with_custom_prompt(config, ui_writer, system_prompt, combined_content).await?;
// Set agent mode for session tracking
agent.set_agent_mode(agent_name);
// Auto-memory is always enabled in agent mode
// This prompts the LLM to save discoveries to project memory after each turn
agent.set_auto_memory(true);
// Enable ACD in agent mode for longer sessions
agent.set_acd_enabled(true);
// If resuming a session, restore context and TODO
let initial_task = if let Some(ref incomplete_session) = resuming_session {
// Restore the session context
match agent.restore_from_continuation(incomplete_session) {
Ok(full_restore) => {
if full_restore {
output.print(" ✅ Full context restored from previous session");
} else {
output.print(" ⚠️ Restored from summary (context was > 80%)");
}
}
Err(e) => {
output.print(&format!(" ⚠️ Could not restore context: {}", e));
}
}
// Copy TODO from old session to new session directory
let todo_content = if let Some(ref content) = incomplete_session.todo_snapshot {
Some(content.clone())
} else {
// Fallback: read from the actual todo.g3.md file in the old session directory
let old_session_dir =
std::path::Path::new(".g3/sessions").join(&incomplete_session.session_id);
let old_todo_path = old_session_dir.join("todo.g3.md");
if old_todo_path.exists() {
std::fs::read_to_string(&old_todo_path).ok()
} else {
None
}
};
if let Some(ref content) = todo_content {
if let Some(session_id) = agent.get_session_id() {
let new_todo_path = g3_core::paths::get_session_todo_path(session_id);
let _ = g3_core::paths::ensure_session_dir(session_id);
if let Err(e) = std::fs::write(&new_todo_path, content) {
output.print(&format!(" ⚠️ Could not restore TODO: {}", e));
} else {
output.print(" ✅ TODO list restored");
}
}
}
output.print("");
// Resume message instead of fresh start
"Continue working on the incomplete tasks. Use todo_read to see the current TODO list and resume from where you left off."
} else {
// Fresh start - the agent prompt should contain instructions to start working immediately
"Begin your analysis and work on the current project. Follow your mission and workflow as specified in your instructions."
};
// Use provided task if available, otherwise use the default initial_task
let final_task = task.as_deref().unwrap_or(initial_task);
let _result = agent.execute_task(final_task, None, true).await?;
// Send auto-memory reminder if enabled and tools were called
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
// Don't print completion message for scout agent - it needs the last line
// to be the report file path for the research tool to read
if agent_name != "scout" {
output.print("\n✅ Agent mode completed");
}
Ok(())
}

View File

@@ -0,0 +1,785 @@
//! Autonomous mode for G3 CLI - coach-player feedback loop.
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::time::Instant;
use tracing::debug;
use g3_core::error_handling::{classify_error, ErrorType, RecoverableError};
use g3_core::project::Project;
use g3_core::{Agent, DiscoveryOptions};
use crate::coach_feedback;
use crate::machine_ui_writer::MachineUiWriter;
use crate::metrics::{format_elapsed_time, generate_turn_histogram, TurnMetrics};
use crate::simple_output::SimpleOutput;
use crate::ui_writer_impl::ConsoleUiWriter;
/// Run autonomous mode with coach-player feedback loop (console output).
pub async fn run_autonomous(
mut agent: Agent<ConsoleUiWriter>,
project: Project,
show_prompt: bool,
show_code: bool,
max_turns: usize,
quiet: bool,
codebase_fast_start: Option<PathBuf>,
) -> Result<Agent<ConsoleUiWriter>> {
let start_time = std::time::Instant::now();
let output = SimpleOutput::new();
let mut turn_metrics: Vec<TurnMetrics> = Vec::new();
output.print("g3 programming agent - autonomous mode");
output.print(&format!(
"📁 Using workspace: {}",
project.workspace().display()
));
// Check if requirements exist
if !project.has_requirements() {
print_no_requirements_error(&output, &agent, &turn_metrics, start_time, max_turns);
return Ok(agent);
}
// Read requirements
let requirements = match project.read_requirements()? {
Some(content) => content,
None => {
print_cannot_read_requirements_error(
&output,
&agent,
&turn_metrics,
start_time,
max_turns,
);
return Ok(agent);
}
};
// Display appropriate message based on requirements source
if project.requirements_text.is_some() {
output.print("📋 Requirements loaded from --requirements flag");
} else {
output.print("📋 Requirements loaded from requirements.md");
}
// Calculate SHA256 of requirements
let mut hasher = Sha256::new();
hasher.update(requirements.as_bytes());
let requirements_sha = hex::encode(hasher.finalize());
output.print(&format!("🔒 Requirements SHA256: {}", requirements_sha));
// Pass SHA to agent for staleness checking
agent.set_requirements_sha(requirements_sha.clone());
let loop_start = Instant::now();
output.print("🔄 Starting coach-player feedback loop...");
// Load fast-discovery messages before the loop starts (if enabled)
let (discovery_messages, discovery_working_dir) =
load_discovery_messages(&agent, &output, &codebase_fast_start, &requirements).await;
let has_discovery = !discovery_messages.is_empty();
let mut turn = 1;
let mut coach_feedback_text = String::new();
let mut implementation_approved = false;
loop {
let turn_start_time = Instant::now();
let turn_start_tokens = agent.get_context_window().used_tokens;
output.print(&format!(
"\n=== TURN {}/{} - PLAYER MODE ===",
turn, max_turns
));
// Surface provider info for player agent
agent.print_provider_banner("Player");
// Player mode: implement requirements (with coach feedback if available)
let player_prompt = build_player_prompt(&requirements, &requirements_sha, &coach_feedback_text);
output.print(&format!(
"🎯 Starting player implementation... (elapsed: {})",
format_elapsed_time(loop_start.elapsed())
));
// Display what feedback the player is receiving
if coach_feedback_text.is_empty() {
if turn > 1 {
return Err(anyhow::anyhow!(
"Player mode error: No coach feedback received on turn {}",
turn
));
}
output.print("📋 Player starting initial implementation (no prior coach feedback)");
} else {
output.print(&format!(
"📋 Player received coach feedback ({} chars):",
coach_feedback_text.len()
));
output.print(&coach_feedback_text);
}
output.print(""); // Empty line for readability
// Execute player task with retry on error
let player_result = execute_player_turn(
&mut agent,
&player_prompt,
show_prompt,
show_code,
&output,
has_discovery,
&discovery_messages,
discovery_working_dir.as_deref(),
turn,
&turn_metrics,
start_time,
max_turns,
)
.await;
let player_failed = match player_result {
PlayerTurnResult::Success => false,
PlayerTurnResult::Failed => true,
PlayerTurnResult::Panic(e) => return Err(e),
};
// If player failed after max retries, increment turn and continue
if player_failed {
output.print(&format!(
"⚠️ Player turn {} failed after max retries. Moving to next turn.",
turn
));
record_turn_metrics(
&mut turn_metrics,
turn,
turn_start_time,
turn_start_tokens,
&agent,
);
turn += 1;
if turn > max_turns {
output.print("\n=== SESSION COMPLETED - MAX TURNS REACHED ===");
output.print(&format!("⏰ Maximum turns ({}) reached", max_turns));
break;
}
coach_feedback_text = String::new();
continue;
}
// Give some time for file operations to complete
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Execute coach turn
let coach_result = execute_coach_turn(
&agent,
&project,
&requirements,
show_prompt,
show_code,
quiet,
&output,
has_discovery,
&discovery_messages,
discovery_working_dir.as_deref(),
turn,
max_turns,
&turn_metrics,
start_time,
loop_start,
)
.await;
match coach_result {
CoachTurnResult::Approved => {
output.print("\n=== SESSION COMPLETED - IMPLEMENTATION APPROVED ===");
output.print("✅ Coach approved the implementation!");
implementation_approved = true;
break;
}
CoachTurnResult::Feedback(feedback) => {
output.print_smart(&format!("Coach feedback:\n{}", feedback));
coach_feedback_text = feedback;
}
CoachTurnResult::Failed => {
output.print(&format!(
"⚠️ Coach turn {} failed after max retries. Using default feedback.",
turn
));
coach_feedback_text = "The implementation needs review. Please ensure all requirements are met and the code compiles without errors.".to_string();
}
CoachTurnResult::Panic(e) => return Err(e),
}
// Check if we've reached max turns
if turn >= max_turns {
output.print("\n=== SESSION COMPLETED - MAX TURNS REACHED ===");
output.print(&format!("⏰ Maximum turns ({}) reached", max_turns));
break;
}
record_turn_metrics(
&mut turn_metrics,
turn,
turn_start_time,
turn_start_tokens,
&agent,
);
turn += 1;
output.print("🔄 Coach provided feedback for next iteration");
}
// Generate final report
print_final_report(
&output,
&agent,
&turn_metrics,
start_time,
turn,
max_turns,
implementation_approved,
);
if implementation_approved {
output.print(&format!(
"\n🎉 Autonomous mode completed successfully (total loop time: {})",
format_elapsed_time(loop_start.elapsed())
));
} else {
output.print(&format!(
"\n🔄 Autonomous mode terminated (max iterations) (total loop time: {})",
format_elapsed_time(loop_start.elapsed())
));
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
Ok(agent)
}
/// Run autonomous mode with machine-friendly output.
pub async fn run_autonomous_machine(
mut agent: Agent<MachineUiWriter>,
project: Project,
show_prompt: bool,
show_code: bool,
max_turns: usize,
_quiet: bool,
_codebase_fast_start: Option<PathBuf>,
) -> Result<()> {
println!("AUTONOMOUS_MODE_STARTED");
println!("WORKSPACE: {}", project.workspace().display());
println!("MAX_TURNS: {}", max_turns);
// Check if requirements exist
if !project.has_requirements() {
println!("ERROR: requirements.md not found in workspace directory");
return Ok(());
}
// Read requirements
let requirements = match project.read_requirements()? {
Some(content) => content,
None => {
println!("ERROR: Could not read requirements");
return Ok(());
}
};
println!("REQUIREMENTS_LOADED");
// For now, just execute a simple autonomous loop
// This is a simplified version - full implementation would need coach-player loop
let task = format!(
"You are G3 in implementation mode. Read and implement the following requirements:\n\n{}\n\nImplement this step by step, creating all necessary files and code.",
requirements
);
println!("TASK_START");
let result = agent
.execute_task_with_timing(&task, None, false, show_prompt, show_code, true, None)
.await?;
println!("AGENT_RESPONSE:");
println!("{}", result.response);
println!("END_AGENT_RESPONSE");
println!("TASK_END");
// Save session continuation for resume capability
agent.save_session_continuation(Some(result.response.clone()));
println!("AUTONOMOUS_MODE_ENDED");
Ok(())
}
// --- Helper types and functions ---
enum PlayerTurnResult {
Success,
Failed,
Panic(anyhow::Error),
}
enum CoachTurnResult {
Approved,
Feedback(String),
Failed,
Panic(anyhow::Error),
}
fn build_player_prompt(requirements: &str, requirements_sha: &str, coach_feedback: &str) -> String {
if coach_feedback.is_empty() {
format!(
"You are G3 in implementation mode. Read and implement the following requirements:\n\n{}\n\nRequirements SHA256: {}\n\nImplement this step by step, creating all necessary files and code.",
requirements, requirements_sha
)
} else {
format!(
"You are G3 in implementation mode. Address the following specific feedback from the coach:\n\n{}\n\nContext: You are improving an implementation based on these requirements:\n{}\n\nFocus on fixing the issues mentioned in the coach feedback above.",
coach_feedback, requirements
)
}
}
fn build_coach_prompt(requirements: &str) -> String {
format!(
"You are G3 in coach mode. Your role is to critique and review implementations against requirements and provide concise, actionable feedback.
REQUIREMENTS:
{}
IMPLEMENTATION REVIEW:
Review the current state of the project and provide a concise critique focusing on:
1. Whether the requirements are correctly implemented
2. Whether the project compiles successfully
3. What requirements are missing or incorrect
4. Specific improvements needed to satisfy requirements
5. Use UI tools such as webdriver to test functionality thoroughly
CRITICAL INSTRUCTIONS:
1. Provide your feedback as your final response message
2. Your feedback should be CONCISE and ACTIONABLE
3. Focus ONLY on what needs to be fixed or improved
4. Do NOT include your analysis process, file contents, or compilation output in your final feedback
If the implementation thoroughly meets all requirements, compiles and is fully tested (especially UI flows) *WITHOUT* minor gaps or errors:
- Respond with: 'IMPLEMENTATION_APPROVED'
If improvements are needed:
- Respond with a brief summary listing ONLY the specific issues to fix
Remember: Be clear in your review and concise in your feedback. APPROVE iff the implementation works and thoroughly fits the requirements (implementation > 95% complete). Be rigorous, especially by testing that all UI features work.",
requirements
)
}
async fn load_discovery_messages(
agent: &Agent<ConsoleUiWriter>,
output: &SimpleOutput,
codebase_fast_start: &Option<PathBuf>,
requirements: &str,
) -> (Vec<g3_providers::Message>, Option<String>) {
if let Some(ref codebase_path) = codebase_fast_start {
let canonical_path = codebase_path
.canonicalize()
.unwrap_or_else(|_| codebase_path.clone());
let path_str = canonical_path.to_string_lossy();
output.print(&format!(
"🔍 Fast-discovery mode: will explore codebase at {}",
path_str
));
match agent.get_provider() {
Ok(provider) => {
let output_clone = output.clone();
let status_callback: g3_planner::StatusCallback = Box::new(move |msg: &str| {
output_clone.print(msg);
});
match g3_planner::get_initial_discovery_messages(
&path_str,
Some(requirements),
provider,
Some(&status_callback),
)
.await
{
Ok(messages) => (messages, Some(path_str.to_string())),
Err(e) => {
output.print(&format!(
"⚠️ LLM discovery failed: {}, skipping fast-start",
e
));
(Vec::new(), None)
}
}
}
Err(e) => {
output.print(&format!(
"⚠️ Could not get provider: {}, skipping fast-start",
e
));
(Vec::new(), None)
}
}
} else {
(Vec::new(), None)
}
}
async fn execute_player_turn(
agent: &mut Agent<ConsoleUiWriter>,
player_prompt: &str,
show_prompt: bool,
show_code: bool,
output: &SimpleOutput,
has_discovery: bool,
discovery_messages: &[g3_providers::Message],
discovery_working_dir: Option<&str>,
turn: usize,
turn_metrics: &[TurnMetrics],
start_time: Instant,
max_turns: usize,
) -> PlayerTurnResult {
const MAX_PLAYER_RETRIES: u32 = 3;
let mut retry_count = 0;
loop {
let discovery_opts = if has_discovery {
Some(DiscoveryOptions {
messages: discovery_messages,
fast_start_path: discovery_working_dir,
})
} else {
None
};
match agent
.execute_task_with_timing(
player_prompt,
None,
false,
show_prompt,
show_code,
true,
discovery_opts,
)
.await
{
Ok(result) => {
output.print("📝 Player implementation completed:");
output.print_smart(&result.response);
return PlayerTurnResult::Success;
}
Err(e) => {
let error_type = classify_error(&e);
if matches!(
error_type,
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
) {
output.print(&format!("⚠️ Context length exceeded in player turn: {}", e));
output.print("📝 Logging error to session and ending current turn...");
let forensic_context = format!(
"Turn: {}\nRole: Player\nContext tokens: {}\nTotal available: {}\nPercentage used: {:.1}%\nPrompt length: {} chars\nError occurred at: {}",
turn,
agent.get_context_window().used_tokens,
agent.get_context_window().total_tokens,
agent.get_context_window().percentage_used(),
player_prompt.len(),
chrono::Utc::now().to_rfc3339()
);
agent.log_error_to_session(&e, "assistant", Some(forensic_context));
return PlayerTurnResult::Failed;
} else if e.to_string().contains("panic") {
output.print(&format!("💥 Player panic detected: {}", e));
print_panic_report(output, agent, turn_metrics, start_time, turn, max_turns, "PLAYER PANIC");
return PlayerTurnResult::Panic(e);
}
retry_count += 1;
output.print(&format!(
"⚠️ Player error (attempt {}/{}): {}",
retry_count, MAX_PLAYER_RETRIES, e
));
if retry_count >= MAX_PLAYER_RETRIES {
output.print("🔄 Max retries reached for player, marking turn as failed...");
return PlayerTurnResult::Failed;
}
output.print("🔄 Retrying player implementation...");
}
}
}
}
async fn execute_coach_turn(
player_agent: &Agent<ConsoleUiWriter>,
project: &Project,
requirements: &str,
show_prompt: bool,
show_code: bool,
quiet: bool,
output: &SimpleOutput,
has_discovery: bool,
discovery_messages: &[g3_providers::Message],
discovery_working_dir: Option<&str>,
turn: usize,
max_turns: usize,
turn_metrics: &[TurnMetrics],
start_time: Instant,
loop_start: Instant,
) -> CoachTurnResult {
const MAX_COACH_RETRIES: u32 = 3;
// Create a new agent instance for coach mode to ensure fresh context
let base_config = player_agent.get_config().clone();
let coach_config = match base_config.for_coach() {
Ok(c) => c,
Err(e) => return CoachTurnResult::Panic(e),
};
// Reset filter suppression state before creating coach agent
crate::filter_json::reset_json_tool_state();
let ui_writer = ConsoleUiWriter::new();
let mut coach_agent =
match Agent::new_autonomous_with_readme_and_quiet(coach_config, ui_writer, None, quiet)
.await
{
Ok(a) => a,
Err(e) => return CoachTurnResult::Panic(e),
};
coach_agent.print_provider_banner("Coach");
if let Err(e) = project.enter_workspace() {
return CoachTurnResult::Panic(e);
}
output.print(&format!(
"\n=== TURN {}/{} - COACH MODE ===",
turn, max_turns
));
let coach_prompt = build_coach_prompt(requirements);
output.print(&format!(
"🎓 Starting coach review... (elapsed: {})",
format_elapsed_time(loop_start.elapsed())
));
let mut retry_count = 0;
loop {
let discovery_opts = if has_discovery {
Some(DiscoveryOptions {
messages: discovery_messages,
fast_start_path: discovery_working_dir,
})
} else {
None
};
match coach_agent
.execute_task_with_timing(
&coach_prompt,
None,
false,
show_prompt,
show_code,
true,
discovery_opts,
)
.await
{
Ok(result) => {
output.print("🎓 Coach review completed");
let feedback_text =
match coach_feedback::extract_from_logs(&result, &coach_agent, output) {
Ok(f) => f,
Err(e) => return CoachTurnResult::Panic(e),
};
debug!(
"Coach feedback extracted: {} characters (from {} total)",
feedback_text.len(),
result.response.len()
);
if feedback_text.is_empty() {
output.print("⚠️ Coach did not provide feedback. This may be a model issue.");
return CoachTurnResult::Failed;
}
if result.is_approved() || feedback_text.contains("IMPLEMENTATION_APPROVED") {
return CoachTurnResult::Approved;
}
return CoachTurnResult::Feedback(feedback_text);
}
Err(e) => {
let error_type = classify_error(&e);
if matches!(
error_type,
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
) {
output.print(&format!("⚠️ Context length exceeded in coach turn: {}", e));
output.print("📝 Logging error to session and ending current turn...");
let forensic_context = format!(
"Turn: {}\nRole: Coach\nContext tokens: {}\nTotal available: {}\nPercentage used: {:.1}%\nPrompt length: {} chars\nError occurred at: {}",
turn,
coach_agent.get_context_window().used_tokens,
coach_agent.get_context_window().total_tokens,
coach_agent.get_context_window().percentage_used(),
coach_prompt.len(),
chrono::Utc::now().to_rfc3339()
);
coach_agent.log_error_to_session(&e, "assistant", Some(forensic_context));
return CoachTurnResult::Failed;
} else if e.to_string().contains("panic") {
output.print(&format!("💥 Coach panic detected: {}", e));
print_panic_report(output, player_agent, turn_metrics, start_time, turn, max_turns, "COACH PANIC");
return CoachTurnResult::Panic(e);
}
retry_count += 1;
output.print(&format!(
"⚠️ Coach error (attempt {}/{}): {}",
retry_count, MAX_COACH_RETRIES, e
));
if retry_count >= MAX_COACH_RETRIES {
output.print("🔄 Max retries reached for coach, using default feedback...");
return CoachTurnResult::Failed;
}
output.print("🔄 Retrying coach review...");
}
}
}
}
fn record_turn_metrics(
turn_metrics: &mut Vec<TurnMetrics>,
turn: usize,
turn_start_time: Instant,
turn_start_tokens: u32,
agent: &Agent<ConsoleUiWriter>,
) {
let turn_duration = turn_start_time.elapsed();
let turn_tokens = agent
.get_context_window()
.used_tokens
.saturating_sub(turn_start_tokens);
turn_metrics.push(TurnMetrics {
turn_number: turn,
tokens_used: turn_tokens,
wall_clock_time: turn_duration,
});
}
fn print_no_requirements_error(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
max_turns: usize,
) {
output.print("❌ Error: requirements.md not found in workspace directory");
output.print(" Please either:");
output.print(" 1. Create a requirements.md file with your project requirements");
output.print(" 2. Or use the --requirements flag to provide requirements text directly:");
output.print(" g3 --autonomous --requirements \"Your requirements here\"");
output.print("");
print_final_report(output, agent, turn_metrics, start_time, 0, max_turns, false);
}
fn print_cannot_read_requirements_error(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
max_turns: usize,
) {
output.print("❌ Error: Could not read requirements (neither --requirements flag nor requirements.md file provided)");
print_final_report(output, agent, turn_metrics, start_time, 0, max_turns, false);
}
fn print_panic_report(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
turn: usize,
max_turns: usize,
status: &str,
) {
let elapsed = start_time.elapsed();
let context_window = agent.get_context_window();
output.print(&format!("\n{}", "=".repeat(60)));
output.print("📊 AUTONOMOUS MODE SESSION REPORT");
output.print(&"=".repeat(60));
output.print(&format!("⏱️ Total Duration: {:.2}s", elapsed.as_secs_f64()));
output.print(&format!("🔄 Turns Taken: {}/{}", turn, max_turns));
output.print(&format!("📝 Final Status: 💥 {}", status));
output.print("\n📈 Token Usage Statistics:");
output.print(&format!(" • Used Tokens: {}", context_window.used_tokens));
output.print(&format!(" • Total Available: {}", context_window.total_tokens));
output.print(&format!(" • Cumulative Tokens: {}", context_window.cumulative_tokens));
output.print(&format!(" • Usage Percentage: {:.1}%", context_window.percentage_used()));
output.print(&generate_turn_histogram(turn_metrics));
output.print(&"=".repeat(60));
}
fn print_final_report(
output: &SimpleOutput,
agent: &Agent<ConsoleUiWriter>,
turn_metrics: &[TurnMetrics],
start_time: Instant,
turn: usize,
max_turns: usize,
implementation_approved: bool,
) {
let elapsed = start_time.elapsed();
let context_window = agent.get_context_window();
output.print(&format!("\n{}", "=".repeat(60)));
output.print("📊 AUTONOMOUS MODE SESSION REPORT");
output.print(&"=".repeat(60));
output.print(&format!("⏱️ Total Duration: {:.2}s", elapsed.as_secs_f64()));
output.print(&format!("🔄 Turns Taken: {}/{}", turn, max_turns));
output.print(&format!(
"📝 Final Status: {}",
if implementation_approved {
"✅ APPROVED"
} else if turn >= max_turns {
"⏰ MAX TURNS REACHED"
} else {
"⚠️ INCOMPLETE"
}
));
output.print("\n📈 Token Usage Statistics:");
output.print(&format!(" • Used Tokens: {}", context_window.used_tokens));
output.print(&format!(" • Total Available: {}", context_window.total_tokens));
output.print(&format!(" • Cumulative Tokens: {}", context_window.cumulative_tokens));
output.print(&format!(" • Usage Percentage: {:.1}%", context_window.percentage_used()));
output.print(&generate_turn_histogram(turn_metrics));
output.print(&"=".repeat(60));
}

View File

@@ -0,0 +1,133 @@
//! CLI argument parsing for G3.
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Clone)]
#[command(name = "g3")]
#[command(about = "A modular, composable AI coding agent")]
#[command(version)]
pub struct Cli {
/// Enable verbose logging
#[arg(short, long)]
pub verbose: bool,
/// Enable manual control of context compaction (disables auto-compact at 90%)
#[arg(long = "manual-compact")]
pub manual_compact: bool,
/// Show the system prompt being sent to the LLM
#[arg(long)]
pub show_prompt: bool,
/// Show the generated code before execution
#[arg(long)]
pub show_code: bool,
/// Configuration file path
#[arg(short, long)]
pub config: Option<String>,
/// Workspace directory (defaults to current directory)
#[arg(short, long)]
pub workspace: Option<PathBuf>,
/// Task to execute (if provided, runs in single-shot mode instead of interactive)
pub task: Option<String>,
/// Enable autonomous mode with coach-player feedback loop
#[arg(long)]
pub autonomous: bool,
/// Maximum number of turns in autonomous mode (default: 5)
#[arg(long, default_value = "5")]
pub max_turns: usize,
/// Override requirements text for autonomous mode (instead of reading from requirements.md)
#[arg(long, value_name = "TEXT")]
pub requirements: Option<String>,
/// Enable accumulative autonomous mode (default is chat mode)
#[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,
/// Override the configured provider (anthropic, databricks, embedded, openai)
#[arg(long, value_name = "PROVIDER")]
pub provider: Option<String>,
/// Override the model for the selected provider
#[arg(long, value_name = "MODEL")]
pub model: Option<String>,
/// Disable log file creation (no logs/ directory or session logs)
#[arg(long)]
pub quiet: bool,
/// Enable WebDriver browser automation tools
#[arg(long)]
pub webdriver: bool,
/// Use Chrome in headless mode for WebDriver (instead of Safari)
#[arg(long)]
pub chrome_headless: bool,
/// Use Safari for WebDriver (this is the default)
#[arg(long)]
pub safari: bool,
/// Enable flock mode - parallel multi-agent development
#[arg(long, requires = "flock_workspace", requires = "segments")]
pub project: Option<PathBuf>,
/// Flock workspace directory (where segment copies will be created)
#[arg(long, requires = "project")]
pub flock_workspace: Option<PathBuf>,
/// Number of segments to partition work into (for flock mode)
#[arg(long, requires = "project")]
pub segments: Option<usize>,
/// Maximum turns per segment in flock mode (default: 5)
#[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>,
/// Run as a specialized agent (loads prompt from agents/<name>.md)
#[arg(long, value_name = "NAME", conflicts_with_all = ["autonomous", "auto", "chat", "planning"])]
pub agent: Option<String>,
/// Skip session resumption and force a new session (for agent mode)
#[arg(long)]
pub new_session: bool,
/// Automatically remind LLM to call remember tool after turns with tool calls
#[arg(long)]
pub auto_memory: bool,
/// Enable aggressive context dehydration (save context to disk on compaction)
#[arg(long)]
pub acd: bool,
}

View File

@@ -0,0 +1,851 @@
//! Interactive mode for G3 CLI.
use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::path::Path;
use tracing::{debug, error};
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use crate::machine_ui_writer::MachineUiWriter;
use crate::project_files::extract_readme_heading;
use crate::simple_output::SimpleOutput;
use crate::task_execution::{execute_task_with_retry, OutputMode};
use crate::utils::display_context_progress;
/// Run interactive mode with console output.
pub async fn run_interactive<W: UiWriter>(
mut agent: Agent<W>,
show_prompt: bool,
show_code: bool,
combined_content: Option<String>,
workspace_path: &Path,
) -> Result<()> {
let output = SimpleOutput::new();
// Check for session continuation
if let Ok(Some(continuation)) = g3_core::load_continuation() {
output.print("");
output.print("🔄 Previous session detected!");
output.print(&format!(
" Session: {}",
&continuation.session_id[..continuation.session_id.len().min(20)]
));
output.print(&format!(
" Context: {:.1}% used",
continuation.context_percentage
));
if let Some(ref summary) = continuation.summary {
let preview: String = summary.chars().take(80).collect();
output.print(&format!(" Last output: {}...", preview));
}
output.print("");
output.print("Resume this session? [Y/n] ");
// Read user input
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" || input == "yes" {
// Resume the session
match agent.restore_from_continuation(&continuation) {
Ok(true) => {
output.print("✅ Full context restored from previous session");
}
Ok(false) => {
output.print("✅ Session resumed with summary (context was > 80%)");
}
Err(e) => {
output.print(&format!("⚠️ Could not restore session: {}", e));
output.print("Starting fresh session instead.");
// Clear the invalid continuation
let _ = g3_core::clear_continuation();
}
}
} else {
// User declined, clear the continuation
output.print("🧹 Starting fresh session...");
let _ = g3_core::clear_continuation();
}
output.print("");
}
output.print("");
output.print("g3 programming agent");
output.print(" >> what shall we build today?");
output.print("");
// Display provider and model information
match agent.get_provider_info() {
Ok((provider, model)) => {
print!(
"🔧 {}{}{} | {}{}{}\n",
SetForegroundColor(Color::Cyan),
provider,
ResetColor,
SetForegroundColor(Color::Yellow),
model,
ResetColor
);
}
Err(e) => {
error!("Failed to get provider info: {}", e);
}
}
// Display message if AGENTS.md or README was loaded
if let Some(ref content) = combined_content {
// Check what was loaded
let has_agents = content.contains("Agent Configuration");
let has_readme = content.contains("Project README");
let has_memory = content.contains("Project Memory");
if has_agents {
print!(
"{}🤖 AGENTS.md configuration loaded{}\n",
SetForegroundColor(Color::DarkGrey),
ResetColor
);
}
if has_readme {
// Extract the first heading or title from the README
let readme_snippet = extract_readme_heading(content)
.unwrap_or_else(|| "Project documentation loaded".to_string());
print!(
"{}📚 detected: {}{}\n",
SetForegroundColor(Color::DarkGrey),
readme_snippet,
ResetColor
);
}
if has_memory {
print!(
"{}🧠 Project memory loaded{}\n",
SetForegroundColor(Color::DarkGrey),
ResetColor
);
}
}
// Display workspace path
print!(
"{}workspace: {}{}\n",
SetForegroundColor(Color::DarkGrey),
workspace_path.display(),
ResetColor
);
output.print("");
// Initialize rustyline editor with history
let mut rl = DefaultEditor::new()?;
// Try to load history from a file in the user's home directory
let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_history");
path
});
if let Some(ref history_path) = history_file {
let _ = rl.load_history(history_path);
}
// Track multiline input
let mut multiline_buffer = String::new();
let mut in_multiline = false;
loop {
// Display context window progress bar before each prompt
display_context_progress(&agent, &output);
// Adjust prompt based on whether we're in multi-line mode
let prompt = if in_multiline { "... > " } else { "g3> " };
let readline = rl.readline(prompt);
match readline {
Ok(line) => {
let trimmed = line.trim_end();
// Check if line ends with backslash for continuation
if let Some(without_backslash) = trimmed.strip_suffix('\\') {
// Remove the backslash and add to buffer
multiline_buffer.push_str(without_backslash);
multiline_buffer.push('\n');
in_multiline = true;
continue;
}
// If we're in multiline mode and no backslash, this is the final line
if in_multiline {
multiline_buffer.push_str(&line);
in_multiline = false;
// Process the complete multiline input
let input = multiline_buffer.trim().to_string();
multiline_buffer.clear();
if input.is_empty() {
continue;
}
// Add complete multiline to history
rl.add_history_entry(&input)?;
if input == "exit" || input == "quit" {
break;
}
// Process the multiline input
execute_task_with_retry(
&mut agent,
&input,
show_prompt,
show_code,
OutputMode::Console(&output),
)
.await;
// Send auto-memory reminder if enabled and tools were called
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
} else {
// Single line input
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
break;
}
// Add to history
rl.add_history_entry(&input)?;
// Check for control commands
if input.starts_with('/') {
if handle_command(&input, &mut agent, &output, &mut rl).await? {
continue;
}
}
// Process the single line input
execute_task_with_retry(
&mut agent,
&input,
show_prompt,
show_code,
OutputMode::Console(&output),
)
.await;
// Send auto-memory reminder if enabled and tools were called
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
}
}
Err(ReadlineError::Interrupted) => {
// Ctrl-C pressed
if in_multiline {
// Cancel multiline input
output.print("Multi-line input cancelled");
multiline_buffer.clear();
in_multiline = false;
} else {
output.print("CTRL-C");
}
continue;
}
Err(ReadlineError::Eof) => {
output.print("CTRL-D");
break;
}
Err(err) => {
error!("Error: {:?}", err);
break;
}
}
}
// Save history before exiting
if let Some(ref history_path) = history_file {
let _ = rl.save_history(history_path);
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
output.print("👋 Goodbye!");
Ok(())
}
/// Run interactive mode with machine-friendly output.
pub async fn run_interactive_machine(
mut agent: Agent<MachineUiWriter>,
show_prompt: bool,
show_code: bool,
) -> Result<()> {
println!("INTERACTIVE_MODE_STARTED");
// Display provider and model information
match agent.get_provider_info() {
Ok((provider, model)) => {
println!("PROVIDER: {}", provider);
println!("MODEL: {}", model);
}
Err(e) => {
println!("ERROR: Failed to get provider info: {}", e);
}
}
// Initialize rustyline editor with history
let mut rl = DefaultEditor::new()?;
// Try to load history from a file in the user's home directory
let history_file = dirs::home_dir().map(|mut path| {
path.push(".g3_history");
path
});
if let Some(ref history_path) = history_file {
let _ = rl.load_history(history_path);
}
loop {
let readline = rl.readline("");
match readline {
Ok(line) => {
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
break;
}
// Add to history
rl.add_history_entry(&input)?;
// Check for control commands
if input.starts_with('/') {
if handle_machine_command(&input, &mut agent).await? {
continue;
}
}
// Execute task
println!("TASK_START");
execute_task_with_retry(&mut agent, &input, show_prompt, show_code, OutputMode::Machine)
.await;
// Send auto-memory reminder if enabled and tools were called
if let Err(e) = agent.send_auto_memory_reminder().await {
debug!("Auto-memory reminder failed: {}", e);
}
println!("TASK_END");
}
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => break,
Err(err) => {
println!("ERROR: {:?}", err);
break;
}
}
}
// Save history before exiting
if let Some(ref history_path) = history_file {
let _ = rl.save_history(history_path);
}
// Save session continuation for resume capability
agent.save_session_continuation(None);
println!("INTERACTIVE_MODE_ENDED");
Ok(())
}
/// Handle a control command. Returns true if the command was handled and the loop should continue.
async fn handle_command<W: UiWriter>(
input: &str,
agent: &mut Agent<W>,
output: &SimpleOutput,
rl: &mut DefaultEditor,
) -> Result<bool> {
match input {
"/help" => {
output.print("");
output.print("📖 Control Commands:");
output.print(" /compact - Trigger compaction (compacts conversation history)");
output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)");
output.print(" /skinnify - Trigger full context thinning (like /thinnify but for entire context, not just first third)");
output.print(" /clear - Clear session and start fresh (discards continuation artifacts)");
output.print(" /fragments - List dehydrated context fragments (ACD)");
output.print(" /rehydrate - Restore a dehydrated fragment by ID");
output.print(" /resume - List and switch to a previous session");
output.print(" /dump - Dump entire context window to file for debugging");
output.print(" /readme - Reload README.md and AGENTS.md from disk");
output.print(" /stats - Show detailed context and performance statistics");
output.print(" /help - Show this help message");
output.print(" exit/quit - Exit the interactive session");
output.print("");
Ok(true)
}
"/compact" => {
output.print("🗜️ Triggering manual compaction...");
match agent.force_compact().await {
Ok(true) => {
output.print("✅ Compaction completed successfully");
}
Ok(false) => {
output.print("⚠️ Compaction failed");
}
Err(e) => {
output.print(&format!("❌ Error during compaction: {}", e));
}
}
Ok(true)
}
"/thinnify" => {
let summary = agent.force_thin();
println!("{}", summary);
Ok(true)
}
"/skinnify" => {
let summary = agent.force_thin_all();
println!("{}", summary);
Ok(true)
}
"/fragments" => {
if let Some(session_id) = agent.get_session_id() {
match g3_core::acd::list_fragments(session_id) {
Ok(fragments) => {
if fragments.is_empty() {
output.print("No dehydrated fragments found for this session.");
} else {
output.print(&format!(
"📦 {} dehydrated fragment(s):\n",
fragments.len()
));
for fragment in &fragments {
output.print(&fragment.generate_stub());
output.print("");
}
}
}
Err(e) => {
output.print(&format!("❌ Error listing fragments: {}", e));
}
}
} else {
output.print("No active session - fragments are session-scoped.");
}
Ok(true)
}
cmd if cmd.starts_with("/rehydrate") => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
if parts.len() < 2 || parts[1].trim().is_empty() {
output.print("Usage: /rehydrate <fragment_id>");
output.print("Use /fragments to list available fragment IDs.");
} else {
let fragment_id = parts[1].trim();
if let Some(session_id) = agent.get_session_id() {
match g3_core::acd::Fragment::load(session_id, fragment_id) {
Ok(fragment) => {
output.print(&format!(
"✅ Fragment '{}' loaded ({} messages, ~{} tokens)",
fragment_id, fragment.message_count, fragment.estimated_tokens
));
output.print("");
output.print(&fragment.generate_stub());
}
Err(e) => {
output.print(&format!(
"❌ Failed to load fragment '{}': {}",
fragment_id, e
));
}
}
} else {
output.print("No active session - fragments are session-scoped.");
}
}
Ok(true)
}
"/dump" => {
// Dump entire context window to a file for debugging
let dump_dir = std::path::Path::new("tmp");
if !dump_dir.exists() {
if let Err(e) = std::fs::create_dir_all(dump_dir) {
output.print(&format!("❌ Failed to create tmp directory: {}", e));
return Ok(true);
}
}
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp));
let context = agent.get_context_window();
let mut dump_content = String::new();
dump_content.push_str("# Context Window Dump\n");
dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now()));
dump_content.push_str(&format!(
"# Messages: {}\n",
context.conversation_history.len()
));
dump_content.push_str(&format!(
"# Used tokens: {} / {} ({:.1}%)\n\n",
context.used_tokens,
context.total_tokens,
context.percentage_used()
));
for (i, msg) in context.conversation_history.iter().enumerate() {
dump_content.push_str(&format!("=== Message {} ===\n", i));
dump_content.push_str(&format!("Role: {:?}\n", msg.role));
dump_content.push_str(&format!("Kind: {:?}\n", msg.kind));
dump_content.push_str(&format!("Content ({} chars):\n", msg.content.len()));
dump_content.push_str(&msg.content);
dump_content.push_str("\n\n");
}
match std::fs::write(&dump_path, &dump_content) {
Ok(_) => output.print(&format!("📄 Context dumped to: {}", dump_path.display())),
Err(e) => output.print(&format!("❌ Failed to write dump: {}", e)),
}
Ok(true)
}
"/clear" => {
output.print("🧹 Clearing session...");
agent.clear_session();
output.print("✅ Session cleared. Starting fresh.");
Ok(true)
}
"/readme" => {
output.print("📚 Reloading README.md and AGENTS.md...");
match agent.reload_readme() {
Ok(true) => {
output.print("✅ README content reloaded successfully")
}
Ok(false) => {
output.print("⚠️ No README was loaded at startup, cannot reload")
}
Err(e) => output.print(&format!("❌ Error reloading README: {}", e)),
}
Ok(true)
}
"/stats" => {
let stats = agent.get_stats();
output.print(&stats);
Ok(true)
}
"/resume" => {
output.print("📋 Scanning for available sessions...");
match g3_core::list_sessions_for_directory() {
Ok(sessions) => {
if sessions.is_empty() {
output.print("No sessions found for this directory.");
return Ok(true);
}
// Get current session ID to mark it
let current_session_id = agent.get_session_id().map(|s| s.to_string());
output.print("");
output.print("Available sessions:");
for (i, session) in sessions.iter().enumerate() {
let time_str = g3_core::format_session_time(&session.created_at);
let context_str = format!("{:.0}%", session.context_percentage);
let current_marker =
if current_session_id.as_deref() == Some(&session.session_id) {
" (current)"
} else {
""
};
let todo_marker = if session.has_incomplete_todos() {
" 📝"
} else {
""
};
// Use description if available, otherwise fall back to session ID
let display_name = match &session.description {
Some(desc) => format!("'{}'", desc),
None => {
if session.session_id.len() > 40 {
format!("{}...", &session.session_id[..40])
} else {
session.session_id.clone()
}
}
};
output.print(&format!(
" {}. [{}] {} ({}){}{}\n",
i + 1,
time_str,
display_name,
context_str,
todo_marker,
current_marker
));
}
output.print("");
output.print("Enter session number to resume (or press Enter to cancel):");
// Read user selection
if let Ok(selection) = rl.readline("> ") {
let selection = selection.trim();
if selection.is_empty() {
output.print("Resume cancelled.");
} else if let Ok(num) = selection.parse::<usize>() {
if num >= 1 && num <= sessions.len() {
let selected = &sessions[num - 1];
output.print(&format!(
"🔄 Switching to session: {}",
selected.session_id
));
match agent.switch_to_session(selected) {
Ok(true) => {
output.print("✅ Full context restored from session.")
}
Ok(false) => {
output.print("✅ Session restored from summary.")
}
Err(e) => {
output.print(&format!("❌ Error restoring session: {}", e))
}
}
} else {
output.print("Invalid selection.");
}
} else {
output.print("Invalid input. Please enter a number.");
}
}
}
Err(e) => output.print(&format!("❌ Error listing sessions: {}", e)),
}
Ok(true)
}
_ => {
output.print(&format!(
"❌ Unknown command: {}. Type /help for available commands.",
input
));
Ok(true)
}
}
}
/// Handle a control command in machine mode. Returns true if the command was handled.
async fn handle_machine_command(
input: &str,
agent: &mut Agent<MachineUiWriter>,
) -> Result<bool> {
match input {
"/compact" => {
println!("COMMAND: compact");
match agent.force_compact().await {
Ok(true) => println!("RESULT: Compaction completed"),
Ok(false) => println!("RESULT: Compaction failed"),
Err(e) => println!("ERROR: {}", e),
}
Ok(true)
}
"/thinnify" => {
println!("COMMAND: thinnify");
let summary = agent.force_thin();
println!("{}", summary);
Ok(true)
}
"/skinnify" => {
println!("COMMAND: skinnify");
let summary = agent.force_thin_all();
println!("{}", summary);
Ok(true)
}
"/fragments" => {
println!("COMMAND: fragments");
if let Some(session_id) = agent.get_session_id() {
match g3_core::acd::list_fragments(session_id) {
Ok(fragments) => {
println!("FRAGMENT_COUNT: {}", fragments.len());
for fragment in &fragments {
println!("FRAGMENT_ID: {}", fragment.fragment_id);
println!("FRAGMENT_MESSAGES: {}", fragment.message_count);
println!("FRAGMENT_TOKENS: {}", fragment.estimated_tokens);
}
}
Err(e) => {
println!("ERROR: {}", e);
}
}
} else {
println!("ERROR: No active session");
}
Ok(true)
}
cmd if cmd.starts_with("/rehydrate") => {
println!("COMMAND: rehydrate");
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
if parts.len() < 2 || parts[1].trim().is_empty() {
println!("ERROR: Usage: /rehydrate <fragment_id>");
} else {
let fragment_id = parts[1].trim();
println!("FRAGMENT_ID: {}", fragment_id);
println!("RESULT: Use the rehydrate tool to restore fragment content");
}
Ok(true)
}
"/dump" => {
println!("COMMAND: dump");
let dump_dir = std::path::Path::new("tmp");
if !dump_dir.exists() {
if let Err(e) = std::fs::create_dir_all(dump_dir) {
println!("ERROR: Failed to create tmp directory: {}", e);
return Ok(true);
}
}
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp));
let context = agent.get_context_window();
let mut dump_content = String::new();
dump_content.push_str("# Context Window Dump\n");
dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now()));
dump_content.push_str(&format!(
"# Messages: {}\n",
context.conversation_history.len()
));
dump_content.push_str(&format!(
"# Used tokens: {} / {} ({:.1}%)\n\n",
context.used_tokens,
context.total_tokens,
context.percentage_used()
));
for (i, msg) in context.conversation_history.iter().enumerate() {
dump_content.push_str(&format!(
"=== Message {} ===\nRole: {:?}\nKind: {:?}\nContent ({} chars):\n{}\n\n",
i,
msg.role,
msg.kind,
msg.content.len(),
msg.content
));
}
match std::fs::write(&dump_path, &dump_content) {
Ok(_) => println!("RESULT: Context dumped to {}", dump_path.display()),
Err(e) => println!("ERROR: Failed to write dump: {}", e),
}
Ok(true)
}
"/clear" => {
println!("COMMAND: clear");
agent.clear_session();
println!("RESULT: Session cleared");
Ok(true)
}
"/readme" => {
println!("COMMAND: readme");
match agent.reload_readme() {
Ok(true) => println!("RESULT: README content reloaded successfully"),
Ok(false) => println!("RESULT: No README was loaded at startup, cannot reload"),
Err(e) => println!("ERROR: {}", e),
}
Ok(true)
}
"/stats" => {
println!("COMMAND: stats");
let stats = agent.get_stats();
// Emit stats as structured data (name: value pairs)
println!("{}", stats);
Ok(true)
}
"/help" => {
println!("COMMAND: help");
println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /clear /dump /fragments /rehydrate /resume /readme /stats /help");
Ok(true)
}
"/resume" => {
println!("COMMAND: resume");
match g3_core::list_sessions_for_directory() {
Ok(sessions) => {
if sessions.is_empty() {
println!("RESULT: No sessions found");
return Ok(true);
}
println!("SESSIONS_START");
for (i, session) in sessions.iter().enumerate() {
let time_str = g3_core::format_session_time(&session.created_at);
let has_todos = if session.has_incomplete_todos() {
"true"
} else {
"false"
};
println!(
"SESSION: {} | {} | {} | {:.0}% | {}",
i + 1,
session.session_id,
time_str,
session.context_percentage,
has_todos
);
}
println!("SESSIONS_END");
println!("HINT: Use /resume <number> to switch to a session");
}
Err(e) => println!("ERROR: {}", e),
}
Ok(true)
}
_ => {
// Check for /resume <number> pattern
if input.starts_with("/resume ") {
let num_str = input.strip_prefix("/resume ").unwrap().trim();
if let Ok(num) = num_str.parse::<usize>() {
println!("COMMAND: resume {}", num);
match g3_core::list_sessions_for_directory() {
Ok(sessions) => {
if num >= 1 && num <= sessions.len() {
let selected = &sessions[num - 1];
match agent.switch_to_session(selected) {
Ok(true) => println!(
"RESULT: Full context restored from session {}",
selected.session_id
),
Ok(false) => println!(
"RESULT: Session {} restored from summary",
selected.session_id
),
Err(e) => println!("ERROR: {}", e),
}
} else {
println!("ERROR: Invalid session number");
}
}
Err(e) => println!("ERROR: {}", e),
}
return Ok(true);
}
}
println!("ERROR: Unknown command: {}", input);
Ok(true)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
//! Task execution with retry logic for G3 CLI.
use g3_core::error_handling::{classify_error, ErrorType, RecoverableError};
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use tokio_util::sync::CancellationToken;
use tracing::error;
use crate::simple_output::SimpleOutput;
/// Maximum number of retry attempts for timeout errors
const MAX_TIMEOUT_RETRIES: u32 = 3;
/// Output mode for task execution feedback
pub enum OutputMode<'a> {
/// Console mode with SimpleOutput for user-friendly messages
Console(&'a SimpleOutput),
/// Machine mode with structured output markers
Machine,
}
impl<'a> OutputMode<'a> {
fn print_thinking(&self) {
match self {
OutputMode::Console(output) => output.print("🤔 Thinking..."),
OutputMode::Machine => {} // No thinking indicator in machine mode
}
}
fn print_cancelled(&self) {
match self {
OutputMode::Console(output) => output.print("\n⚠️ Operation cancelled by user (Ctrl+C)"),
OutputMode::Machine => println!("CANCELLED"),
}
}
fn print_cancelled_simple(&self) {
match self {
OutputMode::Console(output) => output.print("⚠️ Operation cancelled by user"),
OutputMode::Machine => println!("CANCELLED"),
}
}
fn print_retry_success(&self, attempt: u32) {
match self {
OutputMode::Console(output) => {
output.print(&format!("✅ Request succeeded after {} attempts", attempt))
}
OutputMode::Machine => println!("RETRY_SUCCESS: attempt {}", attempt),
}
}
fn print_response(&self, response: &str, output: Option<&SimpleOutput>) {
match self {
OutputMode::Console(o) => o.print_smart(response),
OutputMode::Machine => {
println!("AGENT_RESPONSE:");
println!("{}", response);
println!("END_AGENT_RESPONSE");
// Ignore the output parameter in machine mode
let _ = output;
}
}
}
fn print_timeout_retry(&self, attempt: u32, delay: std::time::Duration) {
match self {
OutputMode::Console(output) => {
output.print(&format!(
"⏱️ Timeout error detected (attempt {}/{}). Retrying in {:?}...",
attempt, MAX_TIMEOUT_RETRIES, delay
));
}
OutputMode::Machine => {
println!(
"TIMEOUT: attempt {} of {}, retrying in {:?}",
attempt, MAX_TIMEOUT_RETRIES, delay
);
}
}
}
fn print_error(&self, e: &anyhow::Error, attempt: u32) {
match self {
OutputMode::Console(_) => {} // Handled by handle_execution_error
OutputMode::Machine => {
println!("ERROR: {}", e);
if attempt > 1 {
println!("FAILED_AFTER_RETRIES: {}", attempt);
}
}
}
}
}
/// Execute a task with retry logic for timeout errors.
///
/// This is the unified implementation used by both console and machine modes.
pub async fn execute_task_with_retry<W: UiWriter>(
agent: &mut Agent<W>,
input: &str,
show_prompt: bool,
show_code: bool,
mode: OutputMode<'_>,
) {
let mut attempt = 0;
mode.print_thinking();
// Create cancellation token for this request
let cancellation_token = CancellationToken::new();
let cancel_token_clone = cancellation_token.clone();
loop {
attempt += 1;
// Execute task with cancellation support
let execution_result = tokio::select! {
result = agent.execute_task_with_timing_cancellable(
input, None, false, show_prompt, show_code, true, cancellation_token.clone(), None
) => {
result
}
_ = tokio::signal::ctrl_c() => {
cancel_token_clone.cancel();
mode.print_cancelled();
return;
}
};
match execution_result {
Ok(result) => {
if attempt > 1 {
mode.print_retry_success(attempt);
}
mode.print_response(&result.response, match &mode {
OutputMode::Console(o) => Some(*o),
OutputMode::Machine => None,
});
return;
}
Err(e) => {
if e.to_string().contains("cancelled") {
mode.print_cancelled_simple();
return;
}
// Check if this is a timeout error that we should retry
let error_type = classify_error(&e);
if matches!(
error_type,
ErrorType::Recoverable(RecoverableError::Timeout)
) && attempt < MAX_TIMEOUT_RETRIES
{
// Calculate retry delay with exponential backoff
let delay_ms = 1000 * (2_u64.pow(attempt - 1));
let delay = std::time::Duration::from_millis(delay_ms);
mode.print_timeout_retry(attempt, delay);
// Wait before retrying
tokio::time::sleep(delay).await;
continue;
}
// For non-timeout errors or after max retries
match &mode {
OutputMode::Console(output) => {
handle_execution_error(&e, input, output, attempt);
}
OutputMode::Machine => {
mode.print_error(&e, attempt);
}
}
return;
}
}
}
}
/// Handle execution errors with detailed logging and user-friendly output.
pub fn handle_execution_error(e: &anyhow::Error, input: &str, output: &SimpleOutput, attempt: u32) {
// Enhanced error logging with detailed information
error!("=== TASK EXECUTION ERROR ===");
error!("Error: {}", e);
if attempt > 1 {
error!("Failed after {} attempts", attempt);
}
// Log error chain
let mut source = e.source();
let mut depth = 1;
while let Some(err) = source {
error!(" Caused by [{}]: {}", depth, err);
source = err.source();
depth += 1;
}
// Log additional context
error!("Task input: {}", input);
error!("Error type: {}", std::any::type_name_of_val(&e));
// Display user-friendly error message
output.print(&format!("❌ Error: {}", e));
// If it's a stream error, provide helpful guidance
if e.to_string().contains("No response received") || e.to_string().contains("timed out") {
output.print("💡 This may be a temporary issue. Please try again or check the logs for more details.");
output.print(" Log files are saved in the '.g3/sessions/' directory.");
}
}

View File

@@ -0,0 +1,91 @@
//! Utility functions for G3 CLI.
use anyhow::Result;
use crossterm::style::{Color, ResetColor, SetForegroundColor};
use g3_core::ui_writer::UiWriter;
use g3_core::Agent;
use std::path::PathBuf;
use crate::simple_output::SimpleOutput;
/// Display context window progress bar.
pub fn display_context_progress<W: UiWriter>(agent: &Agent<W>, _output: &SimpleOutput) {
let context = agent.get_context_window();
let percentage = context.percentage_used();
// Create 10 dots representing context fullness
let total_dots: usize = 10;
let filled_dots = ((percentage / 100.0) * total_dots as f32).round() as usize;
let empty_dots = total_dots.saturating_sub(filled_dots);
let filled_str = "".repeat(filled_dots);
let empty_str = "".repeat(empty_dots);
// Determine color based on percentage
let color = if percentage < 40.0 {
Color::Green
} else if percentage < 60.0 {
Color::Yellow
} else if percentage < 80.0 {
Color::Rgb {
r: 255,
g: 165,
b: 0,
} // Orange
} else {
Color::Red
};
// Format tokens as compact strings (e.g., "38.5k" instead of "38531")
let format_tokens = |tokens: u32| -> String {
if tokens >= 1_000_000 {
format!("{:.1}m", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
let k = tokens as f64 / 1000.0;
if k >= 100.0 {
format!("{:.0}k", k)
} else {
format!("{:.1}k", k)
}
} else {
format!("{}", tokens)
}
};
// Print with colored dots (using print! directly to handle color codes)
print!(
"{}{}{}{} {}/{} ◉ | {:.0}%\n",
SetForegroundColor(color),
filled_str,
empty_str,
ResetColor,
format_tokens(context.used_tokens),
format_tokens(context.total_tokens),
percentage
);
}
/// Set up the workspace directory for autonomous mode.
/// Uses G3_WORKSPACE environment variable or defaults to ~/tmp/workspace.
pub fn setup_workspace_directory(machine_mode: bool) -> Result<PathBuf> {
let workspace_dir = if let Ok(env_workspace) = std::env::var("G3_WORKSPACE") {
PathBuf::from(env_workspace)
} else {
// Default to ~/tmp/workspace
let home_dir = dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
home_dir.join("tmp").join("workspace")
};
// Create the directory if it doesn't exist
if !workspace_dir.exists() {
std::fs::create_dir_all(&workspace_dir)?;
let output = SimpleOutput::new_with_mode(machine_mode);
output.print(&format!(
"📁 Created workspace directory: {}",
workspace_dir.display()
));
}
Ok(workspace_dir)
}