Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Jean-Baptiste Muscat <jeanbaptiste.muscatdupuis@mistral.ai>
Co-authored-by: JeroenvdV <JeroenvdV@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-05-05 14:40:11 +02:00 committed by GitHub
parent 71f373c60c
commit 4972dd5694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2892 additions and 842 deletions

View file

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

View file

@ -52,6 +52,7 @@ from acp.schema import (
SessionConfigOptionSelect,
SessionForkCapabilities,
SessionInfo,
SessionInfoUpdate,
SessionListCapabilities,
SetSessionConfigOptionResponse,
SseMcpServer,
@ -122,6 +123,10 @@ from vibe.core.proxy_setup import (
set_proxy_var,
unset_proxy_var,
)
from vibe.core.session.saved_sessions import (
update_saved_session_title,
update_saved_session_title_at_path,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
@ -159,6 +164,15 @@ class ForkSessionParams(BaseModel):
message_id: str | None = Field(default=None, alias="messageId")
class SessionSetTitleRequest(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
session_id: str = Field(
validation_alias=AliasChoices("session_id", "sessionId"), min_length=1
)
title: str = Field(min_length=1)
class TelemetrySendNotification(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -167,7 +181,20 @@ class TelemetrySendNotification(BaseModel):
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {}
def _dispatch_at_mention_inserted(
client: TelemetryClient, properties: dict[str, Any]
) -> None:
client.send_at_mention_inserted(
nb_mentions=properties.get("nb_mentions", 0),
context_types=properties.get("context_types", {}),
file_extensions=properties.get("file_extensions"),
message_id=properties.get("message_id"),
)
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {
"vibe.at_mention_inserted": _dispatch_at_mention_inserted
}
def _resolved_user_message_id(client_message_id: str | None) -> str:
@ -301,9 +328,20 @@ class VibeAcpAgentLoop(AcpAgent):
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
session.spawn(self._send_available_commands(session))
session.spawn(self._warm_up_agent_loop(agent_loop))
return session
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
without waiting for the user's first prompt. Errors are swallowed here
and will resurface on the first `act()` call via `requires_init`.
"""
try:
await agent_loop.wait_until_ready()
except Exception:
pass
def _create_agent_loop(
self, config: VibeConfig, agent_name: str, hook_config_result: Any = None
) -> AgentLoop:
@ -398,6 +436,11 @@ class VibeAcpAgentLoop(AcpAgent):
case ToolOption.ALLOW_ALWAYS:
session.agent_loop.approve_always(tool_name, required_permissions)
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS_PERMANENT:
session.agent_loop.approve_always(
tool_name, required_permissions, save_permanently=True
)
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE:
session.agent_loop.telemetry_client.send_user_cancelled_action(
"reject_approval"
@ -454,6 +497,29 @@ class VibeAcpAgentLoop(AcpAgent):
raise SessionNotFoundError(session_id)
return self.sessions[session_id]
def _find_acp_session_by_vibe_session_id(
self, session_id: str
) -> AcpSessionLoop | None:
for candidate in self.sessions.values():
if candidate.agent_loop.session_id == session_id:
return candidate
return None
def _load_session_logging_config(self) -> SessionLoggingConfig:
try:
return VibeConfig.load().session_logging
except MissingAPIKeyError:
try:
persisted_config = VibeConfig.get_persisted_config()
return SessionLoggingConfig.model_validate(
persisted_config.get("session_logging", {})
)
except Exception as e:
raise ConfigurationError(str(e)) from e
except Exception as e:
raise ConfigurationError(str(e)) from e
def _build_usage(self, session: AcpSessionLoop) -> Usage:
stats = session.agent_loop.stats
return Usage(
@ -1008,9 +1074,97 @@ class VibeAcpAgentLoop(AcpAgent):
) -> ResumeSessionResponse:
raise NotImplementedMethodError("resume_session")
async def _emit_session_info_update(
self, session_id: str, *, title: str, updated_at: str | None
) -> None:
update_kwargs: dict[str, Any] = {
"session_update": "session_info_update",
"title": title,
}
if updated_at is not None:
update_kwargs["updated_at"] = updated_at
await self.client.session_update(
session_id=session_id, update=SessionInfoUpdate(**update_kwargs)
)
async def _persist_live_session_title(
self, session: AcpSessionLoop, title: str
) -> dict[str, Any] | None:
logger = session.agent_loop.session_logger
if not logger.enabled or logger.session_dir is None:
return None
if not logger.metadata_filepath.exists():
return None
try:
return await update_saved_session_title_at_path(logger.session_dir, title)
except ValueError as exc:
raise InternalError(
f"Failed to persist title update for session {logger.session_id}: {exc}"
) from exc
def _set_live_session_title(self, session: AcpSessionLoop, title: str) -> None:
try:
session.agent_loop.session_logger.set_title(title)
except ValueError as exc:
raise InvalidRequestError(
f"Invalid ACP session title request: {exc}"
) from exc
async def _handle_session_set_title(self, params: dict[str, Any]) -> dict[str, Any]:
try:
request = SessionSetTitleRequest.model_validate(params)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP session title request: {exc}"
) from exc
live_session = self.sessions.get(
request.session_id
) or self._find_acp_session_by_vibe_session_id(request.session_id)
if live_session is None:
try:
metadata = await update_saved_session_title(
request.session_id,
request.title,
self._load_session_logging_config(),
)
except ValueError as exc:
raise SessionNotFoundError(request.session_id) from exc
await self._emit_session_info_update(
request.session_id,
title=request.title,
updated_at=metadata.get("end_time"),
)
return {}
persisted_metadata = await self._persist_live_session_title(
live_session, request.title
)
self._set_live_session_title(live_session, request.title)
updated_at = (
persisted_metadata.get("end_time")
if persisted_metadata is not None
else (
live_session.agent_loop.session_logger.session_metadata.end_time
if live_session.agent_loop.session_logger.session_metadata is not None
else None
)
)
await self._emit_session_info_update(
live_session.id, title=request.title, updated_at=updated_at
)
return {}
@override
async def ext_method(self, method: str, params: dict) -> dict:
raise NotImplementedMethodError("ext_method")
if method == "session/set_title":
return await self._handle_session_set_title(params)
raise NotImplementedMethodError(method)
@override
async def ext_notification(self, method: str, params: dict) -> None:

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from enum import StrEnum
from typing import TYPE_CHECKING, Literal, cast
from typing import TYPE_CHECKING
from acp.schema import (
AgentMessageChunk,
@ -9,6 +9,7 @@ from acp.schema import (
ContentToolCallContent,
ModelInfo,
PermissionOption,
PermissionOptionKind,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionMode,
@ -34,25 +35,31 @@ if TYPE_CHECKING:
class ToolOption(StrEnum):
ALLOW_ONCE = "allow_once"
ALLOW_ALWAYS = "allow_always"
ALLOW_ALWAYS_PERMANENT = "allow_always_permanent"
REJECT_ONCE = "reject_once"
REJECT_ALWAYS = "reject_always"
_KIND_ALLOW_ONCE: PermissionOptionKind = "allow_once"
_KIND_ALLOW_ALWAYS: PermissionOptionKind = "allow_always"
_KIND_REJECT_ONCE: PermissionOptionKind = "reject_once"
TOOL_OPTIONS = [
PermissionOption(
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
option_id=ToolOption.ALLOW_ONCE, name="Allow once", kind=_KIND_ALLOW_ONCE
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name="Allow for this session",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
name="Allow for remainder of this session",
kind=_KIND_ALLOW_ALWAYS,
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
option_id=ToolOption.ALLOW_ALWAYS_PERMANENT,
name="Always allow",
kind=_KIND_ALLOW_ALWAYS,
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE, name="Deny", kind=_KIND_REJECT_ONCE
),
]
@ -64,7 +71,6 @@ def build_permission_options(
if not required_permissions:
return TOOL_OPTIONS
labels = ", ".join(rp.label for rp in required_permissions)
permissions_meta = [
{
"scope": rp.scope,
@ -77,20 +83,22 @@ def build_permission_options(
return [
PermissionOption(
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
option_id=ToolOption.ALLOW_ONCE, name="Allow once", kind=_KIND_ALLOW_ONCE
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name=f"Allow for this session: {labels}",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
name="Allow for remainder of this session",
kind=_KIND_ALLOW_ALWAYS,
field_meta={"required_permissions": permissions_meta},
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
option_id=ToolOption.ALLOW_ALWAYS_PERMANENT,
name="Always allow",
kind=_KIND_ALLOW_ALWAYS,
field_meta={"required_permissions": permissions_meta},
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE, name="Deny", kind=_KIND_REJECT_ONCE
),
]

View file

@ -265,6 +265,7 @@ def run_cli(args: argparse.Namespace) -> None:
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
show_resume_picker=args.resume is True,
is_resuming_session=loaded_session is not None,
),
)

View file

@ -128,6 +128,11 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
"rename": Command(
aliases=frozenset(["/rename"]),
description="Rename the current session",
handler="_rename_session",
),
"mcp": Command(
aliases=frozenset(["/mcp", "/connectors"]),
description=(
@ -225,7 +230,7 @@ class CommandRegistry:
"- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Shift+Tab` Toggle auto-approve mode",
"- `Shift+Tab` Cycle through agents (default, plan, ...)",
f"- `{ALT_KEY}+↑↓` / `Ctrl+P/N` Rewind to previous/next message",
"",
"### Special Features",

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import codecs
from collections.abc import AsyncGenerator
from contextlib import aclosing
from dataclasses import dataclass
@ -9,9 +10,9 @@ import gc
import os
from pathlib import Path
import signal
import subprocess
import time
from typing import Any, ClassVar, assert_never, cast
from uuid import uuid4
from weakref import WeakKeyDictionary
import webbrowser
@ -121,6 +122,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
@ -135,6 +137,7 @@ from vibe.core.session.resume_sessions import (
list_remote_resume_sessions,
short_session_id,
)
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.types import (
@ -294,6 +297,7 @@ class StartupOptions:
initial_prompt: str | None = None
teleport_on_start: bool = False
show_resume_picker: bool = False
is_resuming_session: bool = False
class VibeApp(App): # noqa: PLR0904
@ -349,6 +353,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
self._bash_task: asyncio.Task | None = None
self._remote_manager = RemoteSessionManager()
self._loading_widget: LoadingWidget | None = None
@ -374,12 +379,7 @@ class VibeApp(App): # noqa: PLR0904
self._update_cache_repository = update_cache_repository
self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._configure_startup_options(startup)
self._last_escape_time: float | None = None
self._quit_manager = QuitManager(self)
self._banner: Banner | None = None
@ -399,6 +399,15 @@ class VibeApp(App): # noqa: PLR0904
self._fatal_init_error = False
self.commands = self._build_command_registry()
def _configure_startup_options(self, startup: StartupOptions | None) -> None:
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._is_resuming_session = opts.is_resuming_session
@property
def config(self) -> VibeConfig:
return self.agent_loop.config
@ -497,7 +506,8 @@ class VibeApp(App): # noqa: PLR0904
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
self._schedule_update_notification()
self.agent_loop.emit_new_session_telemetry()
if not self._is_resuming_session:
self.agent_loop.emit_new_session_telemetry()
self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once()
@ -586,11 +596,15 @@ class VibeApp(App): # noqa: PLR0904
input_widget = self.query_one(ChatInputContainer)
input_widget.value = ""
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._bash_task = None
if self._agent_running:
await self._interrupt_agent_loop()
if value.startswith("!"):
await self._handle_bash_command(value[1:])
self._bash_task = asyncio.create_task(self._handle_bash_command(value[1:]))
return
if value.startswith("&") and self.commands.has_command("teleport"):
@ -619,6 +633,16 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
async def on_approval_app_approval_granted_always_permanent(
self, message: ApprovalApp.ApprovalGrantedAlwaysPermanent
) -> None:
self.agent_loop.approve_always(
message.tool_name, message.required_permissions, save_permanently=True
)
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
async def on_approval_app_approval_rejected(
self, message: ApprovalApp.ApprovalRejected
) -> None:
@ -905,6 +929,40 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_user_message(prompt)
return True
@staticmethod
async def _bash_read_stream(
stream: asyncio.StreamReader | None,
parts: list[str],
bash_msg: BashOutputMessage,
) -> None:
if not stream:
return
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = decoder.decode(chunk)
if not text:
continue
parts.append(text)
await bash_msg.append_output(text)
final_text = decoder.decode(b"", final=True)
if not final_text:
return
parts.append(final_text)
await bash_msg.append_output(final_text)
@staticmethod
async def _kill_running_process(proc: asyncio.subprocess.Process | None) -> None:
if proc is None or proc.returncode is not None:
return
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
async def _handle_bash_command(self, command: str) -> None:
if not command:
await self._mount_and_scroll(
@ -914,21 +972,52 @@ class VibeApp(App): # noqa: PLR0904
)
return
bash_msg = BashOutputMessage(command, str(Path.cwd()), pending=True)
await self._mount_and_scroll(bash_msg)
proc: asyncio.subprocess.Process | None = None
stdout_parts: list[str] = []
stderr_parts: list[str] = []
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=False, timeout=30
)
stdout = (
result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
)
stderr = (
result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
)
output = stdout or stderr or "(no output)"
exit_code = result.returncode
await self._mount_and_scroll(
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
proc = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
await asyncio.wait_for(
asyncio.gather(
self._bash_read_stream(proc.stdout, stdout_parts, bash_msg),
self._bash_read_stream(proc.stderr, stderr_parts, bash_msg),
proc.wait(),
),
timeout=30,
)
except TimeoutError:
await self._kill_running_process(proc)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await bash_msg.finish(1)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
)
)
return
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
exit_code = proc.returncode or 0
await bash_msg.finish(exit_code)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
@ -938,33 +1027,25 @@ class VibeApp(App): # noqa: PLR0904
stderr=stderr,
)
)
except subprocess.TimeoutExpired as error:
stdout = (
error.stdout.decode("utf-8", errors="replace")
if isinstance(error.stdout, bytes)
else (error.stdout or "")
)
stderr = (
error.stderr.decode("utf-8", errors="replace")
if isinstance(error.stderr, bytes)
else (error.stderr or "")
)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
except asyncio.CancelledError:
await self._kill_running_process(proc)
await bash_msg.finish(1, interrupted=True)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
status="interrupted by user",
)
)
except Exception as e:
await self._kill_running_process(proc)
await bash_msg.finish(1)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await self._mount_and_scroll(
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
)
@ -972,6 +1053,8 @@ class VibeApp(App): # noqa: PLR0904
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status=f"failed before completion: {e}",
)
)
@ -1230,10 +1313,30 @@ class VibeApp(App): # noqa: PLR0904
try:
await self._handle_agent_loop_init()
await self._ensure_loading_widget()
message_id = str(uuid4())
prompt_payload = build_path_prompt_payload(prompt, base_dir=Path.cwd())
if prompt_payload.all_resources:
context_types: dict[str, int] = {}
for r in prompt_payload.all_resources:
context_types[r.kind] = context_types.get(r.kind, 0) + 1
file_ext_counts: dict[str, int] = {}
for r in prompt_payload.all_resources:
if r.kind == "file" and r.path.suffix:
file_ext_counts[r.path.suffix] = (
file_ext_counts.get(r.path.suffix, 0) + 1
)
self.agent_loop.telemetry_client.send_at_mention_inserted(
nb_mentions=len(prompt_payload.all_resources),
context_types=context_types,
file_extensions=file_ext_counts or None,
message_id=message_id,
)
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
async with aclosing(self.agent_loop.act(rendered_prompt)) as events:
async with aclosing(
self.agent_loop.act(rendered_prompt, client_message_id=message_id)
) as events:
await self._handle_agent_loop_events(events)
except asyncio.CancelledError:
await self._handle_turn_error()
@ -1566,6 +1669,53 @@ class VibeApp(App): # noqa: PLR0904
async def _show_data_retention(self, **kwargs: Any) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
async def _rename_local_session(self, title: str) -> str:
session_logger = self.agent_loop.session_logger
if not session_logger.enabled or session_logger.session_metadata is None:
raise ValueError("Session logging is disabled in configuration.")
if (
session_logger.session_dir is not None
and session_logger.metadata_filepath.exists()
):
await update_saved_session_title_at_path(session_logger.session_dir, title)
session_logger.set_title(title)
renamed_title = session_logger.session_metadata.title
assert renamed_title is not None
return renamed_title
async def _rename_session(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._remote_manager.is_active:
await self._mount_and_scroll(
ErrorMessage(
"Renaming is only supported for local sessions.",
collapsed=self._tools_collapsed,
)
)
return
title = cmd_args.strip()
if not title:
await self._mount_and_scroll(
ErrorMessage("Usage: /rename <title>", collapsed=self._tools_collapsed)
)
return
try:
renamed_title = await self._rename_local_session(title)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to rename session: {e}", collapsed=self._tools_collapsed
)
)
return
await self._mount_and_scroll(
UserCommandMessage(f'Session renamed to "{renamed_title}".')
)
async def _show_session_picker(self, **kwargs: Any) -> None:
cwd = str(Path.cwd())
local_sessions = (
@ -1608,7 +1758,8 @@ class VibeApp(App): # noqa: PLR0904
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
latest_messages = {
s.option_id: SessionLoader.get_first_user_message(
s.option_id: s.title
or SessionLoader.get_first_user_message(
s.session_id, self.config.session_logging
)
for s in sessions
@ -2598,6 +2749,8 @@ class VibeApp(App): # noqa: PLR0904
def _force_quit(self) -> None:
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._remote_manager.cancel_stream_task()
self._log_reader.shutdown()

View file

@ -413,6 +413,10 @@ Markdown {
&.bash-error {
color: ansi_red;
}
&.bash-interrupted {
color: ansi_yellow;
}
}
.bash-command {
@ -786,7 +790,7 @@ StatusMessage {
.approval-title {
height: auto;
text-style: bold;
color: ansi_yellow;
color: ansi_bright_yellow;
}
.approval-tool-info-container {
@ -808,6 +812,11 @@ StatusMessage {
.approval-option {
height: auto;
color: ansi_default;
margin-bottom: 1;
&:last-of-type {
margin-bottom: 0;
}
}
.approval-cursor-selected {
@ -817,14 +826,14 @@ StatusMessage {
}
&.approval-option-no {
color: ansi_red;
color: ansi_bright_red;
text-style: bold;
}
}
.approval-option-selected {
&.approval-option-yes {
color: ansi_green;
color: ansi_white;
}
&.approval-option-no {
@ -839,7 +848,7 @@ StatusMessage {
.approval-description {
height: auto;
color: ansi_default;
color: ansi_bright_black;
}
.code-block {

View file

@ -20,6 +20,8 @@ class ApprovalApp(Container):
can_focus = True
can_focus_children = False
NUM_OPTIONS = 4
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
@ -27,8 +29,9 @@ class ApprovalApp(Container):
Binding("1", "select_1", "Yes", show=False),
Binding("y", "select_1", "Yes", show=False),
Binding("2", "select_2", "Always Tool Session", show=False),
Binding("3", "select_3", "No", show=False),
Binding("n", "select_3", "No", show=False),
Binding("3", "select_3", "Always Permanent", show=False),
Binding("4", "select_4", "No", show=False),
Binding("n", "select_4", "No", show=False),
]
class ApprovalGranted(Message):
@ -49,6 +52,18 @@ class ApprovalApp(Container):
self.tool_args = tool_args
self.required_permissions = required_permissions
class ApprovalGrantedAlwaysPermanent(Message):
def __init__(
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission],
) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
self.required_permissions = required_permissions
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
super().__init__()
@ -77,20 +92,18 @@ class ApprovalApp(Container):
def compose(self) -> ComposeResult:
with Vertical(id="approval-options"):
yield NoMarkupStatic("")
for _ in range(3):
for _ in range(self.NUM_OPTIONS):
widget = NoMarkupStatic("", classes="approval-option")
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Enter select ESC reject", classes="approval-help"
)
yield self.help_widget
with Vertical(id="approval-content"):
self.title_widget = NoMarkupStatic(
f"{self.tool_name} command", classes="approval-title"
)
title = self._build_title()
self.title_widget = NoMarkupStatic(title, classes="approval-title")
yield self.title_widget
with VerticalScroll(classes="approval-tool-info-scroll"):
@ -99,6 +112,12 @@ class ApprovalApp(Container):
)
yield self.tool_info_container
def _build_title(self) -> str:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
return f"Permission for the {self.tool_name} tool ({labels})"
return f"Permission for the {self.tool_name} tool"
async def on_mount(self) -> None:
await self._update_tool_info()
self._update_options()
@ -113,16 +132,11 @@ class ApprovalApp(Container):
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
always_text = f"Yes and always allow for this session: {labels}"
else:
always_text = f"Yes and always allow {self.tool_name} for this session"
options = [
("Yes", "yes"),
(always_text, "yes"),
("No and tell the agent what to do instead", "no"),
("Allow once", "yes"),
("Allow for remainder of this session", "yes"),
("Always allow", "yes"),
("Deny", "no"),
]
for idx, ((text, color_type), widget) in enumerate(
@ -154,11 +168,11 @@ class ApprovalApp(Container):
widget.add_class("approval-option-no")
def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % 3
self.selected_option = (self.selected_option - 1) % self.NUM_OPTIONS
self._update_options()
def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % 3
self.selected_option = (self.selected_option + 1) % self.NUM_OPTIONS
self._update_options()
def action_select(self) -> None:
@ -176,9 +190,13 @@ class ApprovalApp(Container):
self.selected_option = 2
self._handle_selection(2)
def action_select_4(self) -> None:
self.selected_option = 3
self._handle_selection(3)
def action_reject(self) -> None:
self.selected_option = 2
self._handle_selection(2)
self.selected_option = 3
self._handle_selection(3)
def _handle_selection(self, option: int) -> None:
match option:
@ -197,6 +215,14 @@ class ApprovalApp(Container):
)
)
case 2:
self.post_message(
self.ApprovalGrantedAlwaysPermanent(
tool_name=self.tool_name,
tool_args=self.tool_args,
required_permissions=self.required_permissions,
)
)
case 3:
self.post_message(
self.ApprovalRejected(
tool_name=self.tool_name, tool_args=self.tool_args

View file

@ -264,23 +264,92 @@ class InterruptMessage(Static):
class BashOutputMessage(Static):
def __init__(self, command: str, cwd: str, output: str, exit_code: int) -> None:
def __init__(
self,
command: str,
cwd: str,
output: str = "",
exit_code: int = 0,
*,
pending: bool = False,
) -> None:
super().__init__()
self.add_class("bash-output-message")
self._command = command
self._cwd = cwd
self._output = output.rstrip("\n")
self._exit_code = exit_code
self._pending = pending
self._output_widget: NoMarkupStatic | None = None
self._output_container: Horizontal | None = None
self._prompt_widget: NonSelectableStatic | None = None
def compose(self) -> ComposeResult:
status_class = "bash-success" if self._exit_code == 0 else "bash-error"
status_class = (
"bash-error"
if not self._pending and self._exit_code != 0
else "bash-success"
)
self.add_class(status_class)
with Horizontal(classes="bash-command-line"):
yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}")
self._prompt_widget = NonSelectableStatic(
"$ ", classes=f"bash-prompt {status_class}"
)
yield self._prompt_widget
yield NoMarkupStatic(self._command, classes="bash-command")
with Horizontal(classes="bash-output-container"):
yield ExpandingBorder(classes="bash-output-border")
yield NoMarkupStatic(self._output, classes="bash-output")
if not self._pending:
self._output_container = Horizontal(classes="bash-output-container")
with self._output_container:
yield ExpandingBorder(classes="bash-output-border")
self._output_widget = NoMarkupStatic(
self._output, classes="bash-output"
)
yield self._output_widget
async def _ensure_output_container(self) -> None:
if self._output_container is not None:
return
self._output_widget = NoMarkupStatic("", classes="bash-output")
self._output_container = Horizontal(
ExpandingBorder(classes="bash-output-border"),
self._output_widget,
classes="bash-output-container",
)
await self.mount(self._output_container)
async def append_output(self, text: str) -> None:
await self._ensure_output_container()
self._output += text
if self._output_widget:
self._output_widget.update(self._output.rstrip("\n"))
async def finish(self, exit_code: int, *, interrupted: bool = False) -> None:
self._exit_code = exit_code
self._pending = False
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")
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")
if interrupted:
suffix = (
"\n(interrupted)"
if self._output and not self._output.endswith("\n")
else "(interrupted)"
)
self._output += suffix
if not self._output:
self._output = "(no output)"
await self._ensure_output_container()
if self._output_widget:
self._output_widget.update(self._output.rstrip("\n"))
class ErrorMessage(Static):

View file

@ -172,6 +172,13 @@ def _is_context_too_long_error(e: Exception) -> bool:
return False
def _is_non_retryable_error(e: BaseException) -> bool:
# Detect Temporal-style ``non_retryable`` flag without importing temporalio.
# Wrapping such an exception in a plain RuntimeError strips the flag, so
# Temporal's activity retry policy will retry the call until exhaustion.
return bool(getattr(e, "non_retryable", False))
def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that awaits deferred initialization before executing the method."""
if inspect.isasyncgenfunction(fn):
@ -451,6 +458,10 @@ class AgentLoop:
session_pattern=rp.session_pattern,
)
)
if save_permanently:
self.config.add_tool_allowlist_patterns(
tool_name, [rp.session_pattern for rp in required_permissions]
)
else:
self.set_tool_permission(
tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently
@ -1199,6 +1210,8 @@ class AgentLoop:
raise RateLimitError(provider.name, active_model.name) from e
if _is_context_too_long_error(e):
raise ContextTooLongError(provider.name, active_model.name) from e
if _is_non_retryable_error(e):
raise
raise RuntimeError(
f"API error from {provider.name} (model: {active_model.name}): {e}"
@ -1275,6 +1288,8 @@ class AgentLoop:
raise RateLimitError(provider.name, active_model.name) from e
if _is_context_too_long_error(e):
raise ContextTooLongError(provider.name, active_model.name) from e
if _is_non_retryable_error(e):
raise
raise RuntimeError(
f"API error from {provider.name} (model: {active_model.name}): {e}"
@ -1414,13 +1429,14 @@ class AgentLoop:
i += 1
def _reset_session(self) -> None:
def _reset_session(self, keep_parent: bool = True) -> None:
old_session_id = self.session_id
suffix = extract_suffix(self.session_id)
self.session_id = generate_session_id(suffix=suffix)
self.parent_session_id = old_session_id
parent_session_id = old_session_id if keep_parent else None
self.parent_session_id = parent_session_id
self.session_logger.reset_session(
self.session_id, parent_session_id=old_session_id
self.session_id, parent_session_id=parent_session_id
)
self.emit_new_session_telemetry()
@ -1500,7 +1516,7 @@ class AgentLoop:
self.middleware_pipeline.reset()
self.tool_manager.reset_all()
self._reset_session()
self._reset_session(keep_parent=False)
@requires_init
async def compact(self, extra_instructions: str = "") -> str:

View file

@ -9,7 +9,7 @@ from typing import Literal
class PathResource:
path: Path
alias: str
kind: Literal["file", "directory"]
kind: Literal["file", "folder"]
@dataclass(frozen=True, slots=True)
@ -17,13 +17,14 @@ class PathPromptPayload:
display_text: str
prompt_text: str
resources: list[PathResource]
all_resources: list[PathResource]
def build_path_prompt_payload(
message: str, *, base_dir: Path | None = None
) -> PathPromptPayload:
if not message:
return PathPromptPayload(message, message, [])
return PathPromptPayload(message, message, [], [])
resolved_base = (base_dir or Path.cwd()).resolve()
prompt_parts: list[str] = []
@ -44,7 +45,7 @@ def build_path_prompt_payload(
prompt_text = "".join(prompt_parts)
unique_resources = _dedupe_resources(resources)
return PathPromptPayload(message, prompt_text, unique_resources)
return PathPromptPayload(message, prompt_text, unique_resources, resources)
def _is_path_anchor(message: str, pos: int) -> bool:
@ -93,7 +94,7 @@ def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
if not resolved.exists():
return None
kind = "directory" if resolved.is_dir() else "file"
kind = "folder" if resolved.is_dir() else "file"
return PathResource(path=resolved, alias=candidate, kind=kind)

View file

@ -44,7 +44,7 @@ def _path_prompt_to_content_blocks(
"uri": resource.path.as_uri(),
"name": resource.alias,
})
case "directory":
case "folder":
blocks.append({
"type": "resource_link",
"uri": resource.path.as_uri(),

View file

@ -332,8 +332,6 @@ MCPServer = Annotated[
class ConnectorConfig(BaseModel):
"""Per-connector settings persisted in config.toml under ``[[connectors]]``."""
name: str = Field(description="Normalized connector alias to match against.")
disabled: bool = Field(
default=False,
@ -937,9 +935,21 @@ class VibeConfig(BaseSettings):
]
type(self).save_updates({"models": models})
def add_tool_allowlist_patterns(self, tool_name: str, patterns: list[str]) -> None:
current_allowlist: list[str] = list(
self.tools.get(tool_name, {}).get("allowlist", [])
)
new_patterns = [p for p in patterns if p not in current_allowlist]
if not new_patterns:
return
merged = sorted(current_allowlist + new_patterns)
self.save_updates({"tools": {tool_name: {"allowlist": merged}}})
if tool_name not in self.tools:
self.tools[tool_name] = {}
self.tools[tool_name]["allowlist"] = merged
@classmethod
def get_persisted_config(cls) -> dict[str, Any]:
"""Return the raw config as persisted in the TOML file (no profile merging)."""
return TomlFileSettingsSource(cls).toml_data
@classmethod

View file

@ -67,8 +67,8 @@ def run_programmatic( # noqa: PLR0913, PLR0917
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
agent_loop.emit_new_session_telemetry()
else:
agent_loop.emit_new_session_telemetry()
if teleport and config.vibe_code_enabled:
gen = agent_loop.teleport_to_vibe_code(prompt or None)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_loader import METADATA_FILENAME, SessionLoader
from vibe.core.session.session_logger import SessionLogger
from vibe.core.utils.io import read_safe
def _normalize_session_title(title: str) -> str:
normalized_title = title.strip()
if not normalized_title:
raise ValueError("Session title cannot be empty.")
return normalized_title
def _resolve_saved_session_dir(
session_id: str, session_config: SessionLoggingConfig
) -> Path:
for session_dir in SessionLoader._find_session_dirs_by_short_id(
session_id, session_config
):
try:
metadata = _load_raw_metadata(session_dir)
except (OSError, json.JSONDecodeError):
continue
if metadata.get("session_id") == session_id:
return session_dir
raise ValueError(f"Session not found: {session_id}")
def _load_raw_metadata(session_dir: Path) -> dict[str, Any]:
metadata_path = session_dir / METADATA_FILENAME
return json.loads(read_safe(metadata_path).text)
async def update_saved_session_title_at_path(
session_dir: Path, title: str
) -> dict[str, Any]:
normalized_title = _normalize_session_title(title)
metadata = _load_raw_metadata(session_dir)
updated_metadata = {**metadata, "title": normalized_title, "title_source": "manual"}
await SessionLogger.persist_metadata(updated_metadata, session_dir)
return updated_metadata
async def update_saved_session_title(
session_id: str, title: str, session_config: SessionLoggingConfig
) -> dict[str, Any]:
session_dir = _resolve_saved_session_dir(session_id, session_config)
return await update_saved_session_title_at_path(session_dir, title)

View file

@ -8,7 +8,7 @@ import os
from pathlib import Path
import subprocess
from threading import Lock
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from anyio import NamedTemporaryFile, Path as AsyncPath
@ -134,6 +134,8 @@ class SessionLogger:
git_branch=git_branch,
username=user_name,
environment={"working_directory": str(Path.cwd())},
title=None,
title_source="auto",
)
def _get_title(self, messages: Sequence[LLMMessage]) -> str:
@ -154,6 +156,37 @@ class SessionLogger:
return title
def _set_title_state(
self, title: str | None, *, source: Literal["auto", "manual"]
) -> None:
if self.session_metadata is None:
return
self.session_metadata.title = title
self.session_metadata.title_source = source
def set_title(self, title: str | None) -> None:
if title is None:
self._set_title_state(None, source="auto")
return
normalized_title = title.strip()
if not normalized_title:
raise ValueError("Session title cannot be empty.")
self._set_title_state(normalized_title, source="manual")
def _resolve_title(self, messages: Sequence[LLMMessage]) -> str | None:
if self.session_metadata is None:
return self._get_title(messages)
if self.session_metadata.title_source == "manual":
return self.session_metadata.title
title = self._get_title(messages)
self._set_title_state(title, source="auto")
return title
@staticmethod
async def persist_metadata(metadata: Any, session_dir: Path) -> None:
temp_metadata_filepath = None
@ -265,7 +298,7 @@ class SessionLogger:
for tool_class in tool_manager.available_tools.values()
]
title = self._get_title(messages)
title = self._resolve_title(messages)
system_prompt = (
messages[0].model_dump()
if len(messages) > 0 and messages[0].role == Role.system

View file

@ -300,6 +300,22 @@ class TelemetryClient:
payload = {"init_duration_ms": init_duration_ms}
self.send_telemetry_event("vibe.ready", payload)
def send_at_mention_inserted(
self,
*,
nb_mentions: int,
context_types: dict[str, int],
file_extensions: dict[str, int] | None,
message_id: str | None,
) -> None:
payload: dict[str, Any] = {
"nb_mentions": nb_mentions,
"context_types": context_types,
"file_extensions": file_extensions,
"message_id": message_id,
}
self.send_telemetry_event("vibe.at_mention_inserted", payload)
def send_user_rating_feedback(self, rating: int, model: str) -> None:
self.send_telemetry_event(
"vibe.user_rating_feedback",

View file

@ -7,9 +7,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
import httpx
from mistralai.client import Mistral
from mistralai.client.models.connectorsqueryfilters import (
ConnectorsQueryFiltersTypedDict,
)
from vibe.core.logger import logger
from vibe.core.tools.base import (
@ -33,9 +30,7 @@ from vibe.core.utils import run_sync
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
_LIST_PAGE_SIZE = 100
_LIST_QUERY_FILTERS: ConnectorsQueryFiltersTypedDict = {"active": True}
_TOOL_FETCH_TIMEOUT = 8.0
_BOOTSTRAP_TIMEOUT = 30.0
def _normalize_name(name: str) -> str:
@ -44,25 +39,15 @@ def _normalize_name(name: str) -> str:
return result or "unnamed"
def _connector_tool_to_remote(tool: dict[str, Any] | Any) -> RemoteTool | None:
"""Convert a ConnectorTool (SDK object or raw dict) to a RemoteTool."""
if isinstance(tool, dict):
name = tool.get("name")
description = tool.get("description")
schema = tool.get("jsonschema") or tool.get("json_schema")
else:
name = getattr(tool, "name", None)
description = getattr(tool, "description", None)
schema = getattr(tool, "jsonschema", None) or getattr(tool, "json_schema", None)
def _connector_tool_to_remote(tool: dict[str, Any]) -> RemoteTool | None:
"""Convert a bootstrap tool dict to a RemoteTool."""
name = tool.get("name")
if not name:
return None
if schema is not None and not isinstance(schema, dict):
dump = getattr(schema, "model_dump", None)
schema = dump() if callable(dump) else None
return RemoteTool.model_validate({
"name": name,
"description": description,
"inputSchema": schema or {"type": "object", "properties": {}},
"description": tool.get("description"),
"inputSchema": tool.get("inputSchema") or {"type": "object", "properties": {}},
})
@ -208,6 +193,36 @@ def create_connector_proxy_tool_class(
return ConnectorProxyTool
def _deduplicate_connectors(
connectors: list[dict[str, Any]],
) -> list[tuple[str, str, dict[str, Any]]]:
"""Deduplicate connectors by normalized alias, preserving order.
When two connectors share the same alias, disambiguate with a
numeric suffix rather than silently dropping.
"""
seen_names: set[str] = set()
result: list[tuple[str, str, dict[str, Any]]] = []
for connector in connectors:
connector_id = str(connector.get("id", ""))
if not connector_id:
continue
raw_name = connector.get("name") or connector_id
alias = _normalize_name(raw_name)
if alias in seen_names:
original = alias
suffix = 2
while f"{original}_{suffix}" in seen_names:
suffix += 1
alias = f"{original}_{suffix}"
logger.warning(
"Connector %r alias %r collides, using %r", raw_name, original, alias
)
seen_names.add(alias)
result.append((connector_id, alias, connector))
return result
class ConnectorRegistry:
"""Discovers connector tools from the Mistral API.
@ -240,6 +255,47 @@ class ConnectorRegistry:
return await self._discover_all()
async def _fetch_bootstrap(self) -> dict[str, Any]:
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:
response = await http.get(url, headers=headers)
response.raise_for_status()
return response.json()
def _build_tools_for_connector(
self,
*,
connector_id: str,
alias: str,
name: str,
raw_tools: list[dict[str, Any]],
) -> dict[str, type[BaseTool]]:
tools_map: dict[str, type[BaseTool]] = {}
for tool in raw_tools:
remote = _connector_tool_to_remote(tool)
if remote is None:
continue
try:
proxy_cls = create_connector_proxy_tool_class(
connector_name=name,
connector_alias=alias,
connector_id=connector_id,
remote=remote,
api_key=self._api_key,
server_url=self._server_url,
)
tools_map[proxy_cls.get_name()] = proxy_cls
except Exception:
logger.warning(
"Failed to register connector tool %s for %s",
tool.get("name", "<unknown>"),
name,
exc_info=True,
)
return tools_map
async def _discover_all(self) -> dict[str, type[BaseTool]]:
async with self._discover_lock:
# Re-check under lock — another coroutine may have finished
@ -250,157 +306,55 @@ class ConnectorRegistry:
result.update(tools)
return result
async with self._get_client() as client:
connectors: list[Any] = []
try:
data = await self._fetch_bootstrap()
except Exception:
logger.warning("Failed to bootstrap connectors", exc_info=True)
self._cache = {}
self._connector_names = []
self._connector_connected = {}
self._alias_to_id = {}
return {}
try:
page = await client.beta.connectors.list_async(
page_size=_LIST_PAGE_SIZE, query_filters=_LIST_QUERY_FILTERS
unique_connectors = _deduplicate_connectors(data.get("connectors") or [])
cache: dict[str, dict[str, type[BaseTool]]] = {}
all_tools: dict[str, type[BaseTool]] = {}
connector_names: list[str] = []
connector_connected: dict[str, bool] = {}
for connector_id, alias, connector in unique_connectors:
connector_names.append(alias)
name = connector.get("name") or connector_id
if bootstrap_errors := connector.get("bootstrap_errors"):
logger.warning(
"Connector %r bootstrap errors: %s", name, bootstrap_errors
)
connectors.extend(page.items or [])
while page.pagination and page.pagination.next_cursor:
page = await client.beta.connectors.list_async(
page_size=_LIST_PAGE_SIZE,
cursor=page.pagination.next_cursor,
query_filters=_LIST_QUERY_FILTERS,
)
connectors.extend(page.items or [])
except Exception as exc:
logger.warning(f"Failed to list connectors: {exc}")
self._cache = {}
self._connector_names = []
self._connector_connected = {}
self._alias_to_id = {}
return {}
# Build results locally to avoid publishing incomplete state.
cache: dict[str, dict[str, type[BaseTool]]] = {}
all_tools: dict[str, type[BaseTool]] = {}
connector_names: list[str] = []
connector_connected: dict[str, bool] = {}
status = connector.get("status") or {}
if not status.get("is_ready", False):
connector_connected[alias] = False
continue
# Deduplicate by normalized name, preserving order.
# When two connectors share the same alias, disambiguate
# with a numeric suffix rather than silently dropping.
seen_names: set[str] = set()
unique_connectors: list[tuple[str, str, Any]] = []
for connector in connectors:
connector_id = str(connector.id)
raw_name = connector.name or connector_id
alias = _normalize_name(raw_name)
if alias in seen_names:
original = alias
suffix = 2
while f"{alias}_{suffix}" in seen_names:
suffix += 1
alias = f"{alias}_{suffix}"
logger.warning(
f"Connector {raw_name!r} alias {original!r} collides, "
f"using {alias!r}"
)
seen_names.add(alias)
unique_connectors.append((connector_id, alias, connector))
# Fetch tools for all connectors in parallel.
tasks = [
self._fetch_connector_tools(client, c, alias)
for _, alias, c in unique_connectors
]
results = await asyncio.gather(*tasks)
for (connector_id, alias, _), tools_map in zip(
unique_connectors, results, strict=True
):
connector_names.append(alias)
if tools_map is None:
# Timeout or error — show in UI but don't cache so
# a refresh will retry discovery for this connector.
connector_connected[alias] = False
continue
cache[connector_id] = tools_map
all_tools.update(tools_map)
# TODO: replace with actual API field when available
connector_connected[alias] = bool(tools_map)
# Publish atomically — concurrent callers waiting on the
# lock will see the completed cache.
self._connector_names = connector_names
self._connector_connected = connector_connected
self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
self._cache = cache
return all_tools
async def _fetch_connector_tools(
self, client: Mistral, connector: Any, connector_alias: str
) -> dict[str, type[BaseTool]] | None:
"""Fetch tools for a single connector via ``list_tools_async``.
The list endpoint does not always include tools inline, so we
call ``list_tools_async`` per connector to get the full set.
"""
connector_id = str(connector.id)
name = connector.name or connector_id
try:
response = await asyncio.wait_for(
client.beta.connectors.list_tools_async(
connector_id_or_name=connector_id
),
timeout=_TOOL_FETCH_TIMEOUT,
)
except TimeoutError:
logger.warning(
f"Timeout fetching tools for connector {name} (>{_TOOL_FETCH_TIMEOUT}s)"
)
return None
except Exception as exc:
logger.warning(f"Failed to list tools for connector {name}: {exc}")
return None
# The SDK may return a plain list, an iterable wrapper, or an
# object with a `data`/`items` attribute. Handle all cases.
if isinstance(response, list):
raw_tools: list[Any] = response
elif hasattr(response, "data"):
raw_tools = list(response.data or [])
elif hasattr(response, "items"):
raw_tools = list(response.items or [])
else:
try:
raw_tools = list(response)
except TypeError:
logger.warning(
f"Unexpected response type from list_tools_async for {name}: "
f"{type(response).__name__}"
)
raw_tools = []
result: dict[str, type[BaseTool]] = {}
for tool in raw_tools:
remote = _connector_tool_to_remote(tool)
if remote is None:
continue
try:
proxy_cls = create_connector_proxy_tool_class(
connector_name=name,
connector_alias=connector_alias,
tools_map = self._build_tools_for_connector(
connector_id=connector_id,
remote=remote,
api_key=self._api_key,
server_url=self._server_url,
alias=alias,
name=name,
raw_tools=connector.get("tools") or [],
)
result[proxy_cls.get_name()] = proxy_cls
except Exception as exc:
tool_name = (
tool.get("name")
if isinstance(tool, dict)
else getattr(tool, "name", "<unknown>")
)
logger.warning(
f"Failed to register connector tool {tool_name} for {name}: {exc}"
)
return result
cache[connector_id] = tools_map
all_tools.update(tools_map)
connector_connected[alias] = bool(tools_map)
# Publish atomically — concurrent callers waiting on the
# lock will see the completed cache.
self._connector_names = connector_names
self._connector_connected = connector_connected
self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
self._cache = cache
return all_tools
@property
def connector_count(self) -> int:
@ -421,6 +375,7 @@ class ConnectorRegistry:
async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]:
"""Re-fetch tools for a single connector by alias.
Calls the bootstrap endpoint and extracts the matching connector.
Updates the internal cache for that connector only. Returns
the new tool map (empty dict on failure).
"""
@ -428,15 +383,27 @@ class ConnectorRegistry:
if connector_id is None:
return {}
tools_map: dict[str, type[BaseTool]] | None = None
try:
async with self._get_client() as client:
connector = await client.beta.connectors.get_async(
connector_id_or_name=connector_id
data = await self._fetch_bootstrap()
for connector in data.get("connectors") or []:
if str(connector.get("id")) != connector_id:
continue
name = connector.get("name") or connector_id
status = connector.get("status") or {}
if not status.get("is_ready", False):
break
tools_map = self._build_tools_for_connector(
connector_id=connector_id,
alias=alias,
name=name,
raw_tools=connector.get("tools") or [],
)
tools_map = await self._fetch_connector_tools(client, connector, alias)
break
except Exception:
logger.debug("Failed to refresh connector %s", alias)
tools_map = None
if self._cache is None:
self._cache = {}

View file

@ -142,6 +142,8 @@ class SessionMetadata(BaseModel):
git_branch: str | None
environment: dict[str, str | None]
username: str
title: str | None = None
title_source: Literal["auto", "manual"] = "auto"
StrToolChoice = Literal["auto", "none", "any", "required"]

View file

@ -1,12 +1,5 @@
# What's new in v2.9.1
# What's new in v2.9.4
- **Default model**: Migrated to `mistral-medium-3.5`.
- **Connector OAuth**: Authenticate connectors via OAuth directly from the `/mcp` menu.
# What's new in v2.9.0
- **Scratchpad directory**: Model's autonomous workspace for temporary files, intermediate results, and drafts shared with subagents
- **`/copy` command**: New slash command to copy content directly from the conversation
- **`--trust` flag**: Grant session-only tool permissions
- **MCP toggle**: Enable/disable MCP servers and individual tools from the `/mcp` menu
- **Custom compaction**: Provide custom instructions when compacting context via `/compact`
- **Thinking level picker**: Choose thinking level with `/thinking`
- **`/rename` command**: Rename the current session from the slash menu.
- **Persistent "always allow"**: Tool permissions granted with "always allow" now stick across sessions.
- **Faster bash bang commands**: `!command` runs via async subprocess.