|
@@ -8,13 +8,13 @@ use api::{
|
|
|
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
|
|
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
|
|
|
ToolDefinition, ToolResultContentBlock,
|
|
ToolDefinition, ToolResultContentBlock,
|
|
|
};
|
|
};
|
|
|
-use plugins::PluginTool;
|
|
|
|
|
|
|
+use plugins::{PluginManager, PluginManagerConfig, PluginTool};
|
|
|
use reqwest::blocking::Client;
|
|
use reqwest::blocking::Client;
|
|
|
use runtime::{
|
|
use runtime::{
|
|
|
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
|
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::{Deserialize, Serialize};
|
|
|
use serde_json::{json, Value};
|
|
use serde_json::{json, Value};
|
|
@@ -1700,13 +1700,15 @@ fn build_agent_runtime(
|
|
|
.clone()
|
|
.clone()
|
|
|
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
|
|
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
|
|
|
let allowed_tools = job.allowed_tools.clone();
|
|
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(
|
|
Ok(ConversationRuntime::new(
|
|
|
Session::new(),
|
|
Session::new(),
|
|
|
api_client,
|
|
api_client,
|
|
|
tool_executor,
|
|
tool_executor,
|
|
|
- agent_permission_policy(),
|
|
|
|
|
|
|
+ agent_permission_policy(&tool_registry),
|
|
|
job.system_prompt.clone(),
|
|
job.system_prompt.clone(),
|
|
|
))
|
|
))
|
|
|
}
|
|
}
|
|
@@ -1815,10 +1817,12 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
|
|
|
tools.into_iter().map(str::to_string).collect()
|
|
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),
|
|
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,
|
|
client: AnthropicClient,
|
|
|
model: String,
|
|
model: String,
|
|
|
allowed_tools: BTreeSet<String>,
|
|
allowed_tools: BTreeSet<String>,
|
|
|
|
|
+ tool_registry: GlobalToolRegistry,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl AnthropicRuntimeClient {
|
|
impl AnthropicRuntimeClient {
|
|
|
- fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
|
|
|
|
|
|
|
+ fn new(
|
|
|
|
|
+ model: String,
|
|
|
|
|
+ allowed_tools: BTreeSet<String>,
|
|
|
|
|
+ tool_registry: GlobalToolRegistry,
|
|
|
|
|
+ ) -> Result<Self, String> {
|
|
|
let client = AnthropicClient::from_env()
|
|
let client = AnthropicClient::from_env()
|
|
|
.map_err(|error| error.to_string())?
|
|
.map_err(|error| error.to_string())?
|
|
|
.with_base_url(read_base_url());
|
|
.with_base_url(read_base_url());
|
|
@@ -1886,20 +1895,14 @@ impl AnthropicRuntimeClient {
|
|
|
client,
|
|
client,
|
|
|
model,
|
|
model,
|
|
|
allowed_tools,
|
|
allowed_tools,
|
|
|
|
|
+ tool_registry,
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl ApiClient for AnthropicRuntimeClient {
|
|
impl ApiClient for AnthropicRuntimeClient {
|
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, 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::<Vec<_>>();
|
|
|
|
|
|
|
+ let tools = self.tool_registry.definitions(Some(&self.allowed_tools));
|
|
|
let message_request = MessageRequest {
|
|
let message_request = MessageRequest {
|
|
|
model: self.model.clone(),
|
|
model: self.model.clone(),
|
|
|
max_tokens: 32_000,
|
|
max_tokens: 32_000,
|
|
@@ -2002,32 +2005,82 @@ impl ApiClient for AnthropicRuntimeClient {
|
|
|
|
|
|
|
|
struct SubagentToolExecutor {
|
|
struct SubagentToolExecutor {
|
|
|
allowed_tools: BTreeSet<String>,
|
|
allowed_tools: BTreeSet<String>,
|
|
|
|
|
+ tool_registry: GlobalToolRegistry,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl SubagentToolExecutor {
|
|
impl SubagentToolExecutor {
|
|
|
- fn new(allowed_tools: BTreeSet<String>) -> Self {
|
|
|
|
|
- Self { allowed_tools }
|
|
|
|
|
|
|
+ fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
|
|
|
|
|
+ Self {
|
|
|
|
|
+ allowed_tools,
|
|
|
|
|
+ tool_registry,
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl ToolExecutor for SubagentToolExecutor {
|
|
impl ToolExecutor for SubagentToolExecutor {
|
|
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
|
|
- 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!(
|
|
return Err(ToolError::new(format!(
|
|
|
"tool `{tool_name}` is not enabled for this sub-agent"
|
|
"tool `{tool_name}` is not enabled for this sub-agent"
|
|
|
)));
|
|
)));
|
|
|
}
|
|
}
|
|
|
let value = serde_json::from_str(input)
|
|
let value = serde_json::from_str(input)
|
|
|
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
|
.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<String>>) -> Vec<ToolSpec> {
|
|
|
|
|
- mvp_tool_specs()
|
|
|
|
|
- .into_iter()
|
|
|
|
|
- .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
|
|
|
|
- .collect()
|
|
|
|
|
|
|
+fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
|
|
|
|
|
+ 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<InputMessage> {
|
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
|
@@ -3142,7 +3195,9 @@ mod tests {
|
|
|
AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
|
|
AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
|
|
|
};
|
|
};
|
|
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
|
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
|
|
|
- use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
|
|
|
|
|
|
|
+ use runtime::{
|
|
|
|
|
+ ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor,
|
|
|
|
|
+ };
|
|
|
use serde_json::json;
|
|
use serde_json::json;
|
|
|
|
|
|
|
|
fn env_lock() -> &'static Mutex<()> {
|
|
fn env_lock() -> &'static Mutex<()> {
|
|
@@ -3221,8 +3276,8 @@ mod tests {
|
|
|
"additionalProperties": false
|
|
"additionalProperties": false
|
|
|
}),
|
|
}),
|
|
|
},
|
|
},
|
|
|
- script.display().to_string(),
|
|
|
|
|
- Vec::new(),
|
|
|
|
|
|
|
+ "sh".to_string(),
|
|
|
|
|
+ vec![script.display().to_string()],
|
|
|
PluginToolPermission::WorkspaceWrite,
|
|
PluginToolPermission::WorkspaceWrite,
|
|
|
script.parent().map(PathBuf::from),
|
|
script.parent().map(PathBuf::from),
|
|
|
)])
|
|
)])
|
|
@@ -3300,6 +3355,48 @@ mod tests {
|
|
|
let _ = std::fs::remove_file(script);
|
|
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]
|
|
#[test]
|
|
|
fn global_registry_rejects_conflicting_plugin_tool_names() {
|
|
fn global_registry_rejects_conflicting_plugin_tool_names() {
|
|
|
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
|
|
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
|
|
@@ -3899,8 +3996,11 @@ mod tests {
|
|
|
calls: 0,
|
|
calls: 0,
|
|
|
input_path: path.display().to_string(),
|
|
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")],
|
|
vec![String::from("system prompt")],
|
|
|
);
|
|
);
|
|
|
|
|
|