From 84fa08ab45e90e4db96ecdfd9ebc07117713dfb3 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 26 Dec 2025 22:47:54 +0100 Subject: [PATCH] feat(plan): Add plan execution system with external tool support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan Execution System: - Add PlanStep, AccumulatedPlan types for multi-turn tool call accumulation - Implement AccumulatedPlanStatus for tracking plan lifecycle - Support selective approval of proposed tool calls before execution External Tools Integration: - Add ExternalToolDefinition and ExternalToolTransport to plugins crate - Extend ToolContext with external_tools registry - Add external_tool_to_llm_tool conversion for LLM compatibility JSON-RPC Communication: - Add jsonrpc crate for JSON-RPC 2.0 protocol support - Enable stdio-based communication with external tool servers UI & Engine Updates: - Add plan_panel.rs component for displaying accumulated plans - Wire plan mode into engine loop - Add plan mode integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.toml | 1 + crates/app/cli/Cargo.toml | 1 + crates/app/cli/src/agent_manager.rs | 18 + crates/app/cli/src/engine.rs | 122 ++++- crates/app/ui/Cargo.toml | 1 + crates/app/ui/src/app.rs | 190 ++++++- crates/app/ui/src/components/mod.rs | 2 + crates/app/ui/src/components/plan_panel.rs | 322 +++++++++++ crates/core/agent/Cargo.toml | 2 + crates/core/agent/src/lib.rs | 115 +++- crates/core/agent/src/session.rs | 56 ++ crates/core/agent/tests/plan_mode.rs | 200 +++++++ crates/core/agent/tests/tool_context.rs | 112 +++- crates/integration/jsonrpc/Cargo.toml | 17 + crates/integration/jsonrpc/src/lib.rs | 391 ++++++++++++++ crates/platform/plugins/src/lib.rs | 305 +++++++++++ crates/tools/plan/src/lib.rs | 596 ++++++++++++++++++++- 17 files changed, 2438 insertions(+), 13 deletions(-) create mode 100644 crates/app/ui/src/components/plan_panel.rs create mode 100644 crates/core/agent/tests/plan_mode.rs create mode 100644 crates/integration/jsonrpc/Cargo.toml create mode 100644 crates/integration/jsonrpc/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 6c5039b..8ae507a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/tools/todo", "crates/tools/web", "crates/integration/mcp-client", + "crates/integration/jsonrpc", ] resolver = "2" diff --git a/crates/app/cli/Cargo.toml b/crates/app/cli/Cargo.toml index bbe72db..b914000 100644 --- a/crates/app/cli/Cargo.toml +++ b/crates/app/cli/Cargo.toml @@ -19,6 +19,7 @@ llm-openai = { path = "../../llm/openai" } tools-fs = { path = "../../tools/fs" } tools-bash = { path = "../../tools/bash" } tools-slash = { path = "../../tools/slash" } +tools-plan = { path = "../../tools/plan" } auth-manager = { path = "../../platform/auth" } config-agent = { package = "config-agent", path = "../../platform/config" } permissions = { path = "../../platform/permissions" } diff --git a/crates/app/cli/src/agent_manager.rs b/crates/app/cli/src/agent_manager.rs index 63b3993..d6b6a06 100644 --- a/crates/app/cli/src/agent_manager.rs +++ b/crates/app/cli/src/agent_manager.rs @@ -214,6 +214,24 @@ impl AgentManager { Ok(()) } + /// Execute a single tool call (used by engine for plan step execution) + pub async fn execute_single_tool(&self, tool_name: &str, args: &serde_json::Value) -> Result { + let dummy_perms = permissions::PermissionManager::new(permissions::Mode::Code); + let ctx = agent_core::ToolContext::new(); + + let result = agent_core::execute_tool(tool_name, args, &dummy_perms, &ctx).await?; + + // Notify UI of tool execution + if let Some(tx) = &self.tx_ui { + let _ = tx.send(Message::AgentResponse(AgentResponse::ToolCall { + name: tool_name.to_string(), + args: args.to_string(), + })).await; + } + + Ok(result) + } + /// Execute the full reasoning loop until a final response is reached pub async fn run(&self, input: &str) -> Result<()> { let tools = agent_core::get_tool_definitions(); diff --git a/crates/app/cli/src/engine.rs b/crates/app/cli/src/engine.rs index 7d4cb1d..ecf9c0b 100644 --- a/crates/app/cli/src/engine.rs +++ b/crates/app/cli/src/engine.rs @@ -1,9 +1,11 @@ -use agent_core::messages::{Message, UserAction, AgentResponse}; +use agent_core::messages::{Message, UserAction, AgentResponse, SystemNotification}; use agent_core::state::AppState; use tokio::sync::{mpsc, Mutex}; use std::sync::Arc; +use std::path::PathBuf; use llm_core::LlmProvider; use ui::ProviderManager; +use tools_plan::PlanManager; use crate::agent_manager::AgentManager; /// The main background task that handles logic, API calls, and state updates. @@ -19,6 +21,9 @@ pub async fn run_engine_loop_dynamic( .with_ui_sender(tx_ui.clone()) ); + // Plan manager for persistence + let plan_manager = PlanManager::new(PathBuf::from(".")); + while let Some(msg) = rx.recv().await { match msg { Message::UserAction(UserAction::Input(text)) => { @@ -35,6 +40,121 @@ pub async fn run_engine_loop_dynamic( let mut guard = state.lock().await; guard.set_permission_result(res); } + Message::UserAction(UserAction::FinalizePlan) => { + let mut guard = state.lock().await; + if let Some(plan) = guard.current_plan_mut() { + plan.finalize(); + let total_steps = plan.steps.len(); + let status = plan.status; + drop(guard); + let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanComplete { + total_steps, + status, + })).await; + } + } + Message::UserAction(UserAction::PlanApproval(approval)) => { + let mut guard = state.lock().await; + if let Some(plan) = guard.current_plan_mut() { + // Apply approval decisions + approval.apply_to(plan); + plan.start_execution(); + + // Get approved steps for execution + let approved_steps: Vec<_> = plan.steps.iter() + .filter(|s| s.is_approved()) + .cloned() + .collect(); + let total = approved_steps.len(); + let skipped = plan.steps.iter().filter(|s| s.is_rejected()).count(); + + drop(guard); + + // Execute approved steps + for (idx, step) in approved_steps.iter().enumerate() { + let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanExecuting { + step_id: step.id.clone(), + step_index: idx, + total_steps: total, + })).await; + + // Execute the tool + let agent_manager_clone = agent_manager.clone(); + let tx_clone = tx_ui.clone(); + let step_clone = step.clone(); + + // Execute tool and send result + if let Err(e) = agent_manager_clone.execute_single_tool(&step_clone.tool, &step_clone.args).await { + let _ = tx_clone.send(Message::AgentResponse(AgentResponse::Error( + format!("Step {} failed: {}", idx + 1, e) + ))).await; + } + } + + // Mark plan as completed + let mut guard = state.lock().await; + if let Some(plan) = guard.current_plan_mut() { + plan.complete(); + } + drop(guard); + + let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanExecutionComplete { + executed: total, + skipped, + })).await; + } + } + Message::UserAction(UserAction::SavePlan(name)) => { + let guard = state.lock().await; + if let Some(plan) = guard.current_plan() { + let mut plan_to_save = plan.clone(); + plan_to_save.name = Some(name.clone()); + drop(guard); + + match plan_manager.save_accumulated_plan(&plan_to_save).await { + Ok(path) => { + let _ = tx_ui.send(Message::System(SystemNotification::PlanSaved { + id: plan_to_save.id.clone(), + path: path.display().to_string(), + })).await; + } + Err(e) => { + let _ = tx_ui.send(Message::AgentResponse(AgentResponse::Error( + format!("Failed to save plan: {}", e) + ))).await; + } + } + } + } + Message::UserAction(UserAction::LoadPlan(id)) => { + match plan_manager.load_accumulated_plan(&id).await { + Ok(plan) => { + let name = plan.name.clone(); + let steps = plan.steps.len(); + let mut guard = state.lock().await; + guard.accumulated_plan = Some(plan); + drop(guard); + + let _ = tx_ui.send(Message::System(SystemNotification::PlanLoaded { + id, + name, + steps, + })).await; + } + Err(e) => { + let _ = tx_ui.send(Message::AgentResponse(AgentResponse::Error( + format!("Failed to load plan: {}", e) + ))).await; + } + } + } + Message::UserAction(UserAction::CancelPlan) => { + let mut guard = state.lock().await; + if let Some(plan) = guard.current_plan_mut() { + plan.cancel(); + } + guard.clear_plan(); + } Message::UserAction(UserAction::Exit) => { let mut guard = state.lock().await; guard.running = false; diff --git a/crates/app/ui/Cargo.toml b/crates/app/ui/Cargo.toml index d6e606f..6084fc3 100644 --- a/crates/app/ui/Cargo.toml +++ b/crates/app/ui/Cargo.toml @@ -28,3 +28,4 @@ llm-ollama = { path = "../../llm/ollama" } llm-openai = { path = "../../llm/openai" } config-agent = { path = "../../platform/config" } tools-todo = { path = "../../tools/todo" } +tools-plan = { path = "../../tools/plan" } diff --git a/crates/app/ui/src/app.rs b/crates/app/ui/src/app.rs index 941c24d..2ec0169 100644 --- a/crates/app/ui/src/app.rs +++ b/crates/app/ui/src/app.rs @@ -1,13 +1,14 @@ use crate::{ components::{ Autocomplete, AutocompleteResult, ChatMessage, ChatPanel, CommandHelp, InputBox, - ModelPicker, PermissionPopup, PickerResult, ProviderTabs, StatusBar, TodoPanel, + ModelPicker, PermissionPopup, PickerResult, PlanPanel, ProviderTabs, StatusBar, TodoPanel, }, events::{handle_key_event, AppEvent}, layout::AppLayout, provider_manager::ProviderManager, theme::{Provider, Theme, VimMode}, }; +use tools_plan::AccumulatedPlanStatus; use tools_todo::TodoList; use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions}; use color_eyre::eyre::Result; @@ -59,6 +60,7 @@ pub struct TuiApp { input_box: InputBox, status_bar: StatusBar, todo_panel: TodoPanel, + plan_panel: PlanPanel, permission_popup: Option, autocomplete: Autocomplete, command_help: CommandHelp, @@ -128,6 +130,7 @@ impl TuiApp { input_box: InputBox::new(theme.clone()), status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()), todo_panel: TodoPanel::new(theme.clone()), + plan_panel: PlanPanel::new(theme.clone()), permission_popup: None, autocomplete: Autocomplete::new(theme.clone()), command_help: CommandHelp::new(theme.clone()), @@ -187,6 +190,7 @@ impl TuiApp { input_box: InputBox::new(theme.clone()), status_bar, todo_panel: TodoPanel::new(theme.clone()), + plan_panel: PlanPanel::new(theme.clone()), permission_popup: None, autocomplete: Autocomplete::new(theme.clone()), command_help: CommandHelp::new(theme.clone()), @@ -248,6 +252,7 @@ impl TuiApp { input_box: InputBox::new(theme.clone()), status_bar, todo_panel: TodoPanel::new(theme.clone()), + plan_panel: PlanPanel::new(theme.clone()), permission_popup: None, autocomplete: Autocomplete::new(theme.clone()), command_help: CommandHelp::new(theme.clone()), @@ -354,6 +359,7 @@ impl TuiApp { self.input_box = InputBox::new(theme.clone()); self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme.clone()); self.todo_panel.set_theme(theme.clone()); + self.plan_panel.set_theme(theme.clone()); self.autocomplete.set_theme(theme.clone()); self.command_help.set_theme(theme.clone()); self.provider_tabs.set_theme(theme.clone()); @@ -479,6 +485,26 @@ Commands: /help, /model , /clear, /theme &self.todo_list } + /// Get the current accumulated plan from shared state (if any) + fn get_current_plan(&self) -> Option { + if let Some(state) = &self.shared_state { + if let Ok(guard) = state.try_lock() { + return guard.accumulated_plan.clone(); + } + } + None + } + + /// Show the plan panel + pub fn show_plan_panel(&mut self) { + self.plan_panel.show(); + } + + /// Hide the plan panel + pub fn hide_plan_panel(&mut self) { + self.plan_panel.hide(); + } + /// Render the header line: OWLEN left, model + vim mode right fn render_header(&self, frame: &mut ratatui::Frame, area: Rect) { let vim_indicator = self.vim_mode.indicator(&self.theme.symbols); @@ -670,12 +696,22 @@ Commands: /help, /model , /clear, /theme self.model_picker.render(frame, size); } - // 3. Command help overlay (centered modal) + // 3. Plan panel (when accumulating or reviewing a plan) + if self.plan_panel.is_visible() { + let plan = self.get_current_plan(); + // Calculate centered area for plan panel (60% width, 50% height) + let plan_width = (size.width * 3 / 5).max(60).min(size.width - 4); + let plan_height = (size.height / 2).max(15).min(size.height - 4); + let plan_area = AppLayout::center_popup(size, plan_width, plan_height); + self.plan_panel.render(frame, plan_area, plan.as_ref()); + } + + // 4. Command help overlay (centered modal) if self.command_help.is_visible() { self.command_help.render(frame, size); } - // 4. Permission popup (highest priority) + // 5. Permission popup (highest priority) if let Some(popup) = &self.permission_popup { popup.render(frame, size); } @@ -785,13 +821,130 @@ Commands: /help, /model , /clear, /theme return Ok(()); } - // 2. Command help overlay + // 2. Plan panel (when reviewing accumulated plan) + if self.plan_panel.is_visible() { + match key.code { + // Navigation + KeyCode::Char('j') | KeyCode::Down => { + if let Some(plan) = self.get_current_plan() { + self.plan_panel.select_next(plan.steps.len()); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.plan_panel.select_prev(); + } + // Toggle step approval + KeyCode::Char(' ') => { + if let Some(state) = &self.shared_state { + if let Ok(mut guard) = state.try_lock() { + if let Some(plan) = guard.current_plan_mut() { + let idx = self.plan_panel.selected_index(); + if let Some(step) = plan.steps.get_mut(idx) { + step.approved = match step.approved { + None => Some(true), + Some(true) => Some(false), + Some(false) => None, + }; + } + } + } + } + } + // Finalize plan (stop accumulating, enter review) + KeyCode::Char('f') | KeyCode::Char('F') => { + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::FinalizePlan)).await; + } + } + // Approve all pending + KeyCode::Char('a') | KeyCode::Char('A') => { + if let Some(state) = &self.shared_state { + if let Ok(mut guard) = state.try_lock() { + if let Some(plan) = guard.current_plan_mut() { + for step in &mut plan.steps { + if step.approved.is_none() { + step.approved = Some(true); + } + } + } + } + } + } + // Reject all pending + KeyCode::Char('r') | KeyCode::Char('R') => { + if let Some(state) = &self.shared_state { + if let Ok(mut guard) = state.try_lock() { + if let Some(plan) = guard.current_plan_mut() { + for step in &mut plan.steps { + if step.approved.is_none() { + step.approved = Some(false); + } + } + } + } + } + } + // Execute approved steps + KeyCode::Enter => { + if let Some(plan) = self.get_current_plan() { + if plan.status == AccumulatedPlanStatus::Reviewing { + // Collect approval decisions + let approved: Vec<_> = plan.steps.iter() + .filter(|s| s.approved == Some(true)) + .map(|s| s.id.clone()) + .collect(); + let rejected: Vec<_> = plan.steps.iter() + .filter(|s| s.approved == Some(false)) + .map(|s| s.id.clone()) + .collect(); + + let approval = tools_plan::PlanApproval { + approved_ids: approved, + rejected_ids: rejected, + }; + + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::PlanApproval(approval))).await; + } + self.plan_panel.hide(); + } + } + } + // Save plan + KeyCode::Char('s') | KeyCode::Char('S') => { + if let Some(tx) = &self.engine_tx { + // TODO: Prompt for plan name + let name = format!("plan-{}", std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()); + let _ = tx.send(Message::UserAction(UserAction::SavePlan(name))).await; + } + } + // Cancel/close + KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => { + if let Some(plan) = self.get_current_plan() { + if plan.status == AccumulatedPlanStatus::Accumulating { + // Cancel the plan entirely + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::CancelPlan)).await; + } + } + } + self.plan_panel.hide(); + } + _ => {} + } + return Ok(()); + } + + // 3. Command help overlay if self.command_help.is_visible() { self.command_help.handle_key(key); return Ok(()); } - // 3. Model picker + // 4. Model picker if self.model_picker.is_visible() { let current_model = self.opts.model.clone(); match self.model_picker.handle_key(key, ¤t_model) { @@ -951,16 +1104,23 @@ Commands: /help, /model , /clear, /theme } ); self.chat_panel.add_message(ChatMessage::System(msg)); + // Auto-show plan panel when steps are being accumulated + if !self.plan_panel.is_visible() { + self.plan_panel.show(); + } } AgentResponse::PlanComplete { total_steps, status: _ } => { self.chat_panel.add_message(ChatMessage::System( format!("--- PLAN COMPLETE ({} steps) ---", total_steps) )); self.chat_panel.add_message(ChatMessage::System( - "Review and approve plan (y/n in status bar)".to_string() + "Press [Enter] to execute or [Esc] to cancel".to_string() )); + // Show plan panel for review + self.plan_panel.show(); + self.plan_panel.reset_selection(); self.status_bar.set_state(crate::components::AppState::WaitingPermission); - self.status_bar.set_pending_permission(Some("PLAN".to_string())); + self.status_bar.set_pending_permission(Some("PLAN REVIEW".to_string())); } AgentResponse::PlanExecuting { step_id: _, step_index, total_steps } => { self.chat_panel.add_message(ChatMessage::System( @@ -971,6 +1131,10 @@ Commands: /help, /model , /clear, /theme self.chat_panel.add_message(ChatMessage::System( format!("Plan execution complete: {} executed, {} skipped", executed, skipped) )); + // Hide plan panel after execution + self.plan_panel.hide(); + self.status_bar.set_state(crate::components::AppState::Idle); + self.status_bar.set_pending_permission(None); } } } @@ -1606,6 +1770,18 @@ Commands: /help, /model , /clear, /theme "Context compaction not yet implemented".to_string() )); } + "/plan" => { + // Toggle plan panel visibility + if self.plan_panel.is_visible() { + self.plan_panel.hide(); + } else if self.get_current_plan().is_some() { + self.plan_panel.show(); + } else { + self.chat_panel.add_message(ChatMessage::System( + "No active plan. Start planning mode with a prompt.".to_string() + )); + } + } "/provider" => { // Show available providers self.chat_panel.add_message(ChatMessage::System( diff --git a/crates/app/ui/src/components/mod.rs b/crates/app/ui/src/components/mod.rs index f9812c2..0f72e9e 100644 --- a/crates/app/ui/src/components/mod.rs +++ b/crates/app/ui/src/components/mod.rs @@ -6,6 +6,7 @@ mod command_help; mod input_box; mod model_picker; mod permission_popup; +mod plan_panel; mod provider_tabs; mod status_bar; mod todo_panel; @@ -16,6 +17,7 @@ pub use command_help::{Command, CommandHelp}; pub use input_box::{InputBox, InputEvent}; pub use model_picker::{ModelPicker, PickerResult, PickerState}; pub use permission_popup::{PermissionOption, PermissionPopup}; +pub use plan_panel::PlanPanel; pub use provider_tabs::ProviderTabs; pub use status_bar::{AppState, StatusBar}; pub use todo_panel::TodoPanel; diff --git a/crates/app/ui/src/components/plan_panel.rs b/crates/app/ui/src/components/plan_panel.rs new file mode 100644 index 0000000..b0d2e9c --- /dev/null +++ b/crates/app/ui/src/components/plan_panel.rs @@ -0,0 +1,322 @@ +//! Plan panel component for displaying accumulated plan steps +//! +//! Shows the current plan with steps, approval status, and keybindings. + +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + Frame, +}; +use tools_plan::{AccumulatedPlan, AccumulatedPlanStatus, PlanStep}; + +use crate::theme::Theme; + +/// Plan panel component for displaying and interacting with accumulated plans +pub struct PlanPanel { + theme: Theme, + /// Currently selected step index + selected: usize, + /// Scroll offset for long lists + scroll_offset: usize, + /// Whether the panel is visible + visible: bool, +} + +impl PlanPanel { + pub fn new(theme: Theme) -> Self { + Self { + theme, + selected: 0, + scroll_offset: 0, + visible: false, + } + } + + /// Show the panel + pub fn show(&mut self) { + self.visible = true; + self.selected = 0; + self.scroll_offset = 0; + } + + /// Hide the panel + pub fn hide(&mut self) { + self.visible = false; + } + + /// Check if visible + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Update theme + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + /// Move selection up + pub fn select_prev(&mut self) { + if self.selected > 0 { + self.selected -= 1; + // Adjust scroll if needed + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } + } + } + + /// Move selection down + pub fn select_next(&mut self, total_steps: usize) { + if self.selected < total_steps.saturating_sub(1) { + self.selected += 1; + } + } + + /// Get the currently selected step index + pub fn selected_index(&self) -> usize { + self.selected + } + + /// Reset selection to first step + pub fn reset_selection(&mut self) { + self.selected = 0; + self.scroll_offset = 0; + } + + /// Get the minimum height needed for the panel + pub fn min_height(&self) -> u16 { + if self.visible { 10 } else { 0 } + } + + /// Render the plan panel + pub fn render(&self, frame: &mut Frame, area: Rect, plan: Option<&AccumulatedPlan>) { + if !self.visible { + return; + } + + let plan = match plan { + Some(p) => p, + None => { + // Show "no plan" message + let block = Block::default() + .borders(Borders::ALL) + .title(" PLAN ") + .border_style(self.theme.border); + let paragraph = Paragraph::new("No active plan") + .style(self.theme.status_dim) + .block(block); + frame.render_widget(paragraph, area); + return; + } + }; + + let mut lines: Vec = Vec::new(); + + // Title line with status + let status_str = match plan.status { + AccumulatedPlanStatus::Accumulating => "Accumulating", + AccumulatedPlanStatus::Reviewing => "Reviewing", + AccumulatedPlanStatus::Executing => "Executing", + AccumulatedPlanStatus::Completed => "Completed", + AccumulatedPlanStatus::Cancelled => "Cancelled", + }; + let status_color = match plan.status { + AccumulatedPlanStatus::Accumulating => self.theme.palette.warning, + AccumulatedPlanStatus::Reviewing => self.theme.palette.info, + AccumulatedPlanStatus::Executing => self.theme.palette.success, + AccumulatedPlanStatus::Completed => self.theme.palette.success, + AccumulatedPlanStatus::Cancelled => self.theme.palette.error, + }; + + let name = plan.name.as_deref().unwrap_or("unnamed"); + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", name), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(" | ", self.theme.status_dim), + Span::styled(status_str, Style::default().fg(status_color)), + Span::styled( + format!(" ({} steps)", plan.steps.len()), + self.theme.status_dim, + ), + ])); + + // Separator + lines.push(Line::from("─".repeat(area.width.saturating_sub(2) as usize))); + + // Steps + if plan.steps.is_empty() { + lines.push(Line::from(Span::styled( + " No steps yet...", + self.theme.status_dim, + ))); + } else { + let visible_height = area.height.saturating_sub(6) as usize; // Account for header, footer, borders + let start = self.scroll_offset; + let end = (start + visible_height).min(plan.steps.len()); + + for (idx, step) in plan.steps.iter().enumerate().skip(start).take(end - start) { + let is_selected = idx == self.selected; + let line = self.render_step(step, idx + 1, is_selected); + lines.push(line); + + // Show rationale if selected and available + if is_selected { + if let Some(rationale) = &step.rationale { + let truncated = if rationale.len() > 60 { + format!("{}...", &rationale[..57]) + } else { + rationale.clone() + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("\"{}\"", truncated), + Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC), + ), + ])); + } + } + } + } + + // Footer with keybindings + lines.push(Line::from("─".repeat(area.width.saturating_sub(2) as usize))); + + let keybinds = match plan.status { + AccumulatedPlanStatus::Accumulating => { + "[F]inalize [C]ancel" + } + AccumulatedPlanStatus::Reviewing => { + "[j/k]nav [Space]toggle [A]pprove all [R]eject all [Enter]execute [S]ave [C]ancel" + } + _ => "[C]lose", + }; + lines.push(Line::from(Span::styled(keybinds, self.theme.status_dim))); + + // Create block with border + let block = Block::default() + .borders(Borders::ALL) + .title(" PLAN ") + .border_style(self.theme.border); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); + + // Scrollbar if needed + if plan.steps.len() > (area.height.saturating_sub(6) as usize) { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + let mut scrollbar_state = ScrollbarState::new(plan.steps.len()) + .position(self.selected); + frame.render_stateful_widget( + scrollbar, + area.inner(ratatui::layout::Margin { vertical: 2, horizontal: 0 }), + &mut scrollbar_state, + ); + } + } + + /// Render a single step line + fn render_step(&self, step: &PlanStep, num: usize, selected: bool) -> Line<'static> { + let checkbox = match step.approved { + None => "☐", // Pending + Some(true) => "☑", // Approved + Some(false) => "☒", // Rejected + }; + + let checkbox_color = match step.approved { + None => self.theme.palette.warning, + Some(true) => self.theme.palette.success, + Some(false) => self.theme.palette.error, + }; + + // Truncate args for display + let args_str = step.args.to_string(); + let args_display = if args_str.len() > 40 { + format!("{}...", &args_str[..37]) + } else { + args_str + }; + + let mut spans = vec![ + Span::styled( + if selected { ">" } else { " " }, + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(format!("[{}] ", num), self.theme.status_dim), + Span::styled(checkbox, Style::default().fg(checkbox_color)), + Span::raw(" "), + Span::styled( + format!("{:<10}", step.tool), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(args_display, self.theme.status_dim), + ]; + + if selected { + // Highlight the entire line for selected step + for span in &mut spans { + span.style = span.style.add_modifier(Modifier::REVERSED); + } + } + + Line::from(spans) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + + #[test] + fn test_plan_panel_creation() { + let theme = Theme::default(); + let panel = PlanPanel::new(theme); + assert!(!panel.is_visible()); + assert_eq!(panel.selected_index(), 0); + } + + #[test] + fn test_plan_panel_navigation() { + let theme = Theme::default(); + let mut panel = PlanPanel::new(theme); + panel.show(); + + // Can't go below 0 + panel.select_prev(); + assert_eq!(panel.selected_index(), 0); + + // Navigate down + panel.select_next(5); + assert_eq!(panel.selected_index(), 1); + panel.select_next(5); + assert_eq!(panel.selected_index(), 2); + + // Can't go past end + panel.select_next(3); + panel.select_next(3); + assert_eq!(panel.selected_index(), 2); + + // Navigate back up + panel.select_prev(); + assert_eq!(panel.selected_index(), 1); + } + + #[test] + fn test_plan_panel_visibility() { + let theme = Theme::default(); + let mut panel = PlanPanel::new(theme); + + assert!(!panel.is_visible()); + panel.show(); + assert!(panel.is_visible()); + panel.hide(); + assert!(!panel.is_visible()); + } +} diff --git a/crates/core/agent/Cargo.toml b/crates/core/agent/Cargo.toml index b18bd1e..a4e21c8 100644 --- a/crates/core/agent/Cargo.toml +++ b/crates/core/agent/Cargo.toml @@ -24,6 +24,8 @@ tools-ask = { path = "../../tools/ask" } tools-todo = { path = "../../tools/todo" } tools-web = { path = "../../tools/web" } tools-plan = { path = "../../tools/plan" } +plugins = { path = "../../platform/plugins" } +jsonrpc = { path = "../../integration/jsonrpc" } [dev-dependencies] tempfile = "3.13" diff --git a/crates/core/agent/src/lib.rs b/crates/core/agent/src/lib.rs index cd76f53..ede0f29 100644 --- a/crates/core/agent/src/lib.rs +++ b/crates/core/agent/src/lib.rs @@ -14,7 +14,10 @@ use color_eyre::eyre::{Result, eyre}; use futures_util::StreamExt; use llm_core::{ChatMessage, ChatOptions, LlmProvider, Tool, ToolParameters}; use permissions::{PermissionDecision, PermissionManager, Tool as PermTool}; +use jsonrpc; +use plugins::{ExternalToolDefinition, ExternalToolTransport}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; @@ -107,6 +110,9 @@ pub struct ToolContext { /// Current agent mode (e.g., Normal or Planning). pub agent_mode: Arc>, + + /// Registry of external tools from plugins + pub external_tools: Arc>, } impl Default for ToolContext { @@ -117,6 +123,7 @@ impl Default for ToolContext { shell_manager: None, plan_manager: None, agent_mode: Arc::new(RwLock::new(AgentMode::Normal)), + external_tools: Arc::new(HashMap::new()), } } } @@ -171,6 +178,22 @@ impl ToolContext { pub async fn set_mode(&self, mode: AgentMode) { *self.agent_mode.write().await = mode; } + + /// Adds external tools from plugins to the context. + pub fn with_external_tools(mut self, tools: HashMap) -> Self { + self.external_tools = Arc::new(tools); + self + } + + /// Gets an external tool by name, if it exists. + pub fn get_external_tool(&self, name: &str) -> Option<&ExternalToolDefinition> { + self.external_tools.get(name) + } + + /// Returns true if an external tool with the given name exists. + pub fn has_external_tool(&self, name: &str) -> bool { + self.external_tools.contains_key(name) + } } /// Returns definitions for all available tools that the agent can use. @@ -430,6 +453,39 @@ pub fn get_tool_definitions() -> Vec { ] } +/// Converts an external tool definition to an LLM-compatible Tool definition. +fn external_tool_to_llm_tool(ext: &ExternalToolDefinition) -> Tool { + // Convert ExternalToolSchema to the JSON format expected by ToolParameters + let properties: serde_json::Map = ext.input_schema.properties + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + Tool::function( + &ext.name, + &ext.description, + ToolParameters::object( + Value::Object(properties), + ext.input_schema.required.clone(), + ), + ) +} + +/// Returns all tool definitions including external tools from plugins. +/// +/// This function merges the built-in tools with any external tools +/// registered in the ToolContext. +pub fn get_tool_definitions_with_external(ctx: &ToolContext) -> Vec { + let mut tools = get_tool_definitions(); + + // Add external tools from plugins + for ext_tool in ctx.external_tools.values() { + tools.push(external_tool_to_llm_tool(ext_tool)); + } + + tools +} + /// Executes a single tool call and returns its result as a string. /// /// This function handles permission checking and interacts with various tool-specific @@ -821,7 +877,64 @@ pub async fn execute_tool( } } } - _ => Err(eyre!("Unknown tool: {}", tool_name)), + // Check for external tools from plugins + _ => { + // Look up the tool in the external tools registry + if let Some(ext_tool) = ctx.get_external_tool(tool_name) { + // Check permission for external tool execution + // External tools require explicit permission (treated like Bash) + match perms.check(PermTool::Bash, Some(&format!("external:{}", tool_name))) { + PermissionDecision::Allow => { + execute_external_tool(ext_tool, arguments).await + } + PermissionDecision::Ask => { + Err(eyre!("Permission required: External tool '{}' needs approval", tool_name)) + } + PermissionDecision::Deny => { + Err(eyre!("Permission denied: External tool '{}' is blocked", tool_name)) + } + } + } else { + Err(eyre!("Unknown tool: {}", tool_name)) + } + } + } +} + +/// Executes an external tool via JSON-RPC. +async fn execute_external_tool( + tool: &ExternalToolDefinition, + arguments: &Value, +) -> Result { + match &tool.transport { + ExternalToolTransport::Stdio => { + // Get command and args + let command = tool.command.as_ref() + .ok_or_else(|| eyre!("Stdio tool '{}' missing command", tool.name))?; + + // Spawn the process and call via JSON-RPC + let executor = jsonrpc::ToolExecutor::with_timeout(tool.timeout_ms); + let result = executor.execute_stdio( + command, + &tool.args, + &std::collections::HashMap::new(), // TODO: env vars from plugin + arguments.clone(), + Some(tool.timeout_ms), + ).await?; + + // Convert JSON result to string + match result { + Value::String(s) => Ok(s), + other => Ok(serde_json::to_string_pretty(&other)?), + } + } + ExternalToolTransport::Http => { + let url = tool.url.as_ref() + .ok_or_else(|| eyre!("HTTP tool '{}' missing url", tool.name))?; + + // HTTP transport not yet implemented + Err(eyre!("HTTP transport for tool '{}' at {} is not yet implemented", tool.name, url)) + } } } diff --git a/crates/core/agent/src/session.rs b/crates/core/agent/src/session.rs index e28fd64..d07c98f 100644 --- a/crates/core/agent/src/session.rs +++ b/crates/core/agent/src/session.rs @@ -335,3 +335,59 @@ impl CheckpointManager { Ok(restored_files) } } + +/// Helper to accumulate streaming tool call deltas +#[derive(Default)] +pub struct ToolCallsBuilder { + calls: Vec, +} + +#[derive(Default)] +struct PartialToolCallBuilder { + id: Option, + name: Option, + arguments: String, +} + +impl ToolCallsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_deltas(&mut self, deltas: &[llm_core::ToolCallDelta]) { + for delta in deltas { + while self.calls.len() <= delta.index { + self.calls.push(PartialToolCallBuilder::default()); + } + let call = &mut self.calls[delta.index]; + if let Some(id) = &delta.id { + call.id = Some(id.clone()); + } + if let Some(name) = &delta.function_name { + call.name = Some(name.clone()); + } + if let Some(args) = &delta.arguments_delta { + call.arguments.push_str(args); + } + } + } + + pub fn build(self) -> Vec { + self.calls + .into_iter() + .filter_map(|p| { + let id = p.id?; + let name = p.name?; + let args: serde_json::Value = serde_json::from_str(&p.arguments).ok()?; + Some(llm_core::ToolCall { + id, + call_type: "function".to_string(), + function: llm_core::FunctionCall { + name, + arguments: args, + }, + }) + }) + .collect() + } +} diff --git a/crates/core/agent/tests/plan_mode.rs b/crates/core/agent/tests/plan_mode.rs new file mode 100644 index 0000000..d1d050e --- /dev/null +++ b/crates/core/agent/tests/plan_mode.rs @@ -0,0 +1,200 @@ +//! Integration tests for Plan Mode multi-turn accumulation + +use agent_core::{ + AccumulatedPlan, AccumulatedPlanStatus, PlanApproval, PlanStep, +}; +use serde_json::json; + +/// Test that PlanStep can be created and has correct initial state +#[test] +fn test_plan_step_creation() { + let step = PlanStep::new( + "call_123".to_string(), + 1, + "read".to_string(), + json!({"path": "/src/main.rs"}), + ); + + assert_eq!(step.id, "call_123"); + assert_eq!(step.turn, 1); + assert_eq!(step.tool, "read"); + assert!(step.is_pending()); + assert!(!step.is_approved()); + assert!(!step.is_rejected()); +} + +/// Test multi-turn accumulation of steps +#[test] +fn test_multi_turn_accumulation() { + let mut plan = AccumulatedPlan::new(); + assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating); + assert_eq!(plan.current_turn, 0); + + // Turn 1: Agent proposes reading a file + plan.next_turn(); + plan.add_step_with_rationale( + "call_1".to_string(), + "read".to_string(), + json!({"path": "Cargo.toml"}), + "I need to read the project configuration".to_string(), + ); + + assert_eq!(plan.steps.len(), 1); + assert_eq!(plan.current_turn, 1); + + // Turn 2: Agent proposes editing the file + plan.next_turn(); + plan.add_step_with_rationale( + "call_2".to_string(), + "edit".to_string(), + json!({"path": "Cargo.toml", "old": "version = \"0.1.0\"", "new": "version = \"0.2.0\""}), + "Bump the version".to_string(), + ); + + // Turn 2: Agent also proposes running tests + plan.add_step_with_rationale( + "call_3".to_string(), + "bash".to_string(), + json!({"command": "cargo test"}), + "Verify the change doesn't break anything".to_string(), + ); + + assert_eq!(plan.steps.len(), 3); + assert_eq!(plan.current_turn, 2); + + // All steps should be pending + let (pending, approved, rejected) = plan.counts(); + assert_eq!((pending, approved, rejected), (3, 0, 0)); +} + +/// Test plan finalization and status transition +#[test] +fn test_plan_finalization() { + let mut plan = AccumulatedPlan::new(); + plan.next_turn(); + plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({})); + + assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating); + + plan.finalize(); + assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing); +} + +/// Test selective approval workflow +#[test] +fn test_selective_approval() { + let mut plan = AccumulatedPlan::new(); + + // Add three steps + plan.add_step_for_current_turn("step_read".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("step_write".to_string(), "write".to_string(), json!({})); + plan.add_step_for_current_turn("step_bash".to_string(), "bash".to_string(), json!({})); + + plan.finalize(); + + // User approves read and write, rejects bash + let approval = PlanApproval { + approved_ids: vec!["step_read".to_string(), "step_write".to_string()], + rejected_ids: vec!["step_bash".to_string()], + }; + + approval.apply_to(&mut plan); + + // Verify approval state + assert!(plan.steps[0].is_approved()); + assert!(plan.steps[1].is_approved()); + assert!(plan.steps[2].is_rejected()); + + assert_eq!(plan.approved_steps().len(), 2); + assert_eq!(plan.rejected_steps().len(), 1); + assert!(plan.all_decided()); +} + +/// Test execution workflow +#[test] +fn test_execution_workflow() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("s2".to_string(), "write".to_string(), json!({})); + + // Finalize + plan.finalize(); + assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing); + + // Approve all + plan.approve_all(); + + // Start execution + plan.start_execution(); + assert_eq!(plan.status, AccumulatedPlanStatus::Executing); + + // Complete execution + plan.complete(); + assert_eq!(plan.status, AccumulatedPlanStatus::Completed); +} + +/// Test plan cancellation +#[test] +fn test_plan_cancellation() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({})); + plan.finalize(); + + plan.cancel(); + assert_eq!(plan.status, AccumulatedPlanStatus::Cancelled); +} + +/// Test approve_all only affects pending steps +#[test] +fn test_approve_all_respects_existing_decisions() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("s2".to_string(), "write".to_string(), json!({})); + plan.add_step_for_current_turn("s3".to_string(), "bash".to_string(), json!({})); + + // Reject s3 before approve_all + plan.reject_step("s3"); + + // Approve all (should only affect pending) + plan.approve_all(); + + assert!(plan.steps[0].is_approved()); // was pending, now approved + assert!(plan.steps[1].is_approved()); // was pending, now approved + assert!(plan.steps[2].is_rejected()); // was rejected, stays rejected +} + +/// Test plan with name +#[test] +fn test_named_plan() { + let plan = AccumulatedPlan::with_name("Fix bug #123".to_string()); + assert_eq!(plan.name, Some("Fix bug #123".to_string())); +} + +/// Test step rationale tracking +#[test] +fn test_step_rationale() { + let step = PlanStep::new("id".to_string(), 1, "read".to_string(), json!({})) + .with_rationale("I need to understand the code structure".to_string()); + + assert_eq!( + step.rationale, + Some("I need to understand the code structure".to_string()) + ); +} + +/// Test filtering steps by approval status +#[test] +fn test_step_filtering() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("a".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("b".to_string(), "write".to_string(), json!({})); + plan.add_step_for_current_turn("c".to_string(), "bash".to_string(), json!({})); + + plan.approve_step("a"); + plan.reject_step("b"); + // c remains pending + + assert_eq!(plan.approved_steps().len(), 1); + assert_eq!(plan.rejected_steps().len(), 1); + assert_eq!(plan.pending_steps().len(), 1); +} diff --git a/crates/core/agent/tests/tool_context.rs b/crates/core/agent/tests/tool_context.rs index b96e5be..c3d2e96 100644 --- a/crates/core/agent/tests/tool_context.rs +++ b/crates/core/agent/tests/tool_context.rs @@ -1,9 +1,12 @@ // Test that ToolContext properly wires up the placeholder tools -use agent_core::{ToolContext, execute_tool}; +use agent_core::{ToolContext, execute_tool, get_tool_definitions_with_external}; use permissions::{Mode, PermissionManager}; +use plugins::{ExternalToolDefinition, ExternalToolTransport, ExternalToolSchema}; use tools_todo::{TodoList, TodoStatus}; use tools_bash::ShellManager; use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; #[tokio::test] async fn test_todo_write_with_context() { @@ -112,3 +115,110 @@ async fn test_ask_user_without_context() { assert!(result.is_err(), "AskUser should fail without AskSender"); assert!(result.unwrap_err().to_string().contains("not available")); } + +// ============================================================================ +// External Tools Tests +// ============================================================================ + +fn create_test_external_tool(name: &str, description: &str) -> ExternalToolDefinition { + ExternalToolDefinition { + name: name.to_string(), + description: description.to_string(), + transport: ExternalToolTransport::Stdio, + command: Some("echo".to_string()), + args: vec![], + url: None, + timeout_ms: 5000, + input_schema: ExternalToolSchema { + schema_type: "object".to_string(), + properties: { + let mut props = HashMap::new(); + props.insert( + "input".to_string(), + json!({"type": "string", "description": "Test input"}), + ); + props + }, + required: vec!["input".to_string()], + }, + source_path: PathBuf::from("/test/plugin"), + plugin_name: "test-plugin".to_string(), + } +} + +#[tokio::test] +async fn test_with_external_tools() { + let mut ext_tools = HashMap::new(); + ext_tools.insert( + "my_custom_tool".to_string(), + create_test_external_tool("my_custom_tool", "A custom tool for testing"), + ); + + let ctx = ToolContext::new().with_external_tools(ext_tools); + + assert!(ctx.has_external_tool("my_custom_tool")); + assert!(!ctx.has_external_tool("nonexistent")); + + let tool = ctx.get_external_tool("my_custom_tool").unwrap(); + assert_eq!(tool.name, "my_custom_tool"); + assert_eq!(tool.description, "A custom tool for testing"); +} + +#[tokio::test] +async fn test_get_tool_definitions_with_external() { + let mut ext_tools = HashMap::new(); + ext_tools.insert( + "external_analyzer".to_string(), + create_test_external_tool("external_analyzer", "Analyze stuff externally"), + ); + ext_tools.insert( + "external_formatter".to_string(), + create_test_external_tool("external_formatter", "Format things externally"), + ); + + let ctx = ToolContext::new().with_external_tools(ext_tools); + let all_tools = get_tool_definitions_with_external(&ctx); + + // Should have built-in tools plus our 2 external tools + let external_tool_names: Vec<_> = all_tools + .iter() + .filter(|t| t.function.name == "external_analyzer" || t.function.name == "external_formatter") + .collect(); + assert_eq!(external_tool_names.len(), 2); + + // Verify external tool schema is correct + let analyzer = all_tools.iter().find(|t| t.function.name == "external_analyzer").unwrap(); + assert!(analyzer.function.description.contains("Analyze stuff")); +} + +#[tokio::test] +async fn test_unknown_tool_without_external() { + let ctx = ToolContext::new(); + let perms = PermissionManager::new(Mode::Code); + + let arguments = json!({"input": "test"}); + let result = execute_tool("completely_unknown_tool", &arguments, &perms, &ctx).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unknown tool")); +} + +#[tokio::test] +async fn test_external_tool_permission_denied() { + let mut ext_tools = HashMap::new(); + ext_tools.insert( + "dangerous_tool".to_string(), + create_test_external_tool("dangerous_tool", "A dangerous tool"), + ); + + let ctx = ToolContext::new().with_external_tools(ext_tools); + let perms = PermissionManager::new(Mode::Plan); // Plan mode denies bash-like tools + + let arguments = json!({"input": "test"}); + let result = execute_tool("dangerous_tool", &arguments, &perms, &ctx).await; + + // External tools are treated like Bash, so should require permission in Plan mode + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Permission required") || err.contains("Permission denied")); +} diff --git a/crates/integration/jsonrpc/Cargo.toml b/crates/integration/jsonrpc/Cargo.toml new file mode 100644 index 0000000..9eaeb8b --- /dev/null +++ b/crates/integration/jsonrpc/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jsonrpc" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "JSON-RPC 2.0 client for external tool communication" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["process", "io-util", "time", "sync"] } +color-eyre = "0.6" +uuid = { version = "1.0", features = ["v4"] } + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/crates/integration/jsonrpc/src/lib.rs b/crates/integration/jsonrpc/src/lib.rs new file mode 100644 index 0000000..6c7c997 --- /dev/null +++ b/crates/integration/jsonrpc/src/lib.rs @@ -0,0 +1,391 @@ +//! JSON-RPC 2.0 client for external tool communication +//! +//! This crate provides a JSON-RPC 2.0 client for invoking external tools +//! via stdio (spawning a process) or HTTP endpoints. + +use color_eyre::eyre::{eyre, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::time::timeout; +use uuid::Uuid; + +// ============================================================================ +// JSON-RPC 2.0 Protocol Types +// ============================================================================ + +/// JSON-RPC 2.0 request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + /// JSON-RPC version (must be "2.0") + pub jsonrpc: String, + /// Request ID for matching responses + pub id: serde_json::Value, + /// Method name to invoke + pub method: String, + /// Method parameters + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcRequest { + /// Create a new JSON-RPC request + pub fn new(method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: serde_json::Value::String(Uuid::new_v4().to_string()), + method: method.into(), + params, + } + } + + /// Create a request with a specific ID + pub fn with_id(id: impl Into, method: impl Into, params: Option) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id: id.into(), + method: method.into(), + params, + } + } + + /// Serialize to JSON string with newline + pub fn to_json_line(&self) -> Result { + let mut json = serde_json::to_string(self)?; + json.push('\n'); + Ok(json) + } +} + +/// JSON-RPC 2.0 response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + /// JSON-RPC version (must be "2.0") + pub jsonrpc: String, + /// Request ID this is responding to + pub id: serde_json::Value, + /// Result (on success) + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Error (on failure) + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + /// Check if the response is an error + pub fn is_error(&self) -> bool { + self.error.is_some() + } + + /// Get the result or error as a Result type + pub fn into_result(self) -> Result { + if let Some(err) = self.error { + Err(eyre!("JSON-RPC error {}: {}", err.code, err.message)) + } else { + self.result.ok_or_else(|| eyre!("No result in response")) + } + } +} + +/// JSON-RPC 2.0 error +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + /// Error code (standard or application-specific) + pub code: i64, + /// Short error message + pub message: String, + /// Additional error data + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// Standard JSON-RPC 2.0 error codes +pub mod error_codes { + /// Invalid JSON was received + pub const PARSE_ERROR: i64 = -32700; + /// The JSON sent is not a valid Request object + pub const INVALID_REQUEST: i64 = -32600; + /// The method does not exist or is not available + pub const METHOD_NOT_FOUND: i64 = -32601; + /// Invalid method parameter(s) + pub const INVALID_PARAMS: i64 = -32602; + /// Internal JSON-RPC error + pub const INTERNAL_ERROR: i64 = -32603; +} + +// ============================================================================ +// Stdio Client +// ============================================================================ + +/// JSON-RPC client over stdio (spawned process) +pub struct StdioClient { + /// Spawned child process + child: Child, + /// Request timeout + timeout: Duration, +} + +impl StdioClient { + /// Spawn a new stdio client + pub async fn spawn( + command: impl AsRef, + args: &[String], + env: &HashMap, + timeout_ms: u64, + ) -> Result { + let mut cmd = Command::new(command.as_ref()); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + // Add environment variables + for (key, value) in env { + cmd.env(key, value); + } + + let child = cmd.spawn()?; + + Ok(Self { + child, + timeout: Duration::from_millis(timeout_ms), + }) + } + + /// Spawn from a path (resolving relative paths) + pub async fn spawn_from_path( + command: &PathBuf, + args: &[String], + env: &HashMap, + working_dir: Option<&PathBuf>, + timeout_ms: u64, + ) -> Result { + let mut cmd = Command::new(command); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + if let Some(dir) = working_dir { + cmd.current_dir(dir); + } + + for (key, value) in env { + cmd.env(key, value); + } + + let child = cmd.spawn()?; + + Ok(Self { + child, + timeout: Duration::from_millis(timeout_ms), + }) + } + + /// Send a request and wait for response + pub async fn call(&mut self, method: &str, params: Option) -> Result { + let request = JsonRpcRequest::new(method, params); + let request_id = request.id.clone(); + + // Get handles to stdin/stdout + let stdin = self.child.stdin.as_mut() + .ok_or_else(|| eyre!("Failed to get stdin handle"))?; + let stdout = self.child.stdout.take() + .ok_or_else(|| eyre!("Failed to get stdout handle"))?; + + // Write request + let request_json = request.to_json_line()?; + stdin.write_all(request_json.as_bytes()).await?; + stdin.flush().await?; + + // Read response with timeout + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + + let result = timeout(self.timeout, reader.read_line(&mut line)).await; + + // Restore stdout for future calls + self.child.stdout = Some(reader.into_inner()); + + match result { + Ok(Ok(0)) => Err(eyre!("Process closed stdout")), + Ok(Ok(_)) => { + let response: JsonRpcResponse = serde_json::from_str(&line)?; + + // Verify ID matches + if response.id != request_id { + return Err(eyre!( + "Response ID mismatch: expected {:?}, got {:?}", + request_id, + response.id + )); + } + + response.into_result() + } + Ok(Err(e)) => Err(e.into()), + Err(_) => Err(eyre!("Request timed out after {:?}", self.timeout)), + } + } + + /// Check if the process is still running + pub fn is_alive(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + /// Kill the process + pub async fn kill(&mut self) -> Result<()> { + self.child.kill().await?; + Ok(()) + } +} + +impl Drop for StdioClient { + fn drop(&mut self) { + // Try to kill the process on drop + let _ = self.child.start_kill(); + } +} + +// ============================================================================ +// Tool Executor +// ============================================================================ + +/// Executor for external tools via JSON-RPC +pub struct ToolExecutor { + /// Default timeout for tool calls + default_timeout: Duration, +} + +impl Default for ToolExecutor { + fn default() -> Self { + Self { + default_timeout: Duration::from_secs(30), + } + } +} + +impl ToolExecutor { + /// Create a new tool executor with custom timeout + pub fn with_timeout(timeout_ms: u64) -> Self { + Self { + default_timeout: Duration::from_millis(timeout_ms), + } + } + + /// Execute a tool via stdio + pub async fn execute_stdio( + &self, + command: &str, + args: &[String], + env: &HashMap, + tool_params: serde_json::Value, + timeout_ms: Option, + ) -> Result { + let timeout = timeout_ms.unwrap_or(self.default_timeout.as_millis() as u64); + + let mut client = StdioClient::spawn(command, args, env, timeout).await?; + + // The standard method name for tool execution + let result = client.call("execute", Some(tool_params)).await?; + + // Kill the process after we're done + let _ = client.kill().await; + + Ok(result) + } + + /// Execute a tool via HTTP (not implemented yet) + pub async fn execute_http( + &self, + _url: &str, + _tool_params: serde_json::Value, + _timeout_ms: Option, + ) -> Result { + Err(eyre!("HTTP transport not yet implemented")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_jsonrpc_request_serialization() { + let request = JsonRpcRequest::new("test_method", Some(serde_json::json!({"arg": "value"}))); + + assert_eq!(request.jsonrpc, "2.0"); + assert_eq!(request.method, "test_method"); + assert!(request.params.is_some()); + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"jsonrpc\":\"2.0\"")); + assert!(json.contains("\"method\":\"test_method\"")); + } + + #[test] + fn test_jsonrpc_response_success() { + let json = r#"{"jsonrpc":"2.0","id":"123","result":{"data":"hello"}}"#; + let response: JsonRpcResponse = serde_json::from_str(json).unwrap(); + + assert!(!response.is_error()); + assert_eq!(response.id, serde_json::json!("123")); + assert_eq!(response.result.unwrap()["data"], "hello"); + } + + #[test] + fn test_jsonrpc_response_error() { + let json = r#"{"jsonrpc":"2.0","id":"123","error":{"code":-32600,"message":"Invalid Request"}}"#; + let response: JsonRpcResponse = serde_json::from_str(json).unwrap(); + + assert!(response.is_error()); + let err = response.error.unwrap(); + assert_eq!(err.code, error_codes::INVALID_REQUEST); + assert_eq!(err.message, "Invalid Request"); + } + + #[test] + fn test_jsonrpc_request_line_format() { + let request = JsonRpcRequest::with_id("test-id", "method", None); + let line = request.to_json_line().unwrap(); + + assert!(line.ends_with('\n')); + assert!(!line.ends_with("\n\n")); + } + + #[test] + fn test_response_into_result_success() { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("1"), + result: Some(serde_json::json!({"success": true})), + error: None, + }; + + let result = response.into_result().unwrap(); + assert_eq!(result["success"], true); + } + + #[test] + fn test_response_into_result_error() { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("1"), + result: None, + error: Some(JsonRpcError { + code: -32600, + message: "Invalid".to_string(), + data: None, + }), + }; + + let result = response.into_result(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("-32600")); + } +} diff --git a/crates/platform/plugins/src/lib.rs b/crates/platform/plugins/src/lib.rs index c225655..39ccf2d 100644 --- a/crates/platform/plugins/src/lib.rs +++ b/crates/platform/plugins/src/lib.rs @@ -637,6 +637,215 @@ impl Default for PluginManager { } } +// ============================================================================ +// External Tool Support (Phase 4) +// ============================================================================ + +/// Transport type for external tools +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ExternalToolTransport { + /// JSON-RPC 2.0 over stdin/stdout + #[default] + Stdio, + /// HTTP JSON-RPC + Http, +} + +/// JSON Schema for tool arguments (simplified) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalToolSchema { + /// Type should be "object" for tool arguments + #[serde(rename = "type")] + pub schema_type: String, + /// Required properties + #[serde(default)] + pub required: Vec, + /// Property definitions + #[serde(default)] + pub properties: HashMap, +} + +impl Default for ExternalToolSchema { + fn default() -> Self { + Self { + schema_type: "object".to_string(), + required: Vec::new(), + properties: HashMap::new(), + } + } +} + +/// External tool definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalToolDefinition { + /// Tool name (must be unique) + pub name: String, + + /// Tool description (shown to LLM) + pub description: String, + + /// Transport type (how to invoke) + #[serde(default)] + pub transport: ExternalToolTransport, + + /// Command to execute (for stdio transport) + pub command: Option, + + /// Arguments for the command + #[serde(default)] + pub args: Vec, + + /// URL endpoint (for http transport) + pub url: Option, + + /// Timeout in milliseconds + #[serde(default = "default_timeout")] + pub timeout_ms: u64, + + /// JSON Schema for tool arguments + #[serde(default)] + pub input_schema: ExternalToolSchema, + + /// Source plugin and path + #[serde(skip)] + pub source_path: PathBuf, + + /// Source plugin name + #[serde(skip)] + pub plugin_name: String, +} + +fn default_timeout() -> u64 { + 30000 // 30 seconds +} + +impl ExternalToolDefinition { + /// Resolve command path relative to plugin directory + pub fn resolved_command(&self, plugin_base: &Path) -> Option { + self.command.as_ref().map(|cmd| { + if cmd.starts_with("./") || cmd.starts_with("../") { + plugin_base.join(cmd) + } else { + PathBuf::from(cmd) + } + }) + } + + /// Convert to LLM tool definition format + pub fn to_llm_tool(&self) -> serde_json::Value { + serde_json::json!({ + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.input_schema + } + }) + } +} + +impl Plugin { + /// Get the path to a tool definition file + pub fn tool_path(&self, tool_name: &str) -> PathBuf { + self.base_path.join("tools").join(format!("{}.yaml", tool_name)) + } + + /// Auto-discover tools in the tools/ directory + pub fn discover_tools(&self) -> Vec { + let tools_dir = self.base_path.join("tools"); + if !tools_dir.exists() { + return Vec::new(); + } + + std::fs::read_dir(&tools_dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| { + let path = e.path(); + path.extension().map(|ext| ext == "yaml" || ext == "yml" || ext == "json").unwrap_or(false) + }) + .filter_map(|e| { + e.path().file_stem() + .map(|s| s.to_string_lossy().to_string()) + }) + .collect() + } + + /// Parse a tool definition file + pub fn parse_tool(&self, name: &str) -> Result { + // Try yaml first, then yml, then json + let yaml_path = self.base_path.join("tools").join(format!("{}.yaml", name)); + let yml_path = self.base_path.join("tools").join(format!("{}.yml", name)); + let json_path = self.base_path.join("tools").join(format!("{}.json", name)); + + let (path, content) = if yaml_path.exists() { + (yaml_path.clone(), fs::read_to_string(&yaml_path)?) + } else if yml_path.exists() { + (yml_path.clone(), fs::read_to_string(&yml_path)?) + } else if json_path.exists() { + (json_path.clone(), fs::read_to_string(&json_path)?) + } else { + return Err(eyre!("Tool definition not found for: {}", name)); + }; + + let mut tool: ExternalToolDefinition = if path.extension().map(|e| e == "json").unwrap_or(false) { + serde_json::from_str(&content)? + } else { + serde_yaml::from_str(&content)? + }; + + // Set metadata + tool.source_path = path; + tool.plugin_name = self.manifest.name.clone(); + + Ok(tool) + } + + /// Get all tool names (from manifest + discovered) + pub fn all_tool_names(&self) -> Vec { + let mut names: std::collections::HashSet = std::collections::HashSet::new(); + + // Tools from manifest (if we add that field) + // For now, just use discovery + names.extend(self.discover_tools()); + + names.into_iter().collect() + } +} + +impl PluginManager { + /// Get all external tools from all plugins + pub fn all_external_tools(&self) -> Vec { + let mut tools = Vec::new(); + + for plugin in &self.plugins { + for tool_name in plugin.all_tool_names() { + match plugin.parse_tool(&tool_name) { + Ok(tool) => tools.push(tool), + Err(e) => { + eprintln!("Warning: Failed to parse tool {} from {}: {}", + tool_name, plugin.manifest.name, e); + } + } + } + } + + tools + } + + /// Find a specific external tool by name + pub fn find_external_tool(&self, name: &str) -> Option { + for plugin in &self.plugins { + if let Ok(tool) = plugin.parse_tool(name) { + return Some(tool); + } + } + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -821,4 +1030,100 @@ mod tests { Ok(()) } + + #[test] + fn test_external_tool_discovery() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let plugin_dir = temp_dir.path().join("tool-plugin"); + + // Create plugin structure + fs::create_dir_all(&plugin_dir)?; + fs::create_dir_all(plugin_dir.join("tools"))?; + + // Write manifest + let manifest = PluginManifest { + name: "tool-plugin".to_string(), + version: "1.0.0".to_string(), + description: None, + author: None, + commands: vec![], + agents: vec![], + skills: vec![], + hooks: HashMap::new(), + mcp_servers: vec![], + }; + fs::write( + plugin_dir.join("plugin.json"), + serde_json::to_string_pretty(&manifest)?, + )?; + + // Write tool definition + let tool_yaml = r#" +name: calculator +description: Perform mathematical calculations +transport: stdio +command: python +args: ["-m", "calculator"] +timeout_ms: 5000 +input_schema: + type: object + required: + - expression + properties: + expression: + type: string + description: Mathematical expression to evaluate +"#; + fs::write(plugin_dir.join("tools/calculator.yaml"), tool_yaml)?; + + // Test discovery + let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]); + manager.load_all()?; + + let tools = manager.all_external_tools(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "calculator"); + assert_eq!(tools[0].description, "Perform mathematical calculations"); + assert_eq!(tools[0].transport, ExternalToolTransport::Stdio); + assert_eq!(tools[0].command, Some("python".to_string())); + assert_eq!(tools[0].args, vec!["-m", "calculator"]); + assert_eq!(tools[0].timeout_ms, 5000); + assert_eq!(tools[0].input_schema.required, vec!["expression"]); + + Ok(()) + } + + #[test] + fn test_external_tool_to_llm_format() -> Result<()> { + let tool = ExternalToolDefinition { + name: "file_reader".to_string(), + description: "Read a file from disk".to_string(), + transport: ExternalToolTransport::Stdio, + command: Some("./tools/reader".to_string()), + args: vec![], + url: None, + timeout_ms: 10000, + input_schema: ExternalToolSchema { + schema_type: "object".to_string(), + required: vec!["path".to_string()], + properties: { + let mut props = HashMap::new(); + props.insert("path".to_string(), serde_json::json!({ + "type": "string", + "description": "File path to read" + })); + props + }, + }, + source_path: PathBuf::new(), + plugin_name: "test".to_string(), + }; + + let llm_format = tool.to_llm_tool(); + assert_eq!(llm_format["type"], "function"); + assert_eq!(llm_format["function"]["name"], "file_reader"); + assert_eq!(llm_format["function"]["description"], "Read a file from disk"); + + Ok(()) + } } diff --git a/crates/tools/plan/src/lib.rs b/crates/tools/plan/src/lib.rs index 0f1f90b..cf9e1a6 100644 --- a/crates/tools/plan/src/lib.rs +++ b/crates/tools/plan/src/lib.rs @@ -1,8 +1,12 @@ //! 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. +//! This crate provides two related planning features: +//! +//! 1. **Plan Documents** - Markdown files for describing implementation plans +//! (EnterPlanMode/ExitPlanMode tools) +//! +//! 2. **Plan Execution** - Accumulating proposed tool calls across LLM turns +//! for selective approval before execution (PlanStep, AccumulatedPlan) use color_eyre::eyre::Result; use serde::{Deserialize, Serialize}; @@ -10,6 +14,291 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; use uuid::Uuid; +// ============================================================================ +// Plan Execution Types (Multi-turn tool call accumulation) +// ============================================================================ + +/// A single proposed tool call in an accumulated plan +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanStep { + /// Unique identifier for this step (matches LLM's tool_call_id) + pub id: String, + /// Which LLM turn proposed this step (1-indexed) + pub turn: usize, + /// Tool name to execute + pub tool: String, + /// Arguments for the tool call + pub args: serde_json::Value, + /// LLM's reasoning/rationale for this step (from response content) + pub rationale: Option, + /// User's approval decision: None = pending, Some(true) = approved, Some(false) = rejected + pub approved: Option, +} + +impl PlanStep { + /// Create a new pending plan step + pub fn new(id: String, turn: usize, tool: String, args: serde_json::Value) -> Self { + Self { + id, + turn, + tool, + args, + rationale: None, + approved: None, + } + } + + /// Set the rationale for this step + pub fn with_rationale(mut self, rationale: String) -> Self { + self.rationale = Some(rationale); + self + } + + /// Check if this step is pending approval + pub fn is_pending(&self) -> bool { + self.approved.is_none() + } + + /// Check if this step is approved + pub fn is_approved(&self) -> bool { + self.approved == Some(true) + } + + /// Check if this step is rejected + pub fn is_rejected(&self) -> bool { + self.approved == Some(false) + } +} + +/// Status of an accumulated plan +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum AccumulatedPlanStatus { + /// Agent is proposing steps (accumulating) + #[default] + Accumulating, + /// User is reviewing the plan + Reviewing, + /// Approved steps are being executed + Executing, + /// All approved steps have been executed + Completed, + /// Plan was cancelled by user + Cancelled, +} + +/// An accumulated plan of proposed tool calls +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccumulatedPlan { + /// Unique identifier for this plan + pub id: String, + /// Optional user-provided name + pub name: Option, + /// When the plan was started + pub created_at: DateTime, + /// Current status + pub status: AccumulatedPlanStatus, + /// Accumulated steps across LLM turns + pub steps: Vec, + /// Current LLM turn counter + pub current_turn: usize, +} + +impl Default for AccumulatedPlan { + fn default() -> Self { + Self::new() + } +} + +impl AccumulatedPlan { + /// Create a new empty accumulated plan + pub fn new() -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: None, + created_at: Utc::now(), + status: AccumulatedPlanStatus::Accumulating, + steps: Vec::new(), + current_turn: 0, + } + } + + /// Create a plan with a specific name + pub fn with_name(name: String) -> Self { + let mut plan = Self::new(); + plan.name = Some(name); + plan + } + + /// Add a step to the plan + pub fn add_step(&mut self, step: PlanStep) { + self.steps.push(step); + } + + /// Start a new turn (increment turn counter) + pub fn next_turn(&mut self) { + self.current_turn += 1; + } + + /// Add a step for the current turn + pub fn add_step_for_current_turn(&mut self, id: String, tool: String, args: serde_json::Value) { + let step = PlanStep::new(id, self.current_turn, tool, args); + self.steps.push(step); + } + + /// Add a step with rationale for the current turn + pub fn add_step_with_rationale( + &mut self, + id: String, + tool: String, + args: serde_json::Value, + rationale: String, + ) { + let step = PlanStep::new(id, self.current_turn, tool, args).with_rationale(rationale); + self.steps.push(step); + } + + /// Get all pending steps + pub fn pending_steps(&self) -> Vec<&PlanStep> { + self.steps.iter().filter(|s| s.is_pending()).collect() + } + + /// Get all approved steps + pub fn approved_steps(&self) -> Vec<&PlanStep> { + self.steps.iter().filter(|s| s.is_approved()).collect() + } + + /// Get all rejected steps + pub fn rejected_steps(&self) -> Vec<&PlanStep> { + self.steps.iter().filter(|s| s.is_rejected()).collect() + } + + /// Approve a step by ID + pub fn approve_step(&mut self, id: &str) -> bool { + if let Some(step) = self.steps.iter_mut().find(|s| s.id == id) { + step.approved = Some(true); + true + } else { + false + } + } + + /// Reject a step by ID + pub fn reject_step(&mut self, id: &str) -> bool { + if let Some(step) = self.steps.iter_mut().find(|s| s.id == id) { + step.approved = Some(false); + true + } else { + false + } + } + + /// Approve all pending steps + pub fn approve_all(&mut self) { + for step in &mut self.steps { + if step.approved.is_none() { + step.approved = Some(true); + } + } + } + + /// Reject all pending steps + pub fn reject_all(&mut self) { + for step in &mut self.steps { + if step.approved.is_none() { + step.approved = Some(false); + } + } + } + + /// Check if all steps have been decided (no pending) + pub fn all_decided(&self) -> bool { + self.steps.iter().all(|s| s.approved.is_some()) + } + + /// Get step count by approval status + pub fn counts(&self) -> (usize, usize, usize) { + let pending = self.steps.iter().filter(|s| s.is_pending()).count(); + let approved = self.steps.iter().filter(|s| s.is_approved()).count(); + let rejected = self.steps.iter().filter(|s| s.is_rejected()).count(); + (pending, approved, rejected) + } + + /// Transition to reviewing status + pub fn finalize(&mut self) { + self.status = AccumulatedPlanStatus::Reviewing; + } + + /// Transition to executing status + pub fn start_execution(&mut self) { + self.status = AccumulatedPlanStatus::Executing; + } + + /// Transition to completed status + pub fn complete(&mut self) { + self.status = AccumulatedPlanStatus::Completed; + } + + /// Cancel the plan + pub fn cancel(&mut self) { + self.status = AccumulatedPlanStatus::Cancelled; + } +} + +/// User's approval decisions for plan steps +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanApproval { + /// IDs of steps that were approved + pub approved_ids: Vec, + /// IDs of steps that were rejected + pub rejected_ids: Vec, +} + +impl PlanApproval { + /// Create a new approval with empty lists + pub fn new() -> Self { + Self { + approved_ids: Vec::new(), + rejected_ids: Vec::new(), + } + } + + /// Create an approval that approves all given IDs + pub fn approve_all(ids: Vec) -> Self { + Self { + approved_ids: ids, + rejected_ids: Vec::new(), + } + } + + /// Create an approval that rejects all given IDs + pub fn reject_all(ids: Vec) -> Self { + Self { + approved_ids: Vec::new(), + rejected_ids: ids, + } + } + + /// Apply this approval to a plan + pub fn apply_to(&self, plan: &mut AccumulatedPlan) { + for id in &self.approved_ids { + plan.approve_step(id); + } + for id in &self.rejected_ids { + plan.reject_step(id); + } + } +} + +impl Default for PlanApproval { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Plan Document Types (Original functionality) +// ============================================================================ + /// Agent mode - normal execution or planning #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum AgentMode { @@ -218,6 +507,75 @@ impl PlanManager { plans.sort(); Ok(plans) } + + // ======================================================================== + // AccumulatedPlan persistence methods + // ======================================================================== + + /// Get the directory for accumulated plans (JSON files) + fn accumulated_plans_dir(&self) -> PathBuf { + self.plans_dir.join("accumulated") + } + + /// Save an accumulated plan to disk + pub async fn save_accumulated_plan(&self, plan: &AccumulatedPlan) -> Result { + let dir = self.accumulated_plans_dir(); + tokio::fs::create_dir_all(&dir).await?; + + let filename = format!("{}.json", plan.id); + let path = dir.join(&filename); + + let json = serde_json::to_string_pretty(plan)?; + tokio::fs::write(&path, json).await?; + + Ok(path) + } + + /// Load an accumulated plan by ID + pub async fn load_accumulated_plan(&self, id: &str) -> Result { + let path = self.accumulated_plans_dir().join(format!("{}.json", id)); + let content = tokio::fs::read_to_string(&path).await?; + let plan: AccumulatedPlan = serde_json::from_str(&content)?; + Ok(plan) + } + + /// List all accumulated plans + pub async fn list_accumulated_plans(&self) -> Result> { + let dir = self.accumulated_plans_dir(); + let mut plans = Vec::new(); + + if !dir.exists() { + return Ok(plans); + } + + let mut entries = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Ok(content) = tokio::fs::read_to_string(&path).await { + if let Ok(plan) = serde_json::from_str(&content) { + plans.push(plan); + } + } + } + } + + // Sort by creation time (newest first) + plans.sort_by(|a: &AccumulatedPlan, b: &AccumulatedPlan| { + b.created_at.cmp(&a.created_at) + }); + + Ok(plans) + } + + /// Delete an accumulated plan + pub async fn delete_accumulated_plan(&self, id: &str) -> Result<()> { + let path = self.accumulated_plans_dir().join(format!("{}.json", id)); + if path.exists() { + tokio::fs::remove_file(&path).await?; + } + Ok(()) + } } /// Enter planning mode @@ -247,6 +605,11 @@ pub fn is_tool_allowed_in_plan_mode(tool_name: &str) -> bool { mod tests { use super::*; use tempfile::TempDir; + use serde_json::json; + + // ======================================================================== + // Plan Document Tests + // ======================================================================== #[tokio::test] async fn test_create_plan() { @@ -288,4 +651,231 @@ mod tests { assert!(!mode.is_planning()); assert!(mode.plan_file().is_none()); } + + // ======================================================================== + // Plan Step Tests + // ======================================================================== + + #[test] + fn test_plan_step_new() { + let step = PlanStep::new( + "call_1".to_string(), + 1, + "read".to_string(), + json!({"path": "/src/main.rs"}), + ); + + assert_eq!(step.id, "call_1"); + assert_eq!(step.turn, 1); + assert_eq!(step.tool, "read"); + assert!(step.is_pending()); + assert!(!step.is_approved()); + assert!(!step.is_rejected()); + } + + #[test] + fn test_plan_step_with_rationale() { + let step = PlanStep::new( + "call_1".to_string(), + 1, + "read".to_string(), + json!({"path": "/src/main.rs"}), + ).with_rationale("Need to read the main entry point".to_string()); + + assert_eq!(step.rationale, Some("Need to read the main entry point".to_string())); + } + + // ======================================================================== + // Accumulated Plan Tests + // ======================================================================== + + #[test] + fn test_accumulated_plan_new() { + let plan = AccumulatedPlan::new(); + assert!(!plan.id.is_empty()); + assert!(plan.name.is_none()); + assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating); + assert!(plan.steps.is_empty()); + assert_eq!(plan.current_turn, 0); + } + + #[test] + fn test_accumulated_plan_with_name() { + let plan = AccumulatedPlan::with_name("Fix bug #123".to_string()); + assert_eq!(plan.name, Some("Fix bug #123".to_string())); + } + + #[test] + fn test_accumulated_plan_add_steps() { + let mut plan = AccumulatedPlan::new(); + + plan.next_turn(); + plan.add_step_for_current_turn( + "call_1".to_string(), + "read".to_string(), + json!({"path": "src/main.rs"}), + ); + + plan.next_turn(); + plan.add_step_with_rationale( + "call_2".to_string(), + "edit".to_string(), + json!({"path": "src/main.rs", "old": "foo", "new": "bar"}), + "Fix the typo".to_string(), + ); + + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].turn, 1); + assert_eq!(plan.steps[1].turn, 2); + assert!(plan.steps[1].rationale.is_some()); + } + + #[test] + fn test_accumulated_plan_approval() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({})); + plan.add_step_for_current_turn("call_3".to_string(), "bash".to_string(), json!({})); + + // Initial state: all pending + let (pending, approved, rejected) = plan.counts(); + assert_eq!((pending, approved, rejected), (3, 0, 0)); + + // Approve one, reject one + plan.approve_step("call_1"); + plan.reject_step("call_3"); + + let (pending, approved, rejected) = plan.counts(); + assert_eq!((pending, approved, rejected), (1, 1, 1)); + + assert!(!plan.all_decided()); + + // Approve remaining + plan.approve_step("call_2"); + assert!(plan.all_decided()); + } + + #[test] + fn test_accumulated_plan_approve_all() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({})); + + plan.approve_all(); + + assert!(plan.all_decided()); + assert_eq!(plan.approved_steps().len(), 2); + } + + #[test] + fn test_accumulated_plan_status_transitions() { + let mut plan = AccumulatedPlan::new(); + assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating); + + plan.finalize(); + assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing); + + plan.start_execution(); + assert_eq!(plan.status, AccumulatedPlanStatus::Executing); + + plan.complete(); + assert_eq!(plan.status, AccumulatedPlanStatus::Completed); + } + + #[test] + fn test_accumulated_plan_cancel() { + let mut plan = AccumulatedPlan::new(); + plan.cancel(); + assert_eq!(plan.status, AccumulatedPlanStatus::Cancelled); + } + + // ======================================================================== + // Plan Approval Tests + // ======================================================================== + + #[test] + fn test_plan_approval_apply() { + let mut plan = AccumulatedPlan::new(); + plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({})); + plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({})); + plan.add_step_for_current_turn("call_3".to_string(), "bash".to_string(), json!({})); + + let approval = PlanApproval { + approved_ids: vec!["call_1".to_string(), "call_2".to_string()], + rejected_ids: vec!["call_3".to_string()], + }; + + approval.apply_to(&mut plan); + + assert!(plan.steps[0].is_approved()); + assert!(plan.steps[1].is_approved()); + assert!(plan.steps[2].is_rejected()); + } + + #[test] + fn test_plan_approval_helpers() { + let ids = vec!["a".to_string(), "b".to_string()]; + + let approval = PlanApproval::approve_all(ids.clone()); + assert_eq!(approval.approved_ids, ids); + assert!(approval.rejected_ids.is_empty()); + + let rejection = PlanApproval::reject_all(ids.clone()); + assert!(rejection.approved_ids.is_empty()); + assert_eq!(rejection.rejected_ids, ids); + } + + // ======================================================================== + // Accumulated Plan Persistence Tests + // ======================================================================== + + #[tokio::test] + async fn test_save_and_load_accumulated_plan() { + let temp_dir = TempDir::new().unwrap(); + let manager = PlanManager::new(temp_dir.path().to_path_buf()); + + let mut plan = AccumulatedPlan::with_name("Test Plan".to_string()); + plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({"path": "test.txt"})); + plan.approve_step("call_1"); + + let path = manager.save_accumulated_plan(&plan).await.unwrap(); + assert!(path.exists()); + + let loaded = manager.load_accumulated_plan(&plan.id).await.unwrap(); + assert_eq!(loaded.id, plan.id); + assert_eq!(loaded.name, Some("Test Plan".to_string())); + assert_eq!(loaded.steps.len(), 1); + assert!(loaded.steps[0].is_approved()); + } + + #[tokio::test] + async fn test_list_accumulated_plans() { + let temp_dir = TempDir::new().unwrap(); + let manager = PlanManager::new(temp_dir.path().to_path_buf()); + + // Create two plans + let plan1 = AccumulatedPlan::with_name("Plan 1".to_string()); + let plan2 = AccumulatedPlan::with_name("Plan 2".to_string()); + + manager.save_accumulated_plan(&plan1).await.unwrap(); + manager.save_accumulated_plan(&plan2).await.unwrap(); + + let plans = manager.list_accumulated_plans().await.unwrap(); + assert_eq!(plans.len(), 2); + } + + #[tokio::test] + async fn test_delete_accumulated_plan() { + let temp_dir = TempDir::new().unwrap(); + let manager = PlanManager::new(temp_dir.path().to_path_buf()); + + let plan = AccumulatedPlan::new(); + let id = plan.id.clone(); + + manager.save_accumulated_plan(&plan).await.unwrap(); + assert!(manager.load_accumulated_plan(&id).await.is_ok()); + + manager.delete_accumulated_plan(&id).await.unwrap(); + assert!(manager.load_accumulated_plan(&id).await.is_err()); + } }