vibe/vibe/core/utils.py
Mathias Gesbert 5103019b01
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>
2026-03-16 17:51:47 +01:00

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)