From 626f905186637a5e528e0c647b1c9f517378a98c Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Mon, 11 May 2026 11:44:53 +0200 Subject: [PATCH] v2.9.6 (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Drouin Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès Co-authored-by: Mistral Vibe --- AGENTS.md | 2 +- CHANGELOG.md | 15 +++ distribution/zed/extension.toml | 12 +-- pyproject.toml | 5 +- scripts/prepare_release.py | 94 +++++++++++++------ tests/acp/test_close_session.py | 6 +- tests/acp/test_initialize.py | 4 +- tests/backend/test_backend.py | 12 +-- tests/core/test_build_ssl_context.py | 78 +++++++++++++++ tests/core/test_config_resolution.py | 62 ++++++++++++ tests/stubs/fake_transcribe_client.py | 3 + tests/stubs/fake_voice_manager.py | 3 + tests/test_agents.py | 44 +++++++++ tests/test_approve_always_permanent.py | 12 +-- tests/test_middleware.py | 29 ++++++ tests/voice_manager/test_voice_manager.py | 15 +++ uv.lock | 6 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 19 ++-- vibe/acp/tools/base.py | 23 ++--- vibe/acp/tools/builtins/grep.py | 4 +- vibe/acp/tools/builtins/read_file.py | 7 +- vibe/acp/tools/builtins/skill.py | 4 +- vibe/acp/tools/builtins/task.py | 4 +- vibe/acp/tools/builtins/web_fetch.py | 4 +- vibe/acp/tools/builtins/web_search.py | 4 +- vibe/acp/tools/session_update.py | 20 +++- vibe/cli/cli.py | 6 +- .../adapters/http_whoami_gateway.py | 3 +- vibe/cli/textual_ui/app.py | 11 +++ vibe/cli/textual_ui/app.tcss | 4 + vibe/cli/textual_ui/widgets/messages.py | 51 ++++++---- .../adapters/github_update_gateway.py | 5 +- .../adapters/pypi_update_gateway.py | 5 +- vibe/cli/voice_manager/voice_manager.py | 10 ++ vibe/cli/voice_manager/voice_manager_port.py | 2 + vibe/core/agent_loop.py | 19 +++- vibe/core/agents/models.py | 10 ++ vibe/core/auth/github.py | 10 +- vibe/core/config/_settings.py | 14 +++ vibe/core/llm/backend/generic.py | 3 + vibe/core/llm/backend/mistral.py | 25 ++++- vibe/core/middleware.py | 30 +++++- vibe/core/nuage/client.py | 9 +- vibe/core/programmatic.py | 1 + vibe/core/telemetry/send.py | 2 + vibe/core/teleport/nuage.py | 9 +- vibe/core/teleport/teleport.py | 9 +- vibe/core/tools/builtins/webfetch.py | 5 +- vibe/core/tools/builtins/websearch.py | 20 ++-- .../tools/connectors/connector_registry.py | 27 ++++-- vibe/core/tools/manager.py | 10 +- .../transcribe/mistral_transcribe_client.py | 24 ++++- .../core/transcribe/transcribe_client_port.py | 2 + vibe/core/tts/mistral_tts_client.py | 25 ++++- vibe/core/utils/__init__.py | 7 +- vibe/core/utils/http.py | 26 +++++ .../auth/http_browser_sign_in_gateway.py | 3 +- vibe/whats_new.md | 5 - 59 files changed, 702 insertions(+), 183 deletions(-) create mode 100644 tests/core/test_build_ssl_context.py diff --git a/AGENTS.md b/AGENTS.md index edba197..fb1ad7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ba2bd..632eb55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index c849971..5b53c3f 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 6901e92..b3e9f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index fcc93ec..e892a68 100755 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -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,13 +318,16 @@ def main() -> None: # Step 1: Ensure public remote exists ensure_public_remote() - # Step 2: Fetch all remotes - print("Fetching all remotes...") - run_git_command("fetch", "--all") - print("Successfully fetched all remotes") + 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") + print("Successfully fetched all remotes") - # Step 3: Find latest version - previous_version = get_latest_version() + # Step 3: Find latest version + previous_version = get_latest_version() print(f"Previous version: {previous_version}") # Step 4: Verify version matches pyproject.toml @@ -316,14 +339,27 @@ def main() -> None: ) print(f"Version verified: {current_version}") - # Step 5: Switch to previous version tag - switch_to_tag(previous_version) + previous_private_tag = f"v{previous_version}-private" + current_private_tag = f"v{current_version}-private" - # Step 6: Create release branch - create_release_branch(current_version) + if not args.resume: + # Step 5: Switch to previous version tag + switch_to_tag(previous_version) - # Step 7: Cherry-pick commits - cherry_pick_commits(previous_version, current_version, squash) + # Step 6: Create release branch + create_release_branch(current_version) + + # Step 7: Cherry-pick commits + 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) diff --git a/tests/acp/test_close_session.py b/tests/acp/test_close_session.py index acd60a6..b62751d 100644 --- a/tests/acp/test_close_session.py +++ b/tests/acp/test_close_session.py @@ -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 diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 7f9b337..4a9e811 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -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 diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 310b3c9..90c4f7d 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -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: diff --git a/tests/core/test_build_ssl_context.py b/tests/core/test_build_ssl_context.py new file mode 100644 index 0000000..95b04cc --- /dev/null +++ b/tests/core/test_build_ssl_context.py @@ -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() diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 973c57d..6f1279a 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -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 diff --git a/tests/stubs/fake_transcribe_client.py b/tests/stubs/fake_transcribe_client.py index 7cd13c2..c9f166e 100644 --- a/tests/stubs/fake_transcribe_client.py +++ b/tests/stubs/fake_transcribe_client.py @@ -20,3 +20,6 @@ class FakeTranscribeClient: ) -> AsyncIterator[TranscribeEvent]: for event in self._events: yield event + + async def close(self) -> None: + pass diff --git a/tests/stubs/fake_voice_manager.py b/tests/stubs/fake_voice_manager.py index 607d817..ec8f02e 100644 --- a/tests/stubs/fake_voice_manager.py +++ b/tests/stubs/fake_voice_manager.py @@ -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 diff --git a/tests/test_agents.py b/tests/test_agents.py index 0bac5ee..2c6cea7 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -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: diff --git a/tests/test_approve_always_permanent.py b/tests/test_approve_always_permanent.py index 174effc..f3f5774 100644 --- a/tests/test_approve_always_permanent.py +++ b/tests/test_approve_always_permanent.py @@ -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"] diff --git a/tests/test_middleware.py b/tests/test_middleware.py index d24ca5b..2aa89c1 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -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: diff --git a/tests/voice_manager/test_voice_manager.py b/tests/voice_manager/test_voice_manager.py index 68503f2..d1f304e 100644 --- a/tests/voice_manager/test_voice_manager.py +++ b/tests/voice_manager/test_voice_manager.py @@ -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() diff --git a/uv.lock b/uv.lock index 87267d0..ed9317f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 9d4b414..d897116 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -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" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 7376d7c..55bef0c 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -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) diff --git a/vibe/acp/tools/base.py b/vibe/acp/tools/base.py index da4ed42..4d01382 100644 --- a/vibe/acp/tools/base.py +++ b/vibe/acp/tools/base.py @@ -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: diff --git a/vibe/acp/tools/builtins/grep.py b/vibe/acp/tools/builtins/grep.py index 3e06a73..e2cf37b 100644 --- a/vibe/acp/tools/builtins/grep.py +++ b/vibe/acp/tools/builtins/grep.py @@ -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, diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read_file.py index 66b017f..69b0b45 100644 --- a/vibe/acp/tools/builtins/read_file.py +++ b/vibe/acp/tools/builtins/read_file.py @@ -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, diff --git a/vibe/acp/tools/builtins/skill.py b/vibe/acp/tools/builtins/skill.py index 966dc4a..8408b3e 100644 --- a/vibe/acp/tools/builtins/skill.py +++ b/vibe/acp/tools/builtins/skill.py @@ -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, diff --git a/vibe/acp/tools/builtins/task.py b/vibe/acp/tools/builtins/task.py index 05eafd7..b3bb9e7 100644 --- a/vibe/acp/tools/builtins/task.py +++ b/vibe/acp/tools/builtins/task.py @@ -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, diff --git a/vibe/acp/tools/builtins/web_fetch.py b/vibe/acp/tools/builtins/web_fetch.py index 25ba9e4..b1cf937 100644 --- a/vibe/acp/tools/builtins/web_fetch.py +++ b/vibe/acp/tools/builtins/web_fetch.py @@ -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, diff --git a/vibe/acp/tools/builtins/web_search.py b/vibe/acp/tools/builtins/web_search.py index c6a0b5f..48e0ca4 100644 --- a/vibe/acp/tools/builtins/web_search.py +++ b/vibe/acp/tools/builtins/web_search.py @@ -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, diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py index c8f9337..ec6d022 100644 --- a/vibe/acp/tools/session_update.py +++ b/vibe/acp/tools/session_update.py @@ -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 diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index d00df80..4f2725a 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -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( diff --git a/vibe/cli/plan_offer/adapters/http_whoami_gateway.py b/vibe/cli/plan_offer/adapters/http_whoami_gateway.py index 6e564a4..c2fd569 100644 --- a/vibe/cli/plan_offer/adapters/http_whoami_gateway.py +++ b/vibe/cli/plan_offer/adapters/http_whoami_gateway.py @@ -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 diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 27bd585..e310a89 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -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: diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 7feeb67..3762e87 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -406,6 +406,10 @@ Markdown { width: auto; height: auto; + &.bash-pending { + color: $foreground; + } + &.bash-success { color: ansi_green; } diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index cfa9413..3fdb3e5 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -263,7 +263,9 @@ class InterruptMessage(Static): ) -class BashOutputMessage(Static): +class BashOutputMessage(SpinnerMixin, Static): + SPINNER_TYPE = SpinnerType.PULSE + def __init__( self, command: str, @@ -274,6 +276,7 @@ class BashOutputMessage(Static): pending: bool = False, ) -> None: super().__init__() + self.init_spinner() self.add_class("bash-output-message") self._command = command self._cwd = cwd @@ -283,17 +286,29 @@ class BashOutputMessage(Static): self._output_widget: NoMarkupStatic | None = None self._output_container: Horizontal | None = None self._prompt_widget: NonSelectableStatic | None = None + self._indicator_widget: Static | None = None + + def _update_spinner_frame(self) -> None: + if not self._is_spinning or not self._prompt_widget: + return + self._prompt_widget.update(f"{self._spinner.next_frame()} ") + + def on_mount(self) -> None: + if self._pending: + self.start_spinner_timer() def compose(self) -> ComposeResult: - status_class = ( - "bash-error" - if not self._pending and self._exit_code != 0 - else "bash-success" - ) + if self._pending: + status_class = "bash-pending" + elif self._exit_code != 0: + status_class = "bash-error" + else: + status_class = "bash-success" self.add_class(status_class) + prompt_text = f"{self._spinner.current_frame()} " if self._pending else "$ " with Horizontal(classes="bash-command-line"): self._prompt_widget = NonSelectableStatic( - "$ ", classes=f"bash-prompt {status_class}" + prompt_text, classes=f"bash-prompt {status_class}" ) yield self._prompt_widget yield NoMarkupStatic(self._command, classes="bash-command") @@ -326,18 +341,20 @@ class BashOutputMessage(Static): async def finish(self, exit_code: int, *, interrupted: bool = False) -> None: self._exit_code = exit_code self._pending = False + self.stop_spinning() + if self._prompt_widget: + self._prompt_widget.update("$ ") if interrupted: - self.remove_class("bash-success") - self.add_class("bash-interrupted") - if self._prompt_widget: - self._prompt_widget.remove_class("bash-success") - self._prompt_widget.add_class("bash-interrupted") + new_class = "bash-interrupted" elif exit_code != 0: - self.remove_class("bash-success") - self.add_class("bash-error") - if self._prompt_widget: - self._prompt_widget.remove_class("bash-success") - self._prompt_widget.add_class("bash-error") + new_class = "bash-error" + else: + new_class = "bash-success" + self.remove_class("bash-pending") + self.add_class(new_class) + if self._prompt_widget: + self._prompt_widget.remove_class("bash-pending") + self._prompt_widget.add_class(new_class) if interrupted: suffix = ( "\n(interrupted)" diff --git a/vibe/cli/update_notifier/adapters/github_update_gateway.py b/vibe/cli/update_notifier/adapters/github_update_gateway.py index edfe1d5..76594b6 100644 --- a/vibe/cli/update_notifier/adapters/github_update_gateway.py +++ b/vibe/cli/update_notifier/adapters/github_update_gateway.py @@ -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: diff --git a/vibe/cli/update_notifier/adapters/pypi_update_gateway.py b/vibe/cli/update_notifier/adapters/pypi_update_gateway.py index 88f7495..828e723 100644 --- a/vibe/cli/update_notifier/adapters/pypi_update_gateway.py +++ b/vibe/cli/update_notifier/adapters/pypi_update_gateway.py @@ -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: diff --git a/vibe/cli/voice_manager/voice_manager.py b/vibe/cli/voice_manager/voice_manager.py index df0678e..f92246e 100644 --- a/vibe/cli/voice_manager/voice_manager.py +++ b/vibe/cli/voice_manager/voice_manager.py @@ -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 diff --git a/vibe/cli/voice_manager/voice_manager_port.py b/vibe/cli/voice_manager/voice_manager_port.py index 0224973..a515dd0 100644 --- a/vibe/cli/voice_manager/voice_manager_port.py +++ b/vibe/cli/voice_manager/voice_manager_port.py @@ -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: ... diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 4f2e082..e32d8d3 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -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 diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py index b7945a7..33989a8 100644 --- a/vibe/core/agents/models.py +++ b/vibe/core/agents/models.py @@ -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 diff --git a/vibe/core/auth/github.py b/vibe/core/auth/github.py index 3ba4370..5fc28f6 100644 --- a/vibe/core/auth/github.py +++ b/vibe/core/auth/github.py @@ -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 diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index d83ae6e..b293c9d 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -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" diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 4c920db..f5544bd 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -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 diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index aa46b03..1ff86c3 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -45,6 +45,7 @@ from vibe.core.types import ( ToolCall, ) from vibe.core.utils import get_server_url_from_api_base +from vibe.core.utils.http import build_ssl_context if TYPE_CHECKING: from vibe.core.config import ModelConfig, ProviderConfig @@ -182,6 +183,7 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = { class MistralBackend: def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None: self._client: Mistral | None = None + self._http_client: httpx.AsyncClient | None = None self._provider = provider self._mapper = MistralMapper() self._api_key = ( @@ -231,17 +233,32 @@ class MistralBackend: exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - if self._client is not None: - await self._client.__aexit__( - exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb - ) + client = self._client + http_client = self._http_client + self._client = None + self._http_client = None + try: + if client is not None: + await client.__aexit__( + exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb + ) + finally: + if http_client is not None: + await http_client.aclose() + + async def aclose(self) -> None: + await self.__aexit__(None, None, None) def _create_mistral_client(self) -> Mistral: + self._http_client = httpx.AsyncClient( + verify=build_ssl_context(), follow_redirects=True + ) return Mistral( api_key=self._api_key, server_url=self._server_url, timeout_ms=int(self._timeout * 1000), retry_config=self._retry_config, + async_client=self._http_client, ) def _get_client(self) -> Mistral: diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py index 29c2c65..cf98e0e 100644 --- a/vibe/core/middleware.py +++ b/vibe/core/middleware.py @@ -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""" +{numbered}""" 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.""" diff --git a/vibe/core/nuage/client.py b/vibe/core/nuage/client.py index 0c6ef42..117b292 100644 --- a/vibe/core/nuage/client.py +++ b/vibe/core/nuage/client.py @@ -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 diff --git a/vibe/core/programmatic.py b/vibe/core/programmatic.py index 16dbd7c..ed7b4ff 100644 --- a/vibe/core/programmatic.py +++ b/vibe/core/programmatic.py @@ -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()) diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index d5f06ae..526b169 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -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 diff --git a/vibe/core/teleport/nuage.py b/vibe/core/teleport/nuage.py index c5fb219..eeffca0 100644 --- a/vibe/core/teleport/nuage.py +++ b/vibe/core/teleport/nuage.py @@ -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 diff --git a/vibe/core/teleport/teleport.py b/vibe/core/teleport/teleport.py index 165092d..d37759c 100644 --- a/vibe/core/teleport/teleport.py +++ b/vibe/core/teleport/teleport.py @@ -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 diff --git a/vibe/core/tools/builtins/webfetch.py b/vibe/core/tools/builtins/webfetch.py index 409441c..ddb3f03 100644 --- a/vibe/core/tools/builtins/webfetch.py +++ b/vibe/core/tools/builtins/webfetch.py @@ -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) diff --git a/vibe/core/tools/builtins/websearch.py b/vibe/core/tools/builtins/websearch.py index a47ee5b..b49206b 100644 --- a/vibe/core/tools/builtins/websearch.py +++ b/vibe/core/tools/builtins/websearch.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator import os from typing import TYPE_CHECKING, ClassVar, final +import httpx from mistralai.client import Mistral from mistralai.client.errors import SDKError from mistralai.client.models import ( @@ -24,7 +25,7 @@ from vibe.core.tools.base import ( ) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.types import Backend, ToolStreamEvent -from vibe.core.utils import get_server_url_from_api_base +from vibe.core.utils.http import build_ssl_context, get_server_url_from_api_base if TYPE_CHECKING: from vibe.core.types import ToolCallEvent, ToolResultEvent @@ -73,14 +74,17 @@ class WebSearch( if not api_key: raise ToolError("MISTRAL_API_KEY environment variable not set.") - client = Mistral( - api_key=api_key, - server_url=self._resolve_server_url(ctx), - timeout_ms=self.config.timeout * 1000, - ) + ssl_context = build_ssl_context() + async_http_client = httpx.AsyncClient(follow_redirects=True, verify=ssl_context) try: - async with client: + client = Mistral( + api_key=api_key, + server_url=self._resolve_server_url(ctx), + timeout_ms=self.config.timeout * 1000, + async_client=async_http_client, + ) + async with async_http_client, client: response = await client.beta.conversations.start_async( model=self.config.model, instructions="Always use the web_search tool to answer queries. Never answer from memory alone.", @@ -93,6 +97,8 @@ class WebSearch( except SDKError as exc: raise ToolError(f"Mistral API error: {exc}") from exc + finally: + await async_http_client.aclose() def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None: if not ctx or not ctx.agent_manager: diff --git a/vibe/core/tools/connectors/connector_registry.py b/vibe/core/tools/connectors/connector_registry.py index 5a6598e..4311c97 100644 --- a/vibe/core/tools/connectors/connector_registry.py +++ b/vibe/core/tools/connectors/connector_registry.py @@ -26,6 +26,7 @@ from vibe.core.tools.mcp.tools import ( from vibe.core.tools.ui import ToolResultDisplay from vibe.core.types import ToolStreamEvent from vibe.core.utils import run_sync +from vibe.core.utils.http import build_ssl_context if TYPE_CHECKING: from vibe.core.types import ToolResultEvent @@ -238,9 +239,6 @@ class ConnectorRegistry: self._alias_to_id: dict[str, str] = {} self._discover_lock = asyncio.Lock() - def _get_client(self) -> Mistral: - return Mistral(api_key=self._api_key, server_url=self._server_url) - def get_tools(self) -> dict[str, type[BaseTool]]: """Return proxy tool classes for all connectors, using cache when possible.""" return run_sync(self.get_tools_async()) @@ -259,7 +257,9 @@ class ConnectorRegistry: base_url = self._server_url or _DEFAULT_BASE_URL url = f"{base_url}/v1/connectors/bootstrap" headers = {"Authorization": f"Bearer {self._api_key}"} - async with httpx.AsyncClient(timeout=_BOOTSTRAP_TIMEOUT) as http: + async with httpx.AsyncClient( + timeout=_BOOTSTRAP_TIMEOUT, verify=build_ssl_context() + ) as http: response = await http.get(url, headers=headers) response.raise_for_status() return response.json() @@ -427,11 +427,22 @@ class ConnectorRegistry: if connector_id is None: return None try: - async with self._get_client() as client: - result = await client.beta.connectors.get_auth_url_async( - connector_id_or_name=connector_id + http_client = httpx.AsyncClient( + verify=build_ssl_context(), follow_redirects=True + ) + try: + sdk_client = Mistral( + api_key=self._api_key, + server_url=self._server_url, + async_client=http_client, ) - return result.auth_url + async with sdk_client as client: + result = await client.beta.connectors.get_auth_url_async( + connector_id_or_name=connector_id + ) + return result.auth_url + finally: + await http_client.aclose() except Exception: logger.debug("Failed to get auth URL for connector %s", alias) return None diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index bc48483..6d6d6ed 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -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). diff --git a/vibe/core/transcribe/mistral_transcribe_client.py b/vibe/core/transcribe/mistral_transcribe_client.py index 6e82e11..d091197 100644 --- a/vibe/core/transcribe/mistral_transcribe_client.py +++ b/vibe/core/transcribe/mistral_transcribe_client.py @@ -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() diff --git a/vibe/core/transcribe/transcribe_client_port.py b/vibe/core/transcribe/transcribe_client_port.py index 963158b..60f0320 100644 --- a/vibe/core/transcribe/transcribe_client_port.py +++ b/vibe/core/transcribe/transcribe_client_port.py @@ -40,3 +40,5 @@ class TranscribeClientPort(Protocol): def transcribe( self, audio_stream: AsyncIterator[bytes] ) -> AsyncIterator[TranscribeEvent]: ... + + async def close(self) -> None: ... diff --git a/vibe/core/tts/mistral_tts_client.py b/vibe/core/tts/mistral_tts_client.py index 401a59c..f39d467 100644 --- a/vibe/core/tts/mistral_tts_client.py +++ b/vibe/core/tts/mistral_tts_client.py @@ -3,11 +3,13 @@ from __future__ import annotations import base64 import os +import httpx from mistralai.client import Mistral from mistralai.client.models import SpeechOutputFormat from vibe.core.config import TTSModelConfig, TTSProviderConfig from vibe.core.tts.tts_client_port import TTSResult +from vibe.core.utils.http import build_ssl_context class MistralTTSClient: @@ -18,10 +20,18 @@ class MistralTTSClient: self._voice = model.voice self._response_format: SpeechOutputFormat = model.response_format self._client: Mistral | None = None + self._http_client: httpx.AsyncClient | None = None def _get_client(self) -> Mistral: if self._client is None: - self._client = Mistral(api_key=self._api_key, server_url=self._server_url) + self._http_client = httpx.AsyncClient( + verify=build_ssl_context(), follow_redirects=True + ) + self._client = Mistral( + api_key=self._api_key, + server_url=self._server_url, + async_client=self._http_client, + ) return self._client async def speak(self, text: str) -> TTSResult: @@ -36,6 +46,13 @@ class MistralTTSClient: return TTSResult(audio_data=audio_bytes) async def close(self) -> None: - if self._client is not None: - await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None) - self._client = None + client = self._client + http_client = self._http_client + self._client = None + self._http_client = None + try: + if client is not None: + await client.__aexit__(exc_type=None, exc_val=None, exc_tb=None) + finally: + if http_client is not None: + await http_client.aclose() diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py index 51f86f1..f7fee7c 100644 --- a/vibe/core/utils/__init__.py +++ b/vibe/core/utils/__init__.py @@ -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", diff --git a/vibe/core/utils/http.py b/vibe/core/utils/http.py index 711c0f8..7616920 100644 --- a/vibe/core/utils/http.py +++ b/vibe/core/utils/http.py @@ -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: diff --git a/vibe/setup/auth/http_browser_sign_in_gateway.py b/vibe/setup/auth/http_browser_sign_in_gateway.py index c460028..5499ade 100644 --- a/vibe/setup/auth/http_browser_sign_in_gateway.py +++ b/vibe/setup/auth/http_browser_sign_in_gateway.py @@ -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 diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 68e7de4..e69de29 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -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.