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`. - 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`. - 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. - 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 ## 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/), 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). 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 ## [2.9.5] - 2026-05-06
### Added ### Added

View file

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

View file

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

View file

@ -102,6 +102,26 @@ def get_latest_version() -> str:
return max(versions)[3] 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]: def parse_version(version_str: str) -> tuple[int, int, int]:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip()) match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip())
if not match: if not match:
@ -130,34 +150,31 @@ def create_release_branch(version: str) -> None:
print(f"Created and switched to branch {branch_name}") print(f"Created and switched to branch {branch_name}")
def cherry_pick_commits( def cherry_pick_commits(previous_private_tag: str, current_private_tag: str) -> None:
previous_version: str, current_version: str, squash: bool
) -> None:
previous_tag = f"v{previous_version}-private"
current_tag = f"v{current_version}-private"
result = run_git_command( 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: 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( 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: 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}...") print(
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}") 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") print("Successfully cherry-picked all commits")
if squash:
squash_commits(previous_version, current_version, previous_tag, current_tag)
def squash_commits( 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: ) -> None:
print("Squashing commits into a single release commit...") print("Squashing commits into a single release commit...")
run_git_command("reset", "--soft", f"v{previous_version}") 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 # Get all contributors between previous and current private tags
result = run_git_command( result = run_git_command(
"log", "log",
f"{previous_tag}..{current_tag}", f"{previous_private_tag}..{current_private_tag}",
"--format=%aN <%aE>", "--format=%aN <%aE>",
capture_output=True, capture_output=True,
) )
@ -289,6 +306,9 @@ def main() -> None:
default=True, default=True,
help="Disable squashing of commits into a single release commit", 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() args = parser.parse_args()
current_version = args.version current_version = args.version
@ -298,6 +318,9 @@ def main() -> None:
# Step 1: Ensure public remote exists # Step 1: Ensure public remote exists
ensure_public_remote() ensure_public_remote()
if args.resume:
previous_version = get_base_version_from_current_branch()
else:
# Step 2: Fetch all remotes # Step 2: Fetch all remotes
print("Fetching all remotes...") print("Fetching all remotes...")
run_git_command("fetch", "--all") run_git_command("fetch", "--all")
@ -316,6 +339,10 @@ def main() -> None:
) )
print(f"Version verified: {current_version}") 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 # Step 5: Switch to previous version tag
switch_to_tag(previous_version) switch_to_tag(previous_version)
@ -323,7 +350,16 @@ def main() -> None:
create_release_branch(current_version) create_release_branch(current_version)
# Step 7: Cherry-pick commits # 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 # Step 8: Get summary information
commits_summary = get_commits_summary(previous_version, current_version) 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_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[])
session = acp_agent_loop.sessions[session_response.session_id] session = acp_agent_loop.sessions[session_response.session_id]
backend_close = AsyncMock() backend_aexit = AsyncMock()
telemetry_close = 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 session.agent_loop.telemetry_client.aclose = telemetry_close
response = await acp_agent_loop.close_session(session_response.session_id) response = await acp_agent_loop.close_session(session_response.session_id)
assert response is not None assert response is not None
assert session_response.session_id not in acp_agent_loop.sessions 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() telemetry_close.assert_awaited_once()
session_closed_events = [ session_closed_events = [
event event

View file

@ -34,7 +34,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( 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 == [] assert response.auth_methods == []
@ -62,7 +62,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( 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 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: with patch("vibe.core.llm.backend.mistral.Mistral") as mock_mistral_class:
mock_mistral_class.return_value = MagicMock() mock_mistral_class.return_value = MagicMock()
backend._get_client() backend._get_client()
mock_mistral_class.assert_called_once_with( call_kwargs = mock_mistral_class.call_args.kwargs
api_key=backend._api_key, assert call_kwargs["api_key"] == backend._api_key
server_url=backend._server_url, assert call_kwargs["server_url"] == backend._server_url
timeout_ms=720000, assert call_kwargs["timeout_ms"] == 720000
retry_config=backend._retry_config, assert call_kwargs["retry_config"] is backend._retry_config
) assert "async_client" in call_kwargs
class TestMistralMapperPrepareMessage: 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 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: class TestMigrateMistralVibeCliLatestDefaults:
def test_updates_alias_temperature_and_thinking_for_default_model( def test_updates_alias_temperature_and_thinking_for_default_model(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View file

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

View file

@ -66,6 +66,9 @@ class FakeVoiceManager:
except ValueError: except ValueError:
pass pass
async def close(self) -> None:
pass
def _set_state(self, state: TranscribeState) -> None: def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state: if self._transcribe_state == state:
return 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.manager import AgentManager
from vibe.core.agents.models import ( from vibe.core.agents.models import (
BUILTIN_AGENTS, BUILTIN_AGENTS,
CHAT,
AgentProfile, AgentProfile,
AgentSafety, AgentSafety,
AgentType, AgentType,
@ -188,6 +189,49 @@ class TestAgentApplyToConfig:
"exit_plan_mode", "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( def test_custom_prompt_found_in_global_when_missing_from_project(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View file

@ -50,7 +50,7 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=True) agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir) 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): def test_also_adds_session_rules(self, config_dir: Path):
agent = build_test_agent_loop() agent = build_test_agent_loop()
@ -75,9 +75,7 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
assert "bash" not in persisted.get("tools", {}) assert "bash" not in persisted.get("tools", {})
def test_does_not_duplicate_existing_allowlist_entries(self, config_dir: Path): def test_does_not_duplicate_existing_allowlist_entries(self, config_dir: Path):
config = build_test_vibe_config( config = build_test_vibe_config(tools={"bash": {"allowlist": ["npm install"]}})
tools={"bash": {"allowlist": ["npm install *"]}}
)
agent = build_test_agent_loop(config=config) agent = build_test_agent_loop(config=config)
perms = self._make_permissions() perms = self._make_permissions()
@ -88,14 +86,14 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
assert persisted.get("tools", {}).get("bash", {}).get("allowlist") is None assert persisted.get("tools", {}).get("bash", {}).get("allowlist") is None
def test_appends_new_patterns_to_existing_allowlist(self, config_dir: Path): 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) agent = build_test_agent_loop(config=config)
perms = self._make_permissions() perms = self._make_permissions()
agent.approve_always("bash", perms, save_permanently=True) agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir) 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): def test_multiple_permissions_persisted(self, config_dir: Path):
agent = build_test_agent_loop() agent = build_test_agent_loop()
@ -117,4 +115,4 @@ class TestApproveAlwaysPermanentWithGranularPermissions:
agent.approve_always("bash", perms, save_permanently=True) agent.approve_always("bash", perms, save_permanently=True)
persisted = _read_persisted_config(config_dir) 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" 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: class TestMiddlewarePipelineWithReadOnlyAgent:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None: async def test_pipeline_includes_injection(self, ctx: ConversationContext) -> None:

View file

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

6
uv.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Protocol, runtime_checkable
from acp.helpers import SessionUpdate, ToolCallContentVariant from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ( from acp.schema import (
ContentToolCallContent, ContentToolCallContent,
@ -10,15 +12,25 @@ from acp.schema import (
) )
from pydantic import BaseModel from pydantic import BaseModel
from vibe.acp.tools.base import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import TaggedText, is_user_cancellation_event 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: def _cancellation_raw_output(event: ToolResultEvent) -> str | None:
if event.skip_reason: if event.skip_reason:
return TaggedText.from_string(event.skip_reason).message 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() stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None: if args.prompt is not None:
warn_if_workdir_trust_is_unset() 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 programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt: if not programmatic_prompt:
print( print(

View file

@ -10,6 +10,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayUnauthorized, WhoAmIGatewayUnauthorized,
WhoAmIResponse, WhoAmIResponse,
) )
from vibe.core.utils.http import build_ssl_context
BASE_URL = "https://console.mistral.ai" BASE_URL = "https://console.mistral.ai"
WHOAMI_PATH = "/api/vibe/whoami" WHOAMI_PATH = "/api/vibe/whoami"
@ -23,7 +24,7 @@ class HttpWhoAmIGateway:
url = f"{self._base_url}{WHOAMI_PATH}" url = f"{self._base_url}{WHOAMI_PATH}"
headers = {"Authorization": f"Bearer {api_key}"} headers = {"Authorization": f"Bearer {api_key}"}
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient(verify=build_ssl_context()) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
except httpx.RequestError as exc: except httpx.RequestError as exc:
raise WhoAmIGatewayError() from 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) bash_msg = BashOutputMessage(command, str(Path.cwd()), pending=True)
await self._mount_and_scroll(bash_msg) 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 proc: asyncio.subprocess.Process | None = None
stdout_parts: list[str] = [] stdout_parts: list[str] = []
@ -1071,6 +1073,9 @@ class VibeApp(App): # noqa: PLR0904
status=f"failed before completion: {e}", 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: def _get_bash_max_output_bytes(self) -> int:
from vibe.core.tools.builtins.bash import BashToolConfig from vibe.core.tools.builtins.bash import BashToolConfig
@ -2132,7 +2137,9 @@ class VibeApp(App): # noqa: PLR0904
self._emit_session_closed_for_active_session() self._emit_session_closed_for_active_session()
await self._loop_runner.stop() await self._loop_runner.stop()
self._log_reader.shutdown() self._log_reader.shutdown()
await self._voice_manager.close()
await self._narrator_manager.close() await self._narrator_manager.close()
await self.agent_loop.aclose()
try: try:
await self.agent_loop.telemetry_client.aclose() await self.agent_loop.telemetry_client.aclose()
except Exception as exc: except Exception as exc:
@ -2629,6 +2636,9 @@ class VibeApp(App): # noqa: PLR0904
return True return True
interrupted = False interrupted = False
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
interrupted = True
if self._agent_running: if self._agent_running:
self._handle_agent_running_escape() self._handle_agent_running_escape()
interrupted = True interrupted = True
@ -2815,6 +2825,7 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown() self._log_reader.shutdown()
self._narrator_manager.cancel() self._narrator_manager.cancel()
await self.agent_loop.aclose()
try: try:
await self.agent_loop.telemetry_client.aclose() await self.agent_loop.telemetry_client.aclose()
except Exception as exc: except Exception as exc:

View file

@ -406,6 +406,10 @@ Markdown {
width: auto; width: auto;
height: auto; height: auto;
&.bash-pending {
color: $foreground;
}
&.bash-success { &.bash-success {
color: ansi_green; 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__( def __init__(
self, self,
command: str, command: str,
@ -274,6 +276,7 @@ class BashOutputMessage(Static):
pending: bool = False, pending: bool = False,
) -> None: ) -> None:
super().__init__() super().__init__()
self.init_spinner()
self.add_class("bash-output-message") self.add_class("bash-output-message")
self._command = command self._command = command
self._cwd = cwd self._cwd = cwd
@ -283,17 +286,29 @@ class BashOutputMessage(Static):
self._output_widget: NoMarkupStatic | None = None self._output_widget: NoMarkupStatic | None = None
self._output_container: Horizontal | None = None self._output_container: Horizontal | None = None
self._prompt_widget: NonSelectableStatic | 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: def compose(self) -> ComposeResult:
status_class = ( if self._pending:
"bash-error" status_class = "bash-pending"
if not self._pending and self._exit_code != 0 elif self._exit_code != 0:
else "bash-success" status_class = "bash-error"
) else:
status_class = "bash-success"
self.add_class(status_class) self.add_class(status_class)
prompt_text = f"{self._spinner.current_frame()} " if self._pending else "$ "
with Horizontal(classes="bash-command-line"): with Horizontal(classes="bash-command-line"):
self._prompt_widget = NonSelectableStatic( self._prompt_widget = NonSelectableStatic(
"$ ", classes=f"bash-prompt {status_class}" prompt_text, classes=f"bash-prompt {status_class}"
) )
yield self._prompt_widget yield self._prompt_widget
yield NoMarkupStatic(self._command, classes="bash-command") 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: async def finish(self, exit_code: int, *, interrupted: bool = False) -> None:
self._exit_code = exit_code self._exit_code = exit_code
self._pending = False self._pending = False
self.stop_spinning()
if self._prompt_widget:
self._prompt_widget.update("$ ")
if interrupted: if interrupted:
self.remove_class("bash-success") new_class = "bash-interrupted"
self.add_class("bash-interrupted")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-interrupted")
elif exit_code != 0: elif exit_code != 0:
self.remove_class("bash-success") new_class = "bash-error"
self.add_class("bash-error") else:
new_class = "bash-success"
self.remove_class("bash-pending")
self.add_class(new_class)
if self._prompt_widget: if self._prompt_widget:
self._prompt_widget.remove_class("bash-success") self._prompt_widget.remove_class("bash-pending")
self._prompt_widget.add_class("bash-error") self._prompt_widget.add_class(new_class)
if interrupted: if interrupted:
suffix = ( suffix = (
"\n(interrupted)" "\n(interrupted)"

View file

@ -8,6 +8,7 @@ from vibe.cli.update_notifier.ports.update_gateway import (
UpdateGatewayCause, UpdateGatewayCause,
UpdateGatewayError, UpdateGatewayError,
) )
from vibe.core.utils.http import build_ssl_context
class GitHubUpdateGateway(UpdateGateway): class GitHubUpdateGateway(UpdateGateway):
@ -47,7 +48,9 @@ class GitHubUpdateGateway(UpdateGateway):
) )
else: else:
async with httpx.AsyncClient( 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: ) as client:
response = await client.get(request_path, headers=headers) response = await client.get(request_path, headers=headers)
except httpx.RequestError as exc: except httpx.RequestError as exc:

View file

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

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from asyncio import CancelledError, create_task, wait_for from asyncio import CancelledError, create_task, wait_for
import contextlib
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
@ -154,6 +155,15 @@ class VoiceManager:
except ValueError: except ValueError:
pass 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: async def _run_transcription(self) -> None:
if self._transcribe_client is None: if self._transcribe_client is None:
return return

View file

@ -54,3 +54,5 @@ class VoiceManagerPort(Protocol):
def add_listener(self, listener: VoiceManagerListener) -> None: ... def add_listener(self, listener: VoiceManagerListener) -> None: ...
def remove_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: def emit_session_closed_telemetry(self) -> None:
self.telemetry_client.send_session_closed() 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: def _create_connector_registry(self) -> ConnectorRegistry | None:
if not connectors_enabled(): if not connectors_enabled():
return None return None
@ -671,7 +675,13 @@ class AgentLoop:
ReadOnlyAgentMiddleware( ReadOnlyAgentMiddleware(
lambda: self.agent_profile, lambda: self.agent_profile,
BuiltinAgentName.PLAN, 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, PLAN_AGENT_EXIT,
) )
) )
@ -1661,7 +1671,12 @@ class AgentLoop:
self._base_config = base_config self._base_config = base_config
self.agent_manager.invalidate_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: if max_turns is not None:
self._max_turns = max_turns self._max_turns = max_turns

View file

@ -7,6 +7,7 @@ import tomllib
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from vibe.core.paths import PLANS_DIR from vibe.core.paths import PLANS_DIR
from vibe.core.utils import name_matches
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
@ -68,6 +69,15 @@ class AgentProfile:
*merged.get("disabled_tools", []), *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) return VC.model_validate(merged)
@classmethod @classmethod

View file

@ -9,6 +9,8 @@ import httpx
import keyring import keyring
import keyring.errors import keyring.errors
from vibe.core.utils.http import build_ssl_context
GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT" GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT"
_SERVICE_NAME = "vibe" _SERVICE_NAME = "vibe"
@ -51,7 +53,9 @@ class GitHubAuthProvider:
async def __aenter__(self) -> GitHubAuthProvider: async def __aenter__(self) -> GitHubAuthProvider:
if self._client is None: 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 return self
async def __aexit__( async def __aexit__(
@ -66,7 +70,9 @@ class GitHubAuthProvider:
def _get_client(self) -> httpx.AsyncClient: def _get_client(self) -> httpx.AsyncClient:
if self._client is None: 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 self._owns_client = True
return self._client 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 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( def deep_update(
mapping: dict[str, Any], updating_mapping: dict[str, Any] mapping: dict[str, Any], updating_mapping: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -946,6 +952,8 @@ class VibeConfig(BaseSettings):
type(self).save_updates({"models": models}) type(self).save_updates({"models": models})
def add_tool_allowlist_patterns(self, tool_name: str, patterns: list[str]) -> None: 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( current_allowlist: list[str] = list(
self.tools.get(tool_name, {}).get("allowlist", []) self.tools.get(tool_name, {}).get("allowlist", [])
) )
@ -1005,6 +1013,12 @@ class VibeConfig(BaseSettings):
allowlist.sort() allowlist.sort()
changed = True 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", []): for model in data.get("models", []):
if ( if (
model.get("name") == "mistral-vibe-cli-latest" model.get("name") == "mistral-vibe-cli-latest"

View file

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

View file

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

View file

@ -125,7 +125,30 @@ class ContextWarningMiddleware:
self.has_warned = False 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. 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 ## 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. 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 ## Instructions
1. Research the user's query using read-only tools (grep, read_file, etc.) {numbered}</{VIBE_WARNING_TAG}>"""
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}>"""
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}>""" 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, WorkflowExecutionListResponse,
WorkflowExecutionStatus, WorkflowExecutionStatus,
) )
from vibe.core.utils.http import build_ssl_context
class WorkflowsClient: class WorkflowsClient:
@ -32,7 +33,9 @@ class WorkflowsClient:
headers: dict[str, str] = {} headers: dict[str, str] = {}
if self._api_key: if self._api_key:
headers["Authorization"] = f"Bearer {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 return self
async def __aexit__( async def __aexit__(
@ -51,7 +54,9 @@ class WorkflowsClient:
headers: dict[str, str] = {} headers: dict[str, str] = {}
if self._api_key: if self._api_key:
headers["Authorization"] = f"Bearer {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 self._owns_client = True
return self._client return self._client

View file

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

View file

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

View file

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

View file

@ -38,6 +38,7 @@ from vibe.core.teleport.types import (
TeleportWaitingForGitHubEvent, TeleportWaitingForGitHubEvent,
TeleportYieldEvent, 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." _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: async def __aenter__(self) -> TeleportService:
if self._client is None: 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._nuage_client_instance = NuageClient(
self._vibe_code_base_url, self._vibe_code_base_url,
self._vibe_code_api_key, self._vibe_code_api_key,
@ -98,7 +101,9 @@ class TeleportService:
@property @property
def _http_client(self) -> httpx.AsyncClient: def _http_client(self) -> httpx.AsyncClient:
if self._client is None: 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 self._owns_client = True
return self._client 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.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent from vibe.core.types import ToolStreamEvent
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -198,7 +199,9 @@ class WebFetch(
self, url: str, timeout: int, headers: dict[str, str] self, url: str, timeout: int, headers: dict[str, str]
) -> httpx.Response: ) -> httpx.Response:
async with httpx.AsyncClient( async with httpx.AsyncClient(
follow_redirects=True, timeout=httpx.Timeout(timeout) follow_redirects=True,
timeout=httpx.Timeout(timeout),
verify=build_ssl_context(),
) as client: ) as client:
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)

View file

@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
import os import os
from typing import TYPE_CHECKING, ClassVar, final from typing import TYPE_CHECKING, ClassVar, final
import httpx
from mistralai.client import Mistral from mistralai.client import Mistral
from mistralai.client.errors import SDKError from mistralai.client.errors import SDKError
from mistralai.client.models import ( 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.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import Backend, ToolStreamEvent 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: if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -73,14 +74,17 @@ class WebSearch(
if not api_key: if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.") 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( client = Mistral(
api_key=api_key, api_key=api_key,
server_url=self._resolve_server_url(ctx), server_url=self._resolve_server_url(ctx),
timeout_ms=self.config.timeout * 1000, timeout_ms=self.config.timeout * 1000,
async_client=async_http_client,
) )
async with async_http_client, client:
try:
async with client:
response = await client.beta.conversations.start_async( response = await client.beta.conversations.start_async(
model=self.config.model, model=self.config.model,
instructions="Always use the web_search tool to answer queries. Never answer from memory alone.", 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: except SDKError as exc:
raise ToolError(f"Mistral API error: {exc}") from 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: def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
if not ctx or not ctx.agent_manager: 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.tools.ui import ToolResultDisplay
from vibe.core.types import ToolStreamEvent from vibe.core.types import ToolStreamEvent
from vibe.core.utils import run_sync from vibe.core.utils import run_sync
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent from vibe.core.types import ToolResultEvent
@ -238,9 +239,6 @@ class ConnectorRegistry:
self._alias_to_id: dict[str, str] = {} self._alias_to_id: dict[str, str] = {}
self._discover_lock = asyncio.Lock() 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]]: def get_tools(self) -> dict[str, type[BaseTool]]:
"""Return proxy tool classes for all connectors, using cache when possible.""" """Return proxy tool classes for all connectors, using cache when possible."""
return run_sync(self.get_tools_async()) return run_sync(self.get_tools_async())
@ -259,7 +257,9 @@ class ConnectorRegistry:
base_url = self._server_url or _DEFAULT_BASE_URL base_url = self._server_url or _DEFAULT_BASE_URL
url = f"{base_url}/v1/connectors/bootstrap" url = f"{base_url}/v1/connectors/bootstrap"
headers = {"Authorization": f"Bearer {self._api_key}"} 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 = await http.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@ -427,11 +427,22 @@ class ConnectorRegistry:
if connector_id is None: if connector_id is None:
return None return None
try: 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( result = await client.beta.connectors.get_auth_url_async(
connector_id_or_name=connector_id connector_id_or_name=connector_id
) )
return result.auth_url return result.auth_url
finally:
await http_client.aclose()
except Exception: except Exception:
logger.debug("Failed to get auth URL for connector %s", alias) logger.debug("Failed to get auth URL for connector %s", alias)
return None return None

View file

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

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
import os import os
import httpx
from mistralai.client import Mistral from mistralai.client import Mistral
from mistralai.client.models import ( from mistralai.client.models import (
AudioFormat, AudioFormat,
@ -21,6 +22,7 @@ from vibe.core.transcribe.transcribe_client_port import (
TranscribeSessionCreated, TranscribeSessionCreated,
TranscribeTextDelta, TranscribeTextDelta,
) )
from vibe.core.utils.http import build_ssl_context
class MistralTranscribeClient: class MistralTranscribeClient:
@ -35,10 +37,18 @@ class MistralTranscribeClient:
) )
self._target_streaming_delay_ms = model.target_streaming_delay_ms self._target_streaming_delay_ms = model.target_streaming_delay_ms
self._client: Mistral | None = None self._client: Mistral | None = None
self._http_client: httpx.AsyncClient | None = None
def _get_client(self) -> Mistral: def _get_client(self) -> Mistral:
if self._client is None: 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 return self._client
async def transcribe( async def transcribe(
@ -61,3 +71,15 @@ class MistralTranscribeClient:
yield TranscribeError(message=str(event.error.message)) yield TranscribeError(message=str(event.error.message))
elif isinstance(event, UnknownRealtimeEvent): elif isinstance(event, UnknownRealtimeEvent):
continue 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( def transcribe(
self, audio_stream: AsyncIterator[bytes] self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]: ... ) -> AsyncIterator[TranscribeEvent]: ...
async def close(self) -> None: ...

View file

@ -3,11 +3,13 @@ from __future__ import annotations
import base64 import base64
import os import os
import httpx
from mistralai.client import Mistral from mistralai.client import Mistral
from mistralai.client.models import SpeechOutputFormat from mistralai.client.models import SpeechOutputFormat
from vibe.core.config import TTSModelConfig, TTSProviderConfig from vibe.core.config import TTSModelConfig, TTSProviderConfig
from vibe.core.tts.tts_client_port import TTSResult from vibe.core.tts.tts_client_port import TTSResult
from vibe.core.utils.http import build_ssl_context
class MistralTTSClient: class MistralTTSClient:
@ -18,10 +20,18 @@ class MistralTTSClient:
self._voice = model.voice self._voice = model.voice
self._response_format: SpeechOutputFormat = model.response_format self._response_format: SpeechOutputFormat = model.response_format
self._client: Mistral | None = None self._client: Mistral | None = None
self._http_client: httpx.AsyncClient | None = None
def _get_client(self) -> Mistral: def _get_client(self) -> Mistral:
if self._client is None: 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 return self._client
async def speak(self, text: str) -> TTSResult: async def speak(self, text: str) -> TTSResult:
@ -36,6 +46,13 @@ class MistralTTSClient:
return TTSResult(audio_data=audio_bytes) return TTSResult(audio_data=audio_bytes)
async def close(self) -> None: async def close(self) -> None:
if self._client is not None: client = self._client
await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None) http_client = self._http_client
self._client = None 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, run_sync,
) )
from vibe.core.utils.display import compact_reduction_display 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.matching import name_matches
from vibe.core.utils.merge import MergeConflictError, MergeStrategy from vibe.core.utils.merge import MergeConflictError, MergeStrategy
from vibe.core.utils.paths import is_dangerous_directory from vibe.core.utils.paths import is_dangerous_directory
@ -46,6 +50,7 @@ __all__ = [
"TaggedText", "TaggedText",
"async_generator_retry", "async_generator_retry",
"async_retry", "async_retry",
"build_ssl_context",
"compact_reduction_display", "compact_reduction_display",
"get_server_url_from_api_base", "get_server_url_from_api_base",
"get_user_agent", "get_user_agent",

View file

@ -1,11 +1,37 @@
from __future__ import annotations from __future__ import annotations
import functools
import os
import re import re
import ssl
import certifi
from vibe import __version__ from vibe import __version__
from vibe.core.types import Backend 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: def get_user_agent(backend: Backend | None) -> str:
user_agent = f"Mistral-Vibe/{__version__}" user_agent = f"Mistral-Vibe/{__version__}"
if backend == Backend.MISTRAL: if backend == Backend.MISTRAL:

View file

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