v2.5.0 (#495)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
9421fbc08e
commit
5103019b01
104 changed files with 7277 additions and 691 deletions
|
|
@ -76,6 +76,13 @@ async def test_decodes_non_utf8_bytes(bash):
|
|||
assert result.stderr == ""
|
||||
|
||||
|
||||
def test_find_not_in_default_allowlist():
|
||||
bash_tool = Bash(config=BashToolConfig(), state=BaseToolState())
|
||||
# find -exec runs arbitrary commands; must not be allowlisted by default
|
||||
permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;"))
|
||||
assert permission is not ToolPermission.ALWAYS
|
||||
|
||||
|
||||
def test_resolve_permission():
|
||||
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
|
||||
bash_tool = Bash(config=config, state=BaseToolState())
|
||||
|
|
@ -89,3 +96,92 @@ def test_resolve_permission():
|
|||
assert denylisted is ToolPermission.NEVER
|
||||
assert mixed is None
|
||||
assert empty is None
|
||||
|
||||
|
||||
class TestResolvePermissionWindowsSyntax:
|
||||
"""Verify allowlist/denylist works with Windows-style commands."""
|
||||
|
||||
def _make_bash(self, **kwargs) -> Bash:
|
||||
config = BashToolConfig(**kwargs)
|
||||
return Bash(config=config, state=BaseToolState())
|
||||
|
||||
def test_dir_with_windows_flags_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s /b"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
||||
def test_type_command_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["type"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="type file.txt"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
||||
def test_findstr_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["findstr"])
|
||||
result = bash_tool.resolve_permission(
|
||||
BashArgs(command="findstr /s pattern *.txt")
|
||||
)
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
||||
def test_ver_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["ver"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="ver"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
||||
def test_where_allowlisted(self):
|
||||
bash_tool = self._make_bash(allowlist=["where"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="where python"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
||||
def test_cmd_k_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist=["cmd /k"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="cmd /k something"))
|
||||
assert result is ToolPermission.NEVER
|
||||
|
||||
def test_powershell_noexit_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist=["powershell -NoExit"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="powershell -NoExit"))
|
||||
assert result is ToolPermission.NEVER
|
||||
|
||||
def test_notepad_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist=["notepad"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt"))
|
||||
assert result is ToolPermission.NEVER
|
||||
|
||||
def test_cmd_standalone_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist_standalone=["cmd"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="cmd"))
|
||||
assert result is ToolPermission.NEVER
|
||||
|
||||
def test_powershell_standalone_denylisted(self):
|
||||
bash_tool = self._make_bash(denylist_standalone=["powershell"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="powershell"))
|
||||
assert result is ToolPermission.NEVER
|
||||
|
||||
def test_powershell_cmdlet_asks(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path ."))
|
||||
assert result is None
|
||||
|
||||
def test_mixed_allowed_and_unknown_asks(self):
|
||||
bash_tool = self._make_bash(allowlist=["git status"])
|
||||
result = bash_tool.resolve_permission(
|
||||
BashArgs(command="git status && npm install")
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_chained_windows_commands_all_allowed(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir", "echo"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done"))
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
||||
def test_chained_commands_one_denied(self):
|
||||
bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"])
|
||||
result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /"))
|
||||
assert result is ToolPermission.NEVER
|
||||
|
||||
def test_piped_windows_commands(self):
|
||||
bash_tool = self._make_bash(allowlist=["findstr", "type"])
|
||||
result = bash_tool.resolve_permission(
|
||||
BashArgs(command="type file.txt | findstr pattern")
|
||||
)
|
||||
assert result is ToolPermission.ALWAYS
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class TestInvokeContext:
|
|||
assert ctx.approval_callback is None
|
||||
|
||||
def test_approval_callback_can_be_set(self) -> None:
|
||||
def dummy_callback(
|
||||
async def dummy_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return ApprovalResponse.YES, None
|
||||
|
|
@ -75,7 +75,7 @@ class TestToolInvokeWithContext:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None:
|
||||
def dummy_callback(
|
||||
async def dummy_callback(
|
||||
_tool_name: str, _args: BaseModel, _tool_call_id: str
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return ApprovalResponse.YES, None
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@ from __future__ import annotations
|
|||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import mistralai
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.errors import SDKError
|
||||
from mistralai.client.models import (
|
||||
ConversationResponse,
|
||||
ConversationUsageInfo,
|
||||
MessageOutputEntry,
|
||||
TextChunk,
|
||||
ToolReferenceChunk,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
|
|
@ -13,13 +21,13 @@ from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSear
|
|||
|
||||
def _make_response(
|
||||
content: list | None = None, outputs: list | None = None
|
||||
) -> mistralai.ConversationResponse:
|
||||
) -> ConversationResponse:
|
||||
if outputs is None:
|
||||
outputs = [mistralai.MessageOutputEntry(content=content or [])]
|
||||
return mistralai.ConversationResponse(
|
||||
outputs = [MessageOutputEntry(content=content or [])]
|
||||
return ConversationResponse(
|
||||
conversation_id="test",
|
||||
outputs=outputs,
|
||||
usage=mistralai.ConversationUsageInfo(
|
||||
usage=ConversationUsageInfo(
|
||||
prompt_tokens=10, completion_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
|
@ -34,7 +42,7 @@ def websearch(monkeypatch):
|
|||
|
||||
def test_parse_text_chunks(websearch):
|
||||
response = _make_response(
|
||||
content=[mistralai.TextChunk(text="Hello "), mistralai.TextChunk(text="world")]
|
||||
content=[TextChunk(text="Hello "), TextChunk(text="world")]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
assert result.answer == "Hello world"
|
||||
|
|
@ -44,16 +52,12 @@ def test_parse_text_chunks(websearch):
|
|||
def test_parse_sources_deduped(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
mistralai.TextChunk(text="Answer"),
|
||||
mistralai.ToolReferenceChunk(
|
||||
tool="web_search", title="Site A", url="https://a.com"
|
||||
),
|
||||
mistralai.ToolReferenceChunk(
|
||||
TextChunk(text="Answer"),
|
||||
ToolReferenceChunk(tool="web_search", title="Site A", url="https://a.com"),
|
||||
ToolReferenceChunk(
|
||||
tool="web_search", title="Site A duplicate", url="https://a.com"
|
||||
),
|
||||
mistralai.ToolReferenceChunk(
|
||||
tool="web_search", title="Site B", url="https://b.com"
|
||||
),
|
||||
ToolReferenceChunk(tool="web_search", title="Site B", url="https://b.com"),
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
|
|
@ -67,8 +71,8 @@ def test_parse_sources_deduped(websearch):
|
|||
def test_parse_skips_source_without_url(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
mistralai.TextChunk(text="Answer"),
|
||||
mistralai.ToolReferenceChunk(tool="web_search", title="No URL"),
|
||||
TextChunk(text="Answer"),
|
||||
ToolReferenceChunk(tool="web_search", title="No URL"),
|
||||
]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
|
|
@ -82,16 +86,14 @@ def test_parse_empty_text_raises(websearch):
|
|||
|
||||
|
||||
def test_parse_whitespace_only_raises(websearch):
|
||||
response = _make_response(content=[mistralai.TextChunk(text=" ")])
|
||||
response = _make_response(content=[TextChunk(text=" ")])
|
||||
with pytest.raises(ToolError, match="No text in agent response"):
|
||||
websearch._parse_response(response)
|
||||
|
||||
|
||||
def test_parse_skips_non_message_entries(websearch):
|
||||
response = _make_response(
|
||||
outputs=[
|
||||
mistralai.MessageOutputEntry(content=[mistralai.TextChunk(text="Answer")])
|
||||
]
|
||||
outputs=[MessageOutputEntry(content=[TextChunk(text="Answer")])]
|
||||
)
|
||||
result = websearch._parse_response(response)
|
||||
assert result.answer == "Answer"
|
||||
|
|
@ -110,18 +112,18 @@ async def test_run_missing_api_key(monkeypatch):
|
|||
async def test_run_returns_parsed_result(websearch):
|
||||
response = _make_response(
|
||||
content=[
|
||||
mistralai.TextChunk(text="The answer"),
|
||||
mistralai.ToolReferenceChunk(
|
||||
TextChunk(text="The answer"),
|
||||
ToolReferenceChunk(
|
||||
tool="web_search", title="Source", url="https://example.com"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
mock_start = AsyncMock(return_value=response)
|
||||
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
|
||||
with patch.object(Mistral, "beta", create=True) as mock_beta:
|
||||
mock_beta.conversations.start_async = mock_start
|
||||
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
|
||||
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
|
||||
with patch.object(Mistral, "__aenter__", return_value=None):
|
||||
with patch.object(Mistral, "__aexit__", return_value=None):
|
||||
result = await collect_result(
|
||||
websearch.run(WebSearchArgs(query="test query"))
|
||||
)
|
||||
|
|
@ -142,12 +144,12 @@ async def test_run_sdk_error_wrapped(websearch):
|
|||
mock_response.text = "error"
|
||||
mock_response.headers = httpx.Headers({"content-type": "application/json"})
|
||||
|
||||
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
|
||||
with patch.object(Mistral, "beta", create=True) as mock_beta:
|
||||
mock_beta.conversations.start_async = AsyncMock(
|
||||
side_effect=mistralai.SDKError("API failed", mock_response)
|
||||
side_effect=SDKError("API failed", mock_response)
|
||||
)
|
||||
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
|
||||
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
|
||||
with patch.object(Mistral, "__aenter__", return_value=None):
|
||||
with patch.object(Mistral, "__aexit__", return_value=None):
|
||||
with pytest.raises(ToolError, match="Mistral API error"):
|
||||
await collect_result(websearch.run(WebSearchArgs(query="test")))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue