use serde_json::Value;
use super::ToolRegistry;
impl ToolRegistry {
pub fn is_mutating_tool(&self, name: &str) -> bool {
let resolved_name = self
.resolve_public_tool_name_sync(name)
.ok()
.unwrap_or_else(|| name.to_string());
if let Some(reg) = self.inventory.get_registration(&resolved_name) {
if let Some(behavior) = reg.metadata().behavior() {
return !matches!(
behavior.mutation_model,
crate::tools::tool_intent::ToolMutationModel::ReadOnly
);
}
if let super::ToolHandler::TraitObject(tool) = reg.handler() {
return tool.is_mutating();
}
}
if let Some(reg) = self.inventory.get_registration(name)
&& let super::ToolHandler::TraitObject(tool) = reg.handler()
{
return tool.is_mutating();
}
true
}
pub fn is_plan_mode_allowed(&self, tool_name: &str, args: &Value) -> bool {
use crate::config::constants::tools;
use crate::tools::names::canonical_tool_name;
let canonical = canonical_tool_name(tool_name);
match canonical.as_ref() {
tools::TASK_TRACKER => return true,
tools::PLAN_TASK_TRACKER => return true,
_ => {}
}
let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
if !intent.mutating {
return true;
}
let allowed_plan_write = self.is_plan_file_operation(tool_name, args);
let allowed_unified_readonly = intent.readonly_unified_action;
allowed_plan_write || allowed_unified_readonly
}
pub fn is_retry_safe_call(&self, tool_name: &str, args: &Value) -> bool {
crate::tools::tool_intent::classify_tool_intent(tool_name, args).retry_safe
}
pub(super) fn is_plan_file_operation(&self, tool_name: &str, args: &Value) -> bool {
use crate::config::constants::tools as tool_names;
use crate::tools::names::canonical_tool_name;
let canonical = canonical_tool_name(tool_name);
let normalized = canonical.as_ref();
let file_writing_tools = [
tool_names::WRITE_FILE,
tool_names::UNIFIED_FILE,
tool_names::CREATE_FILE,
tool_names::EDIT_FILE,
tool_names::SEARCH_REPLACE,
];
if !file_writing_tools.contains(&normalized) {
return false;
}
let path_str = args
.get("path")
.or_else(|| args.get("file_path"))
.or_else(|| args.get("filePath"))
.or_else(|| args.get("destination"))
.or_else(|| args.get("destination_path"))
.and_then(|v| v.as_str());
let Some(path_str) = path_str else {
return false;
};
let path = std::path::Path::new(path_str);
let plans_suffix = std::path::Path::new(".vtcode").join("plans");
if path_str.contains(".vtcode/plans/") || path_str.contains(".vtcode\\plans\\") {
return true;
}
if path.starts_with(&plans_suffix) {
return true;
}
let workspace = self.inventory.workspace_root();
let plans_dir = workspace.join(".vtcode").join("plans");
if path.starts_with(&plans_dir) {
return true;
}
if path_str.contains("/tmp/vtcode-plans/") || path_str.contains("\\tmp\\vtcode-plans\\") {
return true;
}
let tmp_plans = std::env::temp_dir().join("vtcode-plans");
if path.starts_with(&tmp_plans) {
return true;
}
false
}
#[allow(dead_code)]
pub(super) fn is_readonly_unified_action(&self, tool_name: &str, args: &Value) -> bool {
crate::tools::tool_intent::classify_tool_intent(tool_name, args).readonly_unified_action
}
}
#[cfg(test)]
mod tests {
use super::ToolRegistry;
use crate::config::constants::tools;
use anyhow::Result;
use serde_json::json;
use tempfile::TempDir;
#[tokio::test]
async fn retry_safe_for_readonly_calls() -> Result<()> {
let temp_dir = TempDir::new()?;
let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
assert!(registry.is_retry_safe_call(
tools::UNIFIED_FILE,
&json!({"action": "read", "path": "README.md"})
));
assert!(registry.is_retry_safe_call(
tools::UNIFIED_EXEC,
&json!({"action": "poll", "session_id": 42})
));
assert!(registry.is_retry_safe_call(
tools::UNIFIED_EXEC,
&json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
));
assert!(registry.is_retry_safe_call(tools::READ_FILE, &json!({"path": "README.md"})));
Ok(())
}
#[tokio::test]
async fn retry_unsafe_for_mutating_calls() -> Result<()> {
let temp_dir = TempDir::new()?;
let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
assert!(!registry.is_retry_safe_call(
tools::UNIFIED_FILE,
&json!({"action": "write", "path": "foo.txt", "content": "x"})
));
assert!(!registry.is_retry_safe_call(
tools::UNIFIED_EXEC,
&json!({"action": "run", "command": "echo hi"})
));
assert!(!registry.is_retry_safe_call(
tools::WRITE_FILE,
&json!({"path": "foo.txt", "content": "x"})
));
Ok(())
}
#[tokio::test]
async fn plan_mode_allows_adaptive_task_tracker_and_plan_alias() -> Result<()> {
let temp_dir = TempDir::new()?;
let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
registry.enable_plan_mode();
assert!(registry.is_plan_mode_allowed(tools::TASK_TRACKER, &json!({"action": "list"})));
assert!(
registry.is_plan_mode_allowed(tools::PLAN_TASK_TRACKER, &json!({"action": "list"}))
);
Ok(())
}
#[tokio::test]
async fn plan_mode_allows_readonly_unified_exec_runs() -> Result<()> {
let temp_dir = TempDir::new()?;
let registry = ToolRegistry::new(temp_dir.path().to_path_buf()).await;
registry.enable_plan_mode();
assert!(registry.is_plan_mode_allowed(
tools::UNIFIED_EXEC,
&json!({"action": "run", "command": "ls -la"})
));
assert!(registry.is_plan_mode_allowed(
tools::UNIFIED_EXEC,
&json!({"action": "run", "command": "npm install --dry-run"})
));
assert!(!registry.is_plan_mode_allowed(
tools::UNIFIED_EXEC,
&json!({"action": "run", "command": "echo hi"})
));
Ok(())
}
}