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,
|
||||
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<String> {
|
||||
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<String>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
}
|
||||
|
||||
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()
|
||||
.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<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 {
|
||||
model: self.model.clone(),
|
||||
max_tokens: 32_000,
|
||||
@@ -2002,32 +2005,82 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
|
||||
struct SubagentToolExecutor {
|
||||
allowed_tools: BTreeSet<String>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
}
|
||||
|
||||
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 {
|
||||
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!(
|
||||
"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<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> {
|
||||
@@ -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")],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user