mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-25 17:14:55 +02:00
Co-authored-by: Bastien <bastien.baret@gmail.com> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
275 lines
8.8 KiB
Python
275 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
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.mcp import MCPRegistry
|
|
from vibe.core.utils import name_matches
|
|
|
|
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,
|
|
*,
|
|
defer_mcp: bool = False,
|
|
) -> None:
|
|
self._config_getter = config_getter
|
|
self._mcp_registry = mcp_registry or MCPRegistry()
|
|
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_mcp()
|
|
|
|
@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()
|
|
}
|
|
|
|
if self._config.enabled_tools:
|
|
return {
|
|
name: cls
|
|
for name, cls in runtime_available.items()
|
|
if name_matches(name, self._config.enabled_tools)
|
|
}
|
|
if self._config.disabled_tools:
|
|
return {
|
|
name: cls
|
|
for name, cls in runtime_available.items()
|
|
if not name_matches(name, self._config.disabled_tools)
|
|
}
|
|
return runtime_available
|
|
|
|
def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
|
|
"""Discover and register MCP tools.
|
|
|
|
Idempotent: subsequent calls after a successful integration are
|
|
no-ops to avoid redundant MCP discovery.
|
|
"""
|
|
if self._mcp_integrated:
|
|
return
|
|
if not self._config.mcp_servers:
|
|
return
|
|
|
|
try:
|
|
mcp_tools = self._mcp_registry.get_tools(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 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()
|