vibe/vibe/core/tools/builtins/websearch.py
Mathias Gesbert 626f905186
v2.9.6 (#682)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-05-11 11:44:53 +02:00

153 lines
5.1 KiB
Python

from __future__ import annotations
from collections.abc import AsyncGenerator
import os
from typing import TYPE_CHECKING, ClassVar, final
import httpx
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
from mistralai.client.models import (
ConversationResponse,
MessageOutputEntry,
TextChunk,
ToolReferenceChunk,
)
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import Backend, ToolStreamEvent
from vibe.core.utils.http import build_ssl_context, get_server_url_from_api_base
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
class WebSearchSource(BaseModel):
title: str
url: str
class WebSearchArgs(BaseModel):
query: str = Field(min_length=1)
class WebSearchResult(BaseModel):
answer: str
sources: list[WebSearchSource] = Field(default_factory=list)
class WebSearchConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
timeout: int = Field(default=120, description="HTTP timeout in seconds.")
model: str = Field(
default="mistral-vibe-cli-with-tools",
description="Mistral model to use for web search.",
)
class WebSearch(
BaseTool[WebSearchArgs, WebSearchResult, WebSearchConfig, BaseToolState],
ToolUIData[WebSearchArgs, WebSearchResult],
):
description: ClassVar[str] = (
"Search the web for current information using Mistral's web search."
)
@classmethod
def is_available(cls) -> bool:
return bool(os.getenv("MISTRAL_API_KEY"))
@final
async def run(
self, args: WebSearchArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | WebSearchResult, None]:
api_key = os.getenv("MISTRAL_API_KEY")
if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.")
ssl_context = build_ssl_context()
async_http_client = httpx.AsyncClient(follow_redirects=True, verify=ssl_context)
try:
client = Mistral(
api_key=api_key,
server_url=self._resolve_server_url(ctx),
timeout_ms=self.config.timeout * 1000,
async_client=async_http_client,
)
async with async_http_client, client:
response = await client.beta.conversations.start_async(
model=self.config.model,
instructions="Always use the web_search tool to answer queries. Never answer from memory alone.",
tools=[{"type": "web_search"}],
inputs=args.query,
store=False,
)
yield self._parse_response(response)
except SDKError as exc:
raise ToolError(f"Mistral API error: {exc}") from exc
finally:
await async_http_client.aclose()
def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
if not ctx or not ctx.agent_manager:
return None
for provider in ctx.agent_manager.config.providers:
if provider.backend == Backend.MISTRAL:
return get_server_url_from_api_base(provider.api_base)
return None
def _parse_response(self, response: ConversationResponse) -> WebSearchResult:
text_parts: list[str] = []
sources: dict[str, WebSearchSource] = {}
for entry in response.outputs:
if not isinstance(entry, MessageOutputEntry):
continue
for chunk in entry.content:
if isinstance(chunk, TextChunk):
text_parts.append(chunk.text)
elif isinstance(chunk, ToolReferenceChunk) and chunk.url:
if chunk.url not in sources:
sources[chunk.url] = WebSearchSource(
title=chunk.title, url=chunk.url
)
answer = "".join(text_parts).strip()
if not answer:
raise ToolError("No text in agent response.")
return WebSearchResult(answer=answer, sources=list(sources.values()))
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if event.args is None:
return ToolCallDisplay(summary="websearch")
if not isinstance(event.args, WebSearchArgs):
return ToolCallDisplay(summary="websearch")
return ToolCallDisplay(summary=f"Searching the web: '{event.args.query}'")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, WebSearchResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
return ToolResultDisplay(
success=True, message=f"{len(event.result.sources)} sources found"
)
@classmethod
def get_status_text(cls) -> str:
return "Searching the web"