v2.9.6 (#682)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
b23f49e5f4
commit
626f905186
59 changed files with 702 additions and 183 deletions
|
|
@ -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
|
||||
|
|
|
|||
15
CHANGELOG.md
15
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -102,6 +102,26 @@ def get_latest_version() -> str:
|
|||
return max(versions)[3]
|
||||
|
||||
|
||||
def get_base_version_from_current_branch() -> str:
|
||||
result = run_git_command(
|
||||
"describe",
|
||||
"--tags",
|
||||
"--match",
|
||||
"v[0-9]*.[0-9]*.[0-9]*",
|
||||
"--exclude",
|
||||
"*-private",
|
||||
"--abbrev=0",
|
||||
"HEAD",
|
||||
capture_output=True,
|
||||
)
|
||||
tag = result.stdout.strip()
|
||||
match = re.match(r"^v(\d+\.\d+\.\d+)$", tag)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid base version tag found: {tag}")
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_version(version_str: str) -> tuple[int, int, int]:
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str.strip())
|
||||
if not match:
|
||||
|
|
@ -130,34 +150,31 @@ def create_release_branch(version: str) -> None:
|
|||
print(f"Created and switched to branch {branch_name}")
|
||||
|
||||
|
||||
def cherry_pick_commits(
|
||||
previous_version: str, current_version: str, squash: bool
|
||||
) -> None:
|
||||
previous_tag = f"v{previous_version}-private"
|
||||
current_tag = f"v{current_version}-private"
|
||||
|
||||
def cherry_pick_commits(previous_private_tag: str, current_private_tag: str) -> None:
|
||||
result = run_git_command(
|
||||
"rev-parse", "--verify", previous_tag, capture_output=True, check=False
|
||||
"rev-parse", "--verify", previous_private_tag, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Tag {previous_tag} does not exist")
|
||||
raise ValueError(f"Tag {previous_private_tag} does not exist")
|
||||
|
||||
result = run_git_command(
|
||||
"rev-parse", "--verify", current_tag, capture_output=True, check=False
|
||||
"rev-parse", "--verify", current_private_tag, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Tag {current_tag} does not exist")
|
||||
raise ValueError(f"Tag {current_private_tag} does not exist")
|
||||
|
||||
print(f"Cherry-picking commits from {previous_tag}..{current_tag}...")
|
||||
run_git_command("cherry-pick", f"{previous_tag}..{current_tag}")
|
||||
print(
|
||||
f"Cherry-picking commits from {previous_private_tag}..{current_private_tag}..."
|
||||
)
|
||||
run_git_command("cherry-pick", f"{previous_private_tag}..{current_private_tag}")
|
||||
print("Successfully cherry-picked all commits")
|
||||
|
||||
if squash:
|
||||
squash_commits(previous_version, current_version, previous_tag, current_tag)
|
||||
|
||||
|
||||
def squash_commits(
|
||||
previous_version: str, current_version: str, previous_tag: str, current_tag: str
|
||||
previous_version: str,
|
||||
current_version: str,
|
||||
previous_private_tag: str,
|
||||
current_private_tag: str,
|
||||
) -> None:
|
||||
print("Squashing commits into a single release commit...")
|
||||
run_git_command("reset", "--soft", f"v{previous_version}")
|
||||
|
|
@ -165,7 +182,7 @@ def squash_commits(
|
|||
# Get all contributors between previous and current private tags
|
||||
result = run_git_command(
|
||||
"log",
|
||||
f"{previous_tag}..{current_tag}",
|
||||
f"{previous_private_tag}..{current_private_tag}",
|
||||
"--format=%aN <%aE>",
|
||||
capture_output=True,
|
||||
)
|
||||
|
|
@ -289,6 +306,9 @@ def main() -> None:
|
|||
default=True,
|
||||
help="Disable squashing of commits into a single release commit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume", action="store_true", help="Resume after cherry-picking commits"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
current_version = args.version
|
||||
|
|
@ -298,6 +318,9 @@ def main() -> None:
|
|||
# Step 1: Ensure public remote exists
|
||||
ensure_public_remote()
|
||||
|
||||
if args.resume:
|
||||
previous_version = get_base_version_from_current_branch()
|
||||
else:
|
||||
# Step 2: Fetch all remotes
|
||||
print("Fetching all remotes...")
|
||||
run_git_command("fetch", "--all")
|
||||
|
|
@ -316,6 +339,10 @@ def main() -> None:
|
|||
)
|
||||
print(f"Version verified: {current_version}")
|
||||
|
||||
previous_private_tag = f"v{previous_version}-private"
|
||||
current_private_tag = f"v{current_version}-private"
|
||||
|
||||
if not args.resume:
|
||||
# Step 5: Switch to previous version tag
|
||||
switch_to_tag(previous_version)
|
||||
|
||||
|
|
@ -323,7 +350,16 @@ def main() -> None:
|
|||
create_release_branch(current_version)
|
||||
|
||||
# Step 7: Cherry-pick commits
|
||||
cherry_pick_commits(previous_version, current_version, squash)
|
||||
cherry_pick_commits(previous_private_tag, current_private_tag)
|
||||
|
||||
# Step 8: Squash commits
|
||||
if squash:
|
||||
squash_commits(
|
||||
previous_version,
|
||||
current_version,
|
||||
previous_private_tag,
|
||||
current_private_tag,
|
||||
)
|
||||
|
||||
# Step 8: Get summary information
|
||||
commits_summary = get_commits_summary(previous_version, current_version)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
78
tests/core/test_build_ssl_context.py
Normal file
78
tests/core/test_build_ssl_context.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,3 +20,6 @@ class FakeTranscribeClient:
|
|||
) -> AsyncIterator[TranscribeEvent]:
|
||||
for event in self._events:
|
||||
yield event
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ class TestStopRecording:
|
|||
return
|
||||
yield # makes this an async generator
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
manager = VoiceManager(
|
||||
|
|
@ -218,6 +221,9 @@ class TestCancelRecording:
|
|||
return
|
||||
yield
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
manager = VoiceManager(
|
||||
|
|
@ -388,6 +394,9 @@ class TestTranscription:
|
|||
raise RuntimeError("network error")
|
||||
yield # makes this an async generator
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
manager = VoiceManager(
|
||||
|
|
@ -488,6 +497,9 @@ class TestTelemetryTracking:
|
|||
raise RuntimeError("network error")
|
||||
yield
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
mock_telemetry = MagicMock()
|
||||
|
|
@ -556,6 +568,9 @@ class TestTelemetryTracking:
|
|||
return
|
||||
yield
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
recorder = FakeAudioRecorder()
|
||||
config = build_test_vibe_config(voice_mode_enabled=True)
|
||||
mock_telemetry = MagicMock()
|
||||
|
|
|
|||
6
uv.lock
generated
6
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -406,6 +406,10 @@ Markdown {
|
|||
width: auto;
|
||||
height: auto;
|
||||
|
||||
&.bash-pending {
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
&.bash-success {
|
||||
color: ansi_green;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,7 +263,9 @@ class InterruptMessage(Static):
|
|||
)
|
||||
|
||||
|
||||
class BashOutputMessage(Static):
|
||||
class BashOutputMessage(SpinnerMixin, Static):
|
||||
SPINNER_TYPE = SpinnerType.PULSE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: str,
|
||||
|
|
@ -274,6 +276,7 @@ class BashOutputMessage(Static):
|
|||
pending: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.init_spinner()
|
||||
self.add_class("bash-output-message")
|
||||
self._command = command
|
||||
self._cwd = cwd
|
||||
|
|
@ -283,17 +286,29 @@ class BashOutputMessage(Static):
|
|||
self._output_widget: NoMarkupStatic | None = None
|
||||
self._output_container: Horizontal | None = None
|
||||
self._prompt_widget: NonSelectableStatic | None = None
|
||||
self._indicator_widget: Static | None = None
|
||||
|
||||
def _update_spinner_frame(self) -> None:
|
||||
if not self._is_spinning or not self._prompt_widget:
|
||||
return
|
||||
self._prompt_widget.update(f"{self._spinner.next_frame()} ")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._pending:
|
||||
self.start_spinner_timer()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
status_class = (
|
||||
"bash-error"
|
||||
if not self._pending and self._exit_code != 0
|
||||
else "bash-success"
|
||||
)
|
||||
if self._pending:
|
||||
status_class = "bash-pending"
|
||||
elif self._exit_code != 0:
|
||||
status_class = "bash-error"
|
||||
else:
|
||||
status_class = "bash-success"
|
||||
self.add_class(status_class)
|
||||
prompt_text = f"{self._spinner.current_frame()} " if self._pending else "$ "
|
||||
with Horizontal(classes="bash-command-line"):
|
||||
self._prompt_widget = NonSelectableStatic(
|
||||
"$ ", classes=f"bash-prompt {status_class}"
|
||||
prompt_text, classes=f"bash-prompt {status_class}"
|
||||
)
|
||||
yield self._prompt_widget
|
||||
yield NoMarkupStatic(self._command, classes="bash-command")
|
||||
|
|
@ -326,18 +341,20 @@ class BashOutputMessage(Static):
|
|||
async def finish(self, exit_code: int, *, interrupted: bool = False) -> None:
|
||||
self._exit_code = exit_code
|
||||
self._pending = False
|
||||
self.stop_spinning()
|
||||
if self._prompt_widget:
|
||||
self._prompt_widget.update("$ ")
|
||||
if interrupted:
|
||||
self.remove_class("bash-success")
|
||||
self.add_class("bash-interrupted")
|
||||
if self._prompt_widget:
|
||||
self._prompt_widget.remove_class("bash-success")
|
||||
self._prompt_widget.add_class("bash-interrupted")
|
||||
new_class = "bash-interrupted"
|
||||
elif exit_code != 0:
|
||||
self.remove_class("bash-success")
|
||||
self.add_class("bash-error")
|
||||
new_class = "bash-error"
|
||||
else:
|
||||
new_class = "bash-success"
|
||||
self.remove_class("bash-pending")
|
||||
self.add_class(new_class)
|
||||
if self._prompt_widget:
|
||||
self._prompt_widget.remove_class("bash-success")
|
||||
self._prompt_widget.add_class("bash-error")
|
||||
self._prompt_widget.remove_class("bash-pending")
|
||||
self._prompt_widget.add_class(new_class)
|
||||
if interrupted:
|
||||
suffix = (
|
||||
"\n(interrupted)"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from vibe.core.types import (
|
|||
ToolCall,
|
||||
)
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
from vibe.core.utils.http import build_ssl_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
|
|
@ -182,6 +183,7 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = {
|
|||
class MistralBackend:
|
||||
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
|
||||
self._client: Mistral | None = None
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._provider = provider
|
||||
self._mapper = MistralMapper()
|
||||
self._api_key = (
|
||||
|
|
@ -231,17 +233,32 @@ class MistralBackend:
|
|||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.__aexit__(
|
||||
client = self._client
|
||||
http_client = self._http_client
|
||||
self._client = None
|
||||
self._http_client = None
|
||||
try:
|
||||
if client is not None:
|
||||
await client.__aexit__(
|
||||
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
|
||||
)
|
||||
finally:
|
||||
if http_client is not None:
|
||||
await http_client.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.__aexit__(None, None, None)
|
||||
|
||||
def _create_mistral_client(self) -> Mistral:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
verify=build_ssl_context(), follow_redirects=True
|
||||
)
|
||||
return Mistral(
|
||||
api_key=self._api_key,
|
||||
server_url=self._server_url,
|
||||
timeout_ms=int(self._timeout * 1000),
|
||||
retry_config=self._retry_config,
|
||||
async_client=self._http_client,
|
||||
)
|
||||
|
||||
def _get_client(self) -> Mistral:
|
||||
|
|
|
|||
|
|
@ -125,7 +125,30 @@ class ContextWarningMiddleware:
|
|||
self.has_warned = False
|
||||
|
||||
|
||||
def make_plan_agent_reminder(plan_file_path: str) -> str:
|
||||
def make_plan_agent_reminder(
|
||||
plan_file_path: str,
|
||||
*,
|
||||
has_ask_user_question: bool = True,
|
||||
has_exit_plan_mode: bool = True,
|
||||
) -> str:
|
||||
instructions = [
|
||||
"Research the user's query using read-only tools (grep, read_file, etc.)"
|
||||
]
|
||||
if has_ask_user_question:
|
||||
instructions.append(
|
||||
"If you are unsure about requirements or approach, use the ask_user_question tool to clarify before finalizing your plan"
|
||||
)
|
||||
instructions.append("Write your plan to the plan file above")
|
||||
if has_exit_plan_mode:
|
||||
instructions.append(
|
||||
"When your plan is complete, call the exit_plan_mode tool to request user approval and switch to implementation mode"
|
||||
)
|
||||
else:
|
||||
instructions.append(
|
||||
"When your plan is complete, present it to the user and tell them to switch modes if they approve the plan"
|
||||
)
|
||||
numbered = "\n".join(f"{i}. {step}" for i, step in enumerate(instructions, start=1))
|
||||
|
||||
return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below, or in your scratchpad), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
||||
|
||||
## Plan File Info
|
||||
|
|
@ -134,10 +157,7 @@ Build your plan incrementally by writing to or editing this file.
|
|||
This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan.
|
||||
|
||||
## Instructions
|
||||
1. Research the user's query using read-only tools (grep, read_file, etc.)
|
||||
2. If you are unsure about requirements or approach, use the ask_user_question tool to clarify before finalizing your plan
|
||||
3. Write your plan to the plan file above
|
||||
4. When your plan is complete, call the exit_plan_mode tool to request user approval and switch to implementation mode</{VIBE_WARNING_TAG}>"""
|
||||
{numbered}</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
|
||||
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
|
|||
import os
|
||||
from typing import TYPE_CHECKING, ClassVar, final
|
||||
|
||||
import httpx
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.errors import SDKError
|
||||
from mistralai.client.models import (
|
||||
|
|
@ -24,7 +25,7 @@ from vibe.core.tools.base import (
|
|||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import Backend, ToolStreamEvent
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
from vibe.core.utils.http import build_ssl_context, get_server_url_from_api_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
|
@ -73,14 +74,17 @@ class WebSearch(
|
|||
if not api_key:
|
||||
raise ToolError("MISTRAL_API_KEY environment variable not set.")
|
||||
|
||||
ssl_context = build_ssl_context()
|
||||
async_http_client = httpx.AsyncClient(follow_redirects=True, verify=ssl_context)
|
||||
|
||||
try:
|
||||
client = Mistral(
|
||||
api_key=api_key,
|
||||
server_url=self._resolve_server_url(ctx),
|
||||
timeout_ms=self.config.timeout * 1000,
|
||||
async_client=async_http_client,
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
async with async_http_client, client:
|
||||
response = await client.beta.conversations.start_async(
|
||||
model=self.config.model,
|
||||
instructions="Always use the web_search tool to answer queries. Never answer from memory alone.",
|
||||
|
|
@ -93,6 +97,8 @@ class WebSearch(
|
|||
|
||||
except SDKError as exc:
|
||||
raise ToolError(f"Mistral API error: {exc}") from exc
|
||||
finally:
|
||||
await async_http_client.aclose()
|
||||
|
||||
def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
|
||||
if not ctx or not ctx.agent_manager:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from vibe.core.tools.mcp.tools import (
|
|||
from vibe.core.tools.ui import ToolResultDisplay
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils import run_sync
|
||||
from vibe.core.utils.http import build_ssl_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
|
@ -238,9 +239,6 @@ class ConnectorRegistry:
|
|||
self._alias_to_id: dict[str, str] = {}
|
||||
self._discover_lock = asyncio.Lock()
|
||||
|
||||
def _get_client(self) -> Mistral:
|
||||
return Mistral(api_key=self._api_key, server_url=self._server_url)
|
||||
|
||||
def get_tools(self) -> dict[str, type[BaseTool]]:
|
||||
"""Return proxy tool classes for all connectors, using cache when possible."""
|
||||
return run_sync(self.get_tools_async())
|
||||
|
|
@ -259,7 +257,9 @@ class ConnectorRegistry:
|
|||
base_url = self._server_url or _DEFAULT_BASE_URL
|
||||
url = f"{base_url}/v1/connectors/bootstrap"
|
||||
headers = {"Authorization": f"Bearer {self._api_key}"}
|
||||
async with httpx.AsyncClient(timeout=_BOOTSTRAP_TIMEOUT) as http:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_BOOTSTRAP_TIMEOUT, verify=build_ssl_context()
|
||||
) as http:
|
||||
response = await http.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
@ -427,11 +427,22 @@ class ConnectorRegistry:
|
|||
if connector_id is None:
|
||||
return None
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
http_client = httpx.AsyncClient(
|
||||
verify=build_ssl_context(), follow_redirects=True
|
||||
)
|
||||
try:
|
||||
sdk_client = Mistral(
|
||||
api_key=self._api_key,
|
||||
server_url=self._server_url,
|
||||
async_client=http_client,
|
||||
)
|
||||
async with sdk_client as client:
|
||||
result = await client.beta.connectors.get_auth_url_async(
|
||||
connector_id_or_name=connector_id
|
||||
)
|
||||
return result.auth_url
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
except Exception:
|
||||
logger.debug("Failed to get auth URL for connector %s", alias)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -40,3 +40,5 @@ class TranscribeClientPort(Protocol):
|
|||
def transcribe(
|
||||
self, audio_stream: AsyncIterator[bytes]
|
||||
) -> AsyncIterator[TranscribeEvent]: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ from __future__ import annotations
|
|||
import base64
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.models import SpeechOutputFormat
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
from vibe.core.utils.http import build_ssl_context
|
||||
|
||||
|
||||
class MistralTTSClient:
|
||||
|
|
@ -18,10 +20,18 @@ class MistralTTSClient:
|
|||
self._voice = model.voice
|
||||
self._response_format: SpeechOutputFormat = model.response_format
|
||||
self._client: Mistral | None = None
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
|
||||
def _get_client(self) -> Mistral:
|
||||
if self._client is None:
|
||||
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
|
||||
self._http_client = httpx.AsyncClient(
|
||||
verify=build_ssl_context(), follow_redirects=True
|
||||
)
|
||||
self._client = Mistral(
|
||||
api_key=self._api_key,
|
||||
server_url=self._server_url,
|
||||
async_client=self._http_client,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
|
|
@ -36,6 +46,13 @@ class MistralTTSClient:
|
|||
return TTSResult(audio_data=audio_bytes)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
|
||||
client = self._client
|
||||
http_client = self._http_client
|
||||
self._client = None
|
||||
self._http_client = None
|
||||
try:
|
||||
if client is not None:
|
||||
await client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
|
||||
finally:
|
||||
if http_client is not None:
|
||||
await http_client.aclose()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue