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

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