vibe/vibe/core/tools/manager.py
Mathias Gesbert 626f905186
v2.9.6 (#682)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-05-11 11:44:53 +02:00

434 lines
15 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterator
import hashlib
import importlib.util
import inspect
from pathlib import Path
import re
import sys
import threading
from typing import TYPE_CHECKING, Any
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import DEFAULT_TOOL_DIR
from vibe.core.tools.base import BaseTool, BaseToolConfig
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.utils import name_matches, run_sync
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
def _try_canonical_module_name(path: Path) -> str | None:
"""Extract canonical module name for vibe package files.
Prevents Pydantic class identity mismatches when the same module
is imported via dynamic discovery and regular imports.
"""
try:
parts = path.resolve().parts
except (OSError, ValueError):
return None
try:
vibe_idx = parts.index("vibe")
except ValueError:
return None
if vibe_idx + 1 >= len(parts):
return None
module_parts = [p.removesuffix(".py") for p in parts[vibe_idx:]]
return ".".join(module_parts)
def _compute_module_name(path: Path) -> str:
"""Return canonical module name for vibe files, hash-based synthetic name otherwise."""
if canonical := _try_canonical_module_name(path):
return canonical
resolved = path.resolve()
path_hash = hashlib.md5(str(resolved).encode()).hexdigest()[:8]
stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
return f"vibe_tools_discovered_{stem}_{path_hash}"
class NoSuchToolError(Exception):
"""Exception raised when a tool is not found."""
class ToolManager:
"""Manages tool discovery and instantiation for an Agent.
Discovers available tools from the provided search paths. Each Agent
should have its own ToolManager instance.
"""
def __init__(
self,
config_getter: Callable[[], VibeConfig],
mcp_registry: MCPRegistry | None = None,
connector_registry: ConnectorRegistry | None = None,
*,
defer_mcp: bool = False,
) -> None:
self._config_getter = config_getter
self._mcp_registry = mcp_registry or MCPRegistry()
self._connector_registry = connector_registry
self._instances: dict[str, BaseTool] = {}
self._search_paths: list[Path] = self._compute_search_paths(self._config)
self._lock = threading.Lock()
self._mcp_integrated = False
self._available: dict[str, type[BaseTool]] = {
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
}
if not defer_mcp:
self.integrate_all()
@property
def _config(self) -> VibeConfig:
return self._config_getter()
@staticmethod
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
paths.extend(config.tool_paths)
mgr = get_harness_files_manager()
paths.extend(mgr.project_tools_dirs)
paths.extend(mgr.user_tools_dirs)
unique: list[Path] = []
seen: set[Path] = set()
for p in paths:
rp = p.resolve()
if rp not in seen:
seen.add(rp)
unique.append(rp)
return unique
@staticmethod
def _iter_tool_classes(search_paths: list[Path]) -> Iterator[type[BaseTool]]:
"""Iterate over all search_paths to find tool classes.
Note: if a search path is not a directory, it is treated as a single tool file.
"""
for base in search_paths:
if not base.is_dir() and base.name.endswith(".py"):
if tools := ToolManager._load_tools_from_file(base):
for tool in tools:
yield tool
for path in base.rglob("*.py"):
if tools := ToolManager._load_tools_from_file(path):
for tool in tools:
yield tool
@staticmethod
def _load_tools_from_file(file_path: Path) -> list[type[BaseTool]] | None:
if not file_path.is_file():
return
name = file_path.name
if name.startswith("_"):
return
module_name = _compute_module_name(file_path)
if module_name in sys.modules:
module = sys.modules[module_name]
else:
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
return
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
return
tools = []
for tool_obj in vars(module).values():
if not inspect.isclass(tool_obj):
continue
if not issubclass(tool_obj, BaseTool) or tool_obj is BaseTool:
continue
if inspect.isabstract(tool_obj):
continue
tools.append(tool_obj)
return tools
@staticmethod
def discover_tool_defaults(
search_paths: list[Path] | None = None,
) -> dict[str, dict[str, Any]]:
if search_paths is None:
search_paths = [DEFAULT_TOOL_DIR.path]
defaults: dict[str, dict[str, Any]] = {}
for cls in ToolManager._iter_tool_classes(search_paths):
try:
tool_name = cls.get_name()
config_class = cls._get_tool_config_class()
defaults[tool_name] = config_class().model_dump(exclude_none=True)
except Exception as e:
logger.warning(
"Failed to get defaults for tool %s: %s", cls.__name__, e
)
continue
return defaults
@property
def registered_tools(self) -> dict[str, type[BaseTool]]:
with self._lock:
return dict(self._available)
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
with self._lock:
runtime_available = {
name: cls for name, cls in self._available.items() if cls.is_available()
}
# Per-source filtering first (MCP server/connector disabled flags).
result = self._apply_per_source_filtering(runtime_available)
# Global overrides take precedence.
if self._config.enabled_tools:
return {
name: cls
for name, cls in result.items()
if name_matches(name, self._config.enabled_tools)
}
if self._config.disabled_tools:
return {
name: cls
for name, cls in result.items()
if not name_matches(name, self._config.disabled_tools)
}
return result
def _apply_per_source_filtering(
self, tools: dict[str, type[BaseTool]]
) -> dict[str, type[BaseTool]]:
"""Filter out MCP/connector tools disabled at the server or connector level."""
disabled_sources, per_source_disabled = self._build_source_disable_index()
if not disabled_sources and not per_source_disabled:
return tools
return {
name: cls
for name, cls in tools.items()
if not self._is_source_disabled(cls, disabled_sources, per_source_disabled)
}
def _build_source_disable_index(
self,
) -> tuple[set[tuple[str, bool]], dict[tuple[str, bool], set[str]]]:
"""Return (fully_disabled, per_tool_disabled) keyed by (source_name, is_connector)."""
disabled_sources: set[tuple[str, bool]] = set()
per_source_disabled: dict[tuple[str, bool], set[str]] = {}
for srv in self._config.mcp_servers:
key = (srv.name, False)
if srv.disabled:
disabled_sources.add(key)
elif srv.disabled_tools:
per_source_disabled[key] = set(srv.disabled_tools)
for cfg in self._config.connectors:
key = (cfg.name, True)
if cfg.disabled:
disabled_sources.add(key)
elif cfg.disabled_tools:
per_source_disabled[key] = set(cfg.disabled_tools)
return disabled_sources, per_source_disabled
@staticmethod
def _is_source_disabled(
tool_cls: type[BaseTool],
disabled_sources: set[tuple[str, bool]],
per_source_disabled: dict[tuple[str, bool], set[str]],
) -> bool:
if not issubclass(tool_cls, MCPTool):
return False
server_name = tool_cls.get_server_name()
if server_name is None:
return False
key = (server_name, tool_cls.is_connector())
if key in disabled_sources:
return True
return tool_cls.get_remote_name() in per_source_disabled.get(key, set())
def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
"""Discover and register MCP tools (sync wrapper).
Idempotent: subsequent calls after a successful integration are
no-ops to avoid redundant MCP discovery.
"""
run_sync(self._integrate_mcp_async(raise_on_failure=raise_on_failure))
async def _integrate_mcp_async(self, *, raise_on_failure: bool = False) -> None:
"""Async MCP discovery — canonical implementation."""
if self._mcp_integrated:
return
if not self._config.mcp_servers:
return
try:
mcp_tools = await self._mcp_registry.get_tools_async(
self._config.mcp_servers
)
except Exception as exc:
logger.warning("MCP integration failed: %s", exc)
if raise_on_failure:
raise
return
with self._lock:
self._available = {**self._available, **mcp_tools}
self._mcp_integrated = True
logger.info(
"MCP integration registered %d tools (via registry)", len(mcp_tools)
)
def _purge_connector_state(self) -> None:
"""Remove stale connector tool classes and cached instances."""
stale_keys = [
name
for name, cls in self._available.items()
if issubclass(cls, MCPTool) and cls.is_connector()
]
for key in stale_keys:
self._available.pop(key, None)
self._instances.pop(key, None)
def _purge_mcp_state(self) -> None:
"""Remove stale MCP tool classes and cached instances."""
stale_keys = [
name
for name, cls in self._available.items()
if issubclass(cls, MCPTool) and not cls.is_connector()
]
for key in stale_keys:
self._available.pop(key, None)
self._instances.pop(key, None)
def integrate_connectors(self) -> None:
"""Discover and register connector tools (sync wrapper)."""
run_sync(self.integrate_connectors_async())
async def integrate_connectors_async(self) -> None:
"""Discover and register connector tools — canonical implementation.
Thread-safe: can be called from the deferred-init background thread.
"""
if self._connector_registry is None:
return
try:
connector_tools = await self._connector_registry.get_tools_async()
except Exception as exc:
logger.warning(f"Connector integration failed: {exc}")
with self._lock:
self._purge_connector_state()
return
with self._lock:
self._purge_connector_state()
self._available.update(connector_tools)
logger.info(f"Connector integration registered {len(connector_tools)} tools")
async def refresh_remote_tools_async(self) -> None:
"""Force MCP and connector re-discovery for the current config."""
with self._lock:
self._mcp_registry.clear()
self._purge_mcp_state()
self._mcp_integrated = False
self._purge_connector_state()
if self._connector_registry is not None:
self._connector_registry.clear()
await self._integrate_all_async()
def refresh_remote_tools(self) -> None:
"""Sync wrapper for :meth:`refresh_remote_tools_async`."""
run_sync(self.refresh_remote_tools_async())
def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None:
"""Discover MCP and connector tools in parallel.
Runs both async discovery paths concurrently via ``asyncio.gather``
inside a single ``run_sync`` call.
"""
run_sync(self._integrate_all_async(raise_on_mcp_failure=raise_on_mcp_failure))
async def _integrate_all_async(self, *, raise_on_mcp_failure: bool = False) -> None:
"""Run MCP and connector discovery concurrently.
Uses ``return_exceptions=True`` so that a failing MCP server does
not cancel in-flight connector discovery (or vice-versa).
"""
mcp_result, connector_result = await asyncio.gather(
self._integrate_mcp_async(raise_on_failure=raise_on_mcp_failure),
self.integrate_connectors_async(),
return_exceptions=True,
)
# Re-raise MCP errors when the caller asked for them.
if isinstance(mcp_result, BaseException):
if raise_on_mcp_failure:
raise mcp_result
logger.warning(f"MCP integration failed: {mcp_result}")
if isinstance(connector_result, BaseException):
logger.warning(f"Connector integration failed: {connector_result}")
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
with self._lock:
tool_class = self._available.get(tool_name)
if tool_class:
config_class = tool_class._get_tool_config_class()
default_config = config_class()
else:
config_class = BaseToolConfig
default_config = BaseToolConfig()
user_overrides = self._config.tools.get(tool_name)
if user_overrides is None:
return config_class()
merged_dict = {**default_config.model_dump(), **user_overrides}
return config_class.model_validate(merged_dict)
def get(self, tool_name: str) -> BaseTool:
"""Get a tool instance, creating it lazily on first call.
Raises:
NoSuchToolError: If the requested tool is not available.
"""
if tool_name in self._instances:
return self._instances[tool_name]
with self._lock:
if tool_name not in self._available:
raise NoSuchToolError(
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
)
tool_class = self._available[tool_name]
self._instances[tool_name] = tool_class.from_config(
lambda: self.get_tool_config(tool_name)
)
return self._instances[tool_name]
def reset_all(self) -> None:
self._instances.clear()