Allow subagent tool flows to reach plugin-provided tools
The subagent runtime still advertised and executed only built-in tools, which left plugin-provided tools outside the Agent execution path. This change loads the same plugin-aware registry used by the CLI for subagent tool definitions, permission policy, and execution lookup so delegated runs can resolve plugin tools consistently. Constraint: Plugin tools must respect the existing runtime plugin config and enabled-plugin state Rejected: Thread plugin-specific exceptions through execute_tool directly | would bypass registry validation and duplicate lookup rules Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep CLI and subagent registry construction aligned when plugin tool loading rules change Tested: cargo test -p tools -p rusty-claude-cli Not-tested: Live Anthropic subagent runs invoking plugin tools end-to-end
This commit is contained in:
@@ -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,
|
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
|
||||||
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
|
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
|
||||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
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_registry = current_tool_registry()?;
|
||||||
let tool_executor = SubagentToolExecutor::new(allowed_tools);
|
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 {
|
fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
|
||||||
mvp_tool_specs().into_iter().fold(
|
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))
|
let tools = self.tool_registry.definitions(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 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 {
|
fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
|
||||||
Self { allowed_tools }
|
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> {
|
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
|
||||||
mvp_tool_specs()
|
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
.into_iter()
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
let runtime_config = loader.load().map_err(|error| error.to_string())?;
|
||||||
.collect()
|
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(),
|
"sh".to_string(),
|
||||||
Vec::new(),
|
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")])),
|
SubagentToolExecutor::new(
|
||||||
agent_permission_policy(),
|
BTreeSet::from([String::from("read_file")]),
|
||||||
|
GlobalToolRegistry::builtin(),
|
||||||
|
),
|
||||||
|
agent_permission_policy(&GlobalToolRegistry::builtin()),
|
||||||
vec![String::from("system prompt")],
|
vec![String::from("system prompt")],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user