feat(plan): Add plan execution system with external tool support
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 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ members = [
|
||||
"crates/tools/todo",
|
||||
"crates/tools/web",
|
||||
"crates/integration/mcp-client",
|
||||
"crates/integration/jsonrpc",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<String> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<PermissionPopup>,
|
||||
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 <name>, /clear, /theme <name>
|
||||
&self.todo_list
|
||||
}
|
||||
|
||||
/// Get the current accumulated plan from shared state (if any)
|
||||
fn get_current_plan(&self) -> Option<tools_plan::AccumulatedPlan> {
|
||||
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 <name>, /clear, /theme <name>
|
||||
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 <name>, /clear, /theme <name>
|
||||
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 <name>, /clear, /theme <name>
|
||||
}
|
||||
);
|
||||
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 <name>, /clear, /theme <name>
|
||||
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 <name>, /clear, /theme <name>
|
||||
"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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
322
crates/app/ui/src/components/plan_panel.rs
Normal file
322
crates/app/ui/src/components/plan_panel.rs
Normal file
@@ -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<Line> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<RwLock<AgentMode>>,
|
||||
|
||||
/// Registry of external tools from plugins
|
||||
pub external_tools: Arc<HashMap<String, ExternalToolDefinition>>,
|
||||
}
|
||||
|
||||
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<String, ExternalToolDefinition>) -> 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<Tool> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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<String, Value> = 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<Tool> {
|
||||
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<String> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,3 +335,59 @@ impl CheckpointManager {
|
||||
Ok(restored_files)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to accumulate streaming tool call deltas
|
||||
#[derive(Default)]
|
||||
pub struct ToolCallsBuilder {
|
||||
calls: Vec<PartialToolCallBuilder>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PartialToolCallBuilder {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
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<llm_core::ToolCall> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
200
crates/core/agent/tests/plan_mode.rs
Normal file
200
crates/core/agent/tests/plan_mode.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
17
crates/integration/jsonrpc/Cargo.toml
Normal file
17
crates/integration/jsonrpc/Cargo.toml
Normal file
@@ -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"] }
|
||||
391
crates/integration/jsonrpc/src/lib.rs
Normal file
391
crates/integration/jsonrpc/src/lib.rs
Normal file
@@ -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<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcRequest {
|
||||
/// Create a new JSON-RPC request
|
||||
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> 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<serde_json::Value>, method: impl Into<String>, params: Option<serde_json::Value>) -> 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<String> {
|
||||
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<serde_json::Value>,
|
||||
/// Error (on failure)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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<str>,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Self> {
|
||||
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<String, String>,
|
||||
working_dir: Option<&PathBuf>,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Self> {
|
||||
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<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
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<String, String>,
|
||||
tool_params: serde_json::Value,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<u64>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Property definitions
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
|
||||
/// Arguments for the command
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
|
||||
/// URL endpoint (for http transport)
|
||||
pub url: Option<String>,
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<String> {
|
||||
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<ExternalToolDefinition> {
|
||||
// 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<String> {
|
||||
let mut names: std::collections::HashSet<String> = 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<ExternalToolDefinition> {
|
||||
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<ExternalToolDefinition> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// User's approval decision: None = pending, Some(true) = approved, Some(false) = rejected
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// When the plan was started
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Current status
|
||||
pub status: AccumulatedPlanStatus,
|
||||
/// Accumulated steps across LLM turns
|
||||
pub steps: Vec<PlanStep>,
|
||||
/// 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<String>,
|
||||
/// IDs of steps that were rejected
|
||||
pub rejected_ids: Vec<String>,
|
||||
}
|
||||
|
||||
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<String>) -> Self {
|
||||
Self {
|
||||
approved_ids: ids,
|
||||
rejected_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an approval that rejects all given IDs
|
||||
pub fn reject_all(ids: Vec<String>) -> 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<PathBuf> {
|
||||
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<AccumulatedPlan> {
|
||||
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<Vec<AccumulatedPlan>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user