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>
343 lines
11 KiB
Python
343 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
|
import concurrent.futures
|
|
from datetime import UTC, datetime
|
|
from enum import Enum, auto
|
|
from fnmatch import fnmatch
|
|
import functools
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from vibe import __version__
|
|
from vibe.core.config import Backend
|
|
from vibe.core.types import BaseEvent, ToolResultEvent
|
|
|
|
CANCELLATION_TAG = "user_cancellation"
|
|
TOOL_ERROR_TAG = "tool_error"
|
|
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
|
VIBE_WARNING_TAG = "vibe_warning"
|
|
|
|
KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG]
|
|
|
|
|
|
class TaggedText:
|
|
_TAG_PATTERN = re.compile(
|
|
rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)</\1>",
|
|
flags=re.DOTALL,
|
|
)
|
|
|
|
def __init__(self, message: str, tag: str = "") -> None:
|
|
self.message = message
|
|
self.tag = tag
|
|
|
|
def __str__(self) -> str:
|
|
if not self.tag:
|
|
return self.message
|
|
return f"<{self.tag}>{self.message}</{self.tag}>"
|
|
|
|
@staticmethod
|
|
def from_string(text: str) -> TaggedText:
|
|
found_tag = ""
|
|
result = text
|
|
|
|
def replace_tag(match: re.Match[str]) -> str:
|
|
nonlocal found_tag
|
|
tag_name = match.group(1)
|
|
content = match.group(2)
|
|
if not found_tag:
|
|
found_tag = tag_name
|
|
return content
|
|
|
|
result = TaggedText._TAG_PATTERN.sub(replace_tag, text)
|
|
|
|
if found_tag:
|
|
return TaggedText(result, found_tag)
|
|
|
|
return TaggedText(text, "")
|
|
|
|
|
|
class CancellationReason(Enum):
|
|
OPERATION_CANCELLED = auto()
|
|
TOOL_INTERRUPTED = auto()
|
|
TOOL_NO_RESPONSE = auto()
|
|
TOOL_SKIPPED = auto()
|
|
|
|
|
|
def get_user_cancellation_message(
|
|
cancellation_reason: CancellationReason, tool_name: str | None = None
|
|
) -> TaggedText:
|
|
match cancellation_reason:
|
|
case CancellationReason.OPERATION_CANCELLED:
|
|
return TaggedText("User cancelled the operation.", CANCELLATION_TAG)
|
|
case CancellationReason.TOOL_INTERRUPTED:
|
|
return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG)
|
|
case CancellationReason.TOOL_NO_RESPONSE:
|
|
return TaggedText(
|
|
"Tool execution interrupted - no response available", CANCELLATION_TAG
|
|
)
|
|
case CancellationReason.TOOL_SKIPPED:
|
|
return TaggedText(
|
|
tool_name or "Tool execution skipped by user.", CANCELLATION_TAG
|
|
)
|
|
|
|
|
|
def is_user_cancellation_event(event: BaseEvent) -> bool:
|
|
if not isinstance(event, ToolResultEvent):
|
|
return False
|
|
return event.cancelled
|
|
|
|
|
|
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
|
"""Check if the current directory is a dangerous folder that would cause
|
|
issues if we were to run the tool there.
|
|
|
|
Args:
|
|
path: Path to check (defaults to current directory)
|
|
|
|
Returns:
|
|
tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous
|
|
"""
|
|
path = Path(path).resolve()
|
|
|
|
home_dir = Path.home()
|
|
|
|
dangerous_paths = {
|
|
home_dir: "home directory",
|
|
home_dir / "Documents": "Documents folder",
|
|
home_dir / "Desktop": "Desktop folder",
|
|
home_dir / "Downloads": "Downloads folder",
|
|
home_dir / "Pictures": "Pictures folder",
|
|
home_dir / "Movies": "Movies folder",
|
|
home_dir / "Music": "Music folder",
|
|
home_dir / "Library": "Library folder",
|
|
Path("/Applications"): "Applications folder",
|
|
Path("/System"): "System folder",
|
|
Path("/Library"): "System Library folder",
|
|
Path("/usr"): "System usr folder",
|
|
Path("/private"): "System private folder",
|
|
}
|
|
|
|
for dangerous_path, description in dangerous_paths.items():
|
|
try:
|
|
if path == dangerous_path:
|
|
return True, f"You are in the {description}"
|
|
except (OSError, ValueError):
|
|
continue
|
|
return False, ""
|
|
|
|
|
|
def get_user_agent(backend: Backend | None) -> str:
|
|
user_agent = f"Mistral-Vibe/{__version__}"
|
|
if backend == Backend.MISTRAL:
|
|
mistral_sdk_prefix = "mistral-client-python/"
|
|
user_agent = f"{mistral_sdk_prefix}{user_agent}"
|
|
return user_agent
|
|
|
|
|
|
def _is_retryable_http_error(e: Exception) -> bool:
|
|
if isinstance(e, httpx.HTTPStatusError):
|
|
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504}
|
|
return False
|
|
|
|
|
|
def async_retry[T, **P](
|
|
tries: int = 3,
|
|
delay_seconds: float = 0.5,
|
|
backoff_factor: float = 2.0,
|
|
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
|
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
"""Args:
|
|
tries: Number of retry attempts
|
|
delay_seconds: Initial delay between retries in seconds
|
|
backoff_factor: Multiplier for delay on each retry
|
|
is_retryable: Function to determine if an exception should trigger a retry
|
|
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
|
|
|
Returns:
|
|
Decorated function with retry logic
|
|
"""
|
|
|
|
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
@functools.wraps(func)
|
|
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
last_exc = None
|
|
for attempt in range(tries):
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
except Exception as e:
|
|
last_exc = e
|
|
if attempt < tries - 1 and is_retryable(e):
|
|
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
|
0.05 * attempt
|
|
)
|
|
await asyncio.sleep(current_delay)
|
|
continue
|
|
raise e
|
|
raise RuntimeError(
|
|
f"Retries exhausted. Last error: {last_exc}"
|
|
) from last_exc
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def async_generator_retry[T, **P](
|
|
tries: int = 3,
|
|
delay_seconds: float = 0.5,
|
|
backoff_factor: float = 2.0,
|
|
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
|
|
) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]:
|
|
"""Retry decorator for async generators.
|
|
|
|
Args:
|
|
tries: Number of retry attempts
|
|
delay_seconds: Initial delay between retries in seconds
|
|
backoff_factor: Multiplier for delay on each retry
|
|
is_retryable: Function to determine if an exception should trigger a retry
|
|
(defaults to checking for retryable HTTP errors from both urllib and httpx)
|
|
|
|
Returns:
|
|
Decorated async generator function with retry logic
|
|
"""
|
|
|
|
def decorator(
|
|
func: Callable[P, AsyncGenerator[T]],
|
|
) -> Callable[P, AsyncGenerator[T]]:
|
|
@functools.wraps(func)
|
|
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
|
|
last_exc = None
|
|
for attempt in range(tries):
|
|
try:
|
|
async for item in func(*args, **kwargs):
|
|
yield item
|
|
return
|
|
except Exception as e:
|
|
last_exc = e
|
|
if attempt < tries - 1 and is_retryable(e):
|
|
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
|
|
0.05 * attempt
|
|
)
|
|
await asyncio.sleep(current_delay)
|
|
continue
|
|
raise e
|
|
raise RuntimeError(
|
|
f"Retries exhausted. Last error: {last_exc}"
|
|
) from last_exc
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
class ConversationLimitException(Exception):
|
|
pass
|
|
|
|
|
|
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
"""Run an async coroutine synchronously, handling nested event loops.
|
|
|
|
If called from within an async context (running event loop), runs the
|
|
coroutine in a thread pool executor. Otherwise, uses asyncio.run().
|
|
|
|
This mirrors the pattern used by ToolManager for MCP integration.
|
|
"""
|
|
try:
|
|
asyncio.get_running_loop()
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(asyncio.run, coro)
|
|
return future.result()
|
|
except RuntimeError:
|
|
return asyncio.run(coro)
|
|
|
|
|
|
def is_windows() -> bool:
|
|
return sys.platform == "win32"
|
|
|
|
|
|
@functools.lru_cache(maxsize=256)
|
|
def _compile_icase(expr: str) -> re.Pattern[str] | None:
|
|
try:
|
|
return re.compile(expr, re.IGNORECASE)
|
|
except re.error:
|
|
return None
|
|
|
|
|
|
def name_matches(name: str, patterns: list[str]) -> bool:
|
|
"""Check if a name matches any of the provided patterns.
|
|
|
|
Supports two forms (case-insensitive):
|
|
- Glob wildcards using fnmatch (e.g., 'serena_*')
|
|
- Regex when prefixed with 're:' (e.g., 're:serena.*')
|
|
"""
|
|
n = name.lower()
|
|
for raw in patterns:
|
|
if not (p := (raw or "").strip()):
|
|
continue
|
|
|
|
if p.startswith("re:"):
|
|
rx = _compile_icase(p.removeprefix("re:"))
|
|
if rx is not None and rx.fullmatch(name) is not None:
|
|
return True
|
|
elif fnmatch(n, p.lower()):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class AsyncExecutor:
|
|
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
|
|
|
|
def __init__(
|
|
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
|
|
) -> None:
|
|
self._executor = concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=max_workers, thread_name_prefix=name
|
|
)
|
|
self._timeout = timeout
|
|
|
|
async def __aenter__(self) -> AsyncExecutor:
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
self.shutdown(wait=False)
|
|
|
|
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
loop = asyncio.get_running_loop()
|
|
future = loop.run_in_executor(
|
|
self._executor, functools.partial(fn, *args, **kwargs)
|
|
)
|
|
try:
|
|
return await asyncio.wait_for(future, timeout=self._timeout)
|
|
except TimeoutError as e:
|
|
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
|
|
|
|
def shutdown(self, wait: bool = True) -> None:
|
|
self._executor.shutdown(wait=wait)
|
|
|
|
|
|
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
|
|
if old_tokens is None or new_tokens is None:
|
|
return "Compaction complete"
|
|
|
|
reduction = old_tokens - new_tokens
|
|
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
|
return (
|
|
f"Compaction complete: {old_tokens:,} → "
|
|
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
|
)
|
|
|
|
|
|
def get_server_url_from_api_base(api_base: str) -> str | None:
|
|
match = re.match(r"(https?://[^/]+)(/v.*)", api_base)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(UTC)
|