diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 33b24e9..72a2fba 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -8,13 +8,13 @@ use api::{ MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; -use plugins::PluginTool; +use plugins::{PluginManager, PluginManagerConfig, PluginTool}; use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, - ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, - ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, - RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, + ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, + ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, + PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1700,13 +1700,15 @@ fn build_agent_runtime( .clone() .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string()); let allowed_tools = job.allowed_tools.clone(); - let api_client = AnthropicRuntimeClient::new(model, allowed_tools.clone())?; - let tool_executor = SubagentToolExecutor::new(allowed_tools); + let tool_registry = current_tool_registry()?; + let api_client = + AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?; + let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone()); Ok(ConversationRuntime::new( Session::new(), api_client, tool_executor, - agent_permission_policy(), + agent_permission_policy(&tool_registry), job.system_prompt.clone(), )) } @@ -1815,10 +1817,12 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { tools.into_iter().map(str::to_string).collect() } -fn agent_permission_policy() -> PermissionPolicy { - mvp_tool_specs().into_iter().fold( +fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy { + tool_registry.permission_specs(None).into_iter().fold( PermissionPolicy::new(PermissionMode::DangerFullAccess), - |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, ) } @@ -1874,10 +1878,15 @@ struct AnthropicRuntimeClient { client: AnthropicClient, model: String, allowed_tools: BTreeSet, + tool_registry: GlobalToolRegistry, } impl AnthropicRuntimeClient { - fn new(model: String, allowed_tools: BTreeSet) -> Result { + fn new( + model: String, + allowed_tools: BTreeSet, + tool_registry: GlobalToolRegistry, + ) -> Result { let client = AnthropicClient::from_env() .map_err(|error| error.to_string())? .with_base_url(read_base_url()); @@ -1886,20 +1895,14 @@ impl AnthropicRuntimeClient { client, model, allowed_tools, + tool_registry, }) } } impl ApiClient for AnthropicRuntimeClient { fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools)) - .into_iter() - .map(|spec| ToolDefinition { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: spec.input_schema, - }) - .collect::>(); + let tools = self.tool_registry.definitions(Some(&self.allowed_tools)); let message_request = MessageRequest { model: self.model.clone(), max_tokens: 32_000, @@ -2002,32 +2005,82 @@ impl ApiClient for AnthropicRuntimeClient { struct SubagentToolExecutor { allowed_tools: BTreeSet, + tool_registry: GlobalToolRegistry, } impl SubagentToolExecutor { - fn new(allowed_tools: BTreeSet) -> Self { - Self { allowed_tools } + fn new(allowed_tools: BTreeSet, tool_registry: GlobalToolRegistry) -> Self { + Self { + allowed_tools, + tool_registry, + } } } impl ToolExecutor for SubagentToolExecutor { fn execute(&mut self, tool_name: &str, input: &str) -> Result { - if !self.allowed_tools.contains(tool_name) { + let entry = self + .tool_registry + .find_entry(tool_name) + .ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?; + if !self.allowed_tools.contains(entry.definition.name.as_str()) { return Err(ToolError::new(format!( "tool `{tool_name}` is not enabled for this sub-agent" ))); } let value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - execute_tool(tool_name, &value).map_err(ToolError::new) + self.tool_registry + .execute(tool_name, &value) + .map_err(ToolError::new) } } -fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet>) -> Vec { - mvp_tool_specs() - .into_iter() - .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) - .collect() +fn current_tool_registry() -> Result { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let plugin_tools = plugin_manager + .aggregated_tools() + .map_err(|error| error.to_string())?; + GlobalToolRegistry::with_plugin_tools(plugin_tools) +} + +fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } } fn convert_messages(messages: &[ConversationMessage]) -> Vec { @@ -3142,7 +3195,9 @@ mod tests { AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor, }; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; - use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session}; + use runtime::{ + ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor, + }; use serde_json::json; fn env_lock() -> &'static Mutex<()> { @@ -3221,8 +3276,8 @@ mod tests { "additionalProperties": false }), }, - script.display().to_string(), - Vec::new(), + "sh".to_string(), + vec![script.display().to_string()], PluginToolPermission::WorkspaceWrite, script.parent().map(PathBuf::from), )]) @@ -3300,6 +3355,48 @@ mod tests { let _ = std::fs::remove_file(script); } + #[test] + fn subagent_executor_executes_allowed_plugin_tools() { + let script = temp_path("subagent-plugin-tool.sh"); + std::fs::write( + &script, + "#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n", + ) + .expect("write script"); + make_executable(&script); + + let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( + "demo@external", + "demo", + PluginToolDefinition { + name: "plugin_echo".to_string(), + description: Some("Echo plugin input".to_string()), + input_schema: json!({ + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"], + "additionalProperties": false + }), + }, + script.display().to_string(), + Vec::new(), + PluginToolPermission::WorkspaceWrite, + script.parent().map(PathBuf::from), + )]) + .expect("registry should build"); + + let mut executor = + SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry); + let output = executor + .execute("plugin-echo", r#"{"message":"hello"}"#) + .expect("plugin tool should execute for subagent"); + let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(payload["tool"], "plugin_echo"); + assert_eq!(payload["input"]["message"], "hello"); + + let _ = std::fs::remove_file(script); + } + #[test] fn global_registry_rejects_conflicting_plugin_tool_names() { let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( @@ -3899,8 +3996,11 @@ mod tests { calls: 0, input_path: path.display().to_string(), }, - SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])), - agent_permission_policy(), + SubagentToolExecutor::new( + BTreeSet::from([String::from("read_file")]), + GlobalToolRegistry::builtin(), + ), + agent_permission_policy(&GlobalToolRegistry::builtin()), vec![String::from("system prompt")], );