Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Quentin <torroba.q@gmail.com>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-23 18:45:21 +01:00 committed by GitHub
parent 5103019b01
commit eb580209d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
180 changed files with 11136 additions and 1030 deletions

View file

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

View file

@ -26,11 +26,14 @@ from acp.schema import (
AgentThoughtChunk,
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
AuthMethodAgent,
AvailableCommand,
AvailableCommandInput,
ClientCapabilities,
CloseSessionResponse,
ContentToolCallContent,
Cost,
EnvVarAuthMethod,
ForkSessionResponse,
HttpMcpServer,
Implementation,
@ -43,11 +46,14 @@ from acp.schema import (
SessionListCapabilities,
SetSessionConfigOptionResponse,
SseMcpServer,
TerminalAuthMethod,
TextContentBlock,
TextResourceContents,
ToolCallProgress,
ToolCallUpdate,
UnstructuredCommandInput,
Usage,
UsageUpdate,
UserMessageChunk,
)
from pydantic import BaseModel, ConfigDict
@ -71,8 +77,8 @@ from vibe.acp.tools.session_update import (
tool_result_session_update,
)
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
build_permission_options,
create_assistant_message_replay,
create_compact_end_session_update,
create_compact_start_session_update,
@ -101,8 +107,9 @@ from vibe.core.proxy_setup import (
unset_proxy_var,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import (
AgentProfileChangedEvent,
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
@ -173,9 +180,10 @@ class VibeAcpAgentLoop(AcpAgent):
and self.client_capabilities.field_meta.get("terminal-auth") is True
)
auth_methods = (
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
[
AuthMethod(
TerminalAuthMethod(
type="terminal",
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
@ -228,9 +236,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(
disabled_tools=["ask_user_question", "exit_plan_mode"]
)
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
@ -263,18 +269,22 @@ class VibeAcpAgentLoop(AcpAgent):
config = self._load_config()
agent_loop = AgentLoop(
config=config,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
# We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context.
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
try:
agent_loop = AgentLoop(
config=config,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
# We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context.
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
except Exception as e:
raise ConfigurationError(str(e)) from e
agent_loop.emit_new_session_telemetry()
modes_state, modes_config = make_mode_response(
@ -314,17 +324,15 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id)
def _handle_permission_selection(
option_id: str, tool_name: str
option_id: str,
tool_name: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
match option_id:
case ToolOption.ALLOW_ONCE:
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS:
if tool_name not in session.agent_loop.config.tools:
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
session.agent_loop.config.tools[
tool_name
].permission = ToolPermission.ALWAYS
session.agent_loop.approve_always(tool_name, required_permissions)
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE:
return (
@ -335,19 +343,33 @@ class VibeAcpAgentLoop(AcpAgent):
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
async def approval_callback(
tool_name: str, args: BaseModel, tool_call_id: str
tool_name: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list | None = None,
) -> tuple[ApprovalResponse, str | None]:
# Create the tool call update
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
response = await self.client.request_permission(
session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
typed_permissions: list[RequiredPermission] | None = (
[
rp
for rp in required_permissions
if isinstance(rp, RequiredPermission)
]
if required_permissions
else None
)
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
options = build_permission_options(typed_permissions)
response = await self.client.request_permission(
session_id=session_id, tool_call=tool_call, options=options
)
# Parse the response using isinstance for proper type narrowing
if response.outcome.outcome == "selected":
outcome = cast(AllowedOutcome, response.outcome)
return _handle_permission_selection(outcome.option_id, tool_name)
return _handle_permission_selection(
outcome.option_id, tool_name, typed_permissions
)
else:
return (
ApprovalResponse.NO,
@ -365,6 +387,39 @@ class VibeAcpAgentLoop(AcpAgent):
raise SessionNotFoundError(session_id)
return self.sessions[session_id]
def _build_usage(self, session: AcpSessionLoop) -> Usage:
stats = session.agent_loop.stats
return Usage(
input_tokens=stats.session_prompt_tokens,
output_tokens=stats.session_completion_tokens,
total_tokens=stats.session_total_llm_tokens,
)
def _build_usage_update(self, session: AcpSessionLoop) -> UsageUpdate:
stats = session.agent_loop.stats
active_model = session.agent_loop.config.get_active_model()
cost = (
Cost(amount=stats.session_cost, currency="USD")
if stats.input_price_per_million > 0 or stats.output_price_per_million > 0
else None
)
return UsageUpdate(
session_update="usage_update",
used=stats.context_tokens,
size=active_model.auto_compact_threshold,
cost=cost,
)
def _send_usage_update(self, session: AcpSessionLoop) -> None:
async def _send() -> None:
try:
update = self._build_usage_update(session)
await self.client.session_update(session_id=session.id, update=update)
except Exception:
pass
asyncio.create_task(_send())
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
if not msg.tool_calls:
return
@ -463,7 +518,7 @@ class VibeAcpAgentLoop(AcpAgent):
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
@ -492,7 +547,7 @@ class VibeAcpAgentLoop(AcpAgent):
})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
@ -549,6 +604,7 @@ class VibeAcpAgentLoop(AcpAgent):
session = await self._create_acp_session(session_id, agent_loop)
await self._replay_conversation_history(session_id, non_system_messages)
self._send_usage_update(session)
modes_state, modes_config = make_mode_response(
list(agent_loop.agent_manager.available_agents.values()),
@ -589,7 +645,7 @@ class VibeAcpAgentLoop(AcpAgent):
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
@ -675,7 +731,11 @@ class VibeAcpAgentLoop(AcpAgent):
@override
async def prompt(
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
self,
prompt: list[ContentBlock],
session_id: str,
message_id: str | None = None,
**kwargs: Any,
) -> PromptResponse:
session = self._get_session(session_id)
@ -708,7 +768,10 @@ class VibeAcpAgentLoop(AcpAgent):
await session.task
except asyncio.CancelledError:
return PromptResponse(stop_reason="cancelled")
self._send_usage_update(session)
return PromptResponse(
stop_reason="cancelled", usage=self._build_usage(session)
)
except CoreRateLimitError as e:
raise RateLimitError.from_core(e) from e
@ -722,7 +785,8 @@ class VibeAcpAgentLoop(AcpAgent):
finally:
session.task = None
return PromptResponse(stop_reason="end_turn")
self._send_usage_update(session)
return PromptResponse(stop_reason="end_turn", usage=self._build_usage(session))
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
text_prompt = ""
@ -841,6 +905,15 @@ class VibeAcpAgentLoop(AcpAgent):
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
elif isinstance(event, AgentProfileChangedEvent):
pass
@override
async def close_session(
self, session_id: str, **kwargs: Any
) -> CloseSessionResponse | None:
raise NotImplementedMethodError("close_session")
@override
async def cancel(self, session_id: str, **kwargs: Any) -> None:
session = self._get_session(session_id)

View file

@ -8,7 +8,7 @@ import sys
import tomli_w
from vibe import __version__
from vibe.core.config import VibeConfig
from vibe.core.config import MissingAPIKeyError, VibeConfig
from vibe.core.config.harness_files import (
get_harness_files_manager,
init_harness_files_manager,
@ -78,13 +78,23 @@ def main() -> None:
init_harness_files_manager("user", "project")
from vibe.acp.acp_agent_loop import run_acp_server
from vibe.core.config import VibeConfig, load_dotenv_values
from vibe.core.tracing import setup_tracing
from vibe.setup.onboarding import run_onboarding
load_dotenv_values()
bootstrap_config_files()
args = parse_arguments()
if args.setup:
run_onboarding()
sys.exit(0)
try:
config = VibeConfig.load()
setup_tracing(config)
except MissingAPIKeyError:
pass # tracing disabled, but server can still handle the error properly in new_session
run_acp_server()

View file

@ -9,7 +9,6 @@ from acp.schema import (
ContentToolCallContent,
ModelInfo,
PermissionOption,
SessionConfigOption,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionMode,
@ -23,6 +22,7 @@ from acp.schema import (
from vibe.core.agents.models import AgentProfile, AgentType
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
@ -45,7 +45,7 @@ TOOL_OPTIONS = [
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name="Allow always",
name="Allow for this session",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
),
PermissionOption(
@ -56,6 +56,44 @@ TOOL_OPTIONS = [
]
def build_permission_options(
required_permissions: list[RequiredPermission] | None,
) -> list[PermissionOption]:
"""Build ACP permission options, including granular labels when available."""
if not required_permissions:
return TOOL_OPTIONS
labels = ", ".join(rp.label for rp in required_permissions)
permissions_meta = [
{
"scope": rp.scope,
"invocation_pattern": rp.invocation_pattern,
"session_pattern": rp.session_pattern,
"label": rp.label,
}
for rp in required_permissions
]
return [
PermissionOption(
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name=f"Allow for this session: {labels}",
kind=cast(Literal["allow_always"], ToolOption.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),
),
]
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
return any(
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
@ -64,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
def make_mode_response(
profiles: list[AgentProfile], current_mode_id: str
) -> tuple[SessionModeState, SessionConfigOption]:
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
session_modes: list[SessionMode] = []
config_options: list[SessionConfigSelectOption] = []
@ -89,22 +127,20 @@ def make_mode_response(
state = SessionModeState(
current_mode_id=current_mode_id, available_modes=session_modes
)
config = SessionConfigOption(
root=SessionConfigOptionSelect(
id="mode",
name="Session Mode",
current_value=current_mode_id,
category="mode",
type="select",
options=config_options,
)
config = SessionConfigOptionSelect(
id="mode",
name="Session Mode",
current_value=current_mode_id,
category="mode",
type="select",
options=config_options,
)
return state, config
def make_model_response(
models: list[ModelConfig], current_model_id: str
) -> tuple[SessionModelState, SessionConfigOption]:
) -> tuple[SessionModelState, SessionConfigOptionSelect]:
model_infos: list[ModelInfo] = []
config_options: list[SessionConfigSelectOption] = []
@ -119,15 +155,13 @@ def make_model_response(
state = SessionModelState(
current_model_id=current_model_id, available_models=model_infos
)
config_option = SessionConfigOption(
root=SessionConfigOptionSelect(
id="model",
name="Model",
current_value=current_model_id,
category="model",
type="select",
options=config_options,
)
config_option = SessionConfigOptionSelect(
id="model",
name="Model",
current_value=current_model_id,
category="model",
type="select",
options=config_options,
)
return state, config_option

View file

@ -8,7 +8,7 @@ from rich import print as rprint
import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
@ -22,6 +22,7 @@ from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tracing import setup_tracing
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
@ -104,6 +105,8 @@ def load_session(
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
elif args.resume is True:
return None
else:
session_to_load = SessionLoader.find_session_by_id(
args.resume, config.session_logging
@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
try:
initial_agent_name = get_initial_agent_name(args)
config = load_config_or_exit()
setup_tracing(config)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
run_textual_ui(
agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
startup=StartupOptions(
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
show_resume_picker=args.resume is True,
),
)
except (KeyboardInterrupt, EOFError):

View file

@ -22,10 +22,15 @@ class CommandRegistry:
handler="_show_help",
),
"config": Command(
aliases=frozenset(["/config", "/model"]),
aliases=frozenset(["/config"]),
description="Edit config settings",
handler="_show_config",
),
"model": Command(
aliases=frozenset(["/model"]),
description="Select active model",
handler="_show_model",
),
"reload": Command(
aliases=frozenset(["/reload"]),
description="Reload configuration from disk",
@ -79,8 +84,8 @@ class CommandRegistry:
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Toggle voice mode on/off",
handler="_toggle_voice_mode",
description="Configure voice settings",
handler="_show_voice_settings",
),
"leanstall": Command(
aliases=frozenset(["/leanstall"]),

View file

@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
)
continuation_group.add_argument(
"--resume",
nargs="?",
const=True,
default=None,
metavar="SESSION_ID",
help="Resume a specific session by its ID (supports partial matching)",
help="Resume a session. Without SESSION_ID, shows an interactive picker.",
)
return parser.parse_args()

View file

@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.types import Backend
logger = logging.getLogger(__name__)

View file

@ -9,6 +9,8 @@ import platform
import subprocess
from typing import Any, Literal
from vibe.core.utils.io import read_safe
class Terminal(Enum):
VSCODE = "vscode"
@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = keybindings_path.read_text()
content = read_safe(keybindings_path)
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
import os
@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
NotificationPort,
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.messages import (
@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
WarningMessage,
WhatsNewMessage,
)
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
@ -65,6 +72,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
from vibe.cli.textual_ui.windowing import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import (
should_resume_history,
sync_backfill_state,
)
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryPort,
TurnSummaryResult,
TurnSummaryTracker,
create_narrator_backend,
)
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
PyPIUpdateGateway,
@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
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_player.audio_player_port import AudioFormat
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import Backend, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
)
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
AskUserQuestionResult,
Choice,
Question,
)
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.tts.factory import make_tts_client
from vibe.core.tts.tts_client_port import TTSClientPort
from vibe.core.types import (
AgentStats,
ApprovalResponse,
Backend,
LLMMessage,
RateLimitError,
Role,
@ -129,6 +149,7 @@ from vibe.core.utils import (
get_user_cancellation_message,
is_dangerous_directory,
)
from vibe.core.utils.io import read_safe
class BottomApp(StrEnum):
@ -142,9 +163,11 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
SessionPicker = auto()
Voice = auto()
class ChatScroll(VerticalScroll):
@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
def is_at_bottom(self) -> bool:
return self.scroll_target_y >= (self.max_scroll_y - 3)
def _check_anchor(self) -> None:
if self._anchored and self._anchor_released and self.is_at_bottom:
self._anchor_released = False
_reanchor_pending: bool = False
_scrolling_down: bool = False
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
super().watch_scroll_y(old_value, new_value)
self._scrolling_down = new_value >= old_value
def release_anchor(self) -> None:
super().release_anchor()
# Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER
# our override, so any re-anchor we do gets immediately undone.
# Defer the re-check until all handlers for this event have finished.
if not self._reanchor_pending:
self._reanchor_pending = True
self.call_later(self._maybe_reanchor)
def _maybe_reanchor(self) -> None:
self._reanchor_pending = False
if (
self._anchored
and self._anchor_released
and self.is_at_bottom
and self._scrolling_down
):
self.anchor()
def update_node_styles(self, animate: bool = True) -> None:
pass
@ -202,6 +247,13 @@ async def prune_oldest_children(
return True
@dataclass(frozen=True, slots=True)
class StartupOptions:
initial_prompt: str | None = None
teleport_on_start: bool = False
show_resume_picker: bool = False
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
def __init__(
self,
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
startup: StartupOptions | None = None,
update_notifier: UpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
self._update_cache_repository = update_cache_repository
self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway
self._initial_prompt = initial_prompt
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
self._show_resume_picker = opts.show_resume_picker
self._last_escape_time: float | None = None
self._banner: Banner | None = None
self._whats_new_message: WhatsNewMessage | None = None
@ -287,6 +340,12 @@ class VibeApp(App): # noqa: PLR0904
self._cached_loading_area: Widget | None = None
self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None
self._turn_summary: TurnSummaryPort = self._make_turn_summary()
self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set()
self._tts_client: TTSClientPort | None = self._make_tts_client()
self._audio_player = AudioPlayer()
self._speak_task: asyncio.Task[None] | None = None
self._cancel_summary: Callable[[], bool] | None = None
@property
def config(self) -> VibeConfig:
@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
yield VerticalGroup(id="messages")
with Horizontal(id="loading-area"):
yield NarratorStatus()
yield Static(id="loading-area-content")
yield FeedbackBar()
with Static(id="bottom-app-container"):
yield ChatInputContainer(
@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll)
self._cached_loading_area = self.query_one("#loading-area-content")
self._feedback_bar = self.query_one(FeedbackBar)
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
get_tools_collapsed=lambda: self._tools_collapsed,
on_profile_changed=self._on_profile_changed,
)
self._chat_input_container = self.query_one(ChatInputContainer)
@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(self._refresh_banner)
if self._initial_prompt or self._teleport_on_start:
if self._show_resume_picker:
self.run_worker(self._show_session_picker(), exclusive=False)
elif self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
gc.collect()
@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_approval_app_approval_granted_always_tool(
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
) -> None:
self._set_tool_permission_always(
message.tool_name, save_permanently=message.save_permanently
)
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
def on_chat_text_area_feedback_key_pressed(
self, message: ChatTextArea.FeedbackKeyPressed
) -> None:
self._feedback_bar.handle_feedback_key(message.rating)
def on_chat_text_area_non_feedback_key_pressed(
self, message: ChatTextArea.NonFeedbackKeyPressed
) -> None:
self._feedback_bar.hide()
def on_feedback_bar_feedback_given(
self, message: FeedbackBar.FeedbackGiven
) -> None:
self.agent_loop.telemetry_client.send_user_rating_feedback(
rating=message.rating, model=self.config.active_model
)
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
self._loading_widget = None
async def on_config_app_open_model_picker(
self, _message: ConfigApp.OpenModelPicker
) -> None:
config_app = self.query_one(ConfigApp)
changes = config_app._convert_changes_for_save()
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
await self._switch_to_input_app()
await self._switch_to_model_picker_app()
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
) -> None:
if message.changes:
VibeConfig.save_updates(message.changes)
await self._handle_config_settings_closed(message.changes)
await self._switch_to_input_app()
async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None:
await self._handle_voice_settings_closed(message.changes)
await self._switch_to_input_app()
async def _handle_config_settings_closed(
self, changes: dict[str, str | bool]
) -> None:
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
else:
await self._mount_and_scroll(
UserCommandMessage("Configuration closed (no changes saved).")
)
async def _handle_voice_settings_closed(
self, changes: dict[str, str | bool]
) -> None:
if not changes:
await self._mount_and_scroll(
UserCommandMessage("Voice settings closed (no changes saved).")
)
return
if "voice_mode_enabled" in changes:
current = self._voice_manager.is_enabled
desired = changes["voice_mode_enabled"]
if current != desired:
self._voice_manager.toggle_voice_mode()
self.agent_loop.telemetry_client.send_telemetry_event(
"vibe.voice_mode_toggled", {"enabled": desired}
)
self.agent_loop.refresh_config()
if desired:
await self._mount_and_scroll(
UserCommandMessage(
"Voice mode enabled. Press ctrl+r to start recording."
)
)
else:
await self._mount_and_scroll(
UserCommandMessage("Voice mode disabled.")
)
non_voice_changes = {
k: v for k, v in changes.items() if k != "voice_mode_enabled"
}
if non_voice_changes:
VibeConfig.save_updates(non_voice_changes)
self.agent_loop.refresh_config()
self._sync_turn_summary()
async def on_model_picker_app_model_selected(
self, message: ModelPickerApp.ModelSelected
) -> None:
VibeConfig.save_updates({"active_model": message.alias})
await self._reload_config()
await self._switch_to_input_app()
async def on_model_picker_app_cancelled(
self, _event: ModelPickerApp.Cancelled
) -> None:
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
for widget in children[:compact_index]:
await widget.remove()
def _set_tool_permission_always(
self, tool_name: str, save_permanently: bool = False
) -> None:
self.agent_loop.set_tool_permission(
tool_name, ToolPermission.ALWAYS, save_permanently
)
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
if cmd_name := self.commands.get_command_name(user_input):
@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
skill_content = read_safe(skill_info.skill_path)
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
user_message = UserMessage(message)
await self._mount_and_scroll(user_message)
if self.agent_loop.telemetry_client.is_active():
self._feedback_bar.maybe_show()
if not self._agent_running:
self._agent_task = asyncio.create_task(
@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904
return tool in self.agent_loop.tool_manager.available_tools
async def _approval_callback(
self, tool: str, args: BaseModel, tool_call_id: str
self,
tool: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
# This ensures subagents respect the main agent's tool restrictions
@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
await self._switch_to_approval_app(tool, args, required_permissions)
result = await self._pending_approval
return result
finally:
@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
self._pending_question = None
await self._switch_to_input_app()
async def _handle_turn_error(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904
try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._cancel_speak()
self._turn_summary.start_turn(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._turn_summary.track(event)
if self.event_handler:
await self.event_handler.handle_event(
event,
@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
)
except asyncio.CancelledError:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
await self._handle_turn_error()
self._turn_summary.cancel_turn()
raise
except Exception as e:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
await self._handle_turn_error()
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
self._turn_summary.set_error(message)
await self._mount_and_scroll(
ErrorMessage(message, collapsed=self._tools_collapsed)
)
finally:
cancel_summary = self._turn_summary.end_turn()
if (
cancel_summary is not None
and self.config.narrator_enabled
and self._tts_client is not None
):
self._cancel_summary = cancel_summary
self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING
self._agent_running = False
self._interrupt_requested = False
self._agent_task = None
@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_config_app()
async def _show_model(self) -> None:
"""Switch to the model picker in the bottom panel."""
if self._current_bottom_app == BottomApp.ModelPicker:
return
await self._switch_to_model_picker_app()
async def _show_proxy_setup(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
await self._resolve_plan()
self._sync_turn_summary()
if self._banner:
self._banner.set_state(
@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
lambda: self.config,
audio_recorder=AudioRecorder(),
transcribe_client=transcribe_client,
telemetry_client=self.agent_loop.telemetry_client,
)
async def _toggle_voice_mode(self) -> None:
result = self._voice_manager.toggle_voice_mode()
self.agent_loop.refresh_config()
if result.enabled:
msg = "Voice mode enabled. Press ctrl+r to start recording."
else:
msg = "Voice mode disabled."
await self._mount_and_scroll(UserCommandMessage(msg))
async def _show_voice_settings(self) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._switch_to_voice_app()
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = False
self._chat_input_container.disabled = True
self._feedback_bar.hide()
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
await bottom_container.mount(widget)
@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
await self._switch_from_input(ConfigApp(self.config))
async def _switch_to_voice_app(self) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._mount_and_scroll(UserCommandMessage("Voice settings opened..."))
await self._switch_from_input(VoiceApp(self.config))
async def _switch_to_model_picker_app(self) -> None:
if self._current_bottom_app == BottomApp.ModelPicker:
return
model_aliases = [m.alias for m in self.config.models]
current_model = str(self.config.active_model)
await self._switch_from_input(
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
)
async def _switch_to_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_from_input(ProxySetupApp())
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission] | None = None,
) -> None:
approval_app = ApprovalApp(
tool_name=tool_name, tool_args=tool_args, config=self.config
tool_name=tool_name,
tool_args=tool_args,
config=self.config,
required_permissions=required_permissions,
)
await self._switch_from_input(approval_app, scroll=True)
@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ChatInputContainer).focus_input()
case BottomApp.Config:
self.query_one(ConfigApp).focus()
case BottomApp.ModelPicker:
self.query_one(ModelPickerApp).focus()
case BottomApp.ProxySetup:
self.query_one(ProxySetupApp).focus()
case BottomApp.Approval:
@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case BottomApp.Voice:
self.query_one(VoiceApp).focus()
case app:
assert_never(app)
except Exception:
@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
pass
self._last_escape_time = None
def _handle_voice_app_escape(self) -> None:
try:
voice_app = self.query_one(VoiceApp)
voice_app.action_close()
except Exception:
pass
self._last_escape_time = None
def _handle_approval_app_escape(self) -> None:
try:
approval_app = self.query_one(ApprovalApp)
@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_model_picker_app_escape(self) -> None:
try:
model_picker = self.query_one(ModelPickerApp)
model_picker.post_message(ModelPickerApp.Cancelled())
except Exception:
pass
self._last_escape_time = None
def _handle_session_picker_app_escape(self) -> None:
try:
session_picker = self.query_one(SessionPickerApp)
@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_config_app_escape()
return
if self._current_bottom_app == BottomApp.Voice:
self._handle_voice_app_escape()
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
proxy_setup_app = self.query_one(ProxySetupApp)
@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape()
return
if self._current_bottom_app == BottomApp.ModelPicker:
self._handle_model_picker_app_escape()
return
if self._current_bottom_app == BottomApp.SessionPicker:
self._handle_session_picker_app_escape()
return
@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904
self._handle_input_app_escape()
return
narrator_status = self.query_one(NarratorStatus)
if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE:
self._cancel_speak()
return
if self._agent_running:
self._handle_agent_running_escape()
@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_profile_widgets(self) -> None:
self._update_profile_widgets(self.agent_loop.agent_profile)
def _on_profile_changed(self) -> None:
self._refresh_profile_widgets()
self._refresh_banner()
def _refresh_banner(self) -> None:
if self._banner:
self._banner.set_state(
@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904
# force a full layout refresh so the UI isn't garbled.
self.refresh(layout=True)
def _make_turn_summary(self) -> TurnSummaryPort:
if not self.config.narrator_enabled:
return NoopTurnSummary()
result = create_narrator_backend(self.config)
if result is None:
return NoopTurnSummary()
backend, model = result
return TurnSummaryTracker(
backend=backend, model=model, on_summary=self._on_turn_summary
)
def _print_session_resume_message(session_id: str | None) -> None:
if not session_id:
return
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
self._cancel_summary = None
if result.generation != self._turn_summary.generation:
self._set_narrator_state(NarratorState.IDLE)
return
if result.summary is None:
self._set_narrator_state(NarratorState.IDLE)
return
if self._tts_client is not None:
self._speak_task = asyncio.create_task(self._speak_summary(result.summary))
else:
self._set_narrator_state(NarratorState.IDLE)
print()
print("To continue this session, run: vibe --continue")
print(f"Or: vibe --resume {session_id}")
async def _speak_summary(self, text: str) -> None:
if self._tts_client is None:
return
try:
loop = asyncio.get_running_loop()
tts_result = await self._tts_client.speak(text)
self._set_narrator_state(NarratorState.SPEAKING)
self._audio_player.play(
tts_result.audio_data,
AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe(
self._set_narrator_state, NarratorState.IDLE
),
)
except Exception:
logger.warning("TTS speak failed", exc_info=True)
self._set_narrator_state(NarratorState.IDLE)
def _cancel_speak(self) -> None:
if self._cancel_summary is not None:
self._cancel_summary()
self._cancel_summary = None
if self._speak_task is not None and not self._speak_task.done():
self._speak_task.cancel()
self._speak_task = None
self._audio_player.stop()
self._set_narrator_state(NarratorState.IDLE)
def _set_narrator_state(self, state: NarratorState) -> None:
self.query_one(NarratorStatus).state = state
def _make_tts_client(self) -> TTSClientPort | None:
if not self.config.narrator_enabled:
return None
try:
model = self.config.get_active_tts_model()
provider = self.config.get_tts_provider_for_model(model)
return make_tts_client(provider, model)
except (ValueError, KeyError) as exc:
logger.error("Failed to initialize TTS client", exc_info=exc)
return None
def _sync_turn_summary(self) -> None:
self._cancel_speak()
task = asyncio.create_task(self._turn_summary.close())
self._turn_summary_close_tasks.add(task)
task.add_done_callback(self._turn_summary_close_tasks.discard)
self._turn_summary = self._make_turn_summary()
old_tts = self._tts_client
self._tts_client = self._make_tts_client()
if old_tts is not None:
close_task = asyncio.create_task(old_tts.close())
self._turn_summary_close_tasks.add(close_task)
close_task.add_done_callback(self._turn_summary_close_tasks.discard)
def run_textual_ui(
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
agent_loop: AgentLoop, startup: StartupOptions | None = None
) -> None:
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
app = VibeApp(
agent_loop=agent_loop,
initial_prompt=initial_prompt,
teleport_on_start=teleport_on_start,
startup=startup,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
)
session_id = app.run()
_print_session_resume_message(session_id)
print_session_resume_message(session_id, agent_loop.stats)

View file

@ -160,7 +160,7 @@ Markdown {
color: ansi_default;
.code_inline {
color: ansi_yellow;
color: ansi_green;
background: transparent;
text-style: bold;
}
@ -625,7 +625,8 @@ StatusMessage {
.loading-hint {
width: auto;
height: auto;
color: $foreground;
color: ansi_bright_black;
}
.history-load-more-message {
@ -655,7 +656,7 @@ StatusMessage {
}
#config-app {
#config-app, #voice-app {
width: 100%;
height: auto;
background: transparent;
@ -664,7 +665,7 @@ StatusMessage {
margin: 0;
}
#config-content {
#config-content, #voice-content {
width: 100%;
height: auto;
}
@ -675,41 +676,14 @@ StatusMessage {
color: ansi_blue;
}
.settings-option {
height: auto;
color: ansi_default;
#config-options {
width: 100%;
max-height: 50vh;
border: none;
}
.settings-cursor-selected {
color: ansi_blue;
text-style: bold;
}
.settings-label-selected {
color: ansi_default;
text-style: bold;
}
.settings-value-toggle-on-selected {
color: ansi_green;
text-style: bold;
}
.settings-value-toggle-on-unselected {
color: ansi_green;
}
.settings-value-toggle-off {
color: ansi_bright_black;
}
.settings-value-cycle-selected {
color: ansi_blue;
text-style: bold;
}
.settings-value-cycle-unselected {
color: ansi_blue;
#config-options:focus {
border: none;
}
.settings-help {
@ -953,6 +927,14 @@ ContextProgress {
color: ansi_bright_black;
}
NarratorStatus {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
}
#banner-container {
align: left middle;
padding: 0 1 0 0;
@ -1083,3 +1065,54 @@ ContextProgress {
color: ansi_bright_black;
margin-top: 1;
}
#modelpicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#modelpicker-content {
width: 100%;
height: auto;
}
.modelpicker-title {
height: auto;
text-style: bold;
color: ansi_blue;
}
#modelpicker-options {
width: 100%;
max-height: 50vh;
text-wrap: nowrap;
text-overflow: ellipsis;
border: none;
}
#modelpicker-options:focus {
border: none;
}
.modelpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
margin-top: 1;
}
FeedbackBar {
width: auto;
height: auto;
margin-left: 1;
}
#feedback-text {
width: auto;
height: auto;
color: ansi_default;
}

View file

@ -0,0 +1,11 @@
from __future__ import annotations
from enum import StrEnum
class MistralColors(StrEnum):
RED = "#E10500"
ORANGE_DARK = "#FA500F"
ORANGE = "#FF8205"
ORANGE_LIGHT = "#FFAF00"
YELLOW = "#FFD800"

View file

@ -6,6 +6,8 @@ import shlex
import subprocess
import tempfile
from vibe.core.utils.io import read_safe
class ExternalEditor:
"""Handles opening an external editor to edit prompt content."""
@ -24,7 +26,7 @@ class ExternalEditor:
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = Path(filepath).read_text().rstrip()
content = read_safe(Path(filepath)).rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return

View file

@ -9,6 +9,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import (
AgentProfileChangedEvent,
AssistantEvent,
BaseEvent,
CompactEndEvent,
@ -27,10 +28,14 @@ if TYPE_CHECKING:
class EventHandler:
def __init__(
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
self,
mount_callback: Callable,
get_tools_collapsed: Callable[[], bool],
on_profile_changed: Callable[[], None] | None = None,
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.on_profile_changed = on_profile_changed
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
@ -62,6 +67,9 @@ class EventHandler:
case CompactEndEvent():
await self.finalize_streaming()
await self._handle_compact_end(event)
case AgentProfileChangedEvent():
if self.on_profile_changed:
self.on_profile_changed()
case UserMessageEvent():
await self.finalize_streaming()
case _:

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from rich import print as rprint
from vibe.core.types import AgentStats
def format_session_usage(stats: AgentStats) -> str:
return (
"Total tokens used this session: "
f"input={stats.session_prompt_tokens:,} "
f"output={stats.session_completion_tokens:,} "
f"(total={stats.session_total_llm_tokens:,})"
)
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
if not session_id:
return
print()
print(format_session_usage(stats))
print()
rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]")
rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]")

View file

@ -13,6 +13,7 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
from vibe.core.config import VibeConfig
from vibe.core.tools.permissions import RequiredPermission
class ApprovalApp(Container):
@ -38,12 +39,15 @@ class ApprovalApp(Container):
class ApprovalGrantedAlwaysTool(Message):
def __init__(
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
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.save_permanently = save_permanently
self.required_permissions = required_permissions
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
@ -52,12 +56,17 @@ class ApprovalApp(Container):
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
self,
tool_name: str,
tool_args: BaseModel,
config: VibeConfig,
required_permissions: list[RequiredPermission] | None = None,
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
self.tool_args = tool_args
self.config = config
self.required_permissions = required_permissions or []
self.selected_option = 0
self.content_container: Vertical | None = None
self.title_widget: Static | None = None
@ -104,9 +113,15 @@ 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"),
(f"Yes and always allow {self.tool_name} for this session", "yes"),
(always_text, "yes"),
("No and tell the agent what to do instead", "no"),
]
@ -178,7 +193,7 @@ class ApprovalApp(Container):
self.ApprovalGrantedAlwaysTool(
tool_name=self.tool_name,
tool_args=self.tool_args,
save_permanently=False,
required_permissions=self.required_permissions,
)
)
case 2:

View file

@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
self.post_message(self.HistoryNext())
return True
class FeedbackKeyPressed(Message):
def __init__(self, rating: int) -> None:
self.rating = rating
super().__init__()
class NonFeedbackKeyPressed(Message):
pass
feedback_active: bool = False
async def _handle_voice_key(self, event: events.Key) -> bool:
if not self._voice_manager:
return False
@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
self._mark_cursor_moved_if_needed()
if self.feedback_active:
if event.character in {"1", "2", "3"}:
event.prevent_default()
event.stop()
self.post_message(self.FeedbackKeyPressed(int(event.character)))
return
if event.character is not None:
self.post_message(self.NonFeedbackKeyPressed())
manager = self._completion_manager
if manager:
match manager.on_key(

View file

@ -1,13 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, TypedDict
from typing import TYPE_CHECKING, ClassVar
from textual import events
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.events import DescendantBlur
from textual.message import Message
from textual.widgets import Static
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@ -15,22 +17,13 @@ if TYPE_CHECKING:
from vibe.core.config import VibeConfig
class SettingDefinition(TypedDict):
key: str
label: str
type: str
options: list[str]
class ConfigApp(Container):
can_focus = True
can_focus_children = False
"""Settings panel with navigatable option picker."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("space", "toggle_setting", "Toggle", show=False),
Binding("enter", "cycle", "Next", show=False),
Binding("escape", "close", "Close", show=False)
]
class SettingChanged(Message):
@ -44,122 +37,92 @@ class ConfigApp(Container):
super().__init__()
self.changes = changes
class OpenModelPicker(Message):
pass
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="config-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
"label": "Model",
"type": "cycle",
"options": [m.alias for m in self.config.models],
},
{
"key": "autocopy_to_clipboard",
"label": "Auto-copy",
"type": "cycle",
"options": ["On", "Off"],
},
{
"key": "file_watcher_for_autocomplete",
"label": "Autocomplete watcher (may delay first autocompletion)",
"type": "cycle",
"options": ["On", "Off"],
},
self._toggle_settings: list[tuple[str, str]] = [
("autocopy_to_clipboard", "Auto-copy"),
(
"file_watcher_for_autocomplete",
"Autocomplete watcher (may delay first autocompletion)",
),
]
self.title_widget: Static | None = None
self.setting_widgets: list[Static] = []
self.help_widget: Static | None = None
def _get_current_model(self) -> str:
return str(getattr(self.config, "active_model", ""))
def compose(self) -> ComposeResult:
with Vertical(id="config-content"):
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
yield self.title_widget
yield NoMarkupStatic("")
for _ in self.settings:
widget = NoMarkupStatic("", classes="settings-option")
self.setting_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
)
yield self.help_widget
def on_mount(self) -> None:
self._update_display()
self.focus()
def _get_display_value(self, setting: SettingDefinition) -> str:
key = setting["key"]
def _get_toggle_value(self, key: str) -> str:
if key in self.changes:
return self.changes[key]
raw_value = getattr(self.config, key, "")
if isinstance(raw_value, bool):
return "On" if raw_value else "Off"
return str(raw_value)
raw = getattr(self.config, key, False)
if isinstance(raw, bool):
return "On" if raw else "Off"
return str(raw)
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
):
is_selected = i == self.selected_index
cursor = " " if is_selected else " "
def _model_prompt(self) -> Text:
text = Text(no_wrap=True)
text.append("Model: ")
text.append(self._get_current_model(), style="bold")
return text
label: str = setting["label"]
value: str = self._get_display_value(setting)
def _toggle_prompt(self, key: str, label: str) -> Text:
value = self._get_toggle_value(key)
text = Text(no_wrap=True)
text.append(f"{label}: ")
if value == "On":
text.append("On", style="green bold")
else:
text.append("Off", style="dim")
return text
text = f"{cursor}{label}: {value}"
def compose(self) -> ComposeResult:
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
for key, label in self._toggle_settings:
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
widget.update(text)
with Vertical(id="config-content"):
yield NoMarkupStatic("Settings", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(*options, id="config-options")
yield NoMarkupStatic("")
yield NoMarkupStatic(
"↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help"
)
widget.remove_class("settings-cursor-selected")
widget.remove_class("settings-value-cycle-selected")
widget.remove_class("settings-value-cycle-unselected")
def on_mount(self) -> None:
self.query_one(OptionList).focus()
if is_selected:
widget.add_class("settings-value-cycle-selected")
else:
widget.add_class("settings-value-cycle-unselected")
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
def action_move_up(self) -> None:
self.selected_index = (self.selected_index - 1) % len(self.settings)
self._update_display()
def _refresh_options(self) -> None:
option_list = self.query_one(OptionList)
option_list.replace_option_prompt("action:active_model", self._model_prompt())
for key, label in self._toggle_settings:
option_list.replace_option_prompt(
f"toggle:{key}", self._toggle_prompt(key, label)
)
def action_move_down(self) -> None:
self.selected_index = (self.selected_index + 1) % len(self.settings)
self._update_display()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id
if not option_id:
return
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self._get_display_value(setting)
if option_id == "action:active_model":
self.post_message(self.OpenModelPicker())
return
options: list[str] = setting["options"]
new_value = ""
try:
current_idx = options.index(current)
next_idx = (current_idx + 1) % len(options)
new_value = options[next_idx]
except (ValueError, IndexError):
new_value = options[0] if options else current
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._update_display()
def action_cycle(self) -> None:
self.action_toggle_setting()
if option_id.startswith("toggle:"):
key = option_id.removeprefix("toggle:")
current = self._get_toggle_value(key)
new_value = "Off" if current == "On" else "On"
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._refresh_options()
def _convert_changes_for_save(self) -> dict[str, str | bool]:
result: dict[str, str | bool] = {}
@ -172,6 +135,3 @@ class ConfigApp(Container):
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -0,0 +1,69 @@
from __future__ import annotations
import random
from rich.text import Text
from textual.app import ComposeResult
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
FEEDBACK_PROBABILITY = 0.02
THANK_YOU_DURATION = 2.0
class FeedbackBar(Widget):
class FeedbackGiven(Message):
def __init__(self, rating: int) -> None:
super().__init__()
self.rating = rating
@staticmethod
def _prompt_text() -> Text:
text = Text()
text.append("How is Vibe doing so far? ")
text.append("1", style="blue")
text.append(": good ")
text.append("2", style="blue")
text.append(": fine ")
text.append("3", style="blue")
text.append(": bad")
return text
def compose(self) -> ComposeResult:
yield Static(self._prompt_text(), id="feedback-text")
def on_mount(self) -> None:
self.display = False
def maybe_show(self) -> None:
if self.display:
return
if random.random() <= FEEDBACK_PROBABILITY:
self._set_active(True)
def hide(self) -> None:
if self.display:
self._set_active(False)
def handle_feedback_key(self, rating: int) -> None:
try:
self.app.query_one(ChatTextArea).feedback_active = False
except Exception:
pass
self.query_one("#feedback-text", Static).update(
Text("Thank you for your feedback!")
)
self.post_message(self.FeedbackGiven(rating))
self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False))
def _set_active(self, active: bool) -> None:
if active:
self.query_one("#feedback-text", Static).update(self._prompt_text())
self.display = active
try:
self.app.query_one(ChatTextArea).feedback_active = active
except Exception:
pass

View file

@ -11,6 +11,7 @@ from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.constants import MistralColors
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
@ -28,7 +29,13 @@ def _format_elapsed(seconds: int) -> str:
class LoadingWidget(SpinnerMixin, Static):
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
TARGET_COLORS = (
MistralColors.YELLOW,
MistralColors.ORANGE_LIGHT,
MistralColors.ORANGE,
MistralColors.ORANGE_DARK,
MistralColors.RED,
)
SPINNER_TYPE = SpinnerType.SNAKE
EASTER_EGGS: ClassVar[list[str]] = [

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any, ClassVar
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
def _build_option_text(alias: str, is_current: bool) -> Text:
text = Text(no_wrap=True)
marker = " " if is_current else " "
style = "bold" if is_current else ""
text.append(marker, style="green" if is_current else "")
text.append(alias, style=style)
return text
class ModelPickerApp(Container):
"""Model picker bottom app for selecting the active model."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False)
]
class ModelSelected(Message):
def __init__(self, alias: str) -> None:
self.alias = alias
super().__init__()
class Cancelled(Message):
pass
def __init__(
self, model_aliases: list[str], current_model: str, **kwargs: Any
) -> None:
super().__init__(id="modelpicker-app", **kwargs)
self._model_aliases = model_aliases
self._current_model = current_model
def compose(self) -> ComposeResult:
options = [
Option(_build_option_text(alias, alias == self._current_model), id=alias)
for alias in self._model_aliases
]
with Vertical(id="modelpicker-content"):
yield NoMarkupStatic("Select Model", classes="modelpicker-title")
yield OptionList(*options, id="modelpicker-options")
yield NoMarkupStatic(
"↑↓ Navigate Enter Select Esc Cancel", classes="modelpicker-help"
)
def on_mount(self) -> None:
option_list = self.query_one(OptionList)
# Pre-select the current model
for i, alias in enumerate(self._model_aliases):
if alias == self._current_model:
option_list.highlighted = i
break
option_list.focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option.id:
self.post_message(self.ModelSelected(event.option.id))
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import Any
from textual.reactive import reactive
from textual.timer import Timer
from textual.widgets import Static
SHRINK_FRAMES = "█▇▆▅▄▃▂▁"
BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"]
ANIMATION_INTERVAL = 0.15
class NarratorState(StrEnum):
IDLE = auto()
SUMMARIZING = auto()
SPEAKING = auto()
class NarratorStatus(Static):
state = reactive(NarratorState.IDLE)
def __init__(self, **kwargs: Any) -> None:
super().__init__("", **kwargs)
self._timer: Timer | None = None
self._frame: int = 0
def watch_state(self, new_state: NarratorState) -> None:
self._stop_timer()
match new_state:
case NarratorState.IDLE:
self.update("")
case NarratorState.SUMMARIZING | NarratorState.SPEAKING:
self._frame = 0
self._tick()
self._timer = self.set_interval(ANIMATION_INTERVAL, self._tick)
def _tick(self) -> None:
match self.state:
case NarratorState.SUMMARIZING:
char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)]
self.update(
f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]"
)
case NarratorState.SPEAKING:
bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)]
self.update(
f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]"
)
self._frame += 1
def _stop_timer(self) -> None:
if self._timer is not None:
self._timer.stop()
self._timer = None

View file

@ -0,0 +1,173 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, TypedDict
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
class SettingDefinition(TypedDict):
key: str
label: str
type: str
options: list[str]
class VoiceApp(Container):
can_focus = True
can_focus_children = False
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("space", "toggle_setting", "Toggle", show=False),
Binding("enter", "cycle", "Next", show=False),
]
class SettingChanged(Message):
def __init__(self, key: str, value: str) -> None:
super().__init__()
self.key = key
self.value = value
class ConfigClosed(Message):
def __init__(self, changes: dict[str, str | bool]) -> None:
super().__init__()
self.changes = changes
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="voice-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
self.settings: list[SettingDefinition] = [
{
"key": "voice_mode_enabled",
"label": "Voice mode",
"type": "cycle",
"options": ["On", "Off"],
},
{
"key": "narrator_enabled",
"label": "Narrator (experimental)",
"type": "cycle",
"options": ["On", "Off"],
},
]
self.title_widget: Static | None = None
self.setting_widgets: list[Static] = []
self.help_widget: Static | None = None
def compose(self) -> ComposeResult:
with Vertical(id="voice-content"):
self.title_widget = NoMarkupStatic(
"Voice Settings", classes="settings-title"
)
yield self.title_widget
yield NoMarkupStatic("")
for _ in self.settings:
widget = NoMarkupStatic("", classes="settings-option")
self.setting_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
)
yield self.help_widget
def on_mount(self) -> None:
self._update_display()
self.focus()
def _get_display_value(self, setting: SettingDefinition) -> str:
key = setting["key"]
if key in self.changes:
return self.changes[key]
raw_value = getattr(self.config, key, "")
if isinstance(raw_value, bool):
return "On" if raw_value else "Off"
return str(raw_value)
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
):
is_selected = i == self.selected_index
cursor = " " if is_selected else " "
label: str = setting["label"]
value: str = self._get_display_value(setting)
text = f"{cursor}{label}: {value}"
widget.update(text)
widget.remove_class("settings-cursor-selected")
widget.remove_class("settings-value-cycle-selected")
widget.remove_class("settings-value-cycle-unselected")
if is_selected:
widget.add_class("settings-value-cycle-selected")
else:
widget.add_class("settings-value-cycle-unselected")
def action_move_up(self) -> None:
self.selected_index = (self.selected_index - 1) % len(self.settings)
self._update_display()
def action_move_down(self) -> None:
self.selected_index = (self.selected_index + 1) % len(self.settings)
self._update_display()
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self._get_display_value(setting)
options: list[str] = setting["options"]
new_value = ""
try:
current_idx = options.index(current)
next_idx = (current_idx + 1) % len(options)
new_value = options[next_idx]
except (ValueError, IndexError):
new_value = options[0] if options else current
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._update_display()
def action_cycle(self) -> None:
self.action_toggle_setting()
def _convert_changes_for_save(self) -> dict[str, str | bool]:
result: dict[str, str | bool] = {}
for key, value in self.changes.items():
if value in {"On", "Off"}:
result[key] = value == "On"
else:
result[key] = value
return result
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -14,7 +14,7 @@ def patch_vscode_space(event: events.Key) -> None:
silently drop the keystroke because there is no printable character.
Assigning ``event.character = " "`` restores normal behaviour.
"""
if event.key == "space" and event.character is None:
if event.key in {"space", "shift+space"} and event.character is None:
event.character = " "

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from vibe.cli.turn_summary.noop import NoopTurnSummary
from vibe.cli.turn_summary.port import (
TurnSummaryData,
TurnSummaryPort,
TurnSummaryResult,
)
from vibe.cli.turn_summary.tracker import TurnSummaryTracker
from vibe.cli.turn_summary.utils import NARRATOR_MODEL, create_narrator_backend
__all__ = [
"NARRATOR_MODEL",
"NoopTurnSummary",
"TurnSummaryData",
"TurnSummaryPort",
"TurnSummaryResult",
"TurnSummaryTracker",
"create_narrator_backend",
]

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from collections.abc import Callable
from vibe.cli.turn_summary.port import TurnSummaryPort
from vibe.core.types import BaseEvent
class NoopTurnSummary(TurnSummaryPort):
@property
def generation(self) -> int:
return 0
def start_turn(self, user_message: str) -> None:
pass
def track(self, event: BaseEvent) -> None:
pass
def set_error(self, message: str) -> None:
pass
def cancel_turn(self) -> None:
pass
def end_turn(self) -> Callable[[], bool] | None:
return None
async def close(self) -> None:
pass

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from pydantic import BaseModel, Field
from vibe.core.types import BaseEvent
class TurnSummaryData(BaseModel):
user_message: str
assistant_fragments: list[str] = Field(default_factory=list)
error: str | None = None
class TurnSummaryResult(BaseModel):
generation: int
summary: str | None
class TurnSummaryPort(ABC):
@property
@abstractmethod
def generation(self) -> int: ...
@abstractmethod
def start_turn(self, user_message: str) -> None: ...
@abstractmethod
def track(self, event: BaseEvent) -> None: ...
@abstractmethod
def set_error(self, message: str) -> None: ...
@abstractmethod
def cancel_turn(self) -> None: ...
@abstractmethod
def end_turn(self) -> Callable[[], bool] | None: ...
@abstractmethod
async def close(self) -> None: ...

View file

@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
from vibe.cli.turn_summary.port import (
TurnSummaryData,
TurnSummaryPort,
TurnSummaryResult,
)
from vibe.core.config import ModelConfig
from vibe.core.llm.types import BackendLike
from vibe.core.logger import logger
from vibe.core.prompts import UtilityPrompt
from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role
class TurnSummaryTracker(TurnSummaryPort):
def __init__(
self,
backend: BackendLike,
model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None],
max_tokens: int = 512,
) -> None:
self._backend = backend
self._model = model
self._on_summary = on_summary
self._max_tokens = max_tokens
self._tasks: set[asyncio.Task[Any]] = set()
self._data: TurnSummaryData | None = None
self._generation: int = 0
@property
def generation(self) -> int:
return self._generation
def start_turn(self, user_message: str) -> None:
self._generation += 1
self._data = TurnSummaryData(user_message=user_message)
def track(self, event: BaseEvent) -> None:
if self._data is None:
return
match event:
case AssistantEvent(content=c) if c:
self._data.assistant_fragments.append(c)
def set_error(self, message: str) -> None:
if self._data is not None:
self._data.error = message
def cancel_turn(self) -> None:
self._data = None
def end_turn(self) -> Callable[[], bool] | None:
if self._data is None:
return None
gen = self._generation
task = asyncio.create_task(self._generate_summary(self._data, gen))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
self._data = None
return task.cancel
async def close(self) -> None:
for task in self._tasks:
task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None:
try:
prompt_text = UtilityPrompt.TURN_SUMMARY.read()
sections: list[str] = []
sections.append(f"## User Request\n{data.user_message}")
full_text = "".join(data.assistant_fragments)
if full_text:
sections.append(f"## Assistant Response\n{full_text}")
if data.error:
sections.append(f"## Error\n{data.error}")
extraction_text = "\n\n".join(sections)
summary_messages = [
LLMMessage(role=Role.system, content=prompt_text),
LLMMessage(role=Role.user, content=extraction_text),
]
result = await self._backend.complete(
model=self._model,
messages=summary_messages,
temperature=0.0,
tools=None,
tool_choice=None,
max_tokens=self._max_tokens,
extra_headers={},
metadata={},
)
summary = result.message.content or ""
self._on_summary(TurnSummaryResult(generation=gen, summary=summary))
except Exception:
logger.warning("Turn summary generation failed", exc_info=True)
self._on_summary(TurnSummaryResult(generation=gen, summary=None))

View file

@ -0,0 +1,30 @@
from __future__ import annotations
import os
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.types import BackendLike
NARRATOR_MODEL = ModelConfig(
name="mistral-vibe-cli-fast",
provider="mistral",
alias="mistral-small",
input_price=0.1,
output_price=0.3,
)
def create_narrator_backend(
config: VibeConfig,
) -> tuple[BackendLike, ModelConfig] | None:
try:
provider = config.get_provider_for_model(NARRATOR_MODEL)
except ValueError:
return None
if provider.api_key_env_var and not os.getenv(provider.api_key_env_var):
return None
backend = BACKEND_FACTORY[provider.backend](
provider=provider, timeout=config.api_timeout
)
return backend, NARRATOR_MODEL

View file

@ -7,6 +7,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.core.utils.io import read_safe
async def should_show_whats_new(
@ -23,7 +24,7 @@ def load_whats_new_content() -> str | None:
if not whats_new_file.exists():
return None
try:
content = whats_new_file.read_text(encoding="utf-8").strip()
content = read_safe(whats_new_file).strip()
return content if content else None
except OSError:
return None

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass, field
import time
@dataclass
class TranscriptionTrackingState:
recording_id: str = ""
start_time: float = field(default_factory=time.monotonic)
accumulated_transcript_length: int = 0
last_recording_duration_ms: float | None = None
def reset(self) -> None:
self.recording_id = ""
self.start_time = time.monotonic()
self.accumulated_transcript_length = 0
self.last_recording_duration_ms = None
def set_recording_id(self, recording_id: str) -> None:
self.recording_id = recording_id
def record_text(self, text: str) -> None:
self.accumulated_transcript_length += len(text)
def elapsed_ms(self) -> float:
return (time.monotonic() - self.start_time) * 1000
def set_recording_duration(self, duration_s: float) -> None:
self.last_recording_duration_ms = duration_s * 1000

View file

@ -1,15 +1,14 @@
from __future__ import annotations
from asyncio import CancelledError, Task, create_task, wait_for
from collections.abc import Callable
from asyncio import CancelledError, create_task, wait_for
from typing import TYPE_CHECKING
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
TranscribeState,
VoiceManagerListener,
VoiceToggleResult,
)
from vibe.core.audio_recorder import AudioRecorderPort
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
@ -19,13 +18,21 @@ from vibe.core.audio_recorder.audio_recorder_port import (
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.transcribe.transcribe_client_port import (
TranscribeClientPort,
TranscribeDone,
TranscribeError,
TranscribeSessionCreated,
TranscribeTextDelta,
)
if TYPE_CHECKING:
from asyncio import Task
from collections.abc import Callable
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerListener
from vibe.core.audio_recorder import AudioRecorderPort
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
@ -35,13 +42,16 @@ class VoiceManager:
config_getter: Callable[[], VibeConfig],
audio_recorder: AudioRecorderPort,
transcribe_client: TranscribeClientPort | None,
telemetry_client: TelemetryClient | None = None,
) -> None:
self._config_getter = config_getter
self._audio_recorder = audio_recorder
self._transcribe_client = transcribe_client
self._telemetry_client = telemetry_client
self._transcribe_state = TranscribeState.IDLE
self._transcribe_task: Task[None] | None = None
self._listeners: list[VoiceManagerListener] = []
self._tracking = TranscriptionTrackingState()
@property
def is_enabled(self) -> bool:
@ -91,6 +101,7 @@ class VoiceManager:
except NoAudioInputDeviceError:
raise RecordingStartError("No audio input device found")
self._tracking.reset()
self._set_state(TranscribeState.RECORDING)
self._transcribe_task = create_task(self._run_transcription())
@ -101,7 +112,8 @@ class VoiceManager:
if should_flush_queue:
self._set_state(TranscribeState.FLUSHING)
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
recording = self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
self._tracking.set_recording_duration(recording.duration)
if self._transcribe_task is not None:
try:
@ -111,6 +123,7 @@ class VoiceManager:
except TimeoutError:
logger.warning("Transcription task timed out, cancelling")
self._transcribe_task.cancel()
self._on_audio_transcription_error("Transcription timed out")
except CancelledError:
pass
self._transcribe_task = None
@ -129,6 +142,7 @@ class VoiceManager:
self._transcribe_task = None
self._set_state(TranscribeState.IDLE)
self._on_audio_transcription_cancel()
def add_listener(self, listener: VoiceManagerListener) -> None:
if listener not in self._listeners:
@ -150,6 +164,7 @@ class VoiceManager:
async for event in self._transcribe_client.transcribe(audio_stream):
match event:
case TranscribeTextDelta(text=text):
self._tracking.record_text(text)
for listener in self._listeners:
try:
listener.on_transcribe_text(text)
@ -158,22 +173,80 @@ class VoiceManager:
"Listener raised during transcribe text",
exc_info=True,
)
case TranscribeDone():
pass
case TranscribeError(message=msg):
raise RuntimeError(msg)
case TranscribeSessionCreated():
case TranscribeSessionCreated(request_id=request_id):
self._tracking.set_recording_id(request_id)
self._on_audio_transcription_start()
case TranscribeDone():
pass
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
self._on_audio_transcription_done()
except CancelledError:
raise
except Exception as exc:
logger.error("Transcription failed", exc_info=exc)
self._audio_recorder.cancel()
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
self._on_audio_transcription_error(str(exc))
def _on_audio_transcription_start(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.start",
{"recording_id": self._tracking.recording_id},
)
def _on_audio_transcription_cancel(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.cancel_recording",
{
"recording_id": self._tracking.recording_id,
"recording_duration_ms": self._tracking.elapsed_ms(),
},
)
def _on_audio_transcription_done(self) -> None:
if not self._telemetry_client:
return
transcription_duration_ms = self._tracking.elapsed_ms()
recording_duration_ms = (
self._tracking.last_recording_duration_ms
if self._tracking.last_recording_duration_ms is not None
else transcription_duration_ms
)
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.done",
{
"recording_id": self._tracking.recording_id,
"transcript_length": self._tracking.accumulated_transcript_length,
"transcription_duration_ms": transcription_duration_ms,
"recording_duration_ms": recording_duration_ms,
},
)
def _on_audio_transcription_error(self, error_message: str) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.error",
{
"recording_id": self._tracking.recording_id,
"error_message": error_message,
"transcription_duration_ms": self._tracking.elapsed_ms(),
"recording_duration_ms": self._tracking.last_recording_duration_ms,
},
)
def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state:
return

View file

@ -5,19 +5,19 @@ from collections.abc import AsyncGenerator, Callable, Generator
import contextlib
from enum import StrEnum, auto
from http import HTTPStatus
import json
from pathlib import Path
from threading import Thread
import time
from typing import TYPE_CHECKING, Any, Literal
from uuid import uuid4
from opentelemetry import trace
from pydantic import BaseModel
from vibe.cli.terminal_setup import detect_terminal
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import (
@ -52,7 +52,6 @@ from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
InvokeContext,
ToolError,
ToolPermission,
@ -61,8 +60,16 @@ from vibe.core.tools.base import (
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.permissions import (
ApprovedRule,
PermissionContext,
RequiredPermission,
)
from vibe.core.tools.utils import wildcard_match
from vibe.core.tracing import agent_span, set_tool_result, tool_span
from vibe.core.trusted_folders import has_agents_md_file
from vibe.core.types import (
AgentProfileChangedEvent,
AgentStats,
ApprovalCallback,
ApprovalResponse,
@ -198,6 +205,9 @@ class AgentLoop:
self.entrypoint_metadata = entrypoint_metadata
self.session_id = str(uuid4())
self._current_user_message_id: str | None = None
self._is_user_prompt_call: bool = False
self._session_rules: list[ApprovedRule] = []
self.telemetry_client = TelemetryClient(
config_getter=lambda: self.config, session_id_getter=lambda: self.session_id
@ -238,11 +248,43 @@ class AgentLoop:
})
if tool_name not in self.config.tools:
self.config.tools[tool_name] = BaseToolConfig()
self.config.tools[tool_name] = {}
self.config.tools[tool_name].permission = permission
self.config.tools[tool_name]["permission"] = permission.value
self.tool_manager.invalidate_tool(tool_name)
def add_session_rule(self, rule: ApprovedRule) -> None:
self._session_rules.append(rule)
def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool:
return any(
rule.tool_name == tool_name
and rule.scope == rp.scope
and wildcard_match(rp.invocation_pattern, rule.session_pattern)
for rule in self._session_rules
)
def approve_always(
self,
tool_name: str,
required_permissions: list[RequiredPermission] | None,
save_permanently: bool = False,
) -> None:
"""Handle 'Allow Always' approval: add session rules or set tool-level permission."""
if required_permissions:
for rp in required_permissions:
self.add_session_rule(
ApprovedRule(
tool_name=tool_name,
scope=rp.scope,
session_pattern=rp.session_pattern,
)
)
else:
self.set_tool_permission(
tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently
)
def refresh_config(self) -> None:
self._base_config = VibeConfig.load()
self.agent_manager.invalidate_config()
@ -253,6 +295,14 @@ class AgentLoop:
if self.entrypoint_metadata
else "unknown"
)
client_name = (
self.entrypoint_metadata.client_name if self.entrypoint_metadata else None
)
client_version = (
self.entrypoint_metadata.client_version
if self.entrypoint_metadata
else None
)
has_agents_md = has_agents_md_file(Path.cwd())
nb_skills = len(self.skill_manager.available_skills)
nb_mcp_servers = len(self.config.mcp_servers)
@ -268,6 +318,8 @@ class AgentLoop:
nb_mcp_servers=nb_mcp_servers,
nb_models=nb_models,
entrypoint=entrypoint,
client_name=client_name,
client_version=client_version,
terminal_emulator=terminal_emulator,
)
@ -288,8 +340,13 @@ class AgentLoop:
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
self._clean_message_history()
async for event in self._conversation_loop(msg):
yield event
try:
model_name = self.config.get_active_model().name
except ValueError:
model_name = None
async with agent_span(model=model_name, session_id=self.session_id):
async for event in self._conversation_loop(msg):
yield event
@property
def teleport_service(self) -> TeleportService:
@ -429,20 +486,22 @@ class AgentLoop:
def _build_metadata(self) -> dict[str, str]:
base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {}
return base | {"session_id": self.session_id}
metadata = base | {
"session_id": self.session_id,
"is_user_prompt": "true" if self._is_user_prompt_call else "false",
"call_type": (
"main_call" if self._is_user_prompt_call else "secondary_call"
),
}
if self._current_user_message_id is not None:
metadata["message_id"] = self._current_user_message_id
return metadata
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
headers: dict[str, str] = {
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
}
if (
provider.backend == Backend.MISTRAL
and self._current_user_message_id is not None
):
headers["metadata"] = json.dumps({
"message_id": self._current_user_message_id
})
return headers
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
@ -458,7 +517,9 @@ class AgentLoop:
try:
should_break_loop = False
first_llm_turn = True
while not should_break_loop:
self._is_user_prompt_call = False
result = await self.middleware_pipeline.run_before_turn(
self._get_context()
)
@ -470,11 +531,15 @@ class AgentLoop:
self.stats.steps += 1
user_cancelled = False
if first_llm_turn:
self._is_user_prompt_call = True
first_llm_turn = False
async for event in self._perform_llm_turn():
if is_user_cancellation_event(event):
user_cancelled = True
yield event
await self._save_messages()
self._is_user_prompt_call = False
last_message = self.messages[-1]
should_break_loop = last_message.role != Role.tool
@ -502,8 +567,11 @@ class AgentLoop:
if not resolved.tool_calls and not resolved.failed_calls:
return
profile_before = self.agent_profile.name
async for event in self._handle_tool_calls(resolved):
yield event
if self.agent_profile.name != profile_before:
yield AgentProfileChangedEvent(agent_name=self.agent_profile.name)
def _build_tool_call_events(
self, tool_calls: list[ToolCall] | None, emitted_ids: set[str]
@ -578,12 +646,23 @@ class AgentLoop:
async def _process_one_tool_call(
self, tool_call: ResolvedToolCall
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
async with tool_span(
tool_name=tool_call.tool_name,
call_id=tool_call.call_id,
arguments=tool_call.validated_args.model_dump_json(),
) as span:
async for event in self._execute_tool_call(span, tool_call):
yield event
async def _execute_tool_call(
self, span: trace.Span, tool_call: ResolvedToolCall
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
try:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
yield self._tool_failure_event(tool_call, error_msg)
yield self._tool_failure_event(tool_call, error_msg, span=span)
return
decision: ToolDecision | None = None
@ -607,7 +686,9 @@ class AgentLoop:
cancelled=f"<{CANCELLATION_TAG}>" in skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
self._handle_tool_response(
tool_call, skip_reason, "skipped", decision, span=span
)
return
self.stats.tool_calls_agreed += 1
@ -625,6 +706,7 @@ class AgentLoop:
sampling_callback=self._sampling_handler,
plan_file_path=self._plan_session.plan_file_path,
switch_agent_callback=self.switch_agent,
skill_manager=self.skill_manager,
),
**tool_call.args_dict,
):
@ -643,7 +725,7 @@ class AgentLoop:
if extra:
text += "\n\n" + extra
self._handle_tool_response(
tool_call, text, "success", decision, result_dict
tool_call, text, "success", decision, result_dict, span=span
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
@ -660,7 +742,9 @@ class AgentLoop:
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
self.stats.tool_calls_failed += 1
yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True)
yield self._tool_failure_event(
tool_call, cancel, decision, cancelled=True, span=span
)
raise
except Exception as exc:
@ -670,7 +754,7 @@ class AgentLoop:
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
yield self._tool_failure_event(tool_call, error_msg, decision)
yield self._tool_failure_event(tool_call, error_msg, decision, span=span)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
@ -751,6 +835,7 @@ class AgentLoop:
status: Literal["success", "failure", "skipped"],
decision: ToolDecision | None = None,
result: dict[str, Any] | None = None,
span: trace.Span | None = None,
) -> None:
self.messages.append(
LLMMessage.model_validate(
@ -758,6 +843,8 @@ class AgentLoop:
)
)
if span is not None:
set_tool_result(span, text)
self.telemetry_client.send_tool_call_finished(
tool_call=tool_call,
agent_profile_name=self.agent_profile.name,
@ -772,9 +859,10 @@ class AgentLoop:
error_msg: str,
decision: ToolDecision | None = None,
cancelled: bool = False,
span: trace.Span | None = None,
) -> ToolResultEvent:
"""Create a ToolResultEvent for a failed tool and record the failure."""
self._handle_tool_response(tool_call, error_msg, "failure", decision)
self._handle_tool_response(tool_call, error_msg, "failure", decision, span=span)
return ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
@ -812,6 +900,9 @@ class AgentLoop:
)
self._update_stats(usage=result.usage, time_seconds=end_time - start_time)
if result.correlation_id:
self.telemetry_client.last_correlation_id = result.correlation_id
processed_message = self.format_handler.process_api_response_message(
result.message
)
@ -848,6 +939,8 @@ class AgentLoop:
max_tokens=max_tokens,
metadata=self._build_metadata(),
):
if chunk.correlation_id:
self.telemetry_client.last_correlation_id = chunk.correlation_id
processed_message = self.format_handler.process_api_response_message(
chunk.message
)
@ -893,12 +986,13 @@ class AgentLoop:
)
tool_name = tool.get_name()
effective = (
tool.resolve_permission(args)
or self.tool_manager.get_tool_config(tool_name).permission
)
ctx = tool.resolve_permission(args)
match effective:
if ctx is None:
config_perm = self.tool_manager.get_tool_config(tool_name).permission
ctx = PermissionContext(permission=config_perm)
match ctx.permission:
case ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
@ -911,10 +1005,26 @@ class AgentLoop:
feedback=f"Tool '{tool_name}' is permanently disabled",
)
case _:
return await self._ask_approval(tool_name, args, tool_call_id)
uncovered = [
rp
for rp in ctx.required_permissions
if not self._is_permission_covered(tool_name, rp)
]
if ctx.required_permissions and not uncovered:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
return await self._ask_approval(
tool_name, args, tool_call_id, uncovered
)
async def _ask_approval(
self, tool_name: str, args: BaseModel, tool_call_id: str
self,
tool_name: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list[RequiredPermission],
) -> ToolDecision:
if not self.approval_callback:
return ToolDecision(
@ -922,7 +1032,9 @@ class AgentLoop:
approval_type=ToolPermission.ASK,
feedback="Tool execution not permitted.",
)
response, feedback = await self.approval_callback(tool_name, args, tool_call_id)
response, feedback = await self.approval_callback(
tool_name, args, tool_call_id, required_permissions
)
match response:
case ApprovalResponse.YES:

View file

@ -57,7 +57,17 @@ class AgentProfile:
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
from vibe.core.config import VibeConfig as VC
merged = _deep_merge(base.model_dump(), self.overrides)
merged = _deep_merge(
base.model_dump(),
{k: v for k, v in self.overrides.items() if k != "base_disabled"},
)
base_disabled = self.overrides.get("base_disabled")
if isinstance(base_disabled, list):
merged["disabled_tools"] = list({
*base_disabled,
*merged.get("disabled_tools", []),
})
return VC.model_validate(merged)
@classmethod
@ -92,6 +102,7 @@ DEFAULT = AgentProfile(
"Default",
"Requires approval for tool executions",
AgentSafety.NEUTRAL,
overrides={"base_disabled": ["exit_plan_mode"]},
)
PLAN = AgentProfile(
BuiltinAgentName.PLAN,
@ -113,10 +124,11 @@ ACCEPT_EDITS = AgentProfile(
"Auto-approves file edits only",
AgentSafety.DESTRUCTIVE,
overrides={
"base_disabled": ["exit_plan_mode"],
"tools": {
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
}
},
},
)
AUTO_APPROVE = AgentProfile(
@ -124,7 +136,7 @@ AUTO_APPROVE = AgentProfile(
"Auto Approve",
"Auto-approves all tool executions",
AgentSafety.YOLO,
overrides={"auto_approve": True},
overrides={"auto_approve": True, "base_disabled": ["exit_plan_mode"]},
)
EXPLORE = AgentProfile(
@ -173,6 +185,7 @@ LEAN = AgentProfile(
"thinking": "off",
},
"tools": {"bash": {"default_timeout": 1200}},
"base_disabled": ["exit_plan_mode"],
},
)

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_player.audio_player_port import (
AlreadyPlayingError,
AudioBackendUnavailableError,
AudioFormat,
AudioPlayerPort,
NoAudioOutputDeviceError,
UnsupportedAudioFormatError,
)
__all__ = [
"AlreadyPlayingError",
"AudioBackendUnavailableError",
"AudioFormat",
"AudioPlayer",
"AudioPlayerPort",
"NoAudioOutputDeviceError",
"UnsupportedAudioFormatError",
]

View file

@ -0,0 +1,130 @@
from __future__ import annotations
from collections.abc import Callable
import threading
from typing import TYPE_CHECKING
from vibe.core.audio_player.audio_player_port import (
AlreadyPlayingError,
AudioBackendUnavailableError,
AudioFormat,
NoAudioOutputDeviceError,
UnsupportedAudioFormatError,
)
from vibe.core.audio_player.utils import decode_wav
from vibe.core.logger import logger
# sounddevice raises OSError on import when no audio driver is available.
try:
import sounddevice as sd
if TYPE_CHECKING:
from sounddevice import CallbackFlags, RawOutputStream
except OSError:
sd = None # type: ignore[assignment]
DEFAULT_BLOCKSIZE = 4096
DTYPE = "int16"
DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
class AudioPlayer:
"""Plays audio through the default output device using sounddevice."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._stream: RawOutputStream | None = None
self._playing: bool = False
self._audio_data: bytes = b""
self._position: int = 0
self._frame_size: int = 0
self._on_finished: Callable[[], object] | None = None
@property
def is_playing(self) -> bool:
return self._playing
def play(
self,
audio_data: bytes,
audio_format: AudioFormat,
*,
on_finished: Callable[[], object] | None = None,
) -> None:
with self._lock:
if self._playing:
raise AlreadyPlayingError("Already playing")
if not sd:
error_message = "sounddevice is not available, audio playback disabled"
logger.error(error_message)
raise AudioBackendUnavailableError(error_message)
self._guard_audio_output()
match audio_format:
case AudioFormat.WAV:
sample_rate, channels, pcm_data = decode_wav(audio_data)
case _:
raise UnsupportedAudioFormatError(
f"Unsupported audio format: {audio_format}"
)
self._audio_data = pcm_data
self._position = 0
self._frame_size = channels * DEFAULT_SAMPLE_WIDTH
self._on_finished = on_finished
self._stream = sd.RawOutputStream(
samplerate=sample_rate,
channels=channels,
dtype=DTYPE,
blocksize=DEFAULT_BLOCKSIZE,
callback=self._audio_callback,
finished_callback=self._on_stream_finished,
)
self._stream.start()
self._playing = True
def stop(self) -> None:
stream = self._stream
if not self._playing or stream is None:
return
stream.close(ignore_errors=True)
def _audio_callback(
self, outdata: memoryview, frames: int, time_info: object, status: CallbackFlags
) -> None:
if not sd:
raise RuntimeError("sounddevice is not available")
if status:
logger.warning(f"Audio playback callback status: {status}")
bytes_needed = frames * self._frame_size
chunk = self._audio_data[self._position : self._position + bytes_needed]
self._position += len(chunk)
if len(chunk) < bytes_needed:
outdata[: len(chunk)] = chunk
outdata[len(chunk) :] = b"\x00" * (bytes_needed - len(chunk))
raise sd.CallbackStop()
else:
outdata[:] = chunk
def _on_stream_finished(self) -> None:
on_finished = None
with self._lock:
self._stream = None
self._playing = False
on_finished = self._on_finished
if on_finished is not None:
on_finished()
@staticmethod
def _guard_audio_output() -> None:
if sd is None:
raise RuntimeError("sounddevice is not available")
try:
sd.query_devices(kind="output")
except Exception as exc:
raise NoAudioOutputDeviceError("No audio output device available") from exc

View file

@ -0,0 +1,40 @@
from __future__ import annotations
from collections.abc import Callable
from enum import StrEnum, auto
from typing import Protocol
class AudioFormat(StrEnum):
WAV = auto()
class AlreadyPlayingError(Exception):
pass
class AudioBackendUnavailableError(Exception):
pass
class NoAudioOutputDeviceError(Exception):
pass
class UnsupportedAudioFormatError(Exception):
pass
class AudioPlayerPort(Protocol):
@property
def is_playing(self) -> bool: ...
def play(
self,
audio_data: bytes,
audio_format: AudioFormat,
*,
on_finished: Callable[[], object] | None = ...,
) -> None: ...
def stop(self) -> None: ...

View file

@ -0,0 +1,12 @@
from __future__ import annotations
import io
import wave
def decode_wav(audio_data: bytes) -> tuple[int, int, bytes]:
with wave.open(io.BytesIO(audio_data), "rb") as wf:
sample_rate = wf.getframerate()
channels = wf.getnchannels()
pcm_data = wf.readframes(wf.getnframes())
return sample_rate, channels, pcm_data

View file

@ -19,6 +19,7 @@ from vibe.core.audio_recorder.audio_recorder_port import (
)
from vibe.core.logger import logger
# sounddevice raises OSError on import when no audio driver is available.
try:
import sounddevice as sd

View file

@ -4,6 +4,8 @@ from dataclasses import dataclass
import fnmatch
from pathlib import Path
from vibe.core.utils.io import read_safe
DEFAULT_IGNORE_PATTERNS: list[tuple[str, bool]] = [
(".git/", True),
("__pycache__/", True),
@ -111,7 +113,7 @@ class IgnoreRules:
gitignore_path = root / ".gitignore"
if gitignore_path.exists():
try:
text = gitignore_path.read_text(encoding="utf-8")
text = read_safe(gitignore_path)
except Exception:
return patterns

View file

@ -6,7 +6,8 @@ from vibe.core.config._settings import (
DEFAULT_PROVIDERS,
DEFAULT_TRANSCRIBE_MODELS,
DEFAULT_TRANSCRIBE_PROVIDERS,
Backend,
DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS,
MCPHttp,
MCPServer,
MCPStdio,
@ -14,6 +15,7 @@ from vibe.core.config._settings import (
MissingAPIKeyError,
MissingPromptFileError,
ModelConfig,
OtelExporterConfig,
ProjectContextConfig,
ProviderConfig,
SessionLoggingConfig,
@ -21,6 +23,9 @@ from vibe.core.config._settings import (
TranscribeClient,
TranscribeModelConfig,
TranscribeProviderConfig,
TTSClient,
TTSModelConfig,
TTSProviderConfig,
VibeConfig,
load_dotenv_values,
)
@ -31,7 +36,8 @@ __all__ = [
"DEFAULT_PROVIDERS",
"DEFAULT_TRANSCRIBE_MODELS",
"DEFAULT_TRANSCRIBE_PROVIDERS",
"Backend",
"DEFAULT_TTS_MODELS",
"DEFAULT_TTS_PROVIDERS",
"MCPHttp",
"MCPServer",
"MCPStdio",
@ -39,9 +45,13 @@ __all__ = [
"MissingAPIKeyError",
"MissingPromptFileError",
"ModelConfig",
"OtelExporterConfig",
"ProjectContextConfig",
"ProviderConfig",
"SessionLoggingConfig",
"TTSClient",
"TTSModelConfig",
"TTSProviderConfig",
"TomlFileSettingsSource",
"TranscribeClient",
"TranscribeModelConfig",

View file

@ -8,9 +8,10 @@ import re
import shlex
import tomllib
from typing import Annotated, Any, Literal
from urllib.parse import urljoin
from dotenv import dotenv_values
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.fields import FieldInfo
from pydantic_core import to_jsonable_python
from pydantic_settings import (
@ -21,9 +22,12 @@ from pydantic_settings import (
import tomli_w
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import SystemPrompt
from vibe.core.tools.base import BaseToolConfig
from vibe.core.types import Backend
from vibe.core.utils import get_server_url_from_api_base
from vibe.core.utils.io import read_safe
def load_dotenv_values(
@ -114,11 +118,6 @@ class SessionLoggingConfig(BaseSettings):
return str(Path(v).expanduser().resolve())
class Backend(StrEnum):
MISTRAL = auto()
GENERIC = auto()
class ProviderConfig(BaseModel):
name: str
api_base: str
@ -271,13 +270,42 @@ class TranscribeModelConfig(BaseModel):
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
class TTSClient(StrEnum):
MISTRAL = auto()
class TTSProviderConfig(BaseModel):
name: str
api_base: str = "https://api.mistral.ai"
api_key_env_var: str = ""
client: TTSClient = TTSClient.MISTRAL
class TTSModelConfig(BaseModel):
name: str
provider: str
alias: str
voice: str = "gb_jane_neutral"
response_format: str = "wav"
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class OtelExporterConfig(BaseModel):
model_config = ConfigDict(frozen=True)
endpoint: str
headers: dict[str, str] | None = None
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
MISTRAL_OTEL_TRACES_PATH = "/telemetry/v1/traces"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_PROVIDERS = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
backend=Backend.MISTRAL,
),
@ -328,6 +356,20 @@ DEFAULT_TRANSCRIBE_MODELS = [
)
]
DEFAULT_TTS_PROVIDERS = [
TTSProviderConfig(
name="mistral",
api_base="https://api.mistral.ai",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
)
]
DEFAULT_TTS_MODELS = [
TTSModelConfig(
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
)
]
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
@ -338,7 +380,9 @@ class VibeConfig(BaseSettings):
displayed_workdir: str = ""
context_warnings: bool = False
voice_mode_enabled: bool = False
narrator_enabled: bool = False
active_transcribe_model: str = "voxtral-realtime"
active_tts_model: str = "voxtral-tts"
auto_approve: bool = False
enable_telemetry: bool = True
system_prompt_id: str = "cli"
@ -360,6 +404,10 @@ class VibeConfig(BaseSettings):
# TODO(vibe-nuage): change default value to MISTRAL_API_KEY once prod has shared vibe-nuage workers
nuage_api_key_env_var: str = Field(default="STAGING_MISTRAL_API_KEY", exclude=True)
# TODO(otel): remove exclude=True once the feature is publicly available
enable_otel: bool = Field(default=False, exclude=True)
otel_endpoint: str = Field(default="", exclude=True)
providers: list[ProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_PROVIDERS)
)
@ -373,9 +421,16 @@ class VibeConfig(BaseSettings):
default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
)
tts_providers: list[TTSProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_TTS_PROVIDERS)
)
tts_models: list[TTSModelConfig] = Field(
default_factory=lambda: list(DEFAULT_TTS_MODELS)
)
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
tools: dict[str, BaseToolConfig] = Field(default_factory=dict)
tools: dict[str, dict[str, Any]] = Field(default_factory=dict)
tool_paths: list[Path] = Field(
default_factory=list,
description=(
@ -468,6 +523,39 @@ class VibeConfig(BaseSettings):
def nuage_api_key(self) -> str:
return os.getenv(self.nuage_api_key_env_var, "")
@property
def otel_exporter_config(self) -> OtelExporterConfig | None:
# When otel_endpoint is set explicitly, authentication is the user's responsibility
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
# Otherwise endpoint and API key are derived from the first MISTRAL provider.
if self.otel_endpoint:
return OtelExporterConfig(endpoint=self.otel_endpoint)
provider = next(
(p for p in self.providers if p.backend == Backend.MISTRAL), None
)
if provider is not None:
server_url = get_server_url_from_api_base(provider.api_base)
api_key_env = provider.api_key_env_var or DEFAULT_MISTRAL_API_ENV_KEY
else:
server_url = None
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
endpoint = urljoin(
server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_TRACES_PATH
)
if not (api_key := os.getenv(api_key_env)):
logger.warning(
"OTEL tracing enabled but %s is not set; skipping.", api_key_env
)
return None
return OtelExporterConfig(
endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"}
)
@property
def system_prompt(self) -> str:
try:
@ -482,7 +570,7 @@ class VibeConfig(BaseSettings):
".md"
)
if custom_sp_path.is_file():
return custom_sp_path.read_text()
return read_safe(custom_sp_path)
raise MissingPromptFileError(
self.system_prompt_id, *(str(d) for d in prompt_dirs)
@ -527,6 +615,22 @@ class VibeConfig(BaseSettings):
f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
)
def get_active_tts_model(self) -> TTSModelConfig:
for model in self.tts_models:
if model.alias == self.active_tts_model:
return model
raise ValueError(
f"Active TTS model '{self.active_tts_model}' not found in configuration."
)
def get_tts_provider_for_model(self, model: TTSModelConfig) -> TTSProviderConfig:
for provider in self.tts_providers:
if provider.name == model.provider:
return provider
raise ValueError(
f"TTS provider '{model.provider}' for TTS model '{model.name}' not found in configuration."
)
@classmethod
def settings_customise_sources(
cls,
@ -608,18 +712,16 @@ class VibeConfig(BaseSettings):
@field_validator("tools", mode="before")
@classmethod
def _normalize_tool_configs(cls, v: Any) -> dict[str, BaseToolConfig]:
def _normalize_tool_configs(cls, v: Any) -> dict[str, dict[str, Any]]:
if not isinstance(v, dict):
return {}
normalized: dict[str, BaseToolConfig] = {}
normalized: dict[str, dict[str, Any]] = {}
for tool_name, tool_config in v.items():
if isinstance(tool_config, BaseToolConfig):
if isinstance(tool_config, dict):
normalized[tool_name] = tool_config
elif isinstance(tool_config, dict):
normalized[tool_name] = BaseToolConfig.model_validate(tool_config)
else:
normalized[tool_name] = BaseToolConfig()
normalized[tool_name] = {}
return normalized
@ -645,6 +747,17 @@ class VibeConfig(BaseSettings):
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _validate_tts_model_uniqueness(self) -> VibeConfig:
seen_aliases: set[str] = set()
for model in self.tts_models:
if model.alias in seen_aliases:
raise ValueError(
f"Duplicate TTS model alias found: '{model.alias}'. Aliases must be unique."
)
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _check_system_prompt(self) -> VibeConfig:
_ = self.system_prompt
@ -674,6 +787,8 @@ class VibeConfig(BaseSettings):
"models",
"transcribe_providers",
"transcribe_models",
"tts_providers",
"tts_models",
"installed_agents",
}:
target[key] = value

View file

@ -12,6 +12,7 @@ from vibe.core.config.harness_files._paths import (
)
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils.io import read_safe
FileSource = Literal["user", "project"]
@ -115,8 +116,7 @@ class HarnessFilesManager:
return ""
path = VIBE_HOME.path / AGENTS_MD_FILENAME
try:
content = path.read_text("utf-8", errors="ignore")
stripped = content.strip()
stripped = read_safe(path).strip()
return stripped if stripped else ""
except (FileNotFoundError, OSError):
return ""
@ -140,8 +140,7 @@ class HarnessFilesManager:
break
path = current / AGENTS_MD_FILENAME
try:
content = path.read_text("utf-8", errors="ignore")
stripped = content.strip()
stripped = read_safe(path).strip()
if stripped:
docs.append((current, stripped))
except (FileNotFoundError, OSError):

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from vibe.core.config import Backend
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.llm.backend.mistral import MistralBackend
from vibe.core.types import Backend
BACKEND_FACTORY = {Backend.MISTRAL: MistralBackend, Backend.GENERIC: GenericBackend}

View file

@ -14,6 +14,7 @@ from mistralai.client.models import (
AssistantMessageContent,
ChatCompletionRequestMessage,
ChatCompletionStreamRequestToolChoice,
ContentChunk,
FileChunk,
Function,
FunctionCall as MistralFunctionCall,
@ -64,15 +65,17 @@ class MistralMapper:
case Role.assistant:
content: AssistantMessageContent
if msg.reasoning_content:
content = [
chunks: list[ContentChunk] = [
ThinkChunk(
type="thinking",
thinking=[
TextChunk(type="text", text=msg.reasoning_content)
],
),
TextChunk(type="text", text=msg.content or ""),
)
]
if msg.content:
chunks.append(TextChunk(type="text", text=msg.content))
content = chunks
else:
content = msg.content or ""
@ -326,7 +329,7 @@ class MistralBackend:
) -> AsyncGenerator[LLMChunk, None]:
try:
merged_messages = merge_consecutive_user_messages(messages)
async for chunk in await self._get_client().chat.stream_async(
stream = await self._get_client().chat.stream_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
temperature=temperature,
@ -339,7 +342,9 @@ class MistralBackend:
else None,
http_headers=extra_headers,
metadata=metadata,
):
)
correlation_id = stream.response.headers.get("mistral-correlation-id")
async for chunk in stream:
parsed = (
self._mapper.parse_content(chunk.data.choices[0].delta.content)
if chunk.data.choices[0].delta.content
@ -364,6 +369,7 @@ class MistralBackend:
if chunk.data.usage
else 0,
),
correlation_id=correlation_id,
)
except SDKError as e:

View file

@ -48,9 +48,10 @@ class ReasoningAdapter(APIAdapter):
{
"type": "thinking",
"thinking": [{"type": "text", "text": msg.reasoning_content}],
},
{"type": "text", "text": msg.content or ""},
}
]
if msg.content:
content.append({"type": "text", "text": msg.content})
result["content"] = content
else:
result["content"] = msg.content or ""

View file

@ -1,6 +1,10 @@
from __future__ import annotations
from vibe.core.paths._local_config_walk import walk_local_config_dirs_all
from vibe.core.paths._local_config_walk import (
WALK_MAX_DEPTH,
has_config_dirs_nearby,
walk_local_config_dirs_all,
)
from vibe.core.paths._vibe_home import (
DEFAULT_TOOL_DIR,
GLOBAL_ENV_FILE,
@ -26,6 +30,8 @@ __all__ = [
"SESSION_LOG_DIR",
"TRUSTED_FOLDERS_FILE",
"VIBE_HOME",
"WALK_MAX_DEPTH",
"GlobalPath",
"has_config_dirs_nearby",
"walk_local_config_dirs_all",
]

View file

@ -1,11 +1,15 @@
from __future__ import annotations
from collections import deque
from functools import cache
import logging
import os
from pathlib import Path
from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
logger = logging.getLogger("vibe")
_VIBE_DIR = ".vibe"
_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
@ -13,27 +17,117 @@ _AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
_AGENTS_DIR = ".agents"
_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
WALK_MAX_DEPTH = 4
_MAX_DIRS = 2000
def _collect_config_dirs_at(
path: Path,
entries: set[str],
tools: list[Path],
skills: list[Path],
agents: list[Path],
) -> None:
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
if _VIBE_DIR in entries:
if (candidate := path / _TOOLS_SUBDIR).is_dir():
tools.append(candidate)
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents.append(candidate)
if _AGENTS_DIR in entries:
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
def _iter_child_dirs(path: Path, entries: set[str]) -> list[Path]:
"""Return sorted child directories to descend into, skipping ignored and dot-dirs."""
children: list[Path] = []
for name in sorted(entries):
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
continue
child = path / name
try:
if child.is_dir():
children.append(child)
except OSError:
continue
return children
@cache
def walk_local_config_dirs_all(
root: Path,
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
"""Discover .vibe/ and .agents/ config directories under *root*.
Uses breadth-first search bounded by ``WALK_MAX_DEPTH`` and ``_MAX_DIRS``
to avoid unbounded traversal in large repositories.
"""
tools_dirs: list[Path] = []
skills_dirs: list[Path] = []
agents_dirs: list[Path] = []
resolved_root = root.resolve()
for dirpath, dirnames, _ in os.walk(resolved_root, topdown=True):
dir_set = frozenset(dirnames)
path = Path(dirpath)
if _VIBE_DIR in dir_set:
if (candidate := path / _TOOLS_SUBDIR).is_dir():
tools_dirs.append(candidate)
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
skills_dirs.append(candidate)
if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents_dirs.append(candidate)
if _AGENTS_DIR in dir_set:
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
skills_dirs.append(candidate)
dirnames[:] = sorted(d for d in dirnames if d not in WALK_SKIP_DIR_NAMES)
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
visited = 0
while queue and visited < _MAX_DIRS:
current, depth = queue.popleft()
visited += 1
try:
entries = set(os.listdir(current))
except OSError:
continue
_collect_config_dirs_at(current, entries, tools_dirs, skills_dirs, agents_dirs)
if depth < WALK_MAX_DEPTH:
queue.extend(
(child, depth + 1) for child in _iter_child_dirs(current, entries)
)
if visited >= _MAX_DIRS:
logger.warning(
"Config directory scan reached directory limit (%d dirs) at %s",
_MAX_DIRS,
resolved_root,
)
return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))
def has_config_dirs_nearby(
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = 200
) -> bool:
"""Quick check for .vibe/ or .agents/ config dirs in the near subtree.
Returns ``True`` as soon as any config directory is found, without
enumerating all of them.
"""
resolved = root.resolve()
queue: deque[tuple[Path, int]] = deque([(resolved, 0)])
visited = 0
found: list[Path] = []
while queue and visited < max_dirs:
current, depth = queue.popleft()
visited += 1
try:
entries = set(os.listdir(current))
except OSError:
continue
_collect_config_dirs_at(current, entries, found, found, found)
if found:
return True
if depth < max_depth:
queue.extend(
(child, depth + 1) for child in _iter_child_dirs(current, entries)
)
return False

View file

@ -4,7 +4,7 @@ from pathlib import Path
import time
from vibe.core.paths import PLANS_DIR
from vibe.core.slug import create_slug
from vibe.core.utils.slug import create_slug
class PlanSession:

View file

@ -4,6 +4,7 @@ from enum import StrEnum, auto
from pathlib import Path
from vibe import VIBE_ROOT
from vibe.core.utils.io import read_safe
_PROMPTS_DIR = VIBE_ROOT / "core" / "prompts"
@ -14,7 +15,7 @@ class Prompt(StrEnum):
return (_PROMPTS_DIR / self.value).with_suffix(".md")
def read(self) -> str:
return self.path.read_text(encoding="utf-8", errors="ignore").strip()
return read_safe(self.path).strip()
class SystemPrompt(Prompt):
@ -29,6 +30,7 @@ class UtilityPrompt(Prompt):
COMPACT = auto()
DANGEROUS_DIRECTORY = auto()
PROJECT_CONTEXT = auto()
TURN_SUMMARY = auto()
__all__ = ["SystemPrompt", "UtilityPrompt"]

View file

@ -1,14 +1,6 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. You have no internet access.
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself.
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
1. Find the matching file in your skill directories.
2. Read it with `read_file`.
3. Follow its instructions step by step. You are the executor.
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
Phase 1 — Orient
Before ANY action:
Restate the goal in one line.

View file

@ -2,14 +2,6 @@ You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with
Use markdown when appropriate. Communicate clearly to the user.
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
1. Find the matching file in your skill directories.
2. Read it with `read_file`.
3. Follow its instructions step by step. You are the executor.
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
Phase 1 — Orient
Before ANY action:
Restate the goal in one line.

View file

@ -0,0 +1,10 @@
You are a concise turn summarizer for an AI coding agent. Given a structured extraction of what happened during one agent turn, produce a brief summary (2-4 sentences).
Cover:
- What the user asked for
- What actions were taken (tools used, files modified)
- The outcome (success, partial, errors)
- Any open questions or issues for the user (only if genuinely relevant, do not force this)
- Do not expand if it does not bring value (e.g. no question to ask)
Be factual and terse. Do not editorialize. Do not repeat the full user message. Respond with ONLY the summary text.

View file

@ -6,6 +6,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict
from vibe.core.types import LLMMessage, SessionMetadata
from vibe.core.utils.io import read_safe
if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig
@ -181,7 +182,7 @@ class SessionLoader:
raise ValueError(f"Session metadata not found at {session_dir}")
try:
metadata_content = metadata_path.read_text("utf-8", errors="ignore")
metadata_content = read_safe(metadata_path)
return SessionMetadata.model_validate_json(metadata_content)
except ValueError:
raise
@ -196,8 +197,9 @@ class SessionLoader:
messages_filepath = filepath / MESSAGES_FILENAME
try:
with messages_filepath.open("r", encoding="utf-8", errors="ignore") as f:
content = f.readlines()
content = read_safe(messages_filepath).split("\n")
if content and content[-1] == "":
content.pop()
except Exception as e:
raise ValueError(
f"Error reading session messages at {filepath}: {e}"

View file

@ -19,6 +19,7 @@ from vibe.core.session.session_loader import (
)
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows, utc_now
from vibe.core.utils.io import read_safe_async
if TYPE_CHECKING:
from vibe.core.agents.models import AgentProfile
@ -230,11 +231,9 @@ class SessionLogger:
# Read old metadata and get total_messages
try:
if self.metadata_filepath.exists():
async with await AsyncPath(self.metadata_filepath).open(
encoding="utf-8", errors="ignore"
) as f:
old_metadata = json.loads(await f.read())
old_total_messages = old_metadata["total_messages"]
raw = await read_safe_async(self.metadata_filepath)
old_metadata = json.loads(raw)
old_total_messages = old_metadata["total_messages"]
else:
old_total_messages = 0
except Exception as e:

View file

@ -6,6 +6,7 @@ from pathlib import Path
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_logger import SessionLogger
from vibe.core.utils.io import read_safe
def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int:
@ -22,8 +23,7 @@ async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
for session_file in session_files:
try:
with open(session_file) as f:
session_data = f.read()
session_data = read_safe(session_file)
session_json = json.loads(session_data)
metadata = session_json["metadata"]
messages = session_json["messages"]

View file

@ -9,6 +9,7 @@ from vibe.core.logger import logger
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
from vibe.core.utils import name_matches
from vibe.core.utils.io import read_safe
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
@ -106,7 +107,7 @@ class SkillManager:
def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
try:
content = skill_path.read_text(encoding="utf-8")
content = read_safe(skill_path)
except OSError as e:
raise SkillParseError(f"Cannot read file: {e}") from e

View file

@ -212,7 +212,7 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str:
"# Available Skills",
"",
"You have access to the following skills. When a task matches a skill's description,",
"read the full SKILL.md file to load detailed instructions.",
"use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually.",
"",
"<available_skills>",
]

View file

@ -8,8 +8,9 @@ from typing import TYPE_CHECKING, Any, Literal
import httpx
from vibe import __version__
from vibe.core.config import Backend, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.types import Backend
from vibe.core.utils import get_user_agent
if TYPE_CHECKING:
@ -28,6 +29,7 @@ class TelemetryClient:
self._session_id_getter = session_id_getter
self._client: httpx.AsyncClient | None = None
self._pending_tasks: set[asyncio.Task[Any]] = set()
self.last_correlation_id: str | None = None
def _get_telemetry_user_agent(self) -> str:
try:
@ -62,6 +64,9 @@ class TelemetryClient:
except ValueError:
return False
def is_active(self) -> bool:
return self._is_enabled() and self._get_mistral_api_key() is not None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
@ -71,7 +76,13 @@ class TelemetryClient:
)
return self._client
def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None:
def send_telemetry_event(
self,
event_name: str,
properties: dict[str, Any],
*,
correlation_id: str | None = None,
) -> None:
mistral_api_key = self._get_mistral_api_key()
if mistral_api_key is None or not self._is_enabled():
return
@ -82,11 +93,15 @@ class TelemetryClient:
):
properties = {**properties, "session_id": session_id}
payload: dict[str, Any] = {"event": event_name, "properties": properties}
if correlation_id:
payload["correlation_id"] = correlation_id
async def _send() -> None:
try:
await self.client.post(
DATALAKE_EVENTS_URL,
json={"event": event_name, "properties": properties},
json=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {mistral_api_key}",
@ -178,6 +193,8 @@ class TelemetryClient:
nb_mcp_servers: int,
nb_models: int,
entrypoint: Literal["cli", "acp", "programmatic", "unknown"],
client_name: str | None,
client_version: str | None,
terminal_emulator: str | None = None,
) -> None:
payload = {
@ -187,6 +204,8 @@ class TelemetryClient:
"nb_models": nb_models,
"entrypoint": entrypoint,
"version": __version__,
"client_name": client_name,
"client_version": client_version,
"terminal_emulator": terminal_emulator,
}
self.send_telemetry_event("vibe.new_session", payload)
@ -195,3 +214,10 @@ class TelemetryClient:
self.send_telemetry_event(
"vibe.onboarding_api_key_added", {"version": __version__}
)
def send_user_rating_feedback(self, rating: int, model: str) -> None:
self.send_telemetry_event(
"vibe.user_rating_feedback",
{"rating": rating, "version": __version__, "model": model},
correlation_id=self.last_correlation_id,
)

158
vibe/core/tools/arity.py Normal file
View file

@ -0,0 +1,158 @@
from __future__ import annotations
ARITY: dict[str, int] = {
"cat": 1,
"cd": 1,
"chmod": 1,
"chown": 1,
"cp": 1,
"echo": 1,
"env": 1,
"export": 1,
"grep": 1,
"kill": 1,
"killall": 1,
"ln": 1,
"ls": 1,
"mkdir": 1,
"mv": 1,
"ps": 1,
"pwd": 1,
"rm": 1,
"rmdir": 1,
"sleep": 1,
"source": 1,
"tail": 1,
"touch": 1,
"unset": 1,
"which": 1,
"aws": 3,
"az": 3,
"bazel": 2,
"brew": 2,
"bun": 2,
"bun run": 3,
"bun x": 3,
"cargo": 2,
"cargo add": 3,
"cargo run": 3,
"cdk": 2,
"cf": 2,
"cmake": 2,
"composer": 2,
"consul": 2,
"consul kv": 3,
"crictl": 2,
"deno": 2,
"deno task": 3,
"doctl": 3,
"docker": 2,
"docker builder": 3,
"docker compose": 3,
"docker container": 3,
"docker image": 3,
"docker network": 3,
"docker volume": 3,
"eksctl": 2,
"eksctl create": 3,
"firebase": 2,
"flyctl": 2,
"gcloud": 3,
"gh": 3,
"git": 2,
"git config": 3,
"git remote": 3,
"git stash": 3,
"go": 2,
"gradle": 2,
"helm": 2,
"heroku": 2,
"hugo": 2,
"ip": 2,
"ip addr": 3,
"ip link": 3,
"ip netns": 3,
"ip route": 3,
"kind": 2,
"kind create": 3,
"kubectl": 2,
"kubectl kustomize": 3,
"kubectl rollout": 3,
"kustomize": 2,
"make": 2,
"mc": 2,
"mc admin": 3,
"minikube": 2,
"mongosh": 2,
"mysql": 2,
"mvn": 2,
"ng": 2,
"npm": 2,
"npm exec": 3,
"npm init": 3,
"npm run": 3,
"npm view": 3,
"nvm": 2,
"nx": 2,
"openssl": 2,
"openssl req": 3,
"openssl x509": 3,
"pip": 2,
"pipenv": 2,
"pnpm": 2,
"pnpm dlx": 3,
"pnpm exec": 3,
"pnpm run": 3,
"poetry": 2,
"podman": 2,
"podman container": 3,
"podman image": 3,
"psql": 2,
"pulumi": 2,
"pulumi stack": 3,
"pyenv": 2,
"python": 2,
"rake": 2,
"rbenv": 2,
"redis-cli": 2,
"rustup": 2,
"serverless": 2,
"sfdx": 3,
"skaffold": 2,
"sls": 2,
"sst": 2,
"swift": 2,
"systemctl": 2,
"terraform": 2,
"terraform workspace": 3,
"tmux": 2,
"turbo": 2,
"ufw": 2,
"uv": 2,
"uv run": 3,
"vault": 2,
"vault auth": 3,
"vault kv": 3,
"vercel": 2,
"volta": 2,
"wp": 2,
"yarn": 2,
"yarn dlx": 3,
"yarn run": 3,
}
def build_session_pattern(tokens: list[str]) -> str:
"""Build a session-level permission pattern from command tokens.
Uses arity rules to find the meaningful command prefix, then appends " *"
to allow matching any arguments. Falls back to first token.
"""
if not tokens:
return ""
for length in range(len(tokens), 0, -1):
prefix = " ".join(tokens[:length])
arity = ARITY.get(prefix)
if arity is not None:
return " ".join(tokens[:arity]) + " *"
return tokens[0] + " *"

View file

@ -24,10 +24,13 @@ from typing import (
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from vibe.core.types import ToolStreamEvent
from vibe.core.utils.io import read_safe
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.permissions import PermissionContext
from vibe.core.types import (
ApprovalCallback,
EntrypointMetadata,
@ -51,6 +54,7 @@ class InvokeContext:
entrypoint_metadata: EntrypointMetadata | None = field(default=None)
plan_file_path: Path | None = field(default=None)
switch_agent_callback: SwitchAgentCallback | None = field(default=None)
skill_manager: SkillManager | None = field(default=None)
class ToolError(Exception):
@ -97,6 +101,7 @@ class BaseToolConfig(BaseModel):
permission: The permission level required to use the tool.
allowlist: Patterns that automatically allow tool execution.
denylist: Patterns that automatically deny tool execution.
sensitive_patterns: Patterns that trigger ASK even when permission is ALWAYS.
"""
model_config = ConfigDict(extra="allow")
@ -104,6 +109,7 @@ class BaseToolConfig(BaseModel):
permission: ToolPermission = ToolPermission.ASK
allowlist: list[str] = Field(default_factory=list)
denylist: list[str] = Field(default_factory=list)
sensitive_patterns: list[str] = Field(default_factory=list)
class BaseToolState(BaseModel):
@ -153,7 +159,7 @@ class BaseTool[
prompt_dir = class_path.parent / "prompts"
prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md"
return prompt_path.read_text("utf-8")
return read_safe(prompt_path)
except (FileNotFoundError, TypeError, OSError):
pass
@ -338,12 +344,12 @@ class BaseTool[
config_class = cls._get_tool_config_class()
return config_class(permission=permission)
def resolve_permission(self, args: ToolArgs) -> ToolPermission | None:
def resolve_permission(self, args: ToolArgs) -> PermissionContext | None:
"""Per-invocation permission override, checked before config-level permission.
Returns:
ALWAYS if auto-approved, NEVER if blocked, ASK to force approval,
or None to fall through to config permission.
PermissionContext with granular required_permissions and a permission
level (ALWAYS/NEVER/ASK), or None to fall through to config permission.
Override in subclasses for domain-specific rules (e.g. workdir checks).
"""

View file

@ -4,6 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator
from functools import lru_cache
import os
from pathlib import Path
import signal
import sys
from typing import ClassVar, Literal, final
@ -12,6 +13,7 @@ from pydantic import BaseModel, Field
from tree_sitter import Language, Node, Parser
import tree_sitter_bash as tsbash
from vibe.core.tools.arity import build_session_pattern
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -20,7 +22,13 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import is_path_within_workdir
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils import is_windows
@ -116,7 +124,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
def _get_default_allowlist() -> list[str]:
common = ["echo", "git diff", "git log", "git status", "tree", "whoami"]
common = ["cd", "echo", "git diff", "git log", "git status", "tree", "whoami"]
if is_windows():
return common + ["dir", "findstr", "more", "type", "ver", "where"]
@ -165,6 +173,68 @@ def _get_default_denylist_standalone() -> list[str]:
return common + ["bash", "sh", "nohup", "vi", "vim", "emacs", "nano", "su"]
_PATH_COMMANDS = {
"cat",
"cd",
"chmod",
"chown",
"cp",
"head",
"ls",
"mkdir",
"mv",
"rm",
"stat",
"tail",
"touch",
"wc",
}
def _collect_outside_dirs(command_parts: list[str]) -> set[str]:
"""Collect parent directories referenced outside the workdir.
Iterates file-manipulating commands (see _PATH_COMMANDS) and inspects
their arguments as candidate paths. Skips flags (-r, --recursive) and
chmod mode strings (+x). For any argument that resolves outside the current
working directory, adds the parent directory (or the path itself when it is
a directory) to the result set suitable for building an OUTSIDE_DIRECTORY
RequiredPermission.
"""
dirs: set[str] = set()
for part in command_parts:
tokens = part.split()
command = tokens[0] if tokens else None
if not command or command not in _PATH_COMMANDS:
continue
for token in tokens[1:]:
# Skip CLI flags like -r, --recursive
if token.startswith("-"):
continue
# Skip chmod mode strings like +x, +rwx — they are not file paths
if command == "chmod" and token.startswith("+"):
continue
# Only consider tokens that look like paths
if not (
token.startswith(os.sep)
or token.startswith("~")
or token.startswith(".")
or os.sep in token
):
continue
if is_path_within_workdir(token):
continue
# Resolve relative / home-relative paths, then collect parent dir
resolved = Path(token).expanduser()
if not resolved.is_absolute():
resolved = Path.cwd() / resolved
resolved = resolved.resolve()
# For a directory target use the dir itself; for a file use its parent
parent = str(resolved) if resolved.is_dir() else str(resolved.parent)
dirs.add(parent)
return dirs
class BashToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
max_output_bytes: int = Field(
@ -185,6 +255,10 @@ class BashToolConfig(BaseToolConfig):
default_factory=_get_default_denylist_standalone,
description="Commands that are denied only when run without arguments",
)
sensitive_patterns: list[str] = Field(
default=["sudo"],
description="Command prefixes that always ASK regardless of arity approval.",
)
class BashArgs(BaseModel):
@ -224,7 +298,10 @@ class Bash(
def get_status_text(cls) -> str:
return "Running command"
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
def resolve_permission(self, args: BashArgs) -> PermissionContext | None: # noqa: PLR0911, PLR0912
if is_windows():
return None
command_parts = _extract_commands(args.command)
if not command_parts:
return None
@ -236,32 +313,92 @@ class Bash(
parts = command.split()
if not parts:
return False
base_command = parts[0]
has_args = len(parts) > 1
if not has_args:
if len(parts) == 1:
command_name = os.path.basename(base_command)
if command_name in self.config.denylist_standalone:
return True
if base_command in self.config.denylist_standalone:
return True
return False
def is_allowlisted(command: str) -> bool:
return any(command.startswith(pattern) for pattern in self.config.allowlist)
def is_sensitive(command: str) -> bool:
tokens = command.split()
if not tokens:
return False
return tokens[0] in self.config.sensitive_patterns
for part in command_parts:
if is_denylisted(part):
return ToolPermission.NEVER
if is_standalone_denylisted(part):
return ToolPermission.NEVER
if is_denylisted(part) or is_standalone_denylisted(part):
return PermissionContext(permission=ToolPermission.NEVER)
if all(is_allowlisted(part) for part in command_parts):
return ToolPermission.ALWAYS
if self.config.permission == ToolPermission.ALWAYS:
return PermissionContext(permission=ToolPermission.ALWAYS)
return None
has_sensitive = any(is_sensitive(part) for part in command_parts)
all_allowlisted = not has_sensitive and all(
is_allowlisted(part) for part in command_parts
)
outside_dirs = _collect_outside_dirs(command_parts)
if all_allowlisted and not outside_dirs:
return PermissionContext(permission=ToolPermission.ALWAYS)
required: list[RequiredPermission] = []
seen_session: set[str] = set()
for part in command_parts:
if not part:
continue
tokens = part.split()
if not tokens:
continue
if not is_sensitive(part) and is_allowlisted(part):
continue
if is_sensitive(part):
required.append(
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern=part,
session_pattern=part,
label=part,
)
)
else:
session_pat = build_session_pattern(tokens)
if session_pat not in seen_session:
seen_session.add(session_pat)
required.append(
RequiredPermission(
scope=PermissionScope.COMMAND_PATTERN,
invocation_pattern=part,
session_pattern=session_pat,
label=session_pat,
)
)
if outside_dirs:
globs = sorted(str(Path(d) / "*") for d in outside_dirs)
for glob in globs:
required.append(
RequiredPermission(
scope=PermissionScope.OUTSIDE_DIRECTORY,
invocation_pattern=glob,
session_pattern=glob,
label=f"outside workdir ({glob})",
)
)
if not required:
return None
return PermissionContext(
permission=ToolPermission.ASK, required_permissions=required
)
@final
def _build_timeout_error(self, command: str, timeout: int) -> ToolError:

View file

@ -21,6 +21,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Question,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.utils.io import read_safe
class ExitPlanModeArgs(BaseModel):
@ -74,7 +75,7 @@ class ExitPlanMode(
plan_content: str | None = None
if ctx.plan_file_path and ctx.plan_file_path.is_file():
try:
plan_content = ctx.plan_file_path.read_text()
plan_content = read_safe(ctx.plan_file_path)
except OSError as e:
raise ToolError(
f"Failed to read plan file at {ctx.plan_file_path}: {e}"

View file

@ -17,8 +17,11 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
from vibe.core.utils.io import read_safe
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
@ -31,6 +34,10 @@ class GrepBackend(StrEnum):
class GrepToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ALWAYS
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_output_bytes: int = Field(
default=64_000, description="Hard cap for the total size of matched lines."
@ -103,6 +110,16 @@ class Grep(
"Respects .gitignore and .codeignore files by default when using ripgrep."
)
def resolve_permission(self, args: GrepArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
def _detect_backend(self) -> GrepBackend:
if shutil.which("rg"):
return GrepBackend.RIPGREP
@ -150,7 +167,7 @@ class Grep(
def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]:
patterns = []
try:
content = codeignore_path.read_text("utf-8")
content = read_safe(codeignore_path)
for line in content.splitlines():
line = line.strip()
if line and not line.startswith("#"):

View file

@ -0,0 +1,19 @@
Use `skill` to load specialized skills that provide domain-specific instructions and workflows.
## When to Use This Tool
- When a task matches one of the available skills listed in your system prompt
- When the user references a skill by name (e.g., "use the review skill")
- When you need specialized workflows, templates, or scripts bundled with a skill
## How It Works
1. Call `skill` with the skill's `name` from the `<available_skills>` section
2. The tool returns the full skill instructions along with a list of bundled files
3. Follow the loaded instructions step by step — you are the executor
## Notes
- Skills may include bundled resources (scripts, references, templates) in their directory
- File paths in skill output are relative to the skill's base directory
- Each skill is loaded once per invocation — re-invoke if you need to reload

View file

@ -16,6 +16,7 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
@ -53,6 +54,10 @@ class ReadFileResult(BaseModel):
class ReadFileToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ALWAYS
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_read_bytes: int = Field(
default=64_000, description="Maximum total bytes to read from a file in one go."
@ -87,12 +92,14 @@ class ReadFile(
was_truncated=read_result.was_truncated,
)
def resolve_permission(self, args: ReadFileArgs) -> ToolPermission | None:
def resolve_permission(self, args: ReadFileArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
def get_result_extra(self, result: ReadFileResult) -> str | None:
@ -127,13 +134,26 @@ class ReadFile(
return file_path
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
try:
return await self._do_read_file(args, file_path, encoding="utf-8")
except (UnicodeDecodeError, ValueError):
return await self._do_read_file(args, file_path, errors="replace")
async def _do_read_file(
self,
args: ReadFileArgs,
file_path: Path,
*,
encoding: str | None = None,
errors: str | None = None,
) -> _ReadResult:
try:
lines_to_return: list[str] = []
bytes_read = 0
was_truncated = False
async with await anyio.Path(file_path).open(
encoding="utf-8", errors="ignore"
encoding=encoding, errors=errors
) as f:
line_index = 0
async for line in f:

View file

@ -16,11 +16,12 @@ from vibe.core.tools.base import (
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils.io import read_safe_async
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
@ -65,6 +66,10 @@ class SearchReplaceResult(BaseModel):
class SearchReplaceConfig(BaseToolConfig):
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_content_size: int = 100_000
create_backup: bool = False
fuzzy_threshold: float = 0.9
@ -105,12 +110,14 @@ class SearchReplace(
def get_status_text(cls) -> str:
return "Editing files"
def resolve_permission(self, args: SearchReplaceArgs) -> ToolPermission | None:
def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.file_path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
@final
@ -211,12 +218,11 @@ class SearchReplace(
async def _read_file(self, file_path: Path) -> str:
try:
async with await anyio.Path(file_path).open(encoding="utf-8") as f:
return await f.read()
except UnicodeDecodeError as e:
raise ToolError(f"Unicode decode error reading {file_path}: {e}") from e
return await read_safe_async(file_path, raise_on_error=True)
except PermissionError:
raise ToolError(f"Permission denied reading file: {file_path}")
except OSError as e:
raise ToolError(f"OS error reading {file_path}: {e}") from e
except Exception as e:
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e

View file

@ -0,0 +1,139 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import ClassVar
from pydantic import BaseModel, Field
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils.io import read_safe
_MAX_LISTED_FILES = 10
class SkillArgs(BaseModel):
name: str = Field(description="The name of the skill to load from available_skills")
class SkillResult(BaseModel):
name: str = Field(description="The name of the loaded skill")
content: str = Field(description="The full skill content block")
skill_dir: str = Field(description="Absolute path to the skill directory")
class SkillToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
class Skill(
BaseTool[SkillArgs, SkillResult, SkillToolConfig, BaseToolState],
ToolUIData[SkillArgs, SkillResult],
):
description: ClassVar[str] = (
"Load a specialized skill that provides domain-specific instructions and workflows. "
"When you recognize that a task matches one of the available skills listed in your system prompt, "
"use this tool to load the full skill instructions. "
"The skill will inject detailed instructions, workflows, and access to bundled resources "
"(scripts, references, templates) into the conversation context."
)
@classmethod
def format_call_display(cls, args: SkillArgs) -> ToolCallDisplay:
return ToolCallDisplay(summary=f"Loading skill: {args.name}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if event.error:
return ToolResultDisplay(success=False, message=event.error)
if not isinstance(event.result, SkillResult):
return ToolResultDisplay(success=True, message="Skill loaded")
return ToolResultDisplay(
success=True, message=f"Loaded skill: {event.result.name}"
)
@classmethod
def get_status_text(cls) -> str:
return "Loading skill"
def resolve_permission(self, args: SkillArgs) -> PermissionContext | None:
return PermissionContext(
permission=self.config.permission,
required_permissions=[
RequiredPermission(
scope=PermissionScope.FILE_PATTERN,
invocation_pattern=args.name,
session_pattern=args.name,
label=f"Load skill: {args.name}",
)
],
)
async def run(
self, args: SkillArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | SkillResult, None]:
if ctx is None or ctx.skill_manager is None:
raise ToolError("Skill manager not available")
skill_manager = ctx.skill_manager
skill_info = skill_manager.get_skill(args.name)
if skill_info is None:
available = ", ".join(sorted(skill_manager.available_skills.keys()))
raise ToolError(
f'Skill "{args.name}" not found. Available skills: {available or "none"}'
)
try:
raw = read_safe(skill_info.skill_path)
_, body = parse_frontmatter(raw)
except (OSError, SkillParseError) as e:
raise ToolError(f"Cannot load skill file: {e}") from e
skill_dir = skill_info.skill_dir
files: list[str] = []
try:
for entry in sorted(skill_dir.rglob("*")):
if not entry.is_file():
continue
if entry.name == "SKILL.md":
continue
files.append(str(entry.relative_to(skill_dir)))
if len(files) >= _MAX_LISTED_FILES:
break
except OSError:
pass
file_lines = "\n".join(f"<file>{f}</file>" for f in files)
output = "\n".join([
f'<skill_content name="{args.name}">',
f"# Skill: {args.name}",
"",
body.strip(),
"",
f"Base directory for this skill: {skill_dir}",
"Relative paths in this skill are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
file_lines,
"</skill_files>",
"</skill_content>",
])
yield SkillResult(name=args.name, content=output, skill_dir=str(skill_dir))

View file

@ -17,6 +17,7 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import (
ToolCallDisplay,
ToolResultDisplay,
@ -89,16 +90,16 @@ class Task(
def get_status_text(cls) -> str:
return "Running subagent"
def resolve_permission(self, args: TaskArgs) -> ToolPermission | None:
def resolve_permission(self, args: TaskArgs) -> PermissionContext | None:
agent_name = args.agent
for pattern in self.config.denylist:
if fnmatch.fnmatch(agent_name, pattern):
return ToolPermission.NEVER
return PermissionContext(permission=ToolPermission.NEVER)
for pattern in self.config.allowlist:
if fnmatch.fnmatch(agent_name, pattern):
return ToolPermission.ALWAYS
return PermissionContext(permission=ToolPermission.ALWAYS)
return None

View file

@ -16,6 +16,11 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
@ -71,14 +76,43 @@ class WebFetch(
"Fetch content from a URL. Converts HTML to markdown for readability."
)
@staticmethod
def _normalize_url(url: str) -> str:
"""Normalise a URL to always have an http(s) scheme.
Handles protocol-relative URLs (//example.com) and bare URLs (example.com).
"""
raw = url.lstrip("/") if url.startswith("//") else url
return raw if raw.startswith(("http://", "https://")) else "https://" + raw
def resolve_permission(self, args: WebFetchArgs) -> PermissionContext | None:
if self.config.permission in {ToolPermission.ALWAYS, ToolPermission.NEVER}:
return PermissionContext(permission=self.config.permission)
parsed = urlparse(self._normalize_url(args.url))
domain = parsed.netloc or parsed.path.split("/")[0]
if not domain:
return None
return PermissionContext(
permission=ToolPermission.ASK,
required_permissions=[
RequiredPermission(
scope=PermissionScope.URL_PATTERN,
invocation_pattern=domain,
session_pattern=domain,
label=f"fetching from {domain}",
)
],
)
@final
async def run(
self, args: WebFetchArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | WebFetchResult, None]:
self._validate_args(args)
raw = args.url.lstrip("/") if args.url.startswith("//") else args.url
url = raw if raw.startswith(("http://", "https://")) else "https://" + raw
url = self._normalize_url(args.url)
timeout = self._resolve_timeout(args.timeout)
content, content_type = await self._fetch_url(url, timeout)

View file

@ -14,7 +14,6 @@ from mistralai.client.models import (
)
from pydantic import BaseModel, Field
from vibe.core.config import Backend
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -24,7 +23,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
from vibe.core.types import Backend, ToolStreamEvent
from vibe.core.utils import get_server_url_from_api_base
if TYPE_CHECKING:

View file

@ -15,6 +15,7 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
@ -37,6 +38,10 @@ class WriteFileResult(BaseModel):
class WriteFileConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_write_bytes: int = 64_000
create_parent_dirs: bool = True
@ -70,12 +75,14 @@ class WriteFile(
def get_status_text(cls) -> str:
return "Writing file"
def resolve_permission(self, args: WriteFileArgs) -> ToolPermission | None:
def resolve_permission(self, args: WriteFileArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
@final

View file

@ -221,10 +221,9 @@ class ToolManager:
user_overrides = self._config.tools.get(tool_name)
if user_overrides is None:
merged_dict = default_config.model_dump()
else:
merged_dict = {**default_config.model_dump(), **user_overrides.model_dump()}
return config_class()
merged_dict = {**default_config.model_dump(), **user_overrides}
return config_class.model_validate(merged_dict)
def get(self, tool_name: str) -> BaseTool:

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from enum import StrEnum, auto
from pydantic import BaseModel, Field
from vibe.core.tools.base import ToolPermission
class PermissionScope(StrEnum):
COMMAND_PATTERN = auto()
OUTSIDE_DIRECTORY = auto()
FILE_PATTERN = auto()
URL_PATTERN = auto()
class RequiredPermission(BaseModel):
scope: PermissionScope
invocation_pattern: str
session_pattern: str
label: str
class PermissionContext(BaseModel):
permission: ToolPermission
required_permissions: list[RequiredPermission] = Field(default_factory=list)
class ApprovedRule(BaseModel):
tool_name: str
scope: PermissionScope
session_pattern: str

View file

@ -1,41 +1,59 @@
from __future__ import annotations
import fnmatch
from pathlib import Path
from pathlib import Path, PurePath
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
)
def wildcard_match(text: str, pattern: str) -> bool:
"""Match text against a wildcard pattern using fnmatch.
If pattern ends with " *", trailing part is optional (matches with or without args).
"""
if fnmatch.fnmatch(text, pattern):
return True
if pattern.endswith(" *") and fnmatch.fnmatch(text, pattern[:-2]):
return True
return False
def _make_absolute(path_str: str) -> Path:
path = Path(path_str).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path
def resolve_path_permission(
path_str: str, *, allowlist: list[str], denylist: list[str]
) -> ToolPermission | None:
) -> PermissionContext | None:
"""Resolve permission for a file path against glob patterns.
Returns NEVER on denylist match, ALWAYS on allowlist match, None otherwise.
"""
file_path = Path(path_str).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
file_str = str(file_path.resolve())
file_str = str(_make_absolute(path_str).resolve())
for pattern in denylist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.NEVER
return PermissionContext(permission=ToolPermission.NEVER)
for pattern in allowlist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.ALWAYS
return PermissionContext(permission=ToolPermission.ALWAYS)
return None
def is_path_within_workdir(path_str: str) -> bool:
"""Return True if the resolved path is inside cwd."""
file_path = Path(path_str).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
try:
file_path.resolve().relative_to(Path.cwd().resolve())
_make_absolute(path_str).resolve().relative_to(Path.cwd().resolve())
return True
except ValueError:
return False
@ -44,15 +62,16 @@ def is_path_within_workdir(path_str: str) -> bool:
def resolve_file_tool_permission(
path_str: str,
*,
tool_name: str,
allowlist: list[str],
denylist: list[str],
config_permission: ToolPermission,
) -> ToolPermission | None:
sensitive_patterns: list[str],
) -> PermissionContext | None:
"""Resolve permission for a file-based tool invocation.
Checks allowlist/denylist first, then escalates to ASK for paths outside
the working directory (unless the tool is configured as NEVER).
Returns None to fall back to the tool's default config permission.
Checks allowlist/denylist, then sensitive patterns, then workdir boundary.
Returns PermissionContext with granular required_permissions when applicable.
"""
if (
result := resolve_path_permission(
@ -61,9 +80,41 @@ def resolve_file_tool_permission(
) is not None:
return result
required: list[RequiredPermission] = []
file_path = _make_absolute(path_str)
file_str = str(file_path.resolve())
for pattern in sensitive_patterns:
if PurePath(file_str).match(pattern):
required.append(
RequiredPermission(
scope=PermissionScope.FILE_PATTERN,
invocation_pattern=file_path.name,
session_pattern="*",
label=f"accessing sensitive files ({tool_name})",
)
)
break
if not is_path_within_workdir(path_str):
if config_permission == ToolPermission.NEVER:
return ToolPermission.NEVER
return ToolPermission.ASK
return PermissionContext(permission=ToolPermission.NEVER)
resolved = file_path.resolve()
parent_dir = str(resolved.parent)
glob = str(Path(parent_dir) / "*")
required.append(
RequiredPermission(
scope=PermissionScope.OUTSIDE_DIRECTORY,
invocation_pattern=glob,
session_pattern=glob,
label=f"outside workdir ({glob})",
)
)
if required:
return PermissionContext(
permission=ToolPermission.ASK, required_permissions=required
)
return None

137
vibe/core/tracing.py Normal file
View file

@ -0,0 +1,137 @@
from __future__ import annotations
import atexit
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from opentelemetry import baggage, context, trace
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
from opentelemetry.trace import StatusCode
from vibe import __version__
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
VIBE_TRACER_NAME = "mistral_vibe"
VIBE_AGENT_NAME = "mistral-vibe"
def setup_tracing(config: VibeConfig) -> None:
if not config.enable_otel:
return
exporter_cfg = config.otel_exporter_config
if exporter_cfg is None:
return
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
resource = Resource.create({
"service.name": VIBE_AGENT_NAME,
"service.version": __version__,
})
exporter = OTLPSpanExporter(**exporter_cfg.model_dump())
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
atexit.register(provider.shutdown)
def _get_tracer() -> trace.Tracer:
return trace.get_tracer(VIBE_TRACER_NAME, __version__)
@asynccontextmanager
async def _safe_span(
name: str, attributes: dict[str, Any]
) -> AsyncGenerator[trace.Span]:
# Tracing errors are logged, never raised.
try:
tracer = _get_tracer()
cm = tracer.start_as_current_span(name, attributes=attributes)
span = cm.__enter__()
except Exception:
logger.warning("Failed to create span", exc_info=True)
yield trace.INVALID_SPAN
return
exc_info: BaseException | None = None
try:
yield span
except BaseException as exc:
exc_info = exc
raise
finally:
try:
if isinstance(exc_info, Exception):
span.set_status(StatusCode.ERROR, str(exc_info))
span.record_exception(exc_info)
elif exc_info is None:
span.set_status(StatusCode.OK)
except Exception:
logger.warning("Failed to record span status", exc_info=True)
finally:
try:
cm.__exit__(None, None, None)
except Exception:
logger.warning("Failed to end span", exc_info=True)
@asynccontextmanager
async def agent_span(
*, model: str | None = None, session_id: str | None = None
) -> AsyncGenerator[trace.Span]:
attributes: dict[str, Any] = {
gen_ai_attributes.GEN_AI_OPERATION_NAME: gen_ai_attributes.GenAiOperationNameValues.INVOKE_AGENT.value,
gen_ai_attributes.GEN_AI_PROVIDER_NAME: gen_ai_attributes.GenAiProviderNameValues.MISTRAL_AI.value,
gen_ai_attributes.GEN_AI_AGENT_NAME: VIBE_AGENT_NAME,
}
if model:
attributes[gen_ai_attributes.GEN_AI_REQUEST_MODEL] = model
if session_id:
attributes[gen_ai_attributes.GEN_AI_CONVERSATION_ID] = session_id
# Propagate conversation ID as OTEL baggage so descendant spans — including
# those created by the Mistral SDK — can read and attach it.
token = None
if session_id:
ctx = baggage.set_baggage(gen_ai_attributes.GEN_AI_CONVERSATION_ID, session_id)
token = context.attach(ctx)
try:
async with _safe_span(f"invoke_agent {VIBE_AGENT_NAME}", attributes) as span:
yield span
finally:
if token is not None:
context.detach(token)
@asynccontextmanager
async def tool_span(
*, tool_name: str, call_id: str, arguments: str
) -> AsyncGenerator[trace.Span]:
attributes: dict[str, Any] = {
gen_ai_attributes.GEN_AI_OPERATION_NAME: gen_ai_attributes.GenAiOperationNameValues.EXECUTE_TOOL.value,
gen_ai_attributes.GEN_AI_TOOL_NAME: tool_name,
gen_ai_attributes.GEN_AI_TOOL_CALL_ID: call_id,
gen_ai_attributes.GEN_AI_TOOL_CALL_ARGUMENTS: arguments,
gen_ai_attributes.GEN_AI_TOOL_TYPE: "function",
}
if conv_id := baggage.get_baggage(gen_ai_attributes.GEN_AI_CONVERSATION_ID):
attributes[gen_ai_attributes.GEN_AI_CONVERSATION_ID] = conv_id
async with _safe_span(f"execute_tool {tool_name}", attributes) as span:
yield span
def set_tool_result(span: trace.Span, result: str) -> None:
try:
span.set_attribute(gen_ai_attributes.GEN_AI_TOOL_CALL_RESULT, result)
except Exception:
pass

View file

@ -52,7 +52,7 @@ class MistralTranscribeClient:
target_streaming_delay_ms=self._target_streaming_delay_ms,
):
if isinstance(event, RealtimeTranscriptionSessionCreated):
yield TranscribeSessionCreated()
yield TranscribeSessionCreated(request_id=event.session.request_id)
elif isinstance(event, TranscriptionStreamTextDelta):
yield TranscribeTextDelta(text=event.text)
elif isinstance(event, TranscriptionStreamDone):

View file

@ -9,7 +9,7 @@ from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
@dataclass(frozen=True, slots=True)
class TranscribeSessionCreated:
pass
request_id: str
@dataclass(frozen=True, slots=True)

View file

@ -8,7 +8,7 @@ import tomli_w
from vibe.core.paths import (
AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE,
walk_local_config_dirs_all,
has_config_dirs_nearby,
)
@ -19,8 +19,9 @@ def has_agents_md_file(path: Path) -> bool:
def has_trustable_content(path: Path) -> bool:
if (path / ".vibe").exists():
return True
tools_dirs, skills_dirs, agents_dirs = walk_local_config_dirs_all(path)
return bool(tools_dirs or skills_dirs or agents_dirs) or has_agents_md_file(path)
if has_agents_md_file(path):
return True
return has_config_dirs_nearby(path)
class TrustedFoldersManager:

View file

@ -0,0 +1,7 @@
from __future__ import annotations
from vibe.core.tts.factory import make_tts_client
from vibe.core.tts.mistral_tts_client import MistralTTSClient
from vibe.core.tts.tts_client_port import TTSClientPort, TTSResult
__all__ = ["MistralTTSClient", "TTSClientPort", "TTSResult", "make_tts_client"]

15
vibe/core/tts/factory.py Normal file
View file

@ -0,0 +1,15 @@
from __future__ import annotations
from vibe.core.config import TTSClient, TTSModelConfig, TTSProviderConfig
from vibe.core.tts.mistral_tts_client import MistralTTSClient
from vibe.core.tts.tts_client_port import TTSClientPort
TTS_CLIENT_MAP: dict[TTSClient, type[TTSClientPort]] = {
TTSClient.MISTRAL: MistralTTSClient
}
def make_tts_client(
provider: TTSProviderConfig, model: TTSModelConfig
) -> TTSClientPort:
return TTS_CLIENT_MAP[provider.client](provider=provider, model=model)

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import base64
import os
import httpx
from vibe.core.config import TTSModelConfig, TTSProviderConfig
from vibe.core.tts.tts_client_port import TTSResult
class MistralTTSClient:
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None:
self._model_name = model.name
self._voice = model.voice
self._response_format = model.response_format
self._client = httpx.AsyncClient(
base_url=f"{provider.api_base}/v1",
headers={
"Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}",
"Content-Type": "application/json",
},
timeout=60.0,
)
async def speak(self, text: str) -> TTSResult:
response = await self._client.post(
"/audio/speech",
json={
"model": self._model_name,
"input": text,
"voice_id": self._voice,
"stream": False,
"response_format": self._response_format,
},
)
response.raise_for_status()
data = response.json()
audio_bytes = base64.b64decode(data["audio_data"])
return TTSResult(audio_data=audio_bytes)
async def close(self) -> None:
await self._client.aclose()

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from vibe.core.config import TTSModelConfig, TTSProviderConfig
@dataclass(frozen=True, slots=True)
class TTSResult:
audio_data: bytes
class TTSClientPort(Protocol):
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: ...
async def speak(self, text: str) -> TTSResult: ...
async def close(self) -> None: ...

View file

@ -11,6 +11,7 @@ from uuid import uuid4
if TYPE_CHECKING:
from vibe.core.tools.base import BaseTool
from vibe.core.tools.permissions import RequiredPermission
else:
BaseTool = Any
@ -25,6 +26,11 @@ from pydantic import (
)
class Backend(StrEnum):
MISTRAL = auto()
GENERIC = auto()
class AgentStats(BaseModel):
steps: int = 0
session_prompt_tokens: int = 0
@ -314,13 +320,18 @@ class LLMChunk(BaseModel):
model_config = ConfigDict(frozen=True)
message: LLMMessage
usage: LLMUsage | None = None
correlation_id: str | None = None
def __add__(self, other: LLMChunk) -> LLMChunk:
if self.usage is None and other.usage is None:
new_usage = None
else:
new_usage = (self.usage or LLMUsage()) + (other.usage or LLMUsage())
return LLMChunk(message=self.message + other.message, usage=new_usage)
return LLMChunk(
message=self.message + other.message,
usage=new_usage,
correlation_id=other.correlation_id or self.correlation_id,
)
class BaseEvent(BaseModel, ABC):
@ -398,6 +409,12 @@ class CompactEndEvent(BaseEvent):
tool_call_id: str
class AgentProfileChangedEvent(BaseEvent):
"""Emitted when the active agent profile changes during a turn."""
agent_name: str
class OutputFormat(StrEnum):
TEXT = auto()
JSON = auto()
@ -405,9 +422,11 @@ class OutputFormat(StrEnum):
type ApprovalCallback = Callable[
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
[str, BaseModel, str, list[RequiredPermission] | None],
Awaitable[tuple[ApprovalResponse, str | None]],
]
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
type SwitchAgentCallback = Callable[[str], Awaitable[None]]

View file

@ -1,343 +0,0 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
import concurrent.futures
from datetime import UTC, datetime
from enum import Enum, auto
from fnmatch import fnmatch
import functools
from pathlib import Path
import re
import sys
from typing import Any
import httpx
from vibe import __version__
from vibe.core.config import Backend
from vibe.core.types import BaseEvent, ToolResultEvent
CANCELLATION_TAG = "user_cancellation"
TOOL_ERROR_TAG = "tool_error"
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
VIBE_WARNING_TAG = "vibe_warning"
KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG]
class TaggedText:
_TAG_PATTERN = re.compile(
rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)</\1>",
flags=re.DOTALL,
)
def __init__(self, message: str, tag: str = "") -> None:
self.message = message
self.tag = tag
def __str__(self) -> str:
if not self.tag:
return self.message
return f"<{self.tag}>{self.message}</{self.tag}>"
@staticmethod
def from_string(text: str) -> TaggedText:
found_tag = ""
result = text
def replace_tag(match: re.Match[str]) -> str:
nonlocal found_tag
tag_name = match.group(1)
content = match.group(2)
if not found_tag:
found_tag = tag_name
return content
result = TaggedText._TAG_PATTERN.sub(replace_tag, text)
if found_tag:
return TaggedText(result, found_tag)
return TaggedText(text, "")
class CancellationReason(Enum):
OPERATION_CANCELLED = auto()
TOOL_INTERRUPTED = auto()
TOOL_NO_RESPONSE = auto()
TOOL_SKIPPED = auto()
def get_user_cancellation_message(
cancellation_reason: CancellationReason, tool_name: str | None = None
) -> TaggedText:
match cancellation_reason:
case CancellationReason.OPERATION_CANCELLED:
return TaggedText("User cancelled the operation.", CANCELLATION_TAG)
case CancellationReason.TOOL_INTERRUPTED:
return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG)
case CancellationReason.TOOL_NO_RESPONSE:
return TaggedText(
"Tool execution interrupted - no response available", CANCELLATION_TAG
)
case CancellationReason.TOOL_SKIPPED:
return TaggedText(
tool_name or "Tool execution skipped by user.", CANCELLATION_TAG
)
def is_user_cancellation_event(event: BaseEvent) -> bool:
if not isinstance(event, ToolResultEvent):
return False
return event.cancelled
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
"""Check if the current directory is a dangerous folder that would cause
issues if we were to run the tool there.
Args:
path: Path to check (defaults to current directory)
Returns:
tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous
"""
path = Path(path).resolve()
home_dir = Path.home()
dangerous_paths = {
home_dir: "home directory",
home_dir / "Documents": "Documents folder",
home_dir / "Desktop": "Desktop folder",
home_dir / "Downloads": "Downloads folder",
home_dir / "Pictures": "Pictures folder",
home_dir / "Movies": "Movies folder",
home_dir / "Music": "Music folder",
home_dir / "Library": "Library folder",
Path("/Applications"): "Applications folder",
Path("/System"): "System folder",
Path("/Library"): "System Library folder",
Path("/usr"): "System usr folder",
Path("/private"): "System private folder",
}
for dangerous_path, description in dangerous_paths.items():
try:
if path == dangerous_path:
return True, f"You are in the {description}"
except (OSError, ValueError):
continue
return False, ""
def get_user_agent(backend: Backend | None) -> str:
user_agent = f"Mistral-Vibe/{__version__}"
if backend == Backend.MISTRAL:
mistral_sdk_prefix = "mistral-client-python/"
user_agent = f"{mistral_sdk_prefix}{user_agent}"
return user_agent
def _is_retryable_http_error(e: Exception) -> bool:
if isinstance(e, httpx.HTTPStatusError):
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504}
return False
def async_retry[T, **P](
tries: int = 3,
delay_seconds: float = 0.5,
backoff_factor: float = 2.0,
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
"""Args:
tries: Number of retry attempts
delay_seconds: Initial delay between retries in seconds
backoff_factor: Multiplier for delay on each retry
is_retryable: Function to determine if an exception should trigger a retry
(defaults to checking for retryable HTTP errors from both urllib and httpx)
Returns:
Decorated function with retry logic
"""
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
last_exc = None
for attempt in range(tries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exc = e
if attempt < tries - 1 and is_retryable(e):
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
0.05 * attempt
)
await asyncio.sleep(current_delay)
continue
raise e
raise RuntimeError(
f"Retries exhausted. Last error: {last_exc}"
) from last_exc
return wrapper
return decorator
def async_generator_retry[T, **P](
tries: int = 3,
delay_seconds: float = 0.5,
backoff_factor: float = 2.0,
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]:
"""Retry decorator for async generators.
Args:
tries: Number of retry attempts
delay_seconds: Initial delay between retries in seconds
backoff_factor: Multiplier for delay on each retry
is_retryable: Function to determine if an exception should trigger a retry
(defaults to checking for retryable HTTP errors from both urllib and httpx)
Returns:
Decorated async generator function with retry logic
"""
def decorator(
func: Callable[P, AsyncGenerator[T]],
) -> Callable[P, AsyncGenerator[T]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
last_exc = None
for attempt in range(tries):
try:
async for item in func(*args, **kwargs):
yield item
return
except Exception as e:
last_exc = e
if attempt < tries - 1 and is_retryable(e):
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
0.05 * attempt
)
await asyncio.sleep(current_delay)
continue
raise e
raise RuntimeError(
f"Retries exhausted. Last error: {last_exc}"
) from last_exc
return wrapper
return decorator
class ConversationLimitException(Exception):
pass
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine synchronously, handling nested event loops.
If called from within an async context (running event loop), runs the
coroutine in a thread pool executor. Otherwise, uses asyncio.run().
This mirrors the pattern used by ToolManager for MCP integration.
"""
try:
asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(asyncio.run, coro)
return future.result()
except RuntimeError:
return asyncio.run(coro)
def is_windows() -> bool:
return sys.platform == "win32"
@functools.lru_cache(maxsize=256)
def _compile_icase(expr: str) -> re.Pattern[str] | None:
try:
return re.compile(expr, re.IGNORECASE)
except re.error:
return None
def name_matches(name: str, patterns: list[str]) -> bool:
"""Check if a name matches any of the provided patterns.
Supports two forms (case-insensitive):
- Glob wildcards using fnmatch (e.g., 'serena_*')
- Regex when prefixed with 're:' (e.g., 're:serena.*')
"""
n = name.lower()
for raw in patterns:
if not (p := (raw or "").strip()):
continue
if p.startswith("re:"):
rx = _compile_icase(p.removeprefix("re:"))
if rx is not None and rx.fullmatch(name) is not None:
return True
elif fnmatch(n, p.lower()):
return True
return False
class AsyncExecutor:
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
def __init__(
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
) -> None:
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix=name
)
self._timeout = timeout
async def __aenter__(self) -> AsyncExecutor:
return self
async def __aexit__(self, *_: object) -> None:
self.shutdown(wait=False)
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
loop = asyncio.get_running_loop()
future = loop.run_in_executor(
self._executor, functools.partial(fn, *args, **kwargs)
)
try:
return await asyncio.wait_for(future, timeout=self._timeout)
except TimeoutError as e:
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
def shutdown(self, wait: bool = True) -> None:
self._executor.shutdown(wait=wait)
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
if old_tokens is None or new_tokens is None:
return "Compaction complete"
reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
return (
f"Compaction complete: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)
def get_server_url_from_api_base(api_base: str) -> str | None:
match = re.match(r"(https?://[^/]+)(/v.*)", api_base)
return match.group(1) if match else None
def utc_now() -> datetime:
return datetime.now(UTC)

View file

@ -0,0 +1,55 @@
"""Utilities package. Re-exports all public and test-used symbols from submodules.
Import read_safe/read_safe_async from vibe.core.utils.io and create_slug from
vibe.core.utils.slug when needed to avoid circular imports with config.
"""
from __future__ import annotations
from vibe.core.utils.concurrency import (
AsyncExecutor,
ConversationLimitException,
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.matching import name_matches
from vibe.core.utils.paths import is_dangerous_directory
from vibe.core.utils.platform import is_windows
from vibe.core.utils.retry import async_generator_retry, async_retry
from vibe.core.utils.tags import (
CANCELLATION_TAG,
KNOWN_TAGS,
TOOL_ERROR_TAG,
VIBE_STOP_EVENT_TAG,
VIBE_WARNING_TAG,
CancellationReason,
TaggedText,
get_user_cancellation_message,
is_user_cancellation_event,
)
from vibe.core.utils.time import utc_now
__all__ = [
"CANCELLATION_TAG",
"KNOWN_TAGS",
"TOOL_ERROR_TAG",
"VIBE_STOP_EVENT_TAG",
"VIBE_WARNING_TAG",
"AsyncExecutor",
"CancellationReason",
"ConversationLimitException",
"TaggedText",
"async_generator_retry",
"async_retry",
"compact_reduction_display",
"get_server_url_from_api_base",
"get_user_agent",
"get_user_cancellation_message",
"is_dangerous_directory",
"is_user_cancellation_event",
"is_windows",
"name_matches",
"run_sync",
"utc_now",
]

View file

@ -0,0 +1,59 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import concurrent.futures
import functools
from typing import Any
class ConversationLimitException(Exception):
pass
def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine synchronously, handling nested event loops.
If called from within an async context (running event loop), runs the
coroutine in a thread pool executor. Otherwise, uses asyncio.run().
This mirrors the pattern used by ToolManager for MCP integration.
"""
try:
asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(asyncio.run, coro)
return future.result()
except RuntimeError:
return asyncio.run(coro)
class AsyncExecutor:
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
def __init__(
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
) -> None:
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix=name
)
self._timeout = timeout
async def __aenter__(self) -> AsyncExecutor:
return self
async def __aexit__(self, *_: object) -> None:
self.shutdown(wait=False)
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
loop = asyncio.get_running_loop()
future = loop.run_in_executor(
self._executor, functools.partial(fn, *args, **kwargs)
)
try:
return await asyncio.wait_for(future, timeout=self._timeout)
except TimeoutError as e:
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
def shutdown(self, wait: bool = True) -> None:
self._executor.shutdown(wait=wait)

View file

@ -0,0 +1,13 @@
from __future__ import annotations
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
if old_tokens is None or new_tokens is None:
return "Compaction complete"
reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
return (
f"Compaction complete: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)

19
vibe/core/utils/http.py Normal file
View file

@ -0,0 +1,19 @@
from __future__ import annotations
import re
from vibe import __version__
from vibe.core.types import Backend
def get_user_agent(backend: Backend | None) -> str:
user_agent = f"Mistral-Vibe/{__version__}"
if backend == Backend.MISTRAL:
mistral_sdk_prefix = "mistral-client-python/"
user_agent = f"{mistral_sdk_prefix}{user_agent}"
return user_agent
def get_server_url_from_api_base(api_base: str) -> str | None:
match = re.match(r"(https?://[^/]+)(/v.*)", api_base)
return match.group(1) if match else None

29
vibe/core/utils/io.py Normal file
View file

@ -0,0 +1,29 @@
from __future__ import annotations
from pathlib import Path
import anyio
def read_safe(path: Path, *, raise_on_error: bool = False) -> str:
"""Read a text file trying UTF-8 first, falling back to OS-default encoding.
On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER).
When raise_on_error is True, decode errors propagate.
"""
try:
return path.read_text(encoding="utf-8")
except (UnicodeDecodeError, ValueError):
if raise_on_error:
return path.read_text()
return path.read_text(errors="replace")
async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str:
apath = anyio.Path(path)
try:
return await apath.read_text(encoding="utf-8")
except (UnicodeDecodeError, ValueError):
if raise_on_error:
return await apath.read_text()
return await apath.read_text(errors="replace")

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from fnmatch import fnmatch
import functools
import re
@functools.lru_cache(maxsize=256)
def _compile_icase(expr: str) -> re.Pattern[str] | None:
try:
return re.compile(expr, re.IGNORECASE)
except re.error:
return None
def name_matches(name: str, patterns: list[str]) -> bool:
"""Check if a name matches any of the provided patterns.
Supports two forms (case-insensitive):
- Glob wildcards using fnmatch (e.g., 'serena_*')
- Regex when prefixed with 're:' (e.g., 're:serena.*')
"""
n = name.lower()
for raw in patterns:
if not (p := (raw or "").strip()):
continue
if p.startswith("re:"):
rx = _compile_icase(p.removeprefix("re:"))
if rx is not None and rx.fullmatch(name) is not None:
return True
elif fnmatch(n, p.lower()):
return True
return False

42
vibe/core/utils/paths.py Normal file
View file

@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
"""Check if the current directory is a dangerous folder that would cause
issues if we were to run the tool there.
Args:
path: Path to check (defaults to current directory)
Returns:
tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous
"""
path = Path(path).resolve()
home_dir = Path.home()
dangerous_paths = {
home_dir: "home directory",
home_dir / "Documents": "Documents folder",
home_dir / "Desktop": "Desktop folder",
home_dir / "Downloads": "Downloads folder",
home_dir / "Pictures": "Pictures folder",
home_dir / "Movies": "Movies folder",
home_dir / "Music": "Music folder",
home_dir / "Library": "Library folder",
Path("/Applications"): "Applications folder",
Path("/System"): "System folder",
Path("/Library"): "System Library folder",
Path("/usr"): "System usr folder",
Path("/private"): "System private folder",
}
for dangerous_path, description in dangerous_paths.items():
try:
if path == dangerous_path:
return True, f"You are in the {description}"
except (OSError, ValueError):
continue
return False, ""

View file

@ -0,0 +1,7 @@
from __future__ import annotations
import sys
def is_windows() -> bool:
return sys.platform == "win32"

103
vibe/core/utils/retry.py Normal file
View file

@ -0,0 +1,103 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable
import functools
import httpx
def _is_retryable_http_error(e: Exception) -> bool:
if isinstance(e, httpx.HTTPStatusError):
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504}
return False
def async_retry[T, **P](
tries: int = 3,
delay_seconds: float = 0.5,
backoff_factor: float = 2.0,
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
"""Args:
tries: Number of retry attempts
delay_seconds: Initial delay between retries in seconds
backoff_factor: Multiplier for delay on each retry
is_retryable: Function to determine if an exception should trigger a retry
(defaults to checking for retryable HTTP errors from both urllib and httpx)
Returns:
Decorated function with retry logic
"""
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
last_exc = None
for attempt in range(tries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exc = e
if attempt < tries - 1 and is_retryable(e):
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
0.05 * attempt
)
await asyncio.sleep(current_delay)
continue
raise e
raise RuntimeError(
f"Retries exhausted. Last error: {last_exc}"
) from last_exc
return wrapper
return decorator
def async_generator_retry[T, **P](
tries: int = 3,
delay_seconds: float = 0.5,
backoff_factor: float = 2.0,
is_retryable: Callable[[Exception], bool] = _is_retryable_http_error,
) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]:
"""Retry decorator for async generators.
Args:
tries: Number of retry attempts
delay_seconds: Initial delay between retries in seconds
backoff_factor: Multiplier for delay on each retry
is_retryable: Function to determine if an exception should trigger a retry
(defaults to checking for retryable HTTP errors from both urllib and httpx)
Returns:
Decorated async generator function with retry logic
"""
def decorator(
func: Callable[P, AsyncGenerator[T]],
) -> Callable[P, AsyncGenerator[T]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
last_exc = None
for attempt in range(tries):
try:
async for item in func(*args, **kwargs):
yield item
return
except Exception as e:
last_exc = e
if attempt < tries - 1 and is_retryable(e):
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
0.05 * attempt
)
await asyncio.sleep(current_delay)
continue
raise e
raise RuntimeError(
f"Retries exhausted. Last error: {last_exc}"
) from last_exc
return wrapper
return decorator

80
vibe/core/utils/tags.py Normal file
View file

@ -0,0 +1,80 @@
from __future__ import annotations
from enum import Enum, auto
import re
from vibe.core.types import BaseEvent, ToolResultEvent
CANCELLATION_TAG = "user_cancellation"
TOOL_ERROR_TAG = "tool_error"
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
VIBE_WARNING_TAG = "vibe_warning"
KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG]
class TaggedText:
_TAG_PATTERN = re.compile(
rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)</\1>",
flags=re.DOTALL,
)
def __init__(self, message: str, tag: str = "") -> None:
self.message = message
self.tag = tag
def __str__(self) -> str:
if not self.tag:
return self.message
return f"<{self.tag}>{self.message}</{self.tag}>"
@staticmethod
def from_string(text: str) -> TaggedText:
found_tag = ""
result = text
def replace_tag(match: re.Match[str]) -> str:
nonlocal found_tag
tag_name = match.group(1)
content = match.group(2)
if not found_tag:
found_tag = tag_name
return content
result = TaggedText._TAG_PATTERN.sub(replace_tag, text)
if found_tag:
return TaggedText(result, found_tag)
return TaggedText(text, "")
class CancellationReason(Enum):
OPERATION_CANCELLED = auto()
TOOL_INTERRUPTED = auto()
TOOL_NO_RESPONSE = auto()
TOOL_SKIPPED = auto()
def get_user_cancellation_message(
cancellation_reason: CancellationReason, tool_name: str | None = None
) -> TaggedText:
match cancellation_reason:
case CancellationReason.OPERATION_CANCELLED:
return TaggedText("User cancelled the operation.", CANCELLATION_TAG)
case CancellationReason.TOOL_INTERRUPTED:
return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG)
case CancellationReason.TOOL_NO_RESPONSE:
return TaggedText(
"Tool execution interrupted - no response available", CANCELLATION_TAG
)
case CancellationReason.TOOL_SKIPPED:
return TaggedText(
tool_name or "Tool execution skipped by user.", CANCELLATION_TAG
)
def is_user_cancellation_event(event: BaseEvent) -> bool:
if not isinstance(event, ToolResultEvent):
return False
return event.cancelled

7
vibe/core/utils/time.py Normal file
View file

@ -0,0 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
def utc_now() -> datetime:
return datetime.now(UTC)

View file

@ -13,9 +13,10 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import Backend, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.types import Backend
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {

View file

@ -1,4 +1,4 @@
# What's new in v2.5.0
- **Lean mode**: Setup a dedicated theorem proving agent powered by leanstral with /leanstall
- **Parallel tool execution**: Parallel tool calls are sped up by being ran at the same time
- **Voice mode**: Real-time transcription support with /voice
# What's new in v2.6.0
- **Text-to-speech**: Added TTS functionality
- **Standalone resume**: New --resume command for session picker
- **Fine-grained permissions**: Improved permissions granularity and persistence