use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::path::{Path, PathBuf};
use codewhale_protocol::runtime::DynamicToolSpec;
use serde_json::Value;
use crate::client::DeepSeekClient;
use crate::models::Tool;
use crate::tools::goal::SharedGoalState;
use super::schema_canonicalize;
use super::schema_sanitize;
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn ToolSpec>>,
context: ToolContext,
api_cache: OnceLock<Vec<Tool>>,
}
impl ToolRegistry {
#[must_use]
pub fn new(context: ToolContext) -> Self {
Self {
tools: HashMap::new(),
context,
api_cache: OnceLock::new(),
}
}
pub fn register(&mut self, tool: Arc<dyn ToolSpec>) {
let name = tool.name().to_string();
if self.tools.insert(name.clone(), tool).is_some() {
tracing::warn!("Overwriting existing tool: {}", name);
}
self.invalidate_api_cache();
}
pub fn register_all(&mut self, tools: Vec<Arc<dyn ToolSpec>>) {
for tool in tools {
self.register(tool);
}
}
#[must_use]
pub fn get(&self, name: &str) -> Option<Arc<dyn ToolSpec>> {
self.tools.get(name).cloned()
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.tools.keys().map(std::string::String::as_str).collect()
}
#[must_use]
pub fn all(&self) -> Vec<Arc<dyn ToolSpec>> {
self.tools.values().cloned().collect()
}
pub async fn execute_full(&self, name: &str, input: Value) -> Result<ToolResult, ToolError> {
let tool = self
.get(name)
.ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
enforce_tool_authority(name, &input, tool.as_ref(), &self.context)?;
tool.execute(input, &self.context).await
}
pub async fn execute_full_with_context(
&self,
name: &str,
input: Value,
context_override: Option<&ToolContext>,
) -> Result<ToolResult, ToolError> {
let tool = self
.get(name)
.ok_or_else(|| ToolError::not_available(format!("tool '{name}' is not registered")))?;
let ctx = context_override.unwrap_or(&self.context);
enforce_tool_authority(name, &input, tool.as_ref(), ctx)?;
let mut result = tool.execute(input.clone(), ctx).await?;
let raw_bypass = input.get("raw").and_then(|v| v.as_bool()).unwrap_or(false);
if let Some(router) = ctx.large_output_router.as_ref() {
use crate::tools::large_output_router::{
LargeOutputRouter, RouteDecision, classic_output_routing_enabled,
};
if !classic_output_routing_enabled() {
let (routing, estimated_tokens, threshold) =
router.evidence_routing(name, &result, raw_bypass);
let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({}));
if let Some(object) = metadata.as_object_mut() {
object.insert(
"evidence_routing".to_string(),
serde_json::to_value(routing)
.unwrap_or_else(|_| serde_json::json!("inline")),
);
object.insert(
"evidence_estimated_tokens".to_string(),
estimated_tokens.into(),
);
object.insert("evidence_threshold_tokens".to_string(), threshold.into());
}
return Ok(result);
}
match router.route(name, &result, raw_bypass) {
RouteDecision::PassThrough => {}
RouteDecision::Synthesise {
estimated_tokens,
threshold,
} => {
if let Some(vars_arc) = ctx.workshop_vars.as_ref() {
let mut vars = vars_arc.lock().await;
vars.store_raw(name, &result.content);
}
let preview_chars = 1_200usize;
let preview: String = result.content.chars().take(preview_chars).collect();
let ellipsis = if result.content.chars().count() > preview_chars {
"\n… [output truncated — full text in workshop variable `last_tool_result`]"
} else {
""
};
let synthesis = format!("{preview}{ellipsis}");
let wrapped = LargeOutputRouter::wrap_synthesis(
name,
&synthesis,
estimated_tokens,
threshold,
);
tracing::debug!(
tool = name,
estimated_tokens,
threshold,
"large-output routed through workshop"
);
return Ok(ToolResult::success(wrapped));
}
}
}
Ok(result)
}
#[must_use]
pub fn context(&self) -> &ToolContext {
&self.context
}
#[must_use]
pub fn to_api_tools(&self) -> Vec<Tool> {
self.api_cache
.get_or_init(|| self.build_api_tools())
.clone()
}
fn build_api_tools(&self) -> Vec<Tool> {
let mut tools: Vec<&Arc<dyn ToolSpec>> = self.tools.values().collect();
tools.sort_by(|a, b| a.name().cmp(b.name()));
tools
.into_iter()
.filter(|tool| tool.model_visible())
.map(|tool| {
let mut schema = tool.input_schema();
schema_sanitize::sanitize(&mut schema);
schema_canonicalize::canonicalize_schema(&mut schema);
Tool {
tool_type: None,
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: schema,
allowed_callers: Some(vec!["direct".to_string()]),
defer_loading: Some(tool.defer_loading()),
input_examples: None,
strict: None,
cache_control: None,
}
})
.collect()
}
fn invalidate_api_cache(&mut self) {
self.api_cache = OnceLock::new();
}
#[must_use]
pub fn to_api_tools_with_cache(&self, enable_cache: bool) -> Vec<Tool> {
let mut tools = self.to_api_tools();
if enable_cache && let Some(last) = tools.last_mut() {
last.cache_control = Some(crate::models::CacheControl {
cache_type: "ephemeral".to_string(),
});
}
tools
}
#[must_use]
pub fn registry_facts(
&self,
plugin_names: &std::collections::HashSet<String>,
) -> Vec<crate::tool_inspection::RegistryFacts> {
let mut facts: Vec<crate::tool_inspection::RegistryFacts> = self
.tools
.values()
.map(|tool| crate::tool_inspection::RegistryFacts {
name: tool.name().to_string(),
description: tool.description().to_string(),
model_visible: tool.model_visible(),
capabilities: tool
.capabilities()
.iter()
.map(|capability| format!("{capability:?}"))
.collect(),
approval: format!("{:?}", tool.approval_requirement()),
plugin: plugin_names.contains(tool.name()),
})
.collect();
facts.sort_by(|a, b| a.name.cmp(&b.name));
facts
}
#[must_use]
pub fn resolve(&self, requested: &str) -> Option<&str> {
let names: Vec<&str> = self.tools.keys().map(String::as_str).collect();
let lower = requested.to_lowercase();
if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {
return Some(n);
}
let snaked = lower.replace(['-', ' '], "_");
if let Some(n) = names.iter().find(|n| **n == snaked) {
return Some(n);
}
let cc = to_snake_case(requested);
if let Some(n) = names.iter().find(|n| **n == cc) {
return Some(n);
}
let mut stripped = cc.clone();
for _ in 0..2 {
for suf in ["_tool", "-tool", "tool"] {
if let Some(s) = stripped.strip_suffix(suf) {
stripped = s.to_string();
break;
}
}
}
if !stripped.is_empty()
&& let Some(n) = names.iter().find(|n| **n == stripped)
{
return Some(n);
}
if lower.len() >= 3 {
for n in &names {
if n.len() >= 3 && (n.starts_with(&lower) || lower.starts_with(n)) {
return Some(n);
}
}
}
None
}
pub fn remove_tool(&mut self, name: &str) -> bool {
let existed = self.tools.remove(name).is_some();
if existed {
self.invalidate_api_cache();
}
existed
}
pub fn apply_overrides(
&mut self,
overrides: &std::collections::HashMap<String, crate::config::ToolOverride>,
plugin_dir: &Path,
) {
for (tool_name, override_cfg) in overrides {
match override_cfg {
crate::config::ToolOverride::Disabled => {
if self.remove_tool(tool_name) {
tracing::info!("Tool '{}' disabled via config override", tool_name);
} else {
tracing::warn!("Cannot disable tool '{}': not registered", tool_name);
}
}
_ => {
use crate::tools::plugin::tool_from_override;
match tool_from_override(tool_name, override_cfg, plugin_dir) {
Some(replacement) => {
self.register(replacement);
tracing::info!("Tool '{}' replaced via config override", tool_name);
}
None => {
if self.remove_tool(tool_name) {
tracing::warn!(
"Tool '{}' override did not create a replacement; removed the original tool to avoid override fallthrough",
tool_name
);
} else {
tracing::warn!(
"Tool '{}' override did not create a replacement and no registered tool existed",
tool_name
);
}
}
}
}
}
}
}
pub fn load_plugins(&mut self, plugin_dir: &Path) {
if !plugin_dir.exists() {
tracing::debug!(
"Plugin directory {} does not exist, skipping",
plugin_dir.display()
);
return;
}
let plugins = crate::tools::plugin::load_plugin_tools(plugin_dir);
let count = plugins.len();
for tool in plugins {
self.register(tool);
}
if count > 0 {
tracing::info!(
"Loaded {count} plugin tool(s) from {}",
plugin_dir.display()
);
}
}
}
fn enforce_tool_authority(
name: &str,
input: &Value,
tool: &dyn ToolSpec,
context: &ToolContext,
) -> Result<(), ToolError> {
let Some(authority) = context.tool_authority.as_ref() else {
return Ok(());
};
let capabilities = tool.capabilities();
if matches!(name, "Bash" | "exec_shell" | "Run") {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot run {name}: arbitrary command execution is outside its machine-readable authority envelope",
authority.owner
)));
}
if name == "Git" || name.starts_with("git_") || name == "review" {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot run {name}: repository-configured Git helpers cannot prove read-only execution under its machine-readable authority envelope",
authority.owner
)));
}
if tool.is_read_only_for(input) {
return Ok(());
}
if capabilities.contains(&ToolCapability::ExecutesCode) {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot run {name}: code or child execution is outside its machine-readable authority envelope",
authority.owner
)));
}
if let Some(paths) = authority_mutation_paths(name, input)? {
if paths.is_empty() {
return Err(ToolError::permission_denied(format!(
"worker '{}' mutation through {name} did not expose a bounded file target",
authority.owner
)));
}
for path in paths {
if !authority.permits_mutation_path(context, &path)? {
return Err(ToolError::permission_denied(format!(
"worker '{}' cannot mutate '{path}' outside its machine-readable authority envelope",
authority.owner
)));
}
}
return Ok(());
}
Err(ToolError::permission_denied(format!(
"worker '{}' cannot run mutating tool {name}: the call has no authorized file target",
authority.owner
)))
}
fn authority_mutation_paths(name: &str, input: &Value) -> Result<Option<Vec<String>>, ToolError> {
let is_patch = name == "apply_patch"
|| (name == "File" && input.get("action").and_then(Value::as_str) == Some("patch"));
if is_patch {
let mut patch_input = input.clone();
if let Some(object) = patch_input.as_object_mut() {
object.remove("action");
}
let paths = crate::tools::apply_patch::preflight_apply_patch(&patch_input)
.map_err(|error| ToolError::invalid_input(error.to_string()))?
.touched_files;
return Ok(Some(paths));
}
let path_bound = matches!(name, "write_file" | "edit_file" | "fim_edit")
|| (name == "File"
&& input
.get("action")
.and_then(Value::as_str)
.is_some_and(|action| matches!(action, "write" | "edit")))
|| (name == "pandoc_convert" && input.get("output_path").is_some());
if !path_bound {
return Ok(None);
}
Ok(Some(
input
.get("path")
.or_else(|| input.get("output_path"))
.and_then(Value::as_str)
.map(|path| vec![path.to_string()])
.unwrap_or_default(),
))
}
pub struct ToolRegistryBuilder {
tools: Vec<Arc<dyn ToolSpec>>,
}
#[derive(Clone)]
pub struct AgentToolSurfaceOptions {
pub shell_policy: crate::worker_profile::ShellPolicy,
pub apply_patch_enabled: bool,
pub web_search_enabled: bool,
pub memory_tool_enabled: bool,
pub vision_config: Option<crate::config::VisionModelConfig>,
pub speech_output_dir: Option<PathBuf>,
pub goal_state: Option<SharedGoalState>,
pub verify_tool_enabled: bool,
}
impl AgentToolSurfaceOptions {
#[must_use]
pub fn new(shell_policy: crate::worker_profile::ShellPolicy) -> Self {
Self {
shell_policy,
apply_patch_enabled: false,
web_search_enabled: false,
memory_tool_enabled: false,
vision_config: None,
speech_output_dir: None,
goal_state: None,
verify_tool_enabled: true,
}
}
}
impl ToolRegistryBuilder {
#[must_use]
pub fn new() -> Self {
Self { tools: Vec::new() }
}
#[must_use]
pub fn with_tool(mut self, tool: Arc<dyn ToolSpec>) -> Self {
self.tools.push(tool);
self
}
#[must_use]
pub fn with_dynamic_tools(mut self, dynamic_tools: &[DynamicToolSpec]) -> Self {
for tool in dynamic_tools {
self = self.with_tool(Arc::new(super::dynamic::RuntimeDynamicTool::new(
tool.clone(),
)));
}
self
}
#[must_use]
pub fn with_file_tools(self) -> Self {
use super::file_tool::FileTool;
self.with_tool(Arc::new(FileTool::new("File")))
}
#[must_use]
pub fn with_read_only_file_tools(self) -> Self {
use super::file_tool::FileTool;
self.with_tool(Arc::new(FileTool::read_only("File")))
.with_tool(Arc::new(
super::tool_result_retrieval::RetrieveToolResultTool,
))
}
#[must_use]
pub fn with_shell_tools(self) -> Self {
use super::shell::BashTool;
self.with_tool(Arc::new(BashTool::new("Bash")))
.with_terminal_tools()
}
#[cfg(not(target_env = "ohos"))]
#[must_use]
pub fn with_terminal_tools(self) -> Self {
use super::terminal_session::{
TerminalCancelTool, TerminalResetTool, TerminalRunTool, TerminalSendTool,
TerminalWaitTool,
};
self.with_tool(Arc::new(TerminalRunTool))
.with_tool(Arc::new(TerminalSendTool))
.with_tool(Arc::new(TerminalWaitTool))
.with_tool(Arc::new(TerminalCancelTool))
.with_tool(Arc::new(TerminalResetTool))
}
#[cfg(target_env = "ohos")]
#[must_use]
pub fn with_terminal_tools(self) -> Self {
self
}
#[must_use]
pub fn with_search_tools(self) -> Self {
self
}
#[must_use]
pub fn with_git_tools(self) -> Self {
use super::git_tool::GitTool;
self.with_tool(Arc::new(GitTool::new("Git")))
}
#[must_use]
pub fn with_git_history_tools(self) -> Self {
self
}
#[must_use]
pub fn with_diagnostics_tool(self) -> Self {
use super::diagnostics::DiagnosticsTool;
self.with_tool(Arc::new(DiagnosticsTool))
}
#[must_use]
pub fn with_pandoc_tools(self) -> Self {
if crate::dependencies::resolve_pandoc().is_some() {
use super::pandoc::PandocConvertTool;
self.with_tool(Arc::new(PandocConvertTool))
} else {
self
}
}
#[must_use]
pub fn with_image_ocr_tools(self) -> Self {
if super::image_ocr::ocr_available() {
use super::image_ocr::ImageOcrTool;
self.with_tool(Arc::new(ImageOcrTool))
} else {
self
}
}
#[must_use]
pub fn with_skill_tools(self) -> Self {
use super::skill::LoadSkillTool;
self.with_tool(Arc::new(LoadSkillTool))
}
#[must_use]
pub fn with_project_tools(self) -> Self {
use super::project::ProjectMapTool;
self.with_tool(Arc::new(ProjectMapTool))
}
#[must_use]
pub fn with_test_runner_tool(self) -> Self {
use super::run_tool::RunTool;
self.with_tool(Arc::new(RunTool::new("Run")))
}
#[must_use]
pub fn with_validation_tools(self) -> Self {
use super::validate_data::ValidateDataTool;
self.with_tool(Arc::new(ValidateDataTool))
}
#[must_use]
pub fn with_tool_result_retrieval_tool(self) -> Self {
use super::tool_result_retrieval::RetrieveToolResultTool;
self.with_tool(Arc::new(RetrieveToolResultTool))
}
#[must_use]
pub fn with_runtime_task_tools(self) -> Self {
use super::automation::AutomationTool;
use super::github::GithubTool;
use super::tasks::TasksTool;
self.with_tool(Arc::new(TasksTool::new("tasks")))
.with_tool(Arc::new(GithubTool::new("github")))
.with_tool(Arc::new(AutomationTool::new("automation")))
}
#[must_use]
pub fn with_runtime_task_shell_tools(self) -> Self {
use super::tasks::{TaskShellStartTool, TaskShellWaitTool};
self.with_tool(Arc::new(TaskShellStartTool))
.with_tool(Arc::new(TaskShellWaitTool))
}
#[must_use]
pub fn with_runtime_read_only_task_tools(self) -> Self {
use super::automation::AutomationTool;
use super::github::GithubTool;
use super::tasks::TasksTool;
self.with_tool(Arc::new(TasksTool::read_only("tasks")))
.with_tool(Arc::new(GithubTool::read_only("github")))
.with_tool(Arc::new(AutomationTool::read_only("automation")))
}
#[must_use]
pub fn with_web_tools(self) -> Self {
use super::web_run::WebRunTool;
use super::web_tool::WebTool;
self.with_tool(Arc::new(WebTool::new("Web")))
.with_tool(Arc::new(WebRunTool))
}
#[must_use]
pub fn with_finance_tool(self) -> Self {
use super::finance::FinanceTool;
self.with_tool(Arc::new(FinanceTool::new()))
}
#[must_use]
pub fn with_vision_tools(self, config: crate::config::VisionModelConfig) -> Self {
use crate::vision::tools::ImageAnalyzeTool;
self.with_tool(Arc::new(ImageAnalyzeTool::new(config)))
}
#[must_use]
pub fn with_parallel_tool(self) -> Self {
self
}
#[must_use]
pub fn with_user_input_tool(self) -> Self {
use super::user_input::RequestUserInputTool;
self.with_tool(Arc::new(RequestUserInputTool))
}
#[must_use]
pub fn with_patch_tools(self) -> Self {
use super::file_tool::FileTool;
self.with_tool(Arc::new(FileTool::with_patch("File")))
.with_tool(Arc::new(FileTool::alias("apply_patch", "patch")))
}
#[must_use]
pub fn with_revert_turn_tool(self) -> Self {
use super::revert_turn::RevertTurnTool;
self.with_tool(Arc::new(RevertTurnTool))
}
#[must_use]
pub fn with_speech_tools(
self,
client: Option<DeepSeekClient>,
output_dir: Option<PathBuf>,
) -> Self {
use super::speech::SpeechTool;
self.with_tool(Arc::new(SpeechTool::new(
"speech",
client.clone(),
output_dir.clone(),
)))
.with_tool(Arc::new(SpeechTool::new("tts", client, output_dir)))
}
#[must_use]
pub fn with_rlm_tool(self, client: Option<DeepSeekClient>, _root_model: String) -> Self {
use super::rlm::RlmTool;
self.with_tool(Arc::new(RlmTool::new("rlm", client)))
}
#[must_use]
pub fn with_handle_tools(self) -> Self {
use super::handle::HandleReadTool;
self.with_tool(Arc::new(HandleReadTool))
}
#[must_use]
pub fn with_review_tool(self, client: Option<DeepSeekClient>, model: String) -> Self {
use super::review::ReviewTool;
self.with_tool(Arc::new(ReviewTool::new(client, model)))
}
#[must_use]
pub fn with_verify_tool(self, client: Option<DeepSeekClient>, model: String) -> Self {
use super::verify::VerifyTool;
self.with_tool(Arc::new(VerifyTool::new(client, model)))
}
#[must_use]
pub fn with_note_tool(self) -> Self {
use super::shell::NoteTool;
self.with_tool(Arc::new(NoteTool))
}
#[must_use]
pub fn with_fim_tool(self, client: Option<DeepSeekClient>, model: String) -> Self {
use super::fim::FimEditTool;
self.with_tool(Arc::new(FimEditTool::new(client, model)))
}
#[must_use]
pub fn with_remember_tool(self) -> Self {
use super::remember::RememberTool;
self.with_tool(Arc::new(RememberTool))
}
#[must_use]
pub fn with_native_memory_tools(self) -> Self {
use super::native_memory::{MemoryGetTool, MemorySearchTool};
self.with_tool(Arc::new(MemorySearchTool))
.with_tool(Arc::new(MemoryGetTool))
}
#[must_use]
pub fn with_lsp_tool(self) -> Self {
use super::lsp::LspTool;
self.with_tool(Arc::new(LspTool))
}
#[must_use]
pub fn with_slop_ledger_tools(self) -> Self {
use crate::slop_ledger::{
SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, SlopLedgerUpdateTool,
};
self.with_tool(Arc::new(SlopLedgerAppendTool))
.with_tool(Arc::new(SlopLedgerQueryTool))
.with_tool(Arc::new(SlopLedgerUpdateTool))
.with_tool(Arc::new(SlopLedgerExportTool))
}
#[must_use]
pub fn with_slop_ledger_read_only_tools(self) -> Self {
use crate::slop_ledger::{SlopLedgerExportTool, SlopLedgerQueryTool};
self.with_tool(Arc::new(SlopLedgerQueryTool))
.with_tool(Arc::new(SlopLedgerExportTool))
}
#[must_use]
pub fn with_notify_tool(self) -> Self {
use super::notify::NotifyTool;
self.with_tool(Arc::new(NotifyTool))
}
#[must_use]
pub fn with_mcp_tools(
mut self,
mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
) -> Self {
if let Ok(pool) = mcp_pool.try_lock() {
for (name, tool) in pool.all_tools() {
let adapter = Arc::new(McpToolAdapter {
name: name.clone(),
tool: tool.clone(),
pool: mcp_pool.clone(),
});
self.tools.push(adapter);
}
}
self
}
#[must_use]
pub fn with_runtime_mcp_tool(
mut self,
mcp_pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
) -> Self {
self.tools
.push(Arc::new(super::runtime_mcp::StartRuntimeMcpServer::new(
mcp_pool,
)));
self
}
#[must_use]
pub fn with_agent_tools_policy(self, shell_policy: crate::worker_profile::ShellPolicy) -> Self {
let builder = self
.with_file_tools()
.with_note_tool()
.with_search_tools()
.with_user_input_tool()
.with_parallel_tool()
.with_git_tools()
.with_git_history_tools()
.with_diagnostics_tool()
.with_lsp_tool()
.with_project_tools()
.with_skill_tools()
.with_test_runner_tool()
.with_validation_tools()
.with_tool_result_retrieval_tool()
.with_handle_tools()
.with_runtime_task_tools()
.with_revert_turn_tool()
.with_pandoc_tools()
.with_image_ocr_tools()
.with_finance_tool();
if shell_policy.allows_shell() {
builder.with_shell_tools().with_runtime_task_shell_tools()
} else {
builder
}
}
#[must_use]
pub fn with_agent_runtime_surface(
self,
client: Option<DeepSeekClient>,
model: String,
options: AgentToolSurfaceOptions,
todo_list: super::todo::SharedTodoList,
plan_state: super::plan::SharedPlanState,
) -> Self {
let speech_client = client.clone();
let verify_client = client.clone();
let verify_model = model.clone();
let mut builder = self
.with_agent_tools_policy(options.shell_policy)
.with_todo_tool(todo_list)
.with_plan_tool(plan_state)
.with_review_tool(client.clone(), model.clone())
.with_slop_ledger_tools()
.with_rlm_tool(client.clone(), model.clone())
.with_fim_tool(client, model)
.with_speech_tools(speech_client, options.speech_output_dir.clone());
if options.verify_tool_enabled {
builder = builder.with_verify_tool(verify_client, verify_model);
}
if let Some(goal_state) = options.goal_state {
builder = builder.with_goal_tools(goal_state);
}
if options.apply_patch_enabled {
builder = builder.with_patch_tools();
}
if options.web_search_enabled {
builder = builder.with_web_tools();
}
if options.memory_tool_enabled {
builder = builder.with_remember_tool().with_native_memory_tools();
}
if let Some(vision_config) = options.vision_config {
builder = builder.with_vision_tools(vision_config);
}
builder.with_notify_tool()
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn with_full_agent_surface_options(
self,
client: Option<DeepSeekClient>,
model: String,
manager: super::subagent::SharedSubAgentManager,
runtime: super::subagent::SubAgentRuntime,
options: AgentToolSurfaceOptions,
todo_list: super::todo::SharedTodoList,
plan_state: super::plan::SharedPlanState,
) -> Self {
self.with_agent_runtime_surface(client, model, options, todo_list, plan_state)
.with_subagent_tools(manager, runtime)
}
#[must_use]
pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
use super::todo::TodoWriteTool;
self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list)))
}
#[must_use]
pub fn with_plan_tool(self, plan_state: super::plan::SharedPlanState) -> Self {
use super::plan::UpdatePlanTool;
self.with_tool(Arc::new(UpdatePlanTool::new(plan_state)))
}
#[must_use]
pub fn with_goal_tools(self, goal_state: super::goal::SharedGoalState) -> Self {
use super::goal::{CreateGoalTool, GetGoalTool, UpdateGoalTool};
self.with_tool(Arc::new(CreateGoalTool::new(goal_state.clone())))
.with_tool(Arc::new(GetGoalTool::new(goal_state.clone())))
.with_tool(Arc::new(UpdateGoalTool::new(goal_state)))
}
#[must_use]
pub fn with_subagent_tools(
self,
manager: super::subagent::SharedSubAgentManager,
runtime: super::subagent::SubAgentRuntime,
) -> Self {
use super::subagent::AgentTool;
use super::subagent::register_coordination_tools;
use super::workflow::WorkflowTool;
use super::workflow_trigger::soft_auto_policy_is_linked;
debug_assert!(
soft_auto_policy_is_linked(),
"workflow soft-auto policy must stay linked"
);
let builder = self
.with_tool(Arc::new(WorkflowTool::new(
Arc::clone(&manager),
runtime.clone(),
)))
.with_tool(Arc::new(AgentTool::new(
Arc::clone(&manager),
runtime.clone(),
)));
register_coordination_tools(builder, manager, runtime)
}
#[must_use]
pub fn build(self, context: ToolContext) -> ToolRegistry {
let mut registry = ToolRegistry::new(context);
registry.register_all(self.tools);
registry
}
}
impl Default for ToolRegistryBuilder {
fn default() -> Self {
Self::new()
}
}
fn to_snake_case(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
struct McpToolAdapter {
name: String,
tool: crate::mcp::McpTool,
pool: std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
}
fn is_mcp_read_helper(name: &str) -> bool {
matches!(
name,
"list_mcp_resources"
| "list_mcp_resource_templates"
| "mcp_read_resource"
| "read_mcp_resource"
| "mcp_get_prompt"
)
}
#[async_trait::async_trait]
impl ToolSpec for McpToolAdapter {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
self.tool.description.as_deref().unwrap_or(&self.name)
}
fn input_schema(&self) -> Value {
self.tool.input_schema.clone()
}
fn capabilities(&self) -> Vec<ToolCapability> {
if is_mcp_read_helper(&self.name) {
vec![ToolCapability::ReadOnly]
} else {
vec![ToolCapability::Network, ToolCapability::RequiresApproval]
}
}
fn approval_requirement(&self) -> ApprovalRequirement {
if is_mcp_read_helper(&self.name) {
ApprovalRequirement::Auto
} else {
ApprovalRequirement::Required
}
}
fn defer_loading(&self) -> bool {
!is_mcp_read_helper(&self.name)
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let mut pool = self.pool.lock().await;
let result = pool
.call_tool(&self.name, input)
.await
.map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
let content = serde_json::to_string(&result).unwrap_or_else(|_| result.to_string());
Ok(ToolResult::success(content))
}
}
#[cfg(test)]
pub(super) fn mcp_tool_adapter_for_test(name: &str) -> Arc<dyn ToolSpec> {
Arc::new(McpToolAdapter {
name: name.to_string(),
tool: crate::mcp::McpTool {
name: name.to_string(),
description: None,
input_schema: serde_json::json!({"type": "object"}),
},
pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
crate::mcp::McpConfig::default(),
))),
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use tempfile::tempdir;
use crate::config::ToolOverride;
use crate::tools::ToolRegistryBuilder;
use crate::tools::spec::{
ApprovalRequirement, ToolAuthorityEnvelope, ToolCapability, ToolContext, ToolError,
ToolMutationAuthority, ToolResult, ToolSpec, required_str,
};
use super::{ToolRegistry, mcp_tool_adapter_for_test};
struct TestTool {
name: String,
description: String,
}
#[async_trait::async_trait]
impl ToolSpec for TestTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
async fn execute(
&self,
input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let message = required_str(&input, "message")?;
Ok(ToolResult::success(format!("Echo: {message}")))
}
}
fn make_test_tool(name: &str) -> Arc<TestTool> {
Arc::new(TestTool {
name: name.to_string(),
description: "A test tool".to_string(),
})
}
#[test]
fn mcp_read_helpers_remain_auto_and_eagerly_loaded() {
for name in [
"list_mcp_resources",
"list_mcp_resource_templates",
"mcp_read_resource",
"read_mcp_resource",
"mcp_get_prompt",
] {
let adapter = mcp_tool_adapter_for_test(name);
assert_eq!(
adapter.approval_requirement(),
ApprovalRequirement::Auto,
"{name} should remain an automatic read helper"
);
assert!(adapter.is_read_only(), "{name} should remain read-only");
assert!(!adapter.defer_loading(), "{name} should remain loaded");
}
}
#[test]
fn mcp_actions_require_approval_with_exact_helper_matching() {
for name in [
"mcp_github_create_pull_request",
"mcp_github_list_mcp_resources_export",
"read_mcp_resource_and_delete",
] {
let adapter = mcp_tool_adapter_for_test(name);
assert_eq!(
adapter.approval_requirement(),
ApprovalRequirement::Required,
"{name} must not inherit read-helper approval"
);
assert!(
adapter
.capabilities()
.contains(&ToolCapability::RequiresApproval),
"{name} should advertise approval gating"
);
assert!(adapter.defer_loading(), "{name} should remain deferred");
}
}
#[test]
fn test_registry_register_and_get() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
let tool = make_test_tool("test_tool");
registry.register(tool);
assert!(registry.contains("test_tool"));
assert!(!registry.contains("nonexistent"));
assert_eq!(registry.all().len(), 1);
}
#[test]
fn resolve_exact_match_is_ascii_case_insensitive() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(make_test_tool("read_file"));
assert_eq!(registry.resolve("READ_FILE"), Some("read_file"));
}
#[test]
fn work_update_is_the_only_registered_progress_surface() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_todo_tool(crate::tools::todo::new_shared_todo_list())
.build(ctx);
assert!(registry.contains("work_update"));
for retired in [
"checklist_write",
"checklist_add",
"checklist_update",
"checklist_list",
"todo_write",
"todo_add",
"todo_update",
"todo_list",
] {
assert!(
!registry.contains(retired),
"{retired} must no longer be callable"
);
}
let api_names = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert!(
api_names.iter().any(|name| name == "work_update"),
"work_update should be the sole model-visible progress surface"
);
for retired in [
"checklist_write",
"checklist_add",
"checklist_update",
"checklist_list",
"todo_write",
"todo_add",
"todo_update",
"todo_list",
] {
assert!(
api_names.iter().all(|name| name != retired),
"{retired} must not appear in the model catalog"
);
}
}
#[test]
fn rlm_is_the_only_registered_session_surface() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_rlm_tool(None, "test-model".to_string())
.build(ctx);
assert!(registry.contains("rlm"));
for retired in [
"rlm_session_objects",
"rlm_open",
"rlm_eval",
"rlm_configure",
"rlm_close",
] {
assert!(
!registry.contains(retired),
"{retired} must no longer be callable"
);
}
}
#[test]
fn apply_overrides_removes_original_when_replacement_is_missing() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
assert!(registry.contains("File"));
let mut overrides = HashMap::new();
overrides.insert(
"File".to_string(),
ToolOverride::Script {
path: "missing-wrapper.sh".to_string(),
args: None,
},
);
registry.apply_overrides(&overrides, tmp.path());
assert!(!registry.contains("File"));
}
#[test]
fn builder_registers_speech_alias_tools() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_speech_tools(None, None)
.build(ctx);
assert!(registry.contains("speech"));
assert!(registry.contains("tts"));
}
#[test]
fn test_registry_names() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(make_test_tool("tool_a"));
registry.register(make_test_tool("tool_b"));
let names = registry.names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"tool_a"));
assert!(names.contains(&"tool_b"));
}
#[test]
fn test_registry_to_api_tools() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(make_test_tool("my_tool"));
let api_tools = registry.to_api_tools();
assert_eq!(api_tools.len(), 1);
assert_eq!(api_tools[0].name, "my_tool");
assert_eq!(api_tools[0].description, "A test tool");
}
#[test]
fn api_tools_with_cache_marks_last_tool_ephemeral() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(make_test_tool("tool_a"));
registry.register(make_test_tool("tool_b"));
let api_tools = registry.to_api_tools_with_cache(true);
assert_eq!(api_tools.len(), 2);
assert!(api_tools[0].cache_control.is_none());
assert_eq!(
api_tools[1]
.cache_control
.as_ref()
.map(|c| c.cache_type.as_str()),
Some("ephemeral")
);
}
struct VaryingDescriptionTool {
name: String,
descriptions: Vec<String>,
next: std::sync::atomic::AtomicUsize,
}
impl VaryingDescriptionTool {
fn new(name: &str, descriptions: &[&str]) -> Self {
Self {
name: name.to_string(),
descriptions: descriptions.iter().map(|s| (*s).to_string()).collect(),
next: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl ToolSpec for VaryingDescriptionTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
let idx = self
.next
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
.min(self.descriptions.len() - 1);
&self.descriptions[idx]
}
fn input_schema(&self) -> Value {
json!({"type": "object", "properties": {}, "required": []})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
Ok(ToolResult::success("ok".to_string()))
}
}
#[test]
fn to_api_tools_pins_description_bytes_across_calls() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(Arc::new(VaryingDescriptionTool::new(
"varying",
&["first description", "second description"],
)));
let first = registry.to_api_tools();
let second = registry.to_api_tools();
assert_eq!(first.len(), 1);
assert_eq!(first[0].description, "first description");
assert_eq!(
first, second,
"api-tools catalog must be byte-identical across reads with no mutation in between"
);
}
#[test]
fn register_invalidates_api_tools_cache() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(Arc::new(VaryingDescriptionTool::new(
"varying",
&["first description", "second description"],
)));
let before = registry.to_api_tools();
assert_eq!(before.len(), 1);
registry.register(make_test_tool("late_arrival"));
let after = registry.to_api_tools();
assert_eq!(after.len(), 2, "cache must rebuild after register");
assert!(after.iter().any(|t| t.name == "varying"));
assert!(after.iter().any(|t| t.name == "late_arrival"));
let varying_after = after
.iter()
.find(|t| t.name == "varying")
.expect("varying tool present");
assert_eq!(varying_after.description, "second description");
}
#[test]
fn remove_tool_invalidates_api_tools_cache() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut registry = ToolRegistry::new(ctx);
registry.register(make_test_tool("alpha"));
registry.register(make_test_tool("beta"));
let before = registry.to_api_tools();
assert_eq!(before.len(), 2);
assert!(registry.remove_tool("alpha"));
let after_remove = registry.to_api_tools();
assert_eq!(after_remove.len(), 1);
assert_eq!(after_remove[0].name, "beta");
}
#[test]
fn to_api_tools_emits_alphabetical_order_regardless_of_registration_order() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let order_a = {
let mut registry = ToolRegistry::new(ctx.clone());
registry.register(make_test_tool("zebra"));
registry.register(make_test_tool("alpha"));
registry.register(make_test_tool("mango"));
registry
.to_api_tools()
.iter()
.map(|t| t.name.clone())
.collect::<Vec<_>>()
};
let order_b = {
let mut registry = ToolRegistry::new(ctx.clone());
registry.register(make_test_tool("alpha"));
registry.register(make_test_tool("mango"));
registry.register(make_test_tool("zebra"));
registry
.to_api_tools()
.iter()
.map(|t| t.name.clone())
.collect::<Vec<_>>()
};
assert_eq!(order_a, vec!["alpha", "mango", "zebra"]);
assert_eq!(order_a, order_b);
}
fn scoped_context(workspace: &std::path::Path) -> ToolContext {
ToolContext::new(workspace.to_path_buf())
.with_tool_authority(
ToolAuthorityEnvelope {
schema_version: 1,
owner: "fleet-worker-1".to_string(),
authority: ToolMutationAuthority::ScopedWrite,
network_access: None,
writable_roots: vec!["src".to_string()],
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
}
.normalized()
.expect("test authority"),
)
.expect("test context authority")
}
#[tokio::test]
async fn fleet_authority_allows_scoped_file_writes_and_rejects_outside_paths() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
std::fs::create_dir(tmp.path().join("docs")).expect("docs");
let registry = ToolRegistryBuilder::new()
.with_file_tools()
.with_patch_tools()
.build(scoped_context(tmp.path()));
registry
.execute_full(
"File",
json!({"action": "write", "path": "src/ok.txt", "content": "ok\n"}),
)
.await
.expect("scoped File write");
assert_eq!(
std::fs::read_to_string(tmp.path().join("src/ok.txt")).expect("written file"),
"ok\n"
);
let error = registry
.execute_full(
"File",
json!({"action": "write", "path": "docs/no.txt", "content": "no\n"}),
)
.await
.expect_err("out-of-scope File write")
.to_string();
assert!(error.contains("outside its machine-readable"), "{error}");
assert!(!tmp.path().join("docs/no.txt").exists());
}
#[tokio::test]
async fn fleet_authority_denies_bash_even_when_command_classifier_calls_it_read_only() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
let registry = ToolRegistryBuilder::new()
.with_shell_tools()
.build(scoped_context(tmp.path()));
let error = registry
.execute_full("Bash", json!({"action": "run", "command": "git status"}))
.await
.expect_err("Bash remains unprovable under a file scope")
.to_string();
assert!(error.contains("arbitrary command execution"), "{error}");
}
#[tokio::test]
async fn fleet_authority_denies_git_even_when_the_action_is_nominally_read_only() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
let registry = ToolRegistryBuilder::new()
.with_git_tools()
.with_git_history_tools()
.with_review_tool(None, "fixture-model".to_string())
.build(scoped_context(tmp.path()));
for (name, input) in [
("Git", json!({"action": "status"})),
("Git", json!({"action": "diff"})),
("Git", json!({"action": "show", "revision": "HEAD"})),
("Git", json!({"action": "blame", "path": "src/lib.rs"})),
("review", json!({"target": "diff"})),
] {
let error = registry
.execute_full(name, input)
.await
.expect_err("Git subprocesses remain unprovable under Fleet authority")
.to_string();
assert!(error.contains("Git helpers"), "{name}: {error}");
}
}
#[tokio::test]
async fn fleet_authority_rejects_fim_edit_outside_its_write_scope() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
std::fs::create_dir(tmp.path().join("docs")).expect("docs");
std::fs::write(tmp.path().join("docs/outside.txt"), "before\nafter\n").expect("fixture");
let registry = ToolRegistryBuilder::new()
.with_fim_tool(None, "fixture-model".to_string())
.build(scoped_context(tmp.path()));
let error = registry
.execute_full(
"fim_edit",
json!({
"path": "docs/outside.txt",
"prefix_anchor": "before\n",
"suffix_anchor": "after\n"
}),
)
.await
.expect_err("FIM mutation must be checked before model execution")
.to_string();
assert!(error.contains("outside its machine-readable"), "{error}");
assert_eq!(
std::fs::read_to_string(tmp.path().join("docs/outside.txt")).unwrap(),
"before\nafter\n"
);
}
struct MixedExecutionTool;
#[async_trait::async_trait]
impl ToolSpec for MixedExecutionTool {
fn name(&self) -> &str {
"mixed_execution"
}
fn description(&self) -> &str {
"inspect or start a child"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ExecutesCode]
}
fn is_read_only_for(&self, input: &Value) -> bool {
input.get("action").and_then(Value::as_str) == Some("inspect")
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
Ok(ToolResult::success("observed"))
}
}
#[tokio::test]
async fn fleet_authority_allows_read_only_actions_but_denies_mixed_family_starts() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
let registry = ToolRegistryBuilder::new()
.with_tool(Arc::new(MixedExecutionTool))
.build(scoped_context(tmp.path()));
registry
.execute_full("mixed_execution", json!({"action": "inspect"}))
.await
.expect("read-only status/inspect actions remain usable");
let error = registry
.execute_full("mixed_execution", json!({"action": "start"}))
.await
.expect_err("child/code starts remain denied")
.to_string();
assert!(error.contains("child execution"), "{error}");
}
struct UnscopedMutator;
#[async_trait::async_trait]
impl ToolSpec for UnscopedMutator {
fn name(&self) -> &str {
"unscoped_mutator"
}
fn description(&self) -> &str {
"mutates state without a file target"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn capabilities(&self) -> Vec<ToolCapability> {
Vec::new()
}
fn is_read_only_for(&self, _input: &Value) -> bool {
false
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
Ok(ToolResult::success("mutated"))
}
}
#[tokio::test]
async fn fleet_authority_denies_every_unscoped_mutator_not_only_file_capabilities() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
let registry = ToolRegistryBuilder::new()
.with_tool(Arc::new(UnscopedMutator))
.build(scoped_context(tmp.path()));
let error = registry
.execute_full("unscoped_mutator", json!({}))
.await
.expect_err("unscoped mutation must fail closed")
.to_string();
assert!(error.contains("mutating tool"), "{error}");
}
#[test]
fn test_builder_basic() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_tool(make_test_tool("custom"))
.build(ctx);
assert!(registry.contains("custom"));
}
#[test]
fn test_builder_with_web_tools_no_longer_includes_finance() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new().with_web_tools().build(ctx);
assert!(registry.contains("Web"));
assert!(registry.contains("web.run"));
for retired in ["web_search", "fetch_url", "wait_for_dev_server"] {
assert!(!registry.contains(retired), "{retired} must stay removed");
}
assert!(!registry.contains("finance"));
}
#[test]
fn canonical_runtime_tools_remove_legacy_aliases() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_file_tools()
.with_search_tools()
.with_git_tools()
.with_git_history_tools()
.with_test_runner_tool()
.with_web_tools()
.with_patch_tools()
.build(ctx);
let api_names = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
for canonical in ["File", "Git", "Run", "Web"] {
assert!(api_names.iter().any(|name| name == canonical));
}
for retired in [
"read_file",
"write_file",
"edit_file",
"list_dir",
"file_search",
"grep_files",
"git_status",
"git_diff",
"git_log",
"git_show",
"git_blame",
"run_tests",
"run_verifiers",
"web_search",
"fetch_url",
"wait_for_dev_server",
] {
assert!(!registry.contains(retired), "{retired} must stay removed");
assert!(
api_names.iter().all(|name| name != retired),
"{retired} must not be advertised"
);
}
assert!(registry.contains("apply_patch"));
assert!(api_names.iter().all(|name| name != "apply_patch"));
}
#[tokio::test]
async fn canonical_file_actions_share_read_before_edit_state() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join("sample.txt"), "before\n").expect("fixture");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new().with_file_tools().build(ctx);
registry
.execute_full("File", json!({"action": "read", "path": "sample.txt"}))
.await
.expect("canonical read should execute");
registry
.execute_full(
"File",
json!({
"action": "edit",
"path": "sample.txt",
"search": "before",
"replace": "after"
}),
)
.await
.expect("canonical edit should execute after the read");
assert_eq!(
std::fs::read_to_string(tmp.path().join("sample.txt")).expect("edited file"),
"after\n"
);
}
#[test]
fn read_only_file_surface_does_not_advertise_write_actions() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_read_only_file_tools()
.with_search_tools()
.build(ctx);
let file = registry
.to_api_tools()
.into_iter()
.find(|tool| tool.name == "File")
.expect("canonical File tool");
let actions = file.input_schema["properties"]["action"]["enum"]
.as_array()
.expect("action enum");
for blocked in ["write", "edit", "patch"] {
assert!(actions.iter().all(|action| action != blocked));
}
}
#[test]
fn test_builder_with_finance_tool() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new().with_finance_tool().build(ctx);
assert!(registry.contains("finance"));
}
#[test]
fn with_verify_tool_registers_and_exposes_verify() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_verify_tool(None, "test-model".to_string())
.build(ctx);
assert!(
registry.contains("verify"),
"verify tool should be registered"
);
let api_names = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert!(
api_names.iter().any(|name| name == "verify"),
"verify tool should be model-visible"
);
}
#[test]
fn agent_runtime_surface_gates_verify_on_option() {
use super::AgentToolSurfaceOptions;
use crate::worker_profile::ShellPolicy;
let build_surface = |verify_enabled: bool| {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
options.verify_tool_enabled = verify_enabled;
ToolRegistryBuilder::new()
.with_agent_runtime_surface(
None,
"test-model".to_string(),
options,
crate::tools::todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
)
.build(ctx)
};
assert!(
build_surface(true).contains("verify"),
"verify should register when enabled"
);
assert!(
!build_surface(false).contains("verify"),
"verify should be absent when the opt-out disables it"
);
}
#[test]
fn test_builder_with_agent_tools_policy_includes_finance() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_agent_tools_policy(crate::worker_profile::ShellPolicy::None)
.build(ctx);
assert!(registry.contains("finance"));
}
#[test]
fn agent_tools_with_shell_policy_none_excludes_shell_tools() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_agent_tools_policy(crate::worker_profile::ShellPolicy::None)
.build(ctx);
assert!(
!registry.contains("Bash"),
"Bash should be excluded when the shell policy is None"
);
assert!(
!registry.contains("exec_shell"),
"retired exec_shell must remain absent"
);
assert!(
!registry.contains("task_shell_start"),
"task_shell_start should be excluded when the shell policy is None"
);
assert!(
!registry.contains("task_shell_wait"),
"task_shell_wait should be excluded when the shell policy is None"
);
}
#[test]
fn agent_tools_with_shell_policy_readonly_includes_shell_tools() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_agent_tools_policy(crate::worker_profile::ShellPolicy::ReadOnly)
.build(ctx);
assert!(registry.contains("Bash"));
assert!(!registry.contains("exec_shell"));
assert!(registry.contains("task_shell_start"));
assert!(registry.contains("task_shell_wait"));
}
#[test]
fn agent_tools_with_shell_policy_full_includes_shell_tools() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_agent_tools_policy(crate::worker_profile::ShellPolicy::Full)
.build(ctx);
assert!(registry.contains("Bash"));
assert!(!registry.contains("exec_shell"));
assert!(
registry.contains("task_shell_start"),
"task_shell_start should be included when the shell policy is Full"
);
assert!(
registry.contains("task_shell_wait"),
"task_shell_wait should be included when the shell policy is Full"
);
}
#[test]
fn shell_surface_contains_only_the_canonical_bash_tool() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new().with_shell_tools().build(ctx);
for alias in [
"exec_shell",
"exec_wait",
"exec_interact",
"exec_shell_wait",
"exec_shell_interact",
"exec_shell_cancel",
] {
assert!(!registry.contains(alias), "{alias} must be removed");
}
let api_names: Vec<String> = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
assert!(
api_names.iter().any(|n| n == "Bash"),
"Bash should be model-visible"
);
for alias in [
"exec_shell",
"exec_wait",
"exec_interact",
"exec_shell_wait",
"exec_shell_interact",
"exec_shell_cancel",
] {
assert!(
api_names.iter().all(|n| n != alias),
"{alias} should be hidden from the model catalog"
);
}
}
#[test]
fn runtime_task_families_expose_only_canonical_tools() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_runtime_task_tools()
.build(ctx);
let legacy_aliases = [
"task_create",
"task_list",
"task_read",
"task_cancel",
"task_gate_run",
"pr_attempt_record",
"pr_attempt_list",
"pr_attempt_read",
"pr_attempt_preflight",
"github_issue_context",
"github_pr_context",
"github_comment",
"github_close_issue",
"github_close_pr",
"automation_create",
"automation_list",
"automation_read",
"automation_update",
"automation_pause",
"automation_resume",
"automation_delete",
"automation_run",
];
for alias in legacy_aliases {
assert!(!registry.contains(alias), "{alias} must be removed");
}
let api_names: Vec<String> = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
for canonical in ["tasks", "github", "automation"] {
assert!(
api_names.iter().any(|n| n == canonical),
"{canonical} should be model-visible"
);
}
for alias in legacy_aliases {
assert!(
api_names.iter().all(|n| n != alias),
"{alias} should be hidden from the model catalog"
);
}
}
#[test]
fn read_only_task_surface_contains_no_per_action_aliases() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_runtime_read_only_task_tools()
.build(ctx);
for name in [
"task_list",
"task_read",
"pr_attempt_list",
"pr_attempt_read",
"github_issue_context",
"github_pr_context",
"automation_list",
"automation_read",
"task_create",
"task_cancel",
"task_gate_run",
"pr_attempt_record",
"pr_attempt_preflight",
"github_comment",
"github_close_issue",
"github_close_pr",
"automation_create",
"automation_update",
"automation_pause",
"automation_resume",
"automation_delete",
"automation_run",
] {
assert!(!registry.contains(name), "{name} must be removed");
}
let api_names: Vec<String> = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
assert_eq!(api_names.len(), 3);
for canonical in ["tasks", "github", "automation"] {
assert!(
api_names.iter().any(|n| n == canonical),
"{canonical} should be model-visible on the read-only surface"
);
}
for tool in registry.all() {
let caps = tool.capabilities();
assert!(
!caps.contains(&ToolCapability::WritesFiles)
&& !caps.contains(&ToolCapability::ExecutesCode),
"read-only surface must not register write/exec tools: {}",
tool.name()
);
}
}
#[test]
fn rlm_family_removes_legacy_aliases() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_rlm_tool(None, "deepseek-v4-pro".to_string())
.build(ctx);
for alias in [
"rlm_session_objects",
"rlm_open",
"rlm_eval",
"rlm_configure",
"rlm_close",
] {
assert!(!registry.contains(alias), "{alias} must stay removed");
}
let api_names: Vec<String> = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
assert!(
api_names.iter().any(|n| n == "rlm"),
"rlm should be model-visible"
);
for retired in [
"rlm_session_objects",
"rlm_open",
"rlm_eval",
"rlm_configure",
"rlm_close",
] {
assert!(
api_names.iter().all(|n| n != retired),
"{retired} must not be advertised"
);
}
}
}