use crate::{
lsp::LspManager,
output::ToolDispatchContext,
sessions::SessionManager,
skills::SkillDiscovery,
tools::{ToolResult, browser, find, fs_cache, hash_edit::HashlineSnapshotStore, workspace},
};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
#[derive(Clone)]
pub struct ToolRuntime {
pub(super) cwd: PathBuf,
pub(super) cwd_canonical: PathBuf,
pub(super) read_absolute_paths: bool,
pub(super) view_image_absolute_paths: bool,
pub(super) view_image_max_image_bytes: u64,
pub(super) view_image_vision_model: Option<crate::config::ViewImageVisionModelSettings>,
pub(super) view_image_custom_providers: BTreeMap<String, crate::config::CustomProviderConfig>,
pub(super) view_image_paths: Option<crate::config::McPaths>,
pub(super) view_image_text_verbosity: Option<crate::config::TextVerbosity>,
pub(super) view_image_codex_text_verbosity: crate::config::TextVerbosity,
pub(super) session_manager: Arc<SessionManager>,
pub(super) checkpoint_context: Arc<Mutex<Option<crate::checkpoints::SnapshotContext>>>,
pub(super) hashline_absolute_paths: bool,
pub(super) write_absolute_paths: bool,
pub(super) grep_absolute_paths: bool,
pub(super) find_absolute_paths: bool,
pub(super) list_files_absolute_paths: bool,
pub(super) repo_map_absolute_paths: bool,
pub(super) ast_grep_absolute_paths: bool,
pub(super) bash_absolute_paths: bool,
pub(super) bash_shell_expansion: bool,
pub(super) subagents_absolute_paths: bool,
pub(super) subagents_max_depth: usize,
pub(super) output_compression: crate::config::ToolOutputCompressionSettings,
pub(super) subdir_discovery: bool,
pub(super) subagent_depth: usize,
pub(super) mutation_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
pub(super) path_search: Arc<find::FindSearchService>,
pub(super) fs_cache: Arc<fs_cache::FsCache>,
pub(super) hashline_snapshots: Arc<Mutex<HashlineSnapshotStore>>,
pub(super) workspace_walker: Arc<workspace::WorkspaceWalker>,
pub(super) skills: Arc<BTreeMap<String, crate::skills::Skill>>,
pub(super) subagents:
Option<Arc<dyn Fn(Value, ToolDispatchContext) -> ToolResult + Send + Sync>>,
pub(super) browser_state: Arc<Mutex<browser::BrowserRuntimeState>>,
pub(super) browser_operation_lock: Arc<Mutex<()>>,
pub(super) mcp: Option<Arc<Mutex<crate::mcp::manager::McpManager>>>,
lsp_manager: Option<Arc<LspManager>>,
disabled_tools: Arc<Mutex<HashSet<String>>>,
}
impl std::fmt::Debug for ToolRuntime {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ToolRuntime")
.field("cwd", &self.cwd)
.field("cwd_canonical", &self.cwd_canonical)
.field("read_absolute_paths", &self.read_absolute_paths)
.field("view_image_absolute_paths", &self.view_image_absolute_paths)
.field(
"view_image_max_image_bytes",
&self.view_image_max_image_bytes,
)
.field(
"view_image_vision_model",
&self
.view_image_vision_model
.as_ref()
.map(|_| "<configured>"),
)
.field(
"view_image_custom_providers",
&self.view_image_custom_providers.keys().collect::<Vec<_>>(),
)
.field("view_image_has_paths", &self.view_image_paths.is_some())
.field("session_root", &"<configured>")
.field("hashline_absolute_paths", &self.hashline_absolute_paths)
.field("write_absolute_paths", &self.write_absolute_paths)
.field("grep_absolute_paths", &self.grep_absolute_paths)
.field("find_absolute_paths", &self.find_absolute_paths)
.field("list_files_absolute_paths", &self.list_files_absolute_paths)
.field("repo_map_absolute_paths", &self.repo_map_absolute_paths)
.field("ast_grep_absolute_paths", &self.ast_grep_absolute_paths)
.field("bash_absolute_paths", &self.bash_absolute_paths)
.field("bash_shell_expansion", &self.bash_shell_expansion)
.field("subagents_absolute_paths", &self.subagents_absolute_paths)
.field("subagents_max_depth", &self.subagents_max_depth)
.field("output_compression", &self.output_compression)
.field("subdir_discovery", &self.subdir_discovery)
.field("subagent_depth", &self.subagent_depth)
.field("path_index_count", &self.path_search.index_count())
.field("fs_cache_entries", &self.fs_cache.len().ok())
.field(
"hashline_snapshot_count",
&self.hashline_snapshots.lock().map(|store| store.len()).ok(),
)
.field("skills_count", &self.skills.len())
.field("has_subagents", &self.subagents.is_some())
.field("has_mcp", &self.mcp.is_some())
.field("has_lsp", &self.lsp_manager.is_some())
.field("disabled_tools", &self.disabled_tool_names().ok())
.field("browser_state", &self.browser_state)
.finish_non_exhaustive()
}
}
impl ToolRuntime {
#[cfg(test)]
pub fn new(cwd: impl Into<PathBuf>) -> anyhow::Result<Self> {
Self::new_with_settings(cwd, crate::config::ToolSettings::default())
}
pub fn new_with_settings(
cwd: impl Into<PathBuf>,
settings: crate::config::ToolSettings,
) -> anyhow::Result<Self> {
let cwd = cwd.into();
let cwd_canonical = cwd.canonicalize()?;
Ok(Self {
cwd: cwd_canonical.clone(),
cwd_canonical: cwd_canonical.clone(),
read_absolute_paths: settings.read.absolute_paths,
view_image_absolute_paths: settings.view_image.absolute_paths,
view_image_max_image_bytes: settings.view_image.max_image_bytes,
view_image_vision_model: settings.view_image.vision_model.clone(),
view_image_custom_providers: BTreeMap::new(),
view_image_paths: None,
view_image_text_verbosity: None,
view_image_codex_text_verbosity: crate::config::TextVerbosity::Low,
session_manager: Arc::new(SessionManager::new(
cwd_canonical.join(".magi-code/sessions"),
)),
checkpoint_context: Arc::new(Mutex::new(None)),
hashline_absolute_paths: settings.hash_edit.absolute_paths,
write_absolute_paths: settings.write.absolute_paths,
grep_absolute_paths: settings.grep.absolute_paths,
find_absolute_paths: settings.find.absolute_paths,
list_files_absolute_paths: settings.list_files.absolute_paths,
repo_map_absolute_paths: settings.repo_map.absolute_paths,
ast_grep_absolute_paths: settings.ast_grep.absolute_paths,
bash_absolute_paths: settings.bash.absolute_paths,
bash_shell_expansion: settings.bash.shell_expansion,
subagents_absolute_paths: settings.subagents.absolute_paths,
subagents_max_depth: crate::config::clamp_subagent_max_depth(
settings.subagents.max_depth,
),
output_compression: settings.output_compression,
subdir_discovery: false,
subagent_depth: 0,
mutation_locks: Arc::new(Mutex::new(HashMap::new())),
path_search: Arc::new(find::FindSearchService),
fs_cache: Arc::new(fs_cache::FsCache::default()),
hashline_snapshots: Arc::new(Mutex::new(HashlineSnapshotStore::default())),
workspace_walker: Arc::new(workspace::WorkspaceWalker),
skills: Arc::new(BTreeMap::new()),
subagents: None,
browser_state: Arc::new(Mutex::new(browser::BrowserRuntimeState::new())),
browser_operation_lock: Arc::new(Mutex::new(())),
mcp: None,
disabled_tools: Arc::new(Mutex::new(HashSet::new())),
lsp_manager: None,
})
}
pub(crate) fn new_with_full_settings_and_mcp(
cwd: impl Into<PathBuf>,
paths: crate::config::McPaths,
settings: crate::config::Settings,
mcp: Option<Arc<Mutex<crate::mcp::manager::McpManager>>>,
) -> anyhow::Result<Self> {
let mcp_servers = settings.mcp_servers.clone();
let custom_providers = settings.custom_providers.clone();
let lsp_settings = settings.lsp.clone();
let shared_text_verbosity = settings.openai_responses.text_verbosity;
let codex_text_verbosity = settings
.text_verbosity_for(crate::providers::OPENAI_CODEX_PROVIDER)
.expect("Codex text verbosity always resolves");
let mut runtime = Self::new_with_settings(cwd, settings.tools)?;
runtime.view_image_text_verbosity = shared_text_verbosity;
runtime.view_image_codex_text_verbosity = codex_text_verbosity;
if lsp_settings.enabled {
runtime.lsp_manager = Some(Arc::new(LspManager::new(
lsp_settings,
runtime.cwd_canonical.clone(),
)));
}
if let Some(mcp) = mcp {
runtime.mcp = Some(mcp);
} else if !mcp_servers.is_empty() {
runtime.mcp = Some(Arc::new(Mutex::new(
crate::mcp::manager::McpManager::from_settings_with_paths(
&mcp_servers,
Some(&paths.root),
),
)));
}
runtime.view_image_custom_providers = custom_providers;
runtime.session_manager = Arc::new(SessionManager::new(paths.sessions.clone()));
runtime.view_image_paths = Some(paths);
runtime.subdir_discovery = settings.instructions.subdir_discovery;
Ok(runtime)
}
pub fn with_skills(mut self, skills: &SkillDiscovery) -> Self {
self.skills = Arc::new(
skills
.skills
.iter()
.map(|(name, skill)| (name.clone(), skill.metadata_only()))
.collect(),
);
self
}
pub fn with_subagents(
mut self,
runner: impl Fn(Value, ToolDispatchContext) -> ToolResult + Send + Sync + 'static,
) -> Self {
self.subagents = Some(Arc::new(runner));
self
}
pub(crate) fn with_disabled_tools(
mut self,
disabled_tools: Arc<Mutex<HashSet<String>>>,
) -> Self {
self.disabled_tools = disabled_tools;
self
}
pub(crate) fn canonical_disabled_key(name: &str) -> anyhow::Result<String> {
if let Some(tool) = crate::tools::ToolCapability::from_dispatch_name(name) {
return Ok(tool.canonical_name().to_string());
}
if name.starts_with("mcp__") {
return Ok(name.to_string());
}
anyhow::bail!("unknown tool '{name}'")
}
#[cfg(test)]
pub(crate) fn set_tool_enabled(&self, name: &str, enabled: bool) -> anyhow::Result<()> {
let key = Self::canonical_disabled_key(name)?;
let mut disabled = self
.disabled_tools
.lock()
.map_err(|_| anyhow::anyhow!("disabled tools lock poisoned"))?;
if enabled {
disabled.remove(&key);
} else {
disabled.insert(key);
}
Ok(())
}
pub(crate) fn disabled_tool_names(&self) -> anyhow::Result<HashSet<String>> {
self.disabled_tools
.lock()
.map(|disabled| disabled.clone())
.map_err(|_| anyhow::anyhow!("disabled tools lock poisoned"))
}
pub(crate) fn is_tool_disabled(&self, name: &str) -> bool {
let Ok(key) = Self::canonical_disabled_key(name) else {
return false;
};
self.disabled_tools
.lock()
.map(|disabled| disabled.contains(&key))
.unwrap_or(true)
}
pub fn clone_for_cwd_with_subagent_depth(
&self,
cwd: &Path,
depth: usize,
) -> anyhow::Result<Self> {
let cwd_canonical = cwd.canonicalize()?;
let lsp_manager = if cwd_canonical == self.cwd_canonical {
self.lsp_manager.clone()
} else {
None
};
Ok(Self {
cwd: cwd_canonical.clone(),
cwd_canonical,
read_absolute_paths: self.read_absolute_paths,
view_image_absolute_paths: self.view_image_absolute_paths,
view_image_max_image_bytes: self.view_image_max_image_bytes,
view_image_vision_model: self.view_image_vision_model.clone(),
view_image_custom_providers: self.view_image_custom_providers.clone(),
view_image_paths: self.view_image_paths.clone(),
view_image_text_verbosity: self.view_image_text_verbosity,
view_image_codex_text_verbosity: self.view_image_codex_text_verbosity,
session_manager: Arc::clone(&self.session_manager),
checkpoint_context: Arc::clone(&self.checkpoint_context),
hashline_absolute_paths: self.hashline_absolute_paths,
write_absolute_paths: self.write_absolute_paths,
grep_absolute_paths: self.grep_absolute_paths,
find_absolute_paths: self.find_absolute_paths,
list_files_absolute_paths: self.list_files_absolute_paths,
repo_map_absolute_paths: self.repo_map_absolute_paths,
ast_grep_absolute_paths: self.ast_grep_absolute_paths,
bash_absolute_paths: self.bash_absolute_paths,
bash_shell_expansion: self.bash_shell_expansion,
subagents_absolute_paths: self.subagents_absolute_paths,
subagents_max_depth: self.subagents_max_depth,
output_compression: self.output_compression.clone(),
subdir_discovery: self.subdir_discovery,
subagent_depth: depth,
mutation_locks: Arc::clone(&self.mutation_locks),
path_search: Arc::clone(&self.path_search),
fs_cache: Arc::clone(&self.fs_cache),
hashline_snapshots: Arc::clone(&self.hashline_snapshots),
workspace_walker: Arc::clone(&self.workspace_walker),
skills: Arc::clone(&self.skills),
subagents: None,
browser_state: Arc::clone(&self.browser_state),
browser_operation_lock: Arc::clone(&self.browser_operation_lock),
mcp: self.mcp.clone(),
disabled_tools: Arc::clone(&self.disabled_tools),
lsp_manager,
})
}
pub(crate) fn subagents_absolute_paths(&self) -> bool {
self.subagents_absolute_paths
}
pub(crate) fn subagents_max_depth(&self) -> usize {
self.subagents_max_depth
}
pub(crate) fn subagents_schema_enabled(&self) -> bool {
self.subagent_depth < self.subagents_max_depth
&& !self.is_tool_disabled(crate::tools::contract::tool_name::SUBAGENTS)
}
pub(crate) fn output_compression_settings(
&self,
) -> &crate::config::ToolOutputCompressionSettings {
&self.output_compression
}
pub(crate) fn subdir_discovery_enabled(&self) -> bool {
self.subdir_discovery
}
pub(crate) fn checkpoint_paths(&self) -> Option<crate::config::McPaths> {
self.view_image_paths.clone()
}
pub(crate) fn set_checkpoint_context(
&self,
context: Option<crate::checkpoints::SnapshotContext>,
) -> anyhow::Result<()> {
let mut slot = self
.checkpoint_context
.lock()
.map_err(|_| anyhow::anyhow!("checkpoint context lock poisoned"))?;
*slot = context;
Ok(())
}
#[cfg(test)]
pub(crate) fn poison_checkpoint_context_lock(&self) {
let checkpoint_context = Arc::clone(&self.checkpoint_context);
let _ = std::thread::spawn(move || {
let _guard = checkpoint_context.lock().expect("checkpoint context lock");
panic!("poison checkpoint context lock");
})
.join();
}
pub(crate) fn checkpoint_context(&self) -> Option<crate::checkpoints::SnapshotContext> {
self.checkpoint_context
.lock()
.ok()
.and_then(|slot| slot.clone())
}
pub(crate) fn cwd_canonical(&self) -> &Path {
&self.cwd_canonical
}
pub(crate) fn lsp_manager(&self) -> Option<&LspManager> {
self.lsp_manager.as_deref()
}
pub(crate) fn dynamic_provider_tool_definitions(&self) -> Vec<Value> {
let mut definitions = Vec::new();
if self.lsp_manager.is_some() {
definitions.extend(crate::tools::lsp_tool_provider_definitions());
}
definitions.extend(
self.mcp
.as_ref()
.and_then(|manager| {
manager.lock().ok().map(|manager| {
manager
.provider_tool_definitions()
.into_iter()
.filter(|definition| {
definition
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| !self.is_tool_disabled(name))
})
.collect::<Vec<_>>()
})
})
.unwrap_or_default(),
);
definitions
}
}
#[cfg(test)]
mod disabled_tool_tests {
use super::*;
use std::{collections::HashSet, fs, sync::Arc};
fn settings_with_marker_mcp(marker: &Path) -> crate::config::Settings {
let mut env = BTreeMap::new();
env.insert("MARKER".to_string(), marker.to_string_lossy().into_owned());
let mut settings = crate::config::Settings::default();
settings.mcp_servers.insert(
"spawncheck".to_string(),
crate::config::McpServerConfig::Stdio(crate::config::McpStdioServerConfig {
command: "/bin/sh".to_string(),
args: vec![
"-c".to_string(),
"printf spawned > \"$MARKER\"; exit 1".to_string(),
],
env,
enabled: true,
timeout: Some(1),
}),
);
settings
}
#[test]
fn full_settings_uses_shared_mcp_without_constructing_settings_manager() {
let temp = tempfile::TempDir::new().unwrap();
let marker = temp.path().join("spawned");
let paths = crate::config::McPaths::from_root(temp.path().join("mc-home"));
let shared = Arc::new(Mutex::new(crate::mcp::manager::McpManager::from_settings(
&crate::config::McpServersSettings::new(),
)));
let runtime = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
paths,
settings_with_marker_mcp(&marker),
Some(Arc::clone(&shared)),
)
.unwrap();
assert!(
!marker.exists(),
"settings-based MCP manager was constructed"
);
assert!(Arc::ptr_eq(runtime.mcp.as_ref().unwrap(), &shared));
}
#[test]
fn full_settings_without_shared_mcp_constructs_settings_manager() {
let temp = tempfile::TempDir::new().unwrap();
let marker = temp.path().join("spawned");
let paths = crate::config::McPaths::from_root(temp.path().join("mc-home"));
let runtime = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
paths,
settings_with_marker_mcp(&marker),
None,
)
.unwrap();
assert!(runtime.mcp.is_some());
assert_eq!(fs::read_to_string(marker).unwrap(), "spawned");
}
#[test]
fn full_settings_constructs_lsp_manager_only_when_enabled() {
let temp = tempfile::TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc-home"));
let disabled = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
paths.clone(),
crate::config::Settings::default(),
None,
)
.unwrap();
assert!(disabled.lsp_manager().is_none());
let mut settings = crate::config::Settings::default();
settings.lsp.enabled = true;
let enabled =
ToolRuntime::new_with_full_settings_and_mcp(temp.path(), paths, settings, None)
.unwrap();
assert!(enabled.lsp_manager().is_some());
}
#[test]
fn dynamic_provider_tools_include_lsp_tools_only_when_enabled() {
let temp = tempfile::TempDir::new().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc-home"));
let disabled = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
paths.clone(),
crate::config::Settings::default(),
None,
)
.unwrap();
assert!(disabled.dynamic_provider_tool_definitions().is_empty());
let mut settings = crate::config::Settings::default();
settings.lsp.enabled = true;
let enabled =
ToolRuntime::new_with_full_settings_and_mcp(temp.path(), paths, settings, None)
.unwrap();
let names = enabled
.dynamic_provider_tool_definitions()
.into_iter()
.filter_map(|definition| definition.get("name")?.as_str().map(str::to_string))
.collect::<Vec<_>>();
assert_eq!(names, vec!["diagnostics", "references"]);
}
#[test]
fn runtime_clones_share_lsp_manager_only_for_same_cwd_root() {
let temp = tempfile::TempDir::new().unwrap();
let mut settings = crate::config::Settings::default();
settings.lsp.enabled = true;
let runtime = ToolRuntime::new_with_full_settings_and_mcp(
temp.path(),
crate::config::McPaths::from_root(temp.path().join("mc-home")),
settings,
None,
)
.unwrap();
let cloned = runtime
.clone_for_cwd_with_subagent_depth(temp.path(), 1)
.unwrap();
let original = runtime.lsp_manager.as_ref().unwrap();
let cloned = cloned.lsp_manager.as_ref().unwrap();
assert!(Arc::ptr_eq(original, cloned));
let other = tempfile::TempDir::new().unwrap();
let other_clone = runtime
.clone_for_cwd_with_subagent_depth(other.path(), 1)
.unwrap();
assert!(other_clone.lsp_manager.is_none());
}
#[test]
fn runtime_clones_share_workspace_services() {
let temp = tempfile::TempDir::new().unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let cloned = runtime
.clone_for_cwd_with_subagent_depth(temp.path(), 1)
.unwrap();
assert!(Arc::ptr_eq(&runtime.fs_cache, &cloned.fs_cache));
assert!(Arc::ptr_eq(
&runtime.hashline_snapshots,
&cloned.hashline_snapshots
));
assert!(Arc::ptr_eq(
&runtime.workspace_walker,
&cloned.workspace_walker
));
}
#[test]
fn shared_disabled_tools_survive_runtime_clones_and_canonicalize_aliases() {
let temp = tempfile::TempDir::new().unwrap();
let disabled = Arc::new(Mutex::new(HashSet::new()));
let runtime = ToolRuntime::new(temp.path())
.unwrap()
.with_disabled_tools(Arc::clone(&disabled));
runtime.set_tool_enabled("shell", false).unwrap();
assert!(runtime.is_tool_disabled("bash"));
assert!(runtime.is_tool_disabled("shell"));
let cloned = runtime
.clone_for_cwd_with_subagent_depth(temp.path(), 1)
.unwrap();
assert!(cloned.is_tool_disabled("bash"));
cloned.set_tool_enabled("bash", true).unwrap();
assert!(!runtime.is_tool_disabled("bash"));
}
#[test]
fn disabled_tool_keys_reject_unknown_non_mcp_names() {
assert!(ToolRuntime::canonical_disabled_key("not_a_tool").is_err());
assert_eq!(
ToolRuntime::canonical_disabled_key("mcp__mock__echo").unwrap(),
"mcp__mock__echo"
);
}
}