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:
2025-12-26 22:47:54 +01:00
parent f97bd44f05
commit 84fa08ab45
17 changed files with 2438 additions and 13 deletions

View File

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