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>
This commit is contained in:
Mathias Gesbert 2026-05-11 11:44:53 +02:00 committed by GitHub
parent b23f49e5f4
commit 626f905186
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 702 additions and 183 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.9.5"
__version__ = "2.9.6"

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from contextlib import aclosing
import inspect
import logging
import os
from pathlib import Path
@ -86,6 +85,7 @@ from vibe.acp.exceptions import (
from vibe.acp.session import AcpSessionLoop
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
resolve_kind,
tool_call_session_update,
tool_result_session_update,
)
@ -158,6 +158,8 @@ from vibe.core.utils import (
logger = logging.getLogger("vibe")
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
class ForkSessionParams(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@ -303,7 +305,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config = VibeConfig.load(disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS)
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
@ -695,7 +697,7 @@ class VibeAcpAgentLoop(AcpAgent):
async def _reload_config(self, session: AcpSessionLoop) -> None:
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question"],
disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS,
)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
@ -969,6 +971,7 @@ class VibeAcpAgentLoop(AcpAgent):
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
kind=resolve_kind(event.tool_name),
content=[
ContentToolCallContent(
type="content",
@ -977,6 +980,7 @@ class VibeAcpAgentLoop(AcpAgent):
),
)
],
field_meta={"tool_name": event.tool_name},
)
elif isinstance(event, CompactStartEvent):
@ -1016,12 +1020,7 @@ class VibeAcpAgentLoop(AcpAgent):
if deferred_init_thread is not None and deferred_init_thread.is_alive():
await asyncio.to_thread(deferred_init_thread.join)
backend_close = getattr(agent_loop.backend, "close", None)
if callable(backend_close):
close_result = backend_close()
if inspect.isawaitable(close_result):
await close_result
await agent_loop.aclose()
await agent_loop.telemetry_client.aclose()
@override
@ -1295,7 +1294,7 @@ class VibeAcpAgentLoop(AcpAgent):
"""Reload config from disk and reinitialize the agent loop."""
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question"],
disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS,
)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)

View file

@ -1,31 +1,17 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Annotated, Protocol, cast, runtime_checkable
from typing import Annotated, cast
from acp import Client
from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.helpers import ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
from vibe.acp.tools.session_update import resolve_kind
from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager
from vibe.core.types import ToolCallEvent, ToolResultEvent
@runtime_checkable
class ToolCallSessionUpdateProtocol(Protocol):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: ...
@runtime_checkable
class ToolResultSessionUpdateProtocol(Protocol):
@classmethod
def tool_result_session_update(
cls, event: ToolResultEvent
) -> SessionUpdate | None: ...
class AcpToolState(BaseModel):
@ -86,6 +72,7 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
if tool_call_id is None:
return
tool_name = self.get_name()
try:
await client.session_update(
session_id=session_id,
@ -93,7 +80,9 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
session_update="tool_call_update",
tool_call_id=tool_call_id,
status="in_progress",
kind=resolve_kind(tool_name),
content=content,
field_meta={"tool_name": tool_name},
),
)
except Exception as e:

View file

@ -12,11 +12,9 @@ from acp.schema import (
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import (
from vibe.acp.tools.session_update import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.acp.tools.session_update import (
failed_tool_result,
fallback_tool_call,
resolve_kind,

View file

@ -12,13 +12,10 @@ from acp.schema import (
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import (
AcpToolState,
BaseAcpTool,
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.acp.tools.session_update import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.acp.tools.session_update import (
failed_tool_result,
fallback_tool_call,
resolve_kind,

View file

@ -12,11 +12,9 @@ from acp.schema import (
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import (
from vibe.acp.tools.session_update import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.acp.tools.session_update import (
failed_tool_result,
fallback_tool_call,
resolve_kind,

View file

@ -9,11 +9,9 @@ from acp.schema import (
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import (
from vibe.acp.tools.session_update import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.acp.tools.session_update import (
failed_tool_result,
fallback_tool_call,
resolve_kind,

View file

@ -10,11 +10,9 @@ from acp.schema import (
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import (
from vibe.acp.tools.session_update import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.acp.tools.session_update import (
failed_tool_result,
fallback_tool_call,
resolve_kind,

View file

@ -10,11 +10,9 @@ from acp.schema import (
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import (
from vibe.acp.tools.session_update import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.acp.tools.session_update import (
failed_tool_result,
fallback_tool_call,
resolve_kind,

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import (
ContentToolCallContent,
@ -10,15 +12,25 @@ from acp.schema import (
)
from pydantic import BaseModel
from vibe.acp.tools.base import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import TaggedText, is_user_cancellation_event
@runtime_checkable
class ToolCallSessionUpdateProtocol(Protocol):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: ...
@runtime_checkable
class ToolResultSessionUpdateProtocol(Protocol):
@classmethod
def tool_result_session_update(
cls, event: ToolResultEvent
) -> SessionUpdate | None: ...
def _cancellation_raw_output(event: ToolResultEvent) -> str | None:
if event.skip_reason:
return TaggedText.from_string(event.skip_reason).message

View file

@ -210,7 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
warn_if_workdir_trust_is_unset()
config.disabled_tools = [*config.disabled_tools, "ask_user_question"]
config.disabled_tools = [
*config.disabled_tools,
"ask_user_question",
"exit_plan_mode",
]
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(

View file

@ -10,6 +10,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
from vibe.core.utils.http import build_ssl_context
BASE_URL = "https://console.mistral.ai"
WHOAMI_PATH = "/api/vibe/whoami"
@ -23,7 +24,7 @@ class HttpWhoAmIGateway:
url = f"{self._base_url}{WHOAMI_PATH}"
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(verify=build_ssl_context()) as client:
response = await client.get(url, headers=headers)
except httpx.RequestError as exc:
raise WhoAmIGatewayError() from exc

View file

@ -987,6 +987,8 @@ class VibeApp(App): # noqa: PLR0904
bash_msg = BashOutputMessage(command, str(Path.cwd()), pending=True)
await self._mount_and_scroll(bash_msg)
await self._ensure_loading_widget("Running command")
bash_loading_widget = self._loading_widget
proc: asyncio.subprocess.Process | None = None
stdout_parts: list[str] = []
@ -1071,6 +1073,9 @@ class VibeApp(App): # noqa: PLR0904
status=f"failed before completion: {e}",
)
)
finally:
if self._loading_widget is bash_loading_widget:
await self._remove_loading_widget()
def _get_bash_max_output_bytes(self) -> int:
from vibe.core.tools.builtins.bash import BashToolConfig
@ -2132,7 +2137,9 @@ class VibeApp(App): # noqa: PLR0904
self._emit_session_closed_for_active_session()
await self._loop_runner.stop()
self._log_reader.shutdown()
await self._voice_manager.close()
await self._narrator_manager.close()
await self.agent_loop.aclose()
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
@ -2629,6 +2636,9 @@ class VibeApp(App): # noqa: PLR0904
return True
interrupted = False
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
interrupted = True
if self._agent_running:
self._handle_agent_running_escape()
interrupted = True
@ -2815,6 +2825,7 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown()
self._narrator_manager.cancel()
await self.agent_loop.aclose()
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:

View file

@ -406,6 +406,10 @@ Markdown {
width: auto;
height: auto;
&.bash-pending {
color: $foreground;
}
&.bash-success {
color: ansi_green;
}

View file

@ -263,7 +263,9 @@ class InterruptMessage(Static):
)
class BashOutputMessage(Static):
class BashOutputMessage(SpinnerMixin, Static):
SPINNER_TYPE = SpinnerType.PULSE
def __init__(
self,
command: str,
@ -274,6 +276,7 @@ class BashOutputMessage(Static):
pending: bool = False,
) -> None:
super().__init__()
self.init_spinner()
self.add_class("bash-output-message")
self._command = command
self._cwd = cwd
@ -283,17 +286,29 @@ class BashOutputMessage(Static):
self._output_widget: NoMarkupStatic | None = None
self._output_container: Horizontal | None = None
self._prompt_widget: NonSelectableStatic | None = None
self._indicator_widget: Static | None = None
def _update_spinner_frame(self) -> None:
if not self._is_spinning or not self._prompt_widget:
return
self._prompt_widget.update(f"{self._spinner.next_frame()} ")
def on_mount(self) -> None:
if self._pending:
self.start_spinner_timer()
def compose(self) -> ComposeResult:
status_class = (
"bash-error"
if not self._pending and self._exit_code != 0
else "bash-success"
)
if self._pending:
status_class = "bash-pending"
elif self._exit_code != 0:
status_class = "bash-error"
else:
status_class = "bash-success"
self.add_class(status_class)
prompt_text = f"{self._spinner.current_frame()} " if self._pending else "$ "
with Horizontal(classes="bash-command-line"):
self._prompt_widget = NonSelectableStatic(
"$ ", classes=f"bash-prompt {status_class}"
prompt_text, classes=f"bash-prompt {status_class}"
)
yield self._prompt_widget
yield NoMarkupStatic(self._command, classes="bash-command")
@ -326,18 +341,20 @@ class BashOutputMessage(Static):
async def finish(self, exit_code: int, *, interrupted: bool = False) -> None:
self._exit_code = exit_code
self._pending = False
self.stop_spinning()
if self._prompt_widget:
self._prompt_widget.update("$ ")
if interrupted:
self.remove_class("bash-success")
self.add_class("bash-interrupted")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-interrupted")
new_class = "bash-interrupted"
elif exit_code != 0:
self.remove_class("bash-success")
self.add_class("bash-error")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-error")
new_class = "bash-error"
else:
new_class = "bash-success"
self.remove_class("bash-pending")
self.add_class(new_class)
if self._prompt_widget:
self._prompt_widget.remove_class("bash-pending")
self._prompt_widget.add_class(new_class)
if interrupted:
suffix = (
"\n(interrupted)"

View file

@ -8,6 +8,7 @@ from vibe.cli.update_notifier.ports.update_gateway import (
UpdateGatewayCause,
UpdateGatewayError,
)
from vibe.core.utils.http import build_ssl_context
class GitHubUpdateGateway(UpdateGateway):
@ -47,7 +48,9 @@ class GitHubUpdateGateway(UpdateGateway):
)
else:
async with httpx.AsyncClient(
base_url=self._base_url, timeout=self._timeout
base_url=self._base_url,
timeout=self._timeout,
verify=build_ssl_context(),
) as client:
response = await client.get(request_path, headers=headers)
except httpx.RequestError as exc:

View file

@ -10,6 +10,7 @@ from vibe.cli.update_notifier.ports.update_gateway import (
UpdateGatewayCause,
UpdateGatewayError,
)
from vibe.core.utils.http import build_ssl_context
_STATUS_CAUSES: dict[int, UpdateGatewayCause] = {
httpx.codes.NOT_FOUND: UpdateGatewayCause.NOT_FOUND,
@ -81,7 +82,9 @@ class PyPIUpdateGateway(UpdateGateway):
)
async with httpx.AsyncClient(
base_url=self._base_url, timeout=self._timeout
base_url=self._base_url,
timeout=self._timeout,
verify=build_ssl_context(),
) as client:
return await client.get(request_path, headers=headers)
except httpx.RequestError as exc:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from asyncio import CancelledError, create_task, wait_for
import contextlib
from typing import TYPE_CHECKING
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
@ -154,6 +155,15 @@ class VoiceManager:
except ValueError:
pass
async def close(self) -> None:
transcribe_task = self._transcribe_task
self.cancel_recording()
if transcribe_task is not None:
with contextlib.suppress(CancelledError):
await transcribe_task
if self._transcribe_client is not None:
await self._transcribe_client.close()
async def _run_transcription(self) -> None:
if self._transcribe_client is None:
return

View file

@ -54,3 +54,5 @@ class VoiceManagerPort(Protocol):
def add_listener(self, listener: VoiceManagerListener) -> None: ...
def remove_listener(self, listener: VoiceManagerListener) -> None: ...
async def close(self) -> None: ...

View file

@ -519,6 +519,10 @@ class AgentLoop:
def emit_session_closed_telemetry(self) -> None:
self.telemetry_client.send_session_closed()
async def aclose(self) -> None:
with contextlib.suppress(Exception):
await self.backend.__aexit__(None, None, None)
def _create_connector_registry(self) -> ConnectorRegistry | None:
if not connectors_enabled():
return None
@ -671,7 +675,13 @@ class AgentLoop:
ReadOnlyAgentMiddleware(
lambda: self.agent_profile,
BuiltinAgentName.PLAN,
lambda: make_plan_agent_reminder(self._plan_session.plan_file_path_str),
lambda: make_plan_agent_reminder(
self._plan_session.plan_file_path_str,
has_ask_user_question="ask_user_question"
in self.tool_manager.available_tools,
has_exit_plan_mode="exit_plan_mode"
in self.tool_manager.available_tools,
),
PLAN_AGENT_EXIT,
)
)
@ -1661,7 +1671,12 @@ class AgentLoop:
self._base_config = base_config
self.agent_manager.invalidate_config()
self.backend = self.backend_factory()
old_backend = self.backend
new_backend = self.backend_factory()
self.backend = new_backend
if new_backend is not old_backend:
with contextlib.suppress(Exception):
await old_backend.__aexit__(None, None, None)
if max_turns is not None:
self._max_turns = max_turns

View file

@ -7,6 +7,7 @@ import tomllib
from typing import TYPE_CHECKING, Any
from vibe.core.paths import PLANS_DIR
from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
@ -68,6 +69,15 @@ class AgentProfile:
*merged.get("disabled_tools", []),
})
# Environment-level disables (set by ACP/programmatic mode) must take
# precedence over an agent's enabled_tools allowlist
if base.disabled_tools and merged.get("enabled_tools"):
merged["enabled_tools"] = [
t
for t in merged["enabled_tools"]
if not name_matches(t, base.disabled_tools)
]
return VC.model_validate(merged)
@classmethod

View file

@ -9,6 +9,8 @@ import httpx
import keyring
import keyring.errors
from vibe.core.utils.http import build_ssl_context
GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT"
_SERVICE_NAME = "vibe"
@ -51,7 +53,9 @@ class GitHubAuthProvider:
async def __aenter__(self) -> GitHubAuthProvider:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
return self
async def __aexit__(
@ -66,7 +70,9 @@ class GitHubAuthProvider:
def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
self._owns_client = True
return self._client

View file

@ -35,6 +35,12 @@ from vibe.core.utils import get_server_url_from_api_base
from vibe.core.utils.io import read_safe
def _strip_bash_pattern_wildcard(pattern: str) -> str:
if pattern.endswith(" *"):
return pattern[:-2]
return pattern
def deep_update(
mapping: dict[str, Any], updating_mapping: dict[str, Any]
) -> dict[str, Any]:
@ -946,6 +952,8 @@ class VibeConfig(BaseSettings):
type(self).save_updates({"models": models})
def add_tool_allowlist_patterns(self, tool_name: str, patterns: list[str]) -> None:
if tool_name == "bash":
patterns = [_strip_bash_pattern_wildcard(p) for p in patterns]
current_allowlist: list[str] = list(
self.tools.get(tool_name, {}).get("allowlist", [])
)
@ -1005,6 +1013,12 @@ class VibeConfig(BaseSettings):
allowlist.sort()
changed = True
if allowlist is not None and any(p.endswith(" *") for p in allowlist):
stripped = [_strip_bash_pattern_wildcard(p) for p in allowlist]
deduped = sorted(set(stripped))
bash_tools["allowlist"] = deduped
changed = True
for model in data.get("models", []):
if (
model.get("name") == "mistral-vibe-cli-latest"

View file

@ -23,6 +23,7 @@ from vibe.core.types import (
StrToolChoice,
)
from vibe.core.utils import async_generator_retry, async_retry
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.config import ModelConfig, ProviderConfig
@ -209,6 +210,7 @@ class GenericBackend:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
verify=build_ssl_context(),
)
return self
@ -227,6 +229,7 @@ class GenericBackend:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
verify=build_ssl_context(),
)
self._owns_client = True
return self._client

View file

@ -45,6 +45,7 @@ from vibe.core.types import (
ToolCall,
)
from vibe.core.utils import get_server_url_from_api_base
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.config import ModelConfig, ProviderConfig
@ -182,6 +183,7 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = {
class MistralBackend:
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
self._client: Mistral | None = None
self._http_client: httpx.AsyncClient | None = None
self._provider = provider
self._mapper = MistralMapper()
self._api_key = (
@ -231,17 +233,32 @@ class MistralBackend:
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
) -> None:
if self._client is not None:
await self._client.__aexit__(
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
)
client = self._client
http_client = self._http_client
self._client = None
self._http_client = None
try:
if client is not None:
await client.__aexit__(
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
)
finally:
if http_client is not None:
await http_client.aclose()
async def aclose(self) -> None:
await self.__aexit__(None, None, None)
def _create_mistral_client(self) -> Mistral:
self._http_client = httpx.AsyncClient(
verify=build_ssl_context(), follow_redirects=True
)
return Mistral(
api_key=self._api_key,
server_url=self._server_url,
timeout_ms=int(self._timeout * 1000),
retry_config=self._retry_config,
async_client=self._http_client,
)
def _get_client(self) -> Mistral:

View file

@ -125,7 +125,30 @@ class ContextWarningMiddleware:
self.has_warned = False
def make_plan_agent_reminder(plan_file_path: str) -> str:
def make_plan_agent_reminder(
plan_file_path: str,
*,
has_ask_user_question: bool = True,
has_exit_plan_mode: bool = True,
) -> str:
instructions = [
"Research the user's query using read-only tools (grep, read_file, etc.)"
]
if has_ask_user_question:
instructions.append(
"If you are unsure about requirements or approach, use the ask_user_question tool to clarify before finalizing your plan"
)
instructions.append("Write your plan to the plan file above")
if has_exit_plan_mode:
instructions.append(
"When your plan is complete, call the exit_plan_mode tool to request user approval and switch to implementation mode"
)
else:
instructions.append(
"When your plan is complete, present it to the user and tell them to switch modes if they approve the plan"
)
numbered = "\n".join(f"{i}. {step}" for i, step in enumerate(instructions, start=1))
return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below, or in your scratchpad), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
## Plan File Info
@ -134,10 +157,7 @@ Build your plan incrementally by writing to or editing this file.
This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan.
## Instructions
1. Research the user's query using read-only tools (grep, read_file, etc.)
2. If you are unsure about requirements or approach, use the ask_user_question tool to clarify before finalizing your plan
3. Write your plan to the plan file above
4. When your plan is complete, call the exit_plan_mode tool to request user approval and switch to implementation mode</{VIBE_WARNING_TAG}>"""
{numbered}</{VIBE_WARNING_TAG}>"""
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""

View file

@ -16,6 +16,7 @@ from vibe.core.nuage.workflow import (
WorkflowExecutionListResponse,
WorkflowExecutionStatus,
)
from vibe.core.utils.http import build_ssl_context
class WorkflowsClient:
@ -32,7 +33,9 @@ class WorkflowsClient:
headers: dict[str, str] = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(timeout=self._timeout, headers=headers)
self._client = httpx.AsyncClient(
timeout=self._timeout, headers=headers, verify=build_ssl_context()
)
return self
async def __aexit__(
@ -51,7 +54,9 @@ class WorkflowsClient:
headers: dict[str, str] = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(timeout=self._timeout, headers=headers)
self._client = httpx.AsyncClient(
timeout=self._timeout, headers=headers, verify=build_ssl_context()
)
self._owns_client = True
return self._client

View file

@ -92,6 +92,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917
return formatter.finalize()
finally:
agent_loop.emit_session_closed_telemetry()
await agent_loop.aclose()
await agent_loop.telemetry_client.aclose()
return asyncio.run(_async_run())

View file

@ -21,6 +21,7 @@ from vibe.core.telemetry.types import (
TeleportFailureStage,
)
from vibe.core.utils import get_server_url_from_api_base, get_user_agent
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.agent_loop import ToolDecision
@ -92,6 +93,7 @@ class TelemetryClient:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
verify=build_ssl_context(),
)
return self._client

View file

@ -11,6 +11,7 @@ import httpx
from pydantic import BaseModel, Field, ValidationError
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.utils.http import build_ssl_context
class GitHubParams(BaseModel):
@ -121,7 +122,9 @@ class NuageClient:
async def __aenter__(self) -> NuageClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
return self
async def __aexit__(
@ -137,7 +140,9 @@ class NuageClient:
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
self._owns_client = True
return self._client

View file

@ -38,6 +38,7 @@ from vibe.core.teleport.types import (
TeleportWaitingForGitHubEvent,
TeleportYieldEvent,
)
from vibe.core.utils.http import build_ssl_context
_DEFAULT_TELEPORT_PROMPT = "Your session has been teleported on a remote workspace. Changes of workspace has been automatically teleported. External workspace changes has NOT been teleported. Environment variables has NOT been teleported. Please continue where you left off."
@ -73,7 +74,9 @@ class TeleportService:
async def __aenter__(self) -> TeleportService:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
self._nuage_client_instance = NuageClient(
self._vibe_code_base_url,
self._vibe_code_api_key,
@ -98,7 +101,9 @@ class TeleportService:
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
self._owns_client = True
return self._client

View file

@ -23,6 +23,7 @@ from vibe.core.tools.permissions import (
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -198,7 +199,9 @@ class WebFetch(
self, url: str, timeout: int, headers: dict[str, str]
) -> httpx.Response:
async with httpx.AsyncClient(
follow_redirects=True, timeout=httpx.Timeout(timeout)
follow_redirects=True,
timeout=httpx.Timeout(timeout),
verify=build_ssl_context(),
) as client:
response = await client.get(url, headers=headers)

View file

@ -4,6 +4,7 @@ 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 (
@ -24,7 +25,7 @@ from vibe.core.tools.base import (
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import Backend, ToolStreamEvent
from vibe.core.utils import get_server_url_from_api_base
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
@ -73,14 +74,17 @@ class WebSearch(
if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.")
client = Mistral(
api_key=api_key,
server_url=self._resolve_server_url(ctx),
timeout_ms=self.config.timeout * 1000,
)
ssl_context = build_ssl_context()
async_http_client = httpx.AsyncClient(follow_redirects=True, verify=ssl_context)
try:
async with client:
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.",
@ -93,6 +97,8 @@ class WebSearch(
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:

View file

@ -26,6 +26,7 @@ from vibe.core.tools.mcp.tools import (
from vibe.core.tools.ui import ToolResultDisplay
from vibe.core.types import ToolStreamEvent
from vibe.core.utils import run_sync
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
@ -238,9 +239,6 @@ class ConnectorRegistry:
self._alias_to_id: dict[str, str] = {}
self._discover_lock = asyncio.Lock()
def _get_client(self) -> Mistral:
return Mistral(api_key=self._api_key, server_url=self._server_url)
def get_tools(self) -> dict[str, type[BaseTool]]:
"""Return proxy tool classes for all connectors, using cache when possible."""
return run_sync(self.get_tools_async())
@ -259,7 +257,9 @@ class ConnectorRegistry:
base_url = self._server_url or _DEFAULT_BASE_URL
url = f"{base_url}/v1/connectors/bootstrap"
headers = {"Authorization": f"Bearer {self._api_key}"}
async with httpx.AsyncClient(timeout=_BOOTSTRAP_TIMEOUT) as http:
async with httpx.AsyncClient(
timeout=_BOOTSTRAP_TIMEOUT, verify=build_ssl_context()
) as http:
response = await http.get(url, headers=headers)
response.raise_for_status()
return response.json()
@ -427,11 +427,22 @@ class ConnectorRegistry:
if connector_id is None:
return None
try:
async with self._get_client() as client:
result = await client.beta.connectors.get_auth_url_async(
connector_id_or_name=connector_id
http_client = httpx.AsyncClient(
verify=build_ssl_context(), follow_redirects=True
)
try:
sdk_client = Mistral(
api_key=self._api_key,
server_url=self._server_url,
async_client=http_client,
)
return result.auth_url
async with sdk_client as client:
result = await client.beta.connectors.get_auth_url_async(
connector_id_or_name=connector_id
)
return result.auth_url
finally:
await http_client.aclose()
except Exception:
logger.debug("Failed to get auth URL for connector %s", alias)
return None

View file

@ -254,19 +254,19 @@ class ToolManager:
@staticmethod
def _is_source_disabled(
cls: type[BaseTool],
tool_cls: type[BaseTool],
disabled_sources: set[tuple[str, bool]],
per_source_disabled: dict[tuple[str, bool], set[str]],
) -> bool:
if not issubclass(cls, MCPTool):
if not issubclass(tool_cls, MCPTool):
return False
server_name = cls.get_server_name()
server_name = tool_cls.get_server_name()
if server_name is None:
return False
key = (server_name, cls.is_connector())
key = (server_name, tool_cls.is_connector())
if key in disabled_sources:
return True
return cls.get_remote_name() in per_source_disabled.get(key, set())
return tool_cls.get_remote_name() in per_source_disabled.get(key, set())
def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
"""Discover and register MCP tools (sync wrapper).

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator
import os
import httpx
from mistralai.client import Mistral
from mistralai.client.models import (
AudioFormat,
@ -21,6 +22,7 @@ from vibe.core.transcribe.transcribe_client_port import (
TranscribeSessionCreated,
TranscribeTextDelta,
)
from vibe.core.utils.http import build_ssl_context
class MistralTranscribeClient:
@ -35,10 +37,18 @@ class MistralTranscribeClient:
)
self._target_streaming_delay_ms = model.target_streaming_delay_ms
self._client: Mistral | None = None
self._http_client: httpx.AsyncClient | None = None
def _get_client(self) -> Mistral:
if self._client is None:
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
self._http_client = httpx.AsyncClient(
verify=build_ssl_context(), follow_redirects=True
)
self._client = Mistral(
api_key=self._api_key,
server_url=self._server_url,
async_client=self._http_client,
)
return self._client
async def transcribe(
@ -61,3 +71,15 @@ class MistralTranscribeClient:
yield TranscribeError(message=str(event.error.message))
elif isinstance(event, UnknownRealtimeEvent):
continue
async def close(self) -> None:
client = self._client
http_client = self._http_client
self._client = None
self._http_client = None
try:
if client is not None:
await client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
finally:
if http_client is not None:
await http_client.aclose()

View file

@ -40,3 +40,5 @@ class TranscribeClientPort(Protocol):
def transcribe(
self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]: ...
async def close(self) -> None: ...

View file

@ -3,11 +3,13 @@ from __future__ import annotations
import base64
import os
import httpx
from mistralai.client import Mistral
from mistralai.client.models import SpeechOutputFormat
from vibe.core.config import TTSModelConfig, TTSProviderConfig
from vibe.core.tts.tts_client_port import TTSResult
from vibe.core.utils.http import build_ssl_context
class MistralTTSClient:
@ -18,10 +20,18 @@ class MistralTTSClient:
self._voice = model.voice
self._response_format: SpeechOutputFormat = model.response_format
self._client: Mistral | None = None
self._http_client: httpx.AsyncClient | None = None
def _get_client(self) -> Mistral:
if self._client is None:
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
self._http_client = httpx.AsyncClient(
verify=build_ssl_context(), follow_redirects=True
)
self._client = Mistral(
api_key=self._api_key,
server_url=self._server_url,
async_client=self._http_client,
)
return self._client
async def speak(self, text: str) -> TTSResult:
@ -36,6 +46,13 @@ class MistralTTSClient:
return TTSResult(audio_data=audio_bytes)
async def close(self) -> None:
if self._client is not None:
await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
self._client = None
client = self._client
http_client = self._http_client
self._client = None
self._http_client = None
try:
if client is not None:
await client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
finally:
if http_client is not None:
await http_client.aclose()

View file

@ -13,7 +13,11 @@ from vibe.core.utils.concurrency import (
run_sync,
)
from vibe.core.utils.display import compact_reduction_display
from vibe.core.utils.http import get_server_url_from_api_base, get_user_agent
from vibe.core.utils.http import (
build_ssl_context,
get_server_url_from_api_base,
get_user_agent,
)
from vibe.core.utils.matching import name_matches
from vibe.core.utils.merge import MergeConflictError, MergeStrategy
from vibe.core.utils.paths import is_dangerous_directory
@ -46,6 +50,7 @@ __all__ = [
"TaggedText",
"async_generator_retry",
"async_retry",
"build_ssl_context",
"compact_reduction_display",
"get_server_url_from_api_base",
"get_user_agent",

View file

@ -1,11 +1,37 @@
from __future__ import annotations
import functools
import os
import re
import ssl
import certifi
from vibe import __version__
from vibe.core.types import Backend
@functools.lru_cache(maxsize=1)
def build_ssl_context() -> ssl.SSLContext:
ctx = ssl.create_default_context(cafile=certifi.where())
# Custom certs are additive so private-CA users don't lose public roots.
ssl_cert_file = os.getenv("SSL_CERT_FILE")
ssl_cert_dir = os.getenv("SSL_CERT_DIR")
if ssl_cert_file or ssl_cert_dir:
try:
ctx.load_verify_locations(cafile=ssl_cert_file, capath=ssl_cert_dir)
except (OSError, ssl.SSLError):
from vibe.core.logger import logger
logger.warning(
"Failed to load custom SSL certificates: SSL_CERT_FILE=%s SSL_CERT_DIR=%s",
ssl_cert_file,
ssl_cert_dir,
)
return ctx
def get_user_agent(backend: Backend | None) -> str:
user_agent = f"Mistral-Vibe/{__version__}"
if backend == Backend.MISTRAL:

View file

@ -10,6 +10,7 @@ from urllib.parse import SplitResult, unquote, urlsplit
import httpx
from vibe.core.logger import logger
from vibe.core.utils.http import build_ssl_context
from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
BrowserSignInErrorCode,
@ -203,7 +204,7 @@ class HttpBrowserSignInGateway(BrowserSignInGateway):
def _ensure_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient()
self._client = httpx.AsyncClient(verify=build_ssl_context())
self._should_manage_client = True
return self._client

View file

@ -1,5 +0,0 @@
# What's new in v2.9.5
- **`/loop` command**: Run a prompt or slash command on a recurring interval.
- **`default_agent` config**: Set which agent profile starts each session.
- **Parallel-safe history**: Multiple `vibe` instances no longer clobber each other's history file.