mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-25 17:14:55 +02:00
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
496 lines
16 KiB
Python
496 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import build_test_vibe_config
|
|
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
|
from vibe.core.tools.manager import ToolManager
|
|
|
|
|
|
@pytest.fixture
|
|
def config():
|
|
return build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def tool_manager(config):
|
|
return ToolManager(lambda: config)
|
|
|
|
|
|
def test_returns_default_config_when_no_overrides(tool_manager):
|
|
config = tool_manager.get_tool_config("bash")
|
|
|
|
assert (
|
|
type(config).__name__ == "BashToolConfig"
|
|
) # due to vibe's discover system isinstance would fail
|
|
assert config.default_timeout == 300 # type: ignore[attr-defined]
|
|
assert config.max_output_bytes == 16000 # type: ignore[attr-defined]
|
|
assert config.permission == ToolPermission.ASK
|
|
|
|
|
|
def test_merges_user_overrides_with_defaults():
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
tools={"bash": {"permission": "always"}},
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
config = manager.get_tool_config("bash")
|
|
|
|
assert (
|
|
type(config).__name__ == "BashToolConfig"
|
|
) # due to vibe's discover system isinstance would fail
|
|
assert config.permission == ToolPermission.ALWAYS
|
|
assert config.default_timeout == 300 # type: ignore[attr-defined]
|
|
|
|
|
|
def test_preserves_tool_specific_fields_from_overrides():
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
tools={"bash": {"permission": "ask"}},
|
|
)
|
|
vibe_config.tools["bash"]["default_timeout"] = 600
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
config = manager.get_tool_config("bash")
|
|
|
|
assert type(config).__name__ == "BashToolConfig"
|
|
assert config.default_timeout == 600 # type: ignore[attr-defined]
|
|
|
|
|
|
def test_falls_back_to_base_config_for_unknown_tool(tool_manager):
|
|
config = tool_manager.get_tool_config("nonexistent_tool")
|
|
|
|
assert type(config) is BaseToolConfig
|
|
assert config.permission == ToolPermission.ASK
|
|
|
|
|
|
def test_partial_override_preserves_tool_defaults():
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
tools={"read_file": {"max_read_bytes": 32000}},
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
config = manager.get_tool_config("read_file")
|
|
|
|
assert (
|
|
config.permission == ToolPermission.ALWAYS
|
|
) # ReadFileToolConfig default, not BaseToolConfig.ASK
|
|
assert config.sensitive_patterns == ["**/.env", "**/.env.*"] # type: ignore[attr-defined]
|
|
assert config.max_read_bytes == 32000 # type: ignore[attr-defined]
|
|
|
|
|
|
class TestToolManagerFiltering:
|
|
def test_enabled_tools_filters_to_only_enabled(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
enabled_tools=["bash", "grep"],
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert len(tools) < len(manager._available)
|
|
assert "bash" in tools
|
|
assert "grep" in tools
|
|
assert "read_file" not in tools
|
|
assert "write_file" not in tools
|
|
|
|
def test_disabled_tools_excludes_disabled(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
disabled_tools=["bash", "write_file"],
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert len(tools) < len(manager._available)
|
|
assert "bash" not in tools
|
|
assert "write_file" not in tools
|
|
assert "grep" in tools
|
|
assert "read_file" in tools
|
|
|
|
def test_enabled_tools_takes_precedence_over_disabled(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
enabled_tools=["bash"],
|
|
disabled_tools=["bash"], # Should be ignored
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert len(tools) == 1
|
|
assert "bash" in tools
|
|
|
|
def test_glob_pattern_matching(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
disabled_tools=["*_file"], # Matches read_file, write_file
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert "read_file" not in tools
|
|
assert "write_file" not in tools
|
|
assert "bash" in tools
|
|
assert "grep" in tools
|
|
|
|
def test_regex_pattern_matching(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
enabled_tools=["re:^(bash|grep)$"],
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert len(tools) == 2
|
|
assert "bash" in tools
|
|
assert "grep" in tools
|
|
|
|
def test_case_insensitive_matching(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
enabled_tools=["BASH", "GREP"],
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert "bash" in tools
|
|
assert "grep" in tools
|
|
|
|
def test_empty_enabled_tools_returns_all(self):
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False, enabled_tools=[]
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert "bash" in tools
|
|
assert "grep" in tools
|
|
assert "read_file" in tools
|
|
|
|
def test_tool_paths_with_file_and_directory(self, tmp_path: Path):
|
|
"""Should handle a mix of file and directory paths in tool_paths."""
|
|
import sys
|
|
|
|
# Create a directory with a tool
|
|
tool_dir = tmp_path / "tools"
|
|
tool_dir.mkdir()
|
|
(tool_dir / "dir_tool.py").write_text("""
|
|
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
|
from pydantic import BaseModel
|
|
from collections.abc import AsyncGenerator
|
|
|
|
class DirToolArgs(BaseModel):
|
|
pass
|
|
|
|
class DirToolResult(BaseModel):
|
|
pass
|
|
|
|
class DirTool(BaseTool[DirToolArgs, DirToolResult, BaseToolConfig, BaseToolState]):
|
|
description = "Tool from directory"
|
|
|
|
async def run(self, args, ctx=None):
|
|
yield DirToolResult()
|
|
""")
|
|
|
|
# Create a standalone tool file
|
|
file_tool = tmp_path / "file_tool.py"
|
|
file_tool.write_text("""
|
|
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
|
from pydantic import BaseModel
|
|
from collections.abc import AsyncGenerator
|
|
|
|
class FileToolArgs(BaseModel):
|
|
pass
|
|
|
|
class FileToolResult(BaseModel):
|
|
pass
|
|
|
|
class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolState]):
|
|
description = "Tool from file path"
|
|
|
|
async def run(self, args, ctx=None):
|
|
yield FileToolResult()
|
|
""")
|
|
|
|
# Clean up any previously loaded modules
|
|
to_remove = [k for k in sys.modules if "dir_tool" in k or "file_tool" in k]
|
|
for k in to_remove:
|
|
del sys.modules[k]
|
|
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
tool_paths=[tool_dir, file_tool],
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
tools = manager.available_tools
|
|
assert "dir_tool" in tools
|
|
assert "file_tool" in tools
|
|
|
|
|
|
class TestToolRuntimeAvailability:
|
|
"""Tests for is_available() filtering in ToolManager."""
|
|
|
|
def test_unavailable_tool_excluded_from_available_tools(
|
|
self, tmp_path: Path, monkeypatch
|
|
):
|
|
"""Tools where is_available() returns False should be excluded."""
|
|
import sys
|
|
|
|
tool_dir = tmp_path / "tools"
|
|
tool_dir.mkdir()
|
|
(tool_dir / "conditional_tool.py").write_text("""
|
|
import os
|
|
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
|
from pydantic import BaseModel
|
|
|
|
class ConditionalToolArgs(BaseModel):
|
|
pass
|
|
|
|
class ConditionalToolResult(BaseModel):
|
|
pass
|
|
|
|
class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseToolConfig, BaseToolState]):
|
|
description = "Tool that requires TEST_VAR"
|
|
|
|
@classmethod
|
|
def is_available(cls) -> bool:
|
|
return bool(os.getenv("TEST_VAR"))
|
|
|
|
async def run(self, args, ctx=None):
|
|
yield ConditionalToolResult()
|
|
""")
|
|
|
|
to_remove = [k for k in sys.modules if "conditional_tool" in k]
|
|
for k in to_remove:
|
|
del sys.modules[k]
|
|
|
|
monkeypatch.delenv("TEST_VAR", raising=False)
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests",
|
|
include_project_context=False,
|
|
tool_paths=[tool_dir],
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
assert "conditional_tool" not in manager.available_tools
|
|
|
|
to_remove = [k for k in sys.modules if "conditional_tool" in k]
|
|
for k in to_remove:
|
|
del sys.modules[k]
|
|
|
|
monkeypatch.setenv("TEST_VAR", "1")
|
|
manager2 = ToolManager(lambda: vibe_config)
|
|
assert "conditional_tool" in manager2.available_tools
|
|
|
|
def test_default_is_available_returns_true(self):
|
|
"""Tools without is_available() override should be available."""
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
manager = ToolManager(lambda: vibe_config)
|
|
|
|
assert "bash" in manager.available_tools
|
|
|
|
|
|
class TestToolManagerModuleReuse:
|
|
"""Tests for module reuse across ToolManager instances.
|
|
|
|
When multiple ToolManager instances are created (e.g., main agent + subagent),
|
|
they should reuse the same tool modules from sys.modules to preserve class identity.
|
|
This prevents Pydantic validation errors when tool results from one agent
|
|
are validated against types from another.
|
|
"""
|
|
|
|
def test_multiple_managers_share_tool_classes(self):
|
|
"""Tool classes should be identical across multiple ToolManager instances."""
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
|
|
manager1 = ToolManager(lambda: vibe_config)
|
|
manager2 = ToolManager(lambda: vibe_config)
|
|
|
|
# Get the same tool class from both managers
|
|
todo_class1 = manager1.available_tools.get("todo")
|
|
todo_class2 = manager2.available_tools.get("todo")
|
|
|
|
assert todo_class1 is not None
|
|
assert todo_class2 is not None
|
|
# Class objects should be identical (same id), not just equal
|
|
assert todo_class1 is todo_class2
|
|
|
|
def test_tool_state_classes_are_identical(self):
|
|
"""Tool state classes should be identical across managers."""
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
|
|
manager1 = ToolManager(lambda: vibe_config)
|
|
manager2 = ToolManager(lambda: vibe_config)
|
|
|
|
todo_class1 = manager1.available_tools["todo"]
|
|
todo_class2 = manager2.available_tools["todo"]
|
|
|
|
state_class1 = todo_class1._get_tool_state_class()
|
|
state_class2 = todo_class2._get_tool_state_class()
|
|
|
|
assert state_class1 is state_class2
|
|
|
|
def test_tool_args_results_classes_are_identical(self):
|
|
"""Tool args and result classes should be identical across managers."""
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
|
|
manager1 = ToolManager(lambda: vibe_config)
|
|
manager2 = ToolManager(lambda: vibe_config)
|
|
|
|
todo_class1 = manager1.available_tools["todo"]
|
|
todo_class2 = manager2.available_tools["todo"]
|
|
|
|
args1, result1 = todo_class1._get_tool_args_results()
|
|
args2, result2 = todo_class2._get_tool_args_results()
|
|
|
|
assert args1 is args2
|
|
assert result1 is result2
|
|
|
|
def test_tool_instances_are_isolated(self):
|
|
"""Tool instances should be separate even though classes are shared.
|
|
|
|
This ensures subagents have isolated state (e.g., separate todo lists)
|
|
while still sharing class definitions for Pydantic validation.
|
|
"""
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
|
|
manager1 = ToolManager(lambda: vibe_config)
|
|
manager2 = ToolManager(lambda: vibe_config)
|
|
|
|
# Get tool instances from each manager
|
|
tool1 = manager1.get("todo")
|
|
tool2 = manager2.get("todo")
|
|
|
|
# Instances should be different objects
|
|
assert tool1 is not tool2
|
|
|
|
# State should be different objects
|
|
assert tool1.state is not tool2.state
|
|
|
|
# Verify state is truly isolated by modifying one
|
|
from vibe.core.tools.builtins.todo import TodoItem
|
|
|
|
tool1.state.todos = [TodoItem(id="1", content="test")]
|
|
assert len(tool1.state.todos) == 1
|
|
assert len(tool2.state.todos) == 0 # Unaffected!
|
|
|
|
def test_class_shared_but_instances_isolated(self):
|
|
"""Classes must be shared (for validation) but instances isolated (for state)."""
|
|
vibe_config = build_test_vibe_config(
|
|
system_prompt_id="tests", include_project_context=False
|
|
)
|
|
|
|
manager1 = ToolManager(lambda: vibe_config)
|
|
manager2 = ToolManager(lambda: vibe_config)
|
|
|
|
tool1 = manager1.get("todo")
|
|
tool2 = manager2.get("todo")
|
|
|
|
# Classes are shared (same object)
|
|
assert type(tool1) is type(tool2)
|
|
assert type(tool1.state) is type(tool2.state)
|
|
|
|
# But instances are different
|
|
assert tool1 is not tool2
|
|
assert tool1.state is not tool2.state
|
|
|
|
def test_different_files_same_stem_get_different_modules(self, tmp_path: Path):
|
|
"""Tools with same stem but different paths should be separate modules.
|
|
|
|
This ensures user tools can override builtins - a custom todo.py in
|
|
~/.config/vibe/tools/ should override the builtin todo.py.
|
|
"""
|
|
import sys
|
|
|
|
# Create two tool files with the same stem but different content
|
|
dir1 = tmp_path / "tools1"
|
|
dir2 = tmp_path / "tools2"
|
|
dir1.mkdir()
|
|
dir2.mkdir()
|
|
|
|
tool_code_v1 = """
|
|
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
|
from pydantic import BaseModel
|
|
from collections.abc import AsyncGenerator
|
|
|
|
class DummyArgs(BaseModel):
|
|
value: str
|
|
|
|
class DummyResult(BaseModel):
|
|
version: str = "v1"
|
|
|
|
class DummyTool(BaseTool[DummyArgs, DummyResult, BaseToolConfig, BaseToolState]):
|
|
description = "Dummy tool v1"
|
|
|
|
async def run(self, args: DummyArgs, ctx=None) -> AsyncGenerator[DummyResult, None]:
|
|
yield DummyResult(version="v1")
|
|
"""
|
|
|
|
tool_code_v2 = """
|
|
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
|
|
from pydantic import BaseModel
|
|
from collections.abc import AsyncGenerator
|
|
|
|
class DummyArgs(BaseModel):
|
|
value: str
|
|
|
|
class DummyResult(BaseModel):
|
|
version: str = "v2"
|
|
|
|
class DummyTool(BaseTool[DummyArgs, DummyResult, BaseToolConfig, BaseToolState]):
|
|
description = "Dummy tool v2"
|
|
|
|
async def run(self, args: DummyArgs, ctx=None) -> AsyncGenerator[DummyResult, None]:
|
|
yield DummyResult(version="v2")
|
|
"""
|
|
|
|
(dir1 / "dummy.py").write_text(tool_code_v1)
|
|
(dir2 / "dummy.py").write_text(tool_code_v2)
|
|
|
|
# Clean up any previously loaded dummy modules
|
|
to_remove = [k for k in sys.modules if "dummy" in k]
|
|
for k in to_remove:
|
|
del sys.modules[k]
|
|
|
|
# Load tools from both directories (dir2 comes after, should override)
|
|
classes = list(ToolManager._iter_tool_classes([dir1, dir2]))
|
|
dummy_classes = [c for c in classes if "dummy" in c.get_name().lower()]
|
|
|
|
# Should have 2 separate classes (from different modules)
|
|
assert len(dummy_classes) == 2
|
|
|
|
# They should be different class objects
|
|
assert dummy_classes[0] is not dummy_classes[1]
|
|
|
|
# When put in a dict (like _available), the second one wins
|
|
available = {c.get_name(): c for c in classes}
|
|
final_class = available.get("dummy_tool")
|
|
assert final_class is not None
|
|
assert final_class.description == "Dummy tool v2"
|