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

@ -67,7 +67,7 @@ Always go through `uv` — never invoke bare `python` or `pip`.
- Use `from vibe.core.logger import logger` — stdlib `logging` with `StructuredLogFormatter`, not `structlog`.
- Configure via env: `LOG_LEVEL` (default `WARNING`), `LOG_MAX_BYTES`. Logs land in `~/.vibe/logs/vibe.log`.
- Pass variables as keyword args, not interpolated into the message: prefer `logger.error("Failed to fetch", url=url)` over `logger.error(f"Failed to fetch {url}")`.
- Pass variables as `%s` positional args, not f-string interpolation: prefer `logger.error("Failed to fetch url=%s", url)` over `logger.error(f"Failed to fetch {url}")`. This defers formatting to the logging framework (only formats if the message is emitted) and keeps messages grep-friendly.
- Define module-local exception hierarchies. Always chain with `raise NewError(...) from e`. Rich exceptions expose a `_fmt()` helper for human-readable output.
## File I/O

View file

@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.9.6] - 2026-05-11
### Added
- Syntax-highlighted file diffs for `write_file` and `search_replace` in the IDE agent webview
- Spinner and loader for `!` (bang) command output
- `prepare release` script can resume after resolving merge conflicts
### Fixed
- Strip wildcard suffix when persisting bash allowlist patterns
- Build a combined SSL context for all outbound HTTPS requests
- Hide non-interactive tools from the LLM in ACP and programmatic mode
## [2.9.5] - 2026-05-06
### Added

View file

@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "2.9.5"
version = "2.9.6"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-darwin-aarch64-2.9.5.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-darwin-aarch64-2.9.6.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-darwin-x86_64-2.9.5.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-darwin-x86_64-2.9.6.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-linux-aarch64-2.9.5.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-linux-aarch64-2.9.6.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-linux-x86_64-2.9.5.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-linux-x86_64-2.9.6.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.5/vibe-acp-windows-x86_64-2.9.5.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.6/vibe-acp-windows-x86_64-2.9.6.zip"
cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.9.5"
version = "2.9.6"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@ -30,6 +30,7 @@ dependencies = [
"agent-client-protocol==0.9.0",
"anyio>=4.12.0",
"cachetools>=5.5.0",
"certifi>=2026.4.22",
"charset-normalizer>=3.4.4",
"cryptography>=44.0.0",
"gitpython>=3.1.46",
@ -173,7 +174,7 @@ order-by-type = true
required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.pylint]
max-public-methods = 25
max-public-methods = 26
max-positional-args = 10
max-statements = 50
max-branches = 15

View file

@ -102,6 +102,26 @@ def get_latest_version() -> str:
return max(versions)[3]
def get_base_version_from_current_branch() -> str:
result = run_git_command(
"describe",
"--tags",
"--match",
"v[0-9]*.[0-9]*.[0-9]*",
"--exclude",
"*-private",
"--abbrev=0",
"HEAD",
capture_output=True,
)
tag = result.stdout.strip()
match = re.match(r"^v(\d+\.\d+\.\d+)$", tag)
if not match:
raise ValueError(f"Invalid base version tag found: {tag}")
return match.group(1)
def parse_version(version_str: str) -> tuple[int, int, int]:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip())
if not match:
@ -130,34 +150,31 @@ def create_release_branch(version: str) -> None:
print(f"Created and switched to branch {branch_name}")
def cherry_pick_commits(
previous_version: str, current_version: str, squash: bool
) -> None:
previous_tag = f"v{previous_version}-private"
current_tag = f"v{current_version}-private"
def cherry_pick_commits(previous_private_tag: str, current_private_tag: str) -> None:
result = run_git_command(
"rev-parse", "--verify", previous_tag, capture_output=True, check=False
"rev-parse", "--verify", previous_private_tag, capture_output=True, check=False
)
if result.returncode != 0:
raise ValueError(f"Tag {previous_tag} does not exist")
raise ValueError(f"Tag {previous_private_tag} does not exist")
result = run_git_command(
"rev-parse", "--verify", current_tag, capture_output=True, check=False
"rev-parse", "--verify", current_private_tag, capture_output=True, check=False
)
if result.returncode != 0:
raise ValueError(f"Tag {current_tag} does not exist")
raise ValueError(f"Tag {current_private_tag} does not exist")
print(f"Cherry-picking commits from {previous_tag}..{current_tag}...")
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
print(
f"Cherry-picking commits from {previous_private_tag}..{current_private_tag}..."
)
run_git_command("cherry-pick", f"{previous_private_tag}..{current_private_tag}")
print("Successfully cherry-picked all commits")
if squash:
squash_commits(previous_version, current_version, previous_tag, current_tag)
def squash_commits(
previous_version: str, current_version: str, previous_tag: str, current_tag: str
previous_version: str,
current_version: str,
previous_private_tag: str,
current_private_tag: str,
) -> None:
print("Squashing commits into a single release commit...")
run_git_command("reset", "--soft", f"v{previous_version}")
@ -165,7 +182,7 @@ def squash_commits(
# Get all contributors between previous and current private tags
result = run_git_command(
"log",
f"{previous_tag}..{current_tag}",
f"{previous_private_tag}..{current_private_tag}",
"--format=%aN <%aE>",
capture_output=True,
)
@ -289,6 +306,9 @@ def main() -> None:
default=True,
help="Disable squashing of commits into a single release commit",
)
parser.add_argument(
"--resume", action="store_true", help="Resume after cherry-picking commits"
)
args = parser.parse_args()
current_version = args.version
@ -298,6 +318,9 @@ def main() -> None:
# Step 1: Ensure public remote exists
ensure_public_remote()
if args.resume:
previous_version = get_base_version_from_current_branch()
else:
# Step 2: Fetch all remotes
print("Fetching all remotes...")
run_git_command("fetch", "--all")
@ -316,6 +339,10 @@ def main() -> None:
)
print(f"Version verified: {current_version}")
previous_private_tag = f"v{previous_version}-private"
current_private_tag = f"v{current_version}-private"
if not args.resume:
# Step 5: Switch to previous version tag
switch_to_tag(previous_version)
@ -323,7 +350,16 @@ def main() -> None:
create_release_branch(current_version)
# Step 7: Cherry-pick commits
cherry_pick_commits(previous_version, current_version, squash)
cherry_pick_commits(previous_private_tag, current_private_tag)
# Step 8: Squash commits
if squash:
squash_commits(
previous_version,
current_version,
previous_private_tag,
current_private_tag,
)
# Step 8: Get summary information
commits_summary = get_commits_summary(previous_version, current_version)

View file

@ -19,16 +19,16 @@ class TestCloseSession:
session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id]
backend_close = AsyncMock()
backend_aexit = AsyncMock()
telemetry_close = AsyncMock()
cast(Any, session.agent_loop.backend).close = backend_close
cast(Any, session.agent_loop.backend).__aexit__ = backend_aexit
session.agent_loop.telemetry_client.aclose = telemetry_close
response = await acp_agent_loop.close_session(session_response.session_id)
assert response is not None
assert session_response.session_id not in acp_agent_loop.sessions
backend_close.assert_awaited_once()
backend_aexit.assert_awaited_once_with(None, None, None)
telemetry_close.assert_awaited_once()
session_closed_events = [
event

View file

@ -34,7 +34,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.5"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.6"
)
assert response.auth_methods == []
@ -62,7 +62,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.5"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.6"
)
assert response.auth_methods is not None

View file

@ -432,12 +432,12 @@ class TestMistralRetry:
with patch("vibe.core.llm.backend.mistral.Mistral") as mock_mistral_class:
mock_mistral_class.return_value = MagicMock()
backend._get_client()
mock_mistral_class.assert_called_once_with(
api_key=backend._api_key,
server_url=backend._server_url,
timeout_ms=720000,
retry_config=backend._retry_config,
)
call_kwargs = mock_mistral_class.call_args.kwargs
assert call_kwargs["api_key"] == backend._api_key
assert call_kwargs["server_url"] == backend._server_url
assert call_kwargs["timeout_ms"] == 720000
assert call_kwargs["retry_config"] is backend._retry_config
assert "async_client" in call_kwargs
class TestMistralMapperPrepareMessage:

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import ssl
from unittest.mock import MagicMock, patch
import pytest
from vibe.core.utils.http import build_ssl_context
@pytest.fixture(autouse=True)
def _clear_ssl_cache():
build_ssl_context.cache_clear()
yield
build_ssl_context.cache_clear()
def test_build_ssl_context_returns_ssl_context():
ctx = build_ssl_context()
assert isinstance(ctx, ssl.SSLContext)
def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
cert_file = tmp_path / "custom.pem"
cert_file.write_text("dummy")
monkeypatch.setenv("SSL_CERT_FILE", str(cert_file))
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.ssl.create_default_context", return_value=mock_ctx
):
build_ssl_context()
mock_ctx.load_verify_locations.assert_called_once_with(
cafile=str(cert_file), capath=None
)
def test_build_ssl_context_loads_custom_cert_dir(monkeypatch, tmp_path):
cert_dir = tmp_path / "certs"
cert_dir.mkdir()
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.setenv("SSL_CERT_DIR", str(cert_dir))
mock_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.ssl.create_default_context", return_value=mock_ctx
):
build_ssl_context()
mock_ctx.load_verify_locations.assert_called_once_with(
cafile=None, capath=str(cert_dir)
)
def test_build_ssl_context_warns_on_invalid_cert(monkeypatch, caplog):
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/path/cert.pem")
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
ctx = build_ssl_context()
assert isinstance(ctx, ssl.SSLContext)
assert any(
"Failed to load custom SSL certificates" in r.message for r in caplog.records
)
def test_build_ssl_context_no_custom_certs(monkeypatch):
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.ssl.create_default_context", return_value=mock_ctx
):
build_ssl_context()
mock_ctx.load_verify_locations.assert_not_called()

View file

@ -293,6 +293,68 @@ class TestMigrateLeavesFindInBashAllowlist:
assert "tools" not in result
class TestMigrateStripsBashAllowlistWildcardSuffix:
def test_strips_trailing_wildcard_from_bash_allowlist_entries(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"tools": {"bash": {"allowlist": ["git commit *", "npm install *", "echo"]}}
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["tools"]["bash"]["allowlist"] == [
"echo",
"find",
"git commit",
"npm install",
]
def test_dedupes_when_stripping_collides_with_existing_entry(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {
"tools": {"bash": {"allowlist": ["git commit *", "git commit", "find"]}}
}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["tools"]["bash"]["allowlist"] == ["find", "git commit"]
def test_noop_when_no_wildcard_suffix_present(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
data = {"tools": {"bash": {"allowlist": ["echo", "find", "ls"]}}}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
reset_harness_files_manager()
init_harness_files_manager("user")
VibeConfig._migrate()
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["tools"]["bash"]["allowlist"] == ["echo", "find", "ls"]
class TestMigrateMistralVibeCliLatestDefaults:
def test_updates_alias_temperature_and_thinking_for_default_model(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View file

@ -20,3 +20,6 @@ class FakeTranscribeClient:
) -> AsyncIterator[TranscribeEvent]:
for event in self._events:
yield event
async def close(self) -> None:
pass

View file

@ -66,6 +66,9 @@ class FakeVoiceManager:
except ValueError:
pass
async def close(self) -> None:
pass
def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state:
return

View file

@ -11,6 +11,7 @@ from tests.stubs.fake_backend import FakeBackend
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import (
BUILTIN_AGENTS,
CHAT,
AgentProfile,
AgentSafety,
AgentType,
@ -188,6 +189,49 @@ class TestAgentApplyToConfig:
"exit_plan_mode",
}
def test_base_disabled_tools_are_filtered_from_profile_enabled_tools(self) -> None:
base = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_tools=["ask_user_question"],
)
result = CHAT.apply_to_config(base)
assert "ask_user_question" not in result.enabled_tools
assert "grep" in result.enabled_tools
assert "read_file" in result.enabled_tools
assert "task" in result.enabled_tools
def test_base_disabled_tools_filter_supports_glob_patterns(self) -> None:
base = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_tools=["ask_*"],
)
agent = AgentProfile(
name="custom",
display_name="Custom",
description="",
safety=AgentSafety.NEUTRAL,
overrides={"enabled_tools": ["grep", "ask_user_question", "ask_extra"]},
)
result = agent.apply_to_config(base)
assert result.enabled_tools == ["grep"]
def test_empty_base_disabled_tools_leaves_enabled_tools_untouched(self) -> None:
base = VibeConfig(
include_project_context=False,
include_prompt_detail=False,
disabled_tools=[],
)
result = CHAT.apply_to_config(base)
assert "ask_user_question" in result.enabled_tools
def test_custom_prompt_found_in_global_when_missing_from_project(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:

View file

@ -50,7 +50,7 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["allowlist"] == ["npm install *"]
assert persisted["tools"]["bash"]["allowlist"] == ["npm install"]
def test_also_adds_session_rules(self, config_dir: Path):
agent = build_test_agent_loop()
@ -75,9 +75,7 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
assert "bash" not in persisted.get("tools", {})
def test_does_not_duplicate_existing_allowlist_entries(self, config_dir: Path):
config = build_test_vibe_config(
tools={"bash": {"allowlist": ["npm install *"]}}
)
config = build_test_vibe_config(tools={"bash": {"allowlist": ["npm install"]}})
agent = build_test_agent_loop(config=config)
perms = self._make_permissions()
@ -88,14 +86,14 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
assert persisted.get("tools", {}).get("bash", {}).get("allowlist") is None
def test_appends_new_patterns_to_existing_allowlist(self, config_dir: Path):
config = build_test_vibe_config(tools={"bash": {"allowlist": ["git *"]}})
config = build_test_vibe_config(tools={"bash": {"allowlist": ["git"]}})
agent = build_test_agent_loop(config=config)
perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["allowlist"] == ["git *", "npm install *"]
assert persisted["tools"]["bash"]["allowlist"] == ["git", "npm install"]
def test_multiple_permissions_persisted(self, config_dir: Path):
agent = build_test_agent_loop()
@ -117,4 +115,4 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir)
assert persisted["tools"]["bash"]["allowlist"] == ["/tmp/*", "npm install *"]
assert persisted["tools"]["bash"]["allowlist"] == ["/tmp/*", "npm install"]

View file

@ -329,6 +329,35 @@ class TestReadOnlyAgentMiddleware:
PLAN_REMINDER_SNIPPET = "Plan mode is active"
class TestMakePlanAgentReminder:
def test_default_includes_both_interactive_tool_instructions(self) -> None:
reminder = make_plan_agent_reminder("/tmp/test-plan.md")
assert "ask_user_question" in reminder
assert "exit_plan_mode" in reminder
def test_omits_ask_user_question_when_tool_unavailable(self) -> None:
reminder = make_plan_agent_reminder(
"/tmp/test-plan.md", has_ask_user_question=False
)
assert "ask_user_question" not in reminder
assert "exit_plan_mode" in reminder
def test_omits_exit_plan_mode_when_tool_unavailable(self) -> None:
reminder = make_plan_agent_reminder(
"/tmp/test-plan.md", has_exit_plan_mode=False
)
assert "exit_plan_mode" not in reminder
assert "tell them to switch modes" in reminder
def test_omits_both_when_neither_available(self) -> None:
reminder = make_plan_agent_reminder(
"/tmp/test-plan.md", has_ask_user_question=False, has_exit_plan_mode=False
)
assert "ask_user_question" not in reminder
assert "exit_plan_mode" not in reminder
assert "Plan mode is active" in reminder
class TestMiddlewarePipelineWithReadOnlyAgent:
@pytest.mark.asyncio
async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None:

View file

@ -159,6 +159,9 @@ class TestStopRecording:
return
yield # makes this an async generator
async def close(self) -> None:
pass
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
@ -218,6 +221,9 @@ class TestCancelRecording:
return
yield
async def close(self) -> None:
pass
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
@ -388,6 +394,9 @@ class TestTranscription:
raise RuntimeError("network error")
yield # makes this an async generator
async def close(self) -> None:
pass
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
manager = VoiceManager(
@ -488,6 +497,9 @@ class TestTelemetryTracking:
raise RuntimeError("network error")
yield
async def close(self) -> None:
pass
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
mock_telemetry = MagicMock()
@ -556,6 +568,9 @@ class TestTelemetryTracking:
return
yield
async def close(self) -> None:
pass
recorder = FakeAudioRecorder()
config = build_test_vibe_config(voice_mode_enabled=True)
mock_telemetry = MagicMock()

6
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2026-04-29T14:28:22.645142Z"
exclude-newer = "2026-05-04T08:14:44.283731Z"
exclude-newer-span = "P7D"
[options.exclude-newer-package]
@ -820,12 +820,13 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.9.5"
version = "2.9.6"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
{ name = "anyio" },
{ name = "cachetools" },
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "cryptography" },
{ name = "gitpython" },
@ -888,6 +889,7 @@ requires-dist = [
{ name = "agent-client-protocol", specifier = "==0.9.0" },
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" },
{ name = "certifi", specifier = ">=2026.4.22" },
{ name = "charset-normalizer", specifier = ">=3.4.4" },
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "gitpython", specifier = ">=3.1.46" },

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")
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-success")
self._prompt_widget.add_class("bash-error")
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__(
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.")
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,
)
try:
async with 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:
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,
)
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)
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.