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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user