//! Planning mode tools for the Owlen agent //! //! Provides EnterPlanMode and ExitPlanMode tools that allow the agent //! to enter a planning phase where only read-only operations are allowed, //! and then present a plan for user approval. use color_eyre::eyre::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use chrono::{DateTime, Utc}; use uuid::Uuid; /// Agent mode - normal execution or planning #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentMode { /// Normal mode - all tools available per permission settings Normal, /// Planning mode - only read-only tools allowed Planning { /// Path to the plan file being written plan_file: PathBuf, /// When planning mode was entered started_at: DateTime, }, } impl Default for AgentMode { fn default() -> Self { Self::Normal } } impl AgentMode { /// Check if we're in planning mode pub fn is_planning(&self) -> bool { matches!(self, AgentMode::Planning { .. }) } /// Get the plan file path if in planning mode pub fn plan_file(&self) -> Option<&PathBuf> { match self { AgentMode::Planning { plan_file, .. } => Some(plan_file), AgentMode::Normal => None, } } } /// Plan file metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanMetadata { pub id: String, pub created_at: DateTime, pub status: PlanStatus, pub title: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PlanStatus { /// Plan is being written Draft, /// Plan is awaiting user approval PendingApproval, /// Plan was approved by user Approved, /// Plan was rejected by user Rejected, } /// Manager for plan files pub struct PlanManager { plans_dir: PathBuf, } impl PlanManager { /// Create a new plan manager pub fn new(project_root: PathBuf) -> Self { let plans_dir = project_root.join(".owlen").join("plans"); Self { plans_dir } } /// Create a new plan manager with custom directory pub fn with_dir(plans_dir: PathBuf) -> Self { Self { plans_dir } } /// Get the plans directory pub fn plans_dir(&self) -> &PathBuf { &self.plans_dir } /// Ensure the plans directory exists pub async fn ensure_dir(&self) -> Result<()> { tokio::fs::create_dir_all(&self.plans_dir).await?; Ok(()) } /// Generate a unique plan file name /// Uses a format like: --.md pub fn generate_plan_name(&self) -> String { // Simple word lists for readable names let adjectives = ["cozy", "swift", "clever", "bright", "calm", "eager", "gentle", "happy"]; let verbs = ["dancing", "jumping", "running", "flying", "singing", "coding", "building", "thinking"]; let nouns = ["owl", "fox", "bear", "wolf", "hawk", "deer", "lion", "tiger"]; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let uuid = Uuid::new_v4(); let mut hasher = DefaultHasher::new(); uuid.hash(&mut hasher); let hash = hasher.finish(); let adj = adjectives[(hash % adjectives.len() as u64) as usize]; let verb = verbs[((hash >> 8) % verbs.len() as u64) as usize]; let noun = nouns[((hash >> 16) % nouns.len() as u64) as usize]; format!("{}-{}-{}.md", adj, verb, noun) } /// Create a new plan file and return the path pub async fn create_plan(&self) -> Result { self.ensure_dir().await?; let filename = self.generate_plan_name(); let plan_path = self.plans_dir.join(&filename); // Create initial plan file with metadata let metadata = PlanMetadata { id: Uuid::new_v4().to_string(), created_at: Utc::now(), status: PlanStatus::Draft, title: None, }; let initial_content = format!( "\n\n\n# Implementation Plan\n\n", metadata.id ); tokio::fs::write(&plan_path, initial_content).await?; Ok(plan_path) } /// Write content to a plan file pub async fn write_plan(&self, path: &PathBuf, content: &str) -> Result<()> { // Preserve the metadata header if it exists let existing = tokio::fs::read_to_string(path).await.unwrap_or_default(); // Extract metadata lines (lines starting with \n"); } else { new_content.push_str(line); new_content.push('\n'); } } new_content.push('\n'); new_content.push_str(content); tokio::fs::write(path, new_content).await?; Ok(()) } /// Read a plan file pub async fn read_plan(&self, path: &PathBuf) -> Result { let content = tokio::fs::read_to_string(path).await?; Ok(content) } /// Update plan status pub async fn set_status(&self, path: &PathBuf, status: PlanStatus) -> Result<()> { let content = tokio::fs::read_to_string(path).await?; let status_str = match status { PlanStatus::Draft => "draft", PlanStatus::PendingApproval => "pending_approval", PlanStatus::Approved => "approved", PlanStatus::Rejected => "rejected", }; // Replace status line let updated: String = content .lines() .map(|line| { if line.contains("", status_str) } else { line.to_string() } }) .collect::>() .join("\n"); tokio::fs::write(path, updated).await?; Ok(()) } /// List all plan files pub async fn list_plans(&self) -> Result> { let mut plans = Vec::new(); if !self.plans_dir.exists() { return Ok(plans); } let mut entries = tokio::fs::read_dir(&self.plans_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().map_or(false, |ext| ext == "md") { plans.push(path); } } plans.sort(); Ok(plans) } } /// Enter planning mode pub fn enter_plan_mode(plan_file: PathBuf) -> AgentMode { AgentMode::Planning { plan_file, started_at: Utc::now(), } } /// Exit planning mode and return to normal pub fn exit_plan_mode() -> AgentMode { AgentMode::Normal } /// Check if a tool is allowed in planning mode /// Only read-only tools are allowed pub fn is_tool_allowed_in_plan_mode(tool_name: &str) -> bool { matches!( tool_name, "read" | "glob" | "grep" | "ls" | "web_fetch" | "web_search" | "todo_write" | "ask_user" | "exit_plan_mode" ) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[tokio::test] async fn test_create_plan() { let temp_dir = TempDir::new().unwrap(); let manager = PlanManager::new(temp_dir.path().to_path_buf()); let plan_path = manager.create_plan().await.unwrap(); assert!(plan_path.exists()); assert!(plan_path.extension().map_or(false, |ext| ext == "md")); } #[tokio::test] async fn test_write_and_read_plan() { let temp_dir = TempDir::new().unwrap(); let manager = PlanManager::new(temp_dir.path().to_path_buf()); let plan_path = manager.create_plan().await.unwrap(); manager.write_plan(&plan_path, "# My Plan\n\nStep 1: Do something").await.unwrap(); let content = manager.read_plan(&plan_path).await.unwrap(); assert!(content.contains("My Plan")); assert!(content.contains("pending_approval")); } #[test] fn test_plan_mode_check() { assert!(is_tool_allowed_in_plan_mode("read")); assert!(is_tool_allowed_in_plan_mode("glob")); assert!(is_tool_allowed_in_plan_mode("grep")); assert!(!is_tool_allowed_in_plan_mode("write")); assert!(!is_tool_allowed_in_plan_mode("bash")); assert!(!is_tool_allowed_in_plan_mode("edit")); } #[test] fn test_agent_mode_default() { let mode = AgentMode::default(); assert!(!mode.is_planning()); assert!(mode.plan_file().is_none()); } }