|
|
@@ -8,6 +8,7 @@ use api::{
|
|
|
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
|
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
|
|
};
|
|
|
+use plugins::PluginTool;
|
|
|
use reqwest::blocking::Client;
|
|
|
use runtime::{
|
|
|
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
|
|
@@ -55,6 +56,161 @@ pub struct ToolSpec {
|
|
|
pub required_permission: PermissionMode,
|
|
|
}
|
|
|
|
|
|
+#[derive(Debug, Clone, PartialEq)]
|
|
|
+pub struct GlobalToolRegistry {
|
|
|
+ plugin_tools: Vec<PluginTool>,
|
|
|
+}
|
|
|
+
|
|
|
+impl GlobalToolRegistry {
|
|
|
+ #[must_use]
|
|
|
+ pub fn builtin() -> Self {
|
|
|
+ Self {
|
|
|
+ plugin_tools: Vec::new(),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
|
|
|
+ let builtin_names = mvp_tool_specs()
|
|
|
+ .into_iter()
|
|
|
+ .map(|spec| spec.name.to_string())
|
|
|
+ .collect::<BTreeSet<_>>();
|
|
|
+ let mut seen_plugin_names = BTreeSet::new();
|
|
|
+
|
|
|
+ for tool in &plugin_tools {
|
|
|
+ let name = tool.definition().name.clone();
|
|
|
+ if builtin_names.contains(&name) {
|
|
|
+ return Err(format!(
|
|
|
+ "plugin tool `{name}` conflicts with a built-in tool name"
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ if !seen_plugin_names.insert(name.clone()) {
|
|
|
+ return Err(format!("duplicate plugin tool name `{name}`"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(Self { plugin_tools })
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn normalize_allowed_tools(&self, values: &[String]) -> Result<Option<BTreeSet<String>>, String> {
|
|
|
+ if values.is_empty() {
|
|
|
+ return Ok(None);
|
|
|
+ }
|
|
|
+
|
|
|
+ let builtin_specs = mvp_tool_specs();
|
|
|
+ let canonical_names = builtin_specs
|
|
|
+ .iter()
|
|
|
+ .map(|spec| spec.name.to_string())
|
|
|
+ .chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone()))
|
|
|
+ .collect::<Vec<_>>();
|
|
|
+ let mut name_map = canonical_names
|
|
|
+ .iter()
|
|
|
+ .map(|name| (normalize_tool_name(name), name.clone()))
|
|
|
+ .collect::<BTreeMap<_, _>>();
|
|
|
+
|
|
|
+ for (alias, canonical) in [
|
|
|
+ ("read", "read_file"),
|
|
|
+ ("write", "write_file"),
|
|
|
+ ("edit", "edit_file"),
|
|
|
+ ("glob", "glob_search"),
|
|
|
+ ("grep", "grep_search"),
|
|
|
+ ] {
|
|
|
+ name_map.insert(alias.to_string(), canonical.to_string());
|
|
|
+ }
|
|
|
+
|
|
|
+ let mut allowed = BTreeSet::new();
|
|
|
+ for value in values {
|
|
|
+ for token in value
|
|
|
+ .split(|ch: char| ch == ',' || ch.is_whitespace())
|
|
|
+ .filter(|token| !token.is_empty())
|
|
|
+ {
|
|
|
+ let normalized = normalize_tool_name(token);
|
|
|
+ let canonical = name_map.get(&normalized).ok_or_else(|| {
|
|
|
+ format!(
|
|
|
+ "unsupported tool in --allowedTools: {token} (expected one of: {})",
|
|
|
+ canonical_names.join(", ")
|
|
|
+ )
|
|
|
+ })?;
|
|
|
+ allowed.insert(canonical.clone());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(Some(allowed))
|
|
|
+ }
|
|
|
+
|
|
|
+ #[must_use]
|
|
|
+ pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
|
|
|
+ let builtin = mvp_tool_specs()
|
|
|
+ .into_iter()
|
|
|
+ .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
|
|
+ .map(|spec| ToolDefinition {
|
|
|
+ name: spec.name.to_string(),
|
|
|
+ description: Some(spec.description.to_string()),
|
|
|
+ input_schema: spec.input_schema,
|
|
|
+ });
|
|
|
+ let plugin = self
|
|
|
+ .plugin_tools
|
|
|
+ .iter()
|
|
|
+ .filter(|tool| {
|
|
|
+ allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
|
|
+ })
|
|
|
+ .map(|tool| ToolDefinition {
|
|
|
+ name: tool.definition().name.clone(),
|
|
|
+ description: tool.definition().description.clone(),
|
|
|
+ input_schema: tool.definition().input_schema.clone(),
|
|
|
+ });
|
|
|
+ builtin.chain(plugin).collect()
|
|
|
+ }
|
|
|
+
|
|
|
+ #[must_use]
|
|
|
+ pub fn permission_specs(
|
|
|
+ &self,
|
|
|
+ allowed_tools: Option<&BTreeSet<String>>,
|
|
|
+ ) -> Vec<(String, PermissionMode)> {
|
|
|
+ let builtin = mvp_tool_specs()
|
|
|
+ .into_iter()
|
|
|
+ .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
|
|
+ .map(|spec| (spec.name.to_string(), spec.required_permission));
|
|
|
+ let plugin = self
|
|
|
+ .plugin_tools
|
|
|
+ .iter()
|
|
|
+ .filter(|tool| {
|
|
|
+ allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
|
|
+ })
|
|
|
+ .map(|tool| {
|
|
|
+ (
|
|
|
+ tool.definition().name.clone(),
|
|
|
+ permission_mode_from_plugin(tool.required_permission()),
|
|
|
+ )
|
|
|
+ });
|
|
|
+ builtin.chain(plugin).collect()
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
|
|
|
+ if mvp_tool_specs().iter().any(|spec| spec.name == name) {
|
|
|
+ return execute_tool(name, input);
|
|
|
+ }
|
|
|
+ self.plugin_tools
|
|
|
+ .iter()
|
|
|
+ .find(|tool| tool.definition().name == name)
|
|
|
+ .ok_or_else(|| format!("unsupported tool: {name}"))?
|
|
|
+ .execute(input)
|
|
|
+ .map_err(|error| error.to_string())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+fn normalize_tool_name(value: &str) -> String {
|
|
|
+ value.trim().replace('-', "_").to_ascii_lowercase()
|
|
|
+}
|
|
|
+
|
|
|
+fn permission_mode_from_plugin(value: &str) -> PermissionMode {
|
|
|
+ match value {
|
|
|
+ "read-only" => PermissionMode::ReadOnly,
|
|
|
+ "workspace-write" => PermissionMode::WorkspaceWrite,
|
|
|
+ "danger-full-access" => PermissionMode::DangerFullAccess,
|
|
|
+ other => panic!("unsupported plugin permission: {other}"),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
#[must_use]
|
|
|
#[allow(clippy::too_many_lines)]
|
|
|
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|