Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-16 17:51:47 +01:00 committed by GitHub
parent 9421fbc08e
commit 5103019b01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 7277 additions and 691 deletions

View file

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

View file

@ -15,7 +15,6 @@ from acp import (
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
RequestError,
SetSessionModelResponse,
SetSessionModeResponse,
run_agent,
@ -55,6 +54,17 @@ from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.exceptions import (
ConfigurationError,
ConversationLimitError,
InternalError,
InvalidRequestError,
NotImplementedMethodError,
RateLimitError,
SessionLoadError,
SessionNotFoundError,
UnauthenticatedError,
)
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
tool_call_session_update,
@ -93,13 +103,14 @@ from vibe.core.proxy_setup import (
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
EntrypointMetadata,
LLMMessage,
RateLimitError as CoreRateLimitError,
ReasoningEvent,
Role,
ToolCallEvent,
@ -107,7 +118,11 @@ from vibe.core.types import (
ToolStreamEvent,
UserMessageEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
from vibe.core.utils import (
CancellationReason,
ConversationLimitException,
get_user_cancellation_message,
)
class AcpSessionLoop(BaseModel):
@ -201,7 +216,7 @@ class VibeAcpAgentLoop(AcpAgent):
async def authenticate(
self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
raise NotImplementedMethodError("authenticate")
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
return EntrypointMetadata(
@ -219,9 +234,9 @@ class VibeAcpAgentLoop(AcpAgent):
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a session"
}) from e
raise UnauthenticatedError.from_missing_api_key(e) from e
except Exception as e:
raise ConfigurationError(str(e)) from e
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
@ -295,7 +310,7 @@ class VibeAcpAgentLoop(AcpAgent):
for override in overrides
]
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
def _create_approval_callback(self, session_id: str) -> ApprovalCallback:
session = self._get_session(session_id)
def _handle_permission_selection(
@ -347,7 +362,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _get_session(self, session_id: str) -> AcpSessionLoop:
if session_id not in self.sessions:
raise RequestError.invalid_params({"session": "Not found"})
raise SessionNotFoundError(session_id)
return self.sessions[session_id]
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
@ -395,7 +410,17 @@ class VibeAcpAgentLoop(AcpAgent):
hint="KEY value to set, KEY to unset, or empty for help"
)
),
)
),
AvailableCommand(
name="leanstall",
description="Install the Lean 4 agent (leanstral)",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
AvailableCommand(
name="unleanstall",
description="Uninstall the Lean 4 agent",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
]
update = update_available_commands(commands)
@ -429,6 +454,60 @@ class VibeAcpAgentLoop(AcpAgent):
)
return PromptResponse(stop_reason="end_turn")
async def _handle_leanstall_command(self, session_id: str) -> PromptResponse:
session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents)
if "lean" in current:
message = "Lean agent is already installed."
else:
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"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
)
message = (
"Lean agent installed. Start a new session to switch to Lean mode."
)
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
),
)
return PromptResponse(stop_reason="end_turn")
async def _handle_unleanstall_command(self, session_id: str) -> PromptResponse:
session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents)
if "lean" not in current:
message = "Lean agent is not installed."
else:
VibeConfig.save_updates({
"installed_agents": [a for a in current if a != "lean"]
})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question", "exit_plan_mode"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
)
message = "Lean agent uninstalled."
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
),
)
return PromptResponse(stop_reason="end_turn")
@override
async def load_session(
self,
@ -446,16 +525,12 @@ class VibeAcpAgentLoop(AcpAgent):
session_id, config.session_logging
)
if session_dir is None:
raise RequestError.invalid_params({
"session_id": f"Session not found: {session_id}"
})
raise SessionNotFoundError(session_id)
try:
loaded_messages, _ = SessionLoader.load_session(session_dir)
except ValueError as e:
raise RequestError.invalid_params({
"session_id": f"Failed to load session: {e}"
}) from e
except Exception as e:
raise SessionLoadError(session_id, str(e)) from e
agent_loop = AgentLoop(
config=config,
@ -605,7 +680,7 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id)
if session.task is not None:
raise RuntimeError(
raise InvalidRequestError(
"Concurrent prompts are not supported yet, wait for agent loop to finish"
)
@ -614,6 +689,12 @@ class VibeAcpAgentLoop(AcpAgent):
if text_prompt.strip().lower().startswith("/proxy-setup"):
return await self._handle_proxy_setup_command(session_id, text_prompt)
if text_prompt.strip().lower().startswith("/unleanstall"):
return await self._handle_unleanstall_command(session_id)
if text_prompt.strip().lower().startswith("/leanstall"):
return await self._handle_leanstall_command(session_id)
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None:
@ -629,16 +710,14 @@ class VibeAcpAgentLoop(AcpAgent):
except asyncio.CancelledError:
return PromptResponse(stop_reason="cancelled")
except Exception as e:
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=f"Error: {e!s}"),
),
)
except CoreRateLimitError as e:
raise RateLimitError.from_core(e) from e
return PromptResponse(stop_reason="refusal")
except ConversationLimitException as e:
raise ConversationLimitError(str(e)) from e
except Exception as e:
raise InternalError(str(e)) from e
finally:
session.task = None
@ -687,7 +766,9 @@ class VibeAcpAgentLoop(AcpAgent):
block_prompt = "\n".join(parts)
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case _:
raise ValueError(f"Unsupported content block type: {block.type}")
raise InvalidRequestError(
f"We currently don't support {block.type} content blocks"
)
return text_prompt
async def _run_agent_loop(
@ -775,7 +856,7 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
raise NotImplementedError()
raise NotImplementedMethodError("fork_session")
@override
async def resume_session(
@ -785,15 +866,15 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ResumeSessionResponse:
raise NotImplementedError()
raise NotImplementedMethodError("resume_session")
@override
async def ext_method(self, method: str, params: dict) -> dict:
raise NotImplementedError()
raise NotImplementedMethodError("ext_method")
@override
async def ext_notification(self, method: str, params: dict) -> None:
raise NotImplementedError()
raise NotImplementedMethodError("ext_notification")
@override
def on_connect(self, conn: Client) -> None:

121
vibe/acp/exceptions.py Normal file
View file

@ -0,0 +1,121 @@
"""Structured ACP error classes for the Vibe agent.
Error codes follow JSON-RPC 2.0 (https://www.jsonrpc.org/specification#error_object)
and ACP error handling (https://agentclientprotocol.com/protocol/overview#error-handling):
-32700 Parse error (JSON-RPC standard)
-32600 Invalid request (JSON-RPC standard)
-32601 Method not found (JSON-RPC standard)
-32602 Invalid params (JSON-RPC standard)
-32603 Internal error (JSON-RPC standard)
-32000 to -32099 Server errors (JSON-RPC implementation-defined)
-31xxx Application errors (Vibe-specific, outside reserved range)
"""
from __future__ import annotations
from typing import Any
from acp import RequestError
from vibe.core.config import MissingAPIKeyError
from vibe.core.types import RateLimitError as CoreRateLimitError
# JSON-RPC 2.0 standard codes
UNAUTHENTICATED = -32000
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -32603
# Vibe application codes (outside JSON-RPC reserved range)
RATE_LIMITED = -31001
CONFIGURATION_ERROR = -31002
CONVERSATION_LIMIT = -31003
class VibeRequestError(RequestError):
code: int
def __init__(self, message: str, data: dict[str, Any] | None = None) -> None:
super().__init__(self.code, message, data)
class UnauthenticatedError(VibeRequestError):
code = UNAUTHENTICATED
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
@classmethod
def from_missing_api_key(cls, exc: MissingAPIKeyError) -> UnauthenticatedError:
return cls(f"Missing API key for {exc.provider_name} provider.")
class NotImplementedMethodError(VibeRequestError):
code = METHOD_NOT_FOUND
def __init__(self, method: str) -> None:
super().__init__(
message=f"Method not implemented: {method}", data={"method": method}
)
class InvalidRequestError(VibeRequestError):
code = INVALID_PARAMS
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
class SessionNotFoundError(VibeRequestError):
code = INVALID_PARAMS
def __init__(self, session_id: str) -> None:
super().__init__(
message=f"Session not found: {session_id}", data={"session_id": session_id}
)
class SessionLoadError(VibeRequestError):
code = INVALID_PARAMS
def __init__(self, session_id: str, detail: str) -> None:
super().__init__(
message=f"Failed to load session {session_id}: {detail}",
data={"session_id": session_id},
)
class RateLimitError(VibeRequestError):
code = RATE_LIMITED
def __init__(self, provider: str, model: str) -> None:
super().__init__(
message=f"Rate limit exceeded for {provider} (model: {model}).",
data={"provider": provider, "model": model},
)
@classmethod
def from_core(cls, exc: CoreRateLimitError) -> RateLimitError:
return cls(exc.provider, exc.model)
class ConfigurationError(VibeRequestError):
code = CONFIGURATION_ERROR
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
class ConversationLimitError(VibeRequestError):
code = CONVERSATION_LIMIT
def __init__(self, detail: str) -> None:
super().__init__(message=detail)
class InternalError(VibeRequestError):
code = INTERNAL_ERROR
def __init__(self, detail: str) -> None:
super().__init__(message=detail or "Internal error")

View file

@ -4,18 +4,19 @@ from pathlib import Path
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
ReadFileArgs,
ReadFileResult,
ReadFileState,
_ReadResult,
)
ReadFileResult = ReadFileResult
class AcpReadFileState(BaseToolState, AcpToolState):
class AcpReadFileState(ReadFileState, AcpToolState):
pass

View file

@ -57,11 +57,14 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
if is_user_cancellation_event(event):
tool_status = "failed"
raw_output = (
TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None
)
if event.skip_reason:
raw_output = TaggedText.from_string(event.skip_reason).message
elif event.error:
raw_output = TaggedText.from_string(event.error).message
elif event.result:
raw_output = event.result.model_dump_json()
else:
raw_output = None
elif event.result:
tool_status = "completed"
raw_output = event.result.model_dump_json()

View file

@ -77,6 +77,21 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Toggle voice mode on/off",
handler="_toggle_voice_mode",
),
"leanstall": Command(
aliases=frozenset(["/leanstall"]),
description="Install the Lean 4 agent (leanstral)",
handler="_install_lean",
),
"unleanstall": Command(
aliases=frozenset(["/unleanstall"]),
description="Uninstall the Lean 4 agent",
handler="_uninstall_lean",
),
}
for command in excluded_commands:

View file

@ -58,7 +58,7 @@ class HistoryManager:
self._save_history()
self.reset_navigation()
def get_previous(self, current_input: str, prefix: str = "") -> str | None:
def get_previous(self, current_input: str) -> str | None:
if not self._entries:
return None
@ -66,21 +66,19 @@ class HistoryManager:
self._temp_input = current_input
self._current_index = len(self._entries)
for i in range(self._current_index - 1, -1, -1):
if self._entries[i].startswith(prefix):
self._current_index = i
return self._entries[i]
if self._current_index <= 0:
return None
return None
self._current_index -= 1
return self._entries[self._current_index]
def get_next(self, prefix: str = "") -> str | None:
def get_next(self) -> str | None:
if self._current_index == -1:
return None
for i in range(self._current_index + 1, len(self._entries)):
if self._entries[i].startswith(prefix):
self._current_index = i
return self._entries[i]
if self._current_index < len(self._entries) - 1:
self._current_index += 1
return self._entries[self._current_index]
result = self._temp_input
self.reset_navigation()

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from enum import StrEnum
import logging
from os import getenv
@ -19,6 +20,11 @@ UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
class MistralCodePlanName(StrEnum):
FREE = "F"
ENTERPRISE = "E"
class PlanInfo:
plan_type: WhoAmIPlanType
plan_name: str
@ -51,6 +57,18 @@ class PlanInfo:
def is_chat_pro_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.CHAT
def is_free_mistral_code_plan(self) -> bool:
return (
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
and self.plan_name.upper() == MistralCodePlanName.FREE
)
def is_mistral_code_enterprise_plan(self) -> bool:
return (
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
and self.plan_name.upper() == MistralCodePlanName.ENTERPRISE
)
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
if not api_key:
@ -79,11 +97,14 @@ def plan_offer_cta(payload: PlanInfo | None) -> str | None:
return
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan()
):
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
def plan_title(payload: PlanInfo | None) -> str | None:
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
if not payload:
return None
if payload.is_chat_pro_plan():
@ -92,4 +113,8 @@ def plan_title(payload: PlanInfo | None) -> str | None:
return "[API] Experiment plan"
if payload.is_paid_api_plan():
return "[API] Scale plan"
if payload.is_free_mistral_code_plan():
return "Mistral Code Free"
if payload.is_mistral_code_enterprise_plan():
return "Mistral Code Enterprise"
return None

View file

@ -11,6 +11,7 @@ from pydantic import TypeAdapter, ValidationError
class WhoAmIPlanType(StrEnum):
API = "API"
CHAT = "CHAT"
MISTRAL_CODE = "MISTRAL_CODE"
UNKNOWN = "UNKNOWN"
UNAUTHORIZED = "UNAUTHORIZED"

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
import gc
import os
from pathlib import Path
import signal
@ -87,8 +88,11 @@ from vibe.cli.update_notifier import (
should_show_whats_new,
)
from vibe.cli.update_notifier.update import do_update
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_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import Backend, VibeConfig
from vibe.core.logger import logger
@ -112,6 +116,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
AgentStats,
ApprovalResponse,
@ -147,7 +152,11 @@ class ChatScroll(VerticalScroll):
@property
def is_at_bottom(self) -> bool:
return self.scroll_offset.y >= (self.max_scroll_y - 3)
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
def update_node_styles(self, animate: bool = True) -> None:
pass
@ -196,6 +205,7 @@ async def prune_oldest_children(
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
PAUSE_GC_ON_SCROLL: ClassVar[bool] = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "clear_quit", "Quit", show=False),
@ -222,10 +232,14 @@ class VibeApp(App): # noqa: PLR0904
current_version: str = CORE_VERSION,
plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.agent_loop = agent_loop
self._voice_manager: VoiceManagerPort = (
voice_manager or self._make_default_voice_manager()
)
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
self,
get_enabled=lambda: self.config.enable_notifications,
@ -238,6 +252,7 @@ class VibeApp(App): # noqa: PLR0904
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
self._pending_question: asyncio.Future | None = None
self._user_interaction_lock = asyncio.Lock()
self.event_handler: EventHandler | None = None
@ -296,6 +311,7 @@ class VibeApp(App): # noqa: PLR0904
skill_entries_getter=self._get_skill_entries,
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
nuage_enabled=self.config.nuage_enabled,
voice_manager=self._voice_manager,
)
with Horizontal(id="bottom-bar"):
@ -346,6 +362,9 @@ class VibeApp(App): # noqa: PLR0904
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
gc.collect()
gc.freeze()
def _process_initial_prompt(self) -> None:
if self._teleport_on_start:
self.run_worker(
@ -402,8 +421,6 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
await self._switch_to_input_app()
async def on_approval_app_approval_granted_always_tool(
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
) -> None:
@ -414,8 +431,6 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
await self._switch_to_input_app()
async def on_approval_app_approval_rejected(
self, message: ApprovalApp.ApprovalRejected
) -> None:
@ -425,8 +440,6 @@ class VibeApp(App): # noqa: PLR0904
)
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
await self._switch_to_input_app()
if self._loading_widget and self._loading_widget.parent:
await self._remove_loading_widget()
@ -435,16 +448,11 @@ class VibeApp(App): # noqa: PLR0904
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
self._pending_question.set_result(result)
await self._switch_to_input_app()
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
await self._switch_to_input_app()
await self._interrupt_agent_loop()
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
@ -682,26 +690,32 @@ class VibeApp(App): # noqa: PLR0904
if self._is_tool_enabled_in_main_agent(tool):
return (ApprovalResponse.YES, None)
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
result = await self._pending_approval
self._pending_approval = None
return result
async with self._user_interaction_lock:
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
result = await self._pending_approval
return result
finally:
self._pending_approval = None
await self._switch_to_input_app()
async def _user_input_callback(self, args: BaseModel) -> BaseModel:
question_args = cast(AskUserQuestionArgs, args)
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget):
await self._switch_to_question_app(question_args)
result = await self._pending_question
self._pending_question = None
return result
async with self._user_interaction_lock:
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_question_app(question_args)
result = await self._pending_question
return result
finally:
self._pending_question = None
await self._switch_to_input_app()
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
@ -756,10 +770,11 @@ class VibeApp(App): # noqa: PLR0904
self._terminal_notifier.notify(NotificationContext.COMPLETE)
def _rate_limit_message(self) -> str:
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
WhoAmIPlanType.API,
WhoAmIPlanType.UNAUTHORIZED,
}
upgrade_to_pro = self._plan_info and (
self._plan_info.plan_type
in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or self._plan_info.is_free_mistral_code_plan()
)
if upgrade_to_pro:
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
return "Rate limits exceeded. Please wait a moment before trying again."
@ -862,6 +877,16 @@ class VibeApp(App): # noqa: PLR0904
self._interrupt_requested = True
if self._pending_approval and not self._pending_approval.done():
feedback = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
if self._pending_question and not self._pending_question.done():
self._pending_question.set_result(
AskUserQuestionResult(answers=[], cancelled=True)
)
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
try:
@ -1035,6 +1060,28 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _install_lean(self) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" in current:
await self._mount_and_scroll(
UserCommandMessage("Lean agent is already installed.")
)
return
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
await self._reload_config()
async def _uninstall_lean(self) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" not in current:
await self._mount_and_scroll(
UserCommandMessage("Lean agent is not installed.")
)
return
VibeConfig.save_updates({
"installed_agents": [a for a in current if a != "lean"]
})
await self._reload_config()
async def _clear_history(self) -> None:
try:
self._reset_ui_state()
@ -1166,6 +1213,33 @@ class VibeApp(App): # noqa: PLR0904
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
def _make_default_voice_manager(self) -> VoiceManager:
try:
model = self.config.get_active_transcribe_model()
provider = self.config.get_transcribe_provider_for_model(model)
transcribe_client = make_transcribe_client(provider, model)
except (ValueError, KeyError) as exc:
logger.error(
"Failed to initialize transcription, check transcribe model configuration",
exc_info=exc,
)
transcribe_client = None
return VoiceManager(
lambda: self.config,
audio_recorder=AudioRecorder(),
transcribe_client=transcribe_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 _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
@ -1291,7 +1365,11 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def action_interrupt(self) -> None:
def action_interrupt(self) -> None: # noqa: PLR0911
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
self._voice_manager.cancel_recording()
return
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config:
@ -1424,6 +1502,7 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container
and self._switch_agent_generation == my_gen
):
self.call_from_thread(self._refresh_banner)
self.call_from_thread(
setattr, self._chat_input_container, "switching_mode", False
)

View file

@ -86,6 +86,7 @@ TextArea > .text-area--cursor {
#completion-popup {
width: 100%;
padding: 1;
padding-left: 3;
color: ansi_default;
}
@ -112,6 +113,11 @@ TextArea > .text-area--cursor {
border: solid ansi_red;
border-title-color: ansi_red;
}
&.border-recording {
border: solid $mistral_orange;
border-title-color: $mistral_orange;
}
}
#input-body {
@ -119,7 +125,8 @@ TextArea > .text-area--cursor {
}
#prompt,
#prompt-spinner {
#prompt-spinner,
#recording-indicator {
width: auto;
background: transparent;
color: $mistral_orange;
@ -137,6 +144,10 @@ TextArea > .text-area--cursor {
border: none;
padding: 0;
scrollbar-visibility: hidden;
&.recording {
color: ansi_bright_black;
}
}
ToastRack {

View file

@ -82,6 +82,7 @@ class EventHandler:
skip_reason=TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None,
cancelled=event.cancelled,
duration=event.duration,
tool_call_id=event.tool_call_id,
)

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
__all__ = ["RecordingIndicator"]

View file

@ -0,0 +1,79 @@
from __future__ import annotations
from textual.timer import Timer
from textual.widgets import Static
from vibe.cli.voice_manager.voice_manager_port import (
TranscribeState,
VoiceManagerListener,
VoiceManagerPort,
)
PEAK_BLOCKS = "▁▂▃▄▅▆▇█"
FILL_BLOCKS = "▏▎▍▌▋▊▉█"
PEAK_POLL_INTERVAL = 0.05
class RecordingIndicator(VoiceManagerListener, Static):
def __init__(self, voice_manager: VoiceManagerPort) -> None:
super().__init__(PEAK_BLOCKS[0], id="recording-indicator")
self._voice_manager = voice_manager
self._peak_timer: Timer | None = None
self._flushing_animation_timer: Timer | None = None
def on_mount(self) -> None:
self._voice_manager.add_listener(self)
if self._voice_manager.transcribe_state == TranscribeState.RECORDING:
self._start_peak_polling()
def on_unmount(self) -> None:
self._voice_manager.remove_listener(self)
self._stop_peak_polling()
self._stop_flushing_animation_timer()
def on_transcribe_state_change(self, state: TranscribeState) -> None:
match state:
case TranscribeState.RECORDING:
self._start_peak_polling()
case TranscribeState.FLUSHING:
self._stop_peak_polling()
self._start_flushing_animation_timer()
case TranscribeState.IDLE:
self._stop_peak_polling()
self._stop_flushing_animation_timer()
def _start_peak_polling(self) -> None:
self._peak_timer = self.set_interval(
PEAK_POLL_INTERVAL, self._poll_peak, pause=False
)
def _poll_peak(self) -> None:
if self._voice_manager.transcribe_state != TranscribeState.RECORDING:
return
index = min(
int(self._voice_manager.peak * len(PEAK_BLOCKS)), len(PEAK_BLOCKS) - 1
)
self.update(PEAK_BLOCKS[index])
def _stop_peak_polling(self) -> None:
if self._peak_timer:
self._peak_timer.stop()
self._peak_timer = None
def _start_flushing_animation_timer(self) -> None:
self._processing_index = 0
self.update(FILL_BLOCKS[0])
self._flushing_animation_timer = self.set_interval(
0.1, self._advance_flushing_animation
)
def _advance_flushing_animation(self) -> None:
if self._voice_manager.transcribe_state != TranscribeState.FLUSHING:
return
self._processing_index = (self._processing_index + 1) % len(FILL_BLOCKS)
self.update(FILL_BLOCKS[self._processing_index])
def _stop_flushing_animation_timer(self) -> None:
if self._flushing_animation_timer:
self._flushing_animation_timer.stop()
self._flushing_animation_timer = None

View file

@ -11,9 +11,16 @@ from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
from vibe.cli.voice_manager.voice_manager_port import (
TranscribeState,
VoiceManagerListener,
VoiceManagerPort,
)
from vibe.core.logger import logger
class _PromptSpinner(SpinnerMixin, Static):
@ -29,7 +36,7 @@ class _PromptSpinner(SpinnerMixin, Static):
self.start_spinner_timer()
class ChatInputBody(Widget):
class ChatInputBody(VoiceManagerListener, Widget):
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
@ -39,6 +46,7 @@ class ChatInputBody(Widget):
self,
history_file: Path | None = None,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -46,6 +54,8 @@ class ChatInputBody(Widget):
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
self._switching_mode = False
self._voice_manager = voice_manager
self._recording_indicator: RecordingIndicator | None = None
if history_file:
self.history = HistoryManager(history_file)
@ -60,13 +70,21 @@ class ChatInputBody(Widget):
yield self.prompt_widget
self.input_widget = ChatTextArea(
id="input", nuage_enabled=self._nuage_enabled
id="input",
nuage_enabled=self._nuage_enabled,
voice_manager=self._voice_manager,
)
yield self.input_widget
def on_mount(self) -> None:
if self.input_widget:
self.input_widget.focus()
if self._voice_manager:
self._voice_manager.add_listener(self)
def on_unmount(self) -> None:
if self._voice_manager:
self._voice_manager.remove_listener(self)
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
if text.startswith("!"):
@ -103,7 +121,6 @@ class ChatInputBody(Widget):
cursor_pos = (0, col)
self.input_widget.move_cursor(cursor_pos)
self.input_widget._last_cursor_col = col
self.input_widget._cursor_pos_after_load = cursor_pos
self.input_widget._cursor_moved_since_load = False
@ -111,7 +128,7 @@ class ChatInputBody(Widget):
self._notify_completion_reset()
def on_chat_text_area_history_previous(
self, event: ChatTextArea.HistoryPrevious
self, _event: ChatTextArea.HistoryPrevious
) -> None:
if not self.history or not self.input_widget:
return
@ -119,52 +136,25 @@ class ChatInputBody(Widget):
if self.history._current_index == -1:
self.input_widget._original_text = self.input_widget.text
if (
self.history._current_index != -1
and self.input_widget._last_used_prefix is not None
and self.input_widget._last_used_prefix != event.prefix
):
self.history.reset_navigation()
self.input_widget._last_used_prefix = event.prefix
previous = self.history.get_previous(
self.input_widget._original_text, prefix=event.prefix
)
previous = self.history.get_previous(self.input_widget._original_text)
if previous is not None:
self._load_history_entry(previous)
def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None:
def on_chat_text_area_history_next(self, _event: ChatTextArea.HistoryNext) -> None:
if not self.history or not self.input_widget:
return
if self.history._current_index == -1:
return
if (
self.input_widget._last_used_prefix is not None
and self.input_widget._last_used_prefix != event.prefix
):
self.history.reset_navigation()
next_entry = self.history.get_next()
if next_entry is not None:
self._load_history_entry(next_entry)
self.input_widget._last_used_prefix = event.prefix
has_next = any(
self.history._entries[i].startswith(event.prefix)
for i in range(self.history._current_index + 1, len(self.history._entries))
)
original_matches = self.input_widget._original_text.startswith(event.prefix)
if has_next or original_matches:
next_entry = self.history.get_next(prefix=event.prefix)
if next_entry is not None:
cursor_col = (
len(event.prefix) if self.history._current_index == -1 else None
)
self._load_history_entry(next_entry, cursor_col=cursor_col)
def on_chat_text_area_history_reset(self, event: ChatTextArea.HistoryReset) -> None:
def on_chat_text_area_history_reset(
self, _event: ChatTextArea.HistoryReset
) -> None:
if self.history:
self.history.reset_navigation()
if self.input_widget:
@ -250,3 +240,68 @@ class ChatInputBody(Widget):
if cursor_offset is not None:
self.input_widget.set_cursor_offset(max(0, min(cursor_offset, len(text))))
def on_transcribe_state_change(self, state: TranscribeState) -> None:
if state == TranscribeState.RECORDING:
self._start_recording_ui()
elif state == TranscribeState.IDLE:
self._stop_recording_ui()
def on_transcribe_text(self, text: str) -> None:
if not self.input_widget:
return
self.input_widget.insert(text)
def _start_recording_ui(self) -> None:
if not self._voice_manager:
return
try:
self.screen.get_widget_by_id("input-box").add_class("border-recording")
if self.input_widget:
self.input_widget.cursor_blink = False
self.input_widget.add_class("recording")
if self.prompt_widget:
self.prompt_widget.display = False
self._recording_indicator = RecordingIndicator(self._voice_manager)
self.query_one(Horizontal).mount(self._recording_indicator, before=0)
except Exception as e:
logger.error("Failed to start recording UI", exc_info=e)
self._reset_recording_ui()
def _stop_recording_ui(self) -> None:
try:
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
if self.input_widget:
self.input_widget.cursor_blink = True
self.input_widget.remove_class("recording")
if self.prompt_widget:
self.prompt_widget.display = True
self._update_prompt()
if self._recording_indicator:
self._recording_indicator.remove()
self._recording_indicator = None
except Exception as e:
logger.error("Failed to stop recording UI", exc_info=e)
self._reset_recording_ui()
def _reset_recording_ui(self) -> None:
try:
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
except Exception:
pass
if self.input_widget:
self.input_widget.cursor_blink = True
self.input_widget.remove_class("recording")
if self.prompt_widget:
self.prompt_widget.display = True
self._update_prompt()
if self._recording_indicator:
try:
self._recording_indicator.remove()
except Exception:
pass
self._recording_indicator = None

View file

@ -27,7 +27,7 @@ class CompletionPopup(Static):
label_style = "bold reverse" if idx == selected else "bold"
description_style = "italic" if idx == selected else "dim"
text.append(label, style=label_style)
text.append(self._display_label(label), style=label_style)
if description:
text.append(" ")
text.append(description, style=description_style)
@ -41,3 +41,8 @@ class CompletionPopup(Static):
def show(self) -> None:
self.styles.display = "block"
def _display_label(self, label: str) -> str:
if label.startswith("@"):
return label[1:]
return label

View file

@ -17,6 +17,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
from vibe.core.agents import AgentSafety
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
@ -44,6 +45,7 @@ class ChatInputContainer(Vertical):
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -56,6 +58,7 @@ class ChatInputContainer(Vertical):
file_watcher_for_autocomplete_getter
)
self._nuage_enabled = nuage_enabled
self._voice_manager = voice_manager
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@ -90,6 +93,7 @@ class ChatInputContainer(Vertical):
history_file=self._history_file,
id="input-body",
nuage_enabled=self._nuage_enabled,
voice_manager=self._voice_manager,
)
yield self._body

View file

@ -13,6 +13,11 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
TranscribeState,
VoiceManagerPort,
)
InputMode = Literal["!", "/", ">", "&"]
@ -37,14 +42,10 @@ class ChatTextArea(TextArea):
super().__init__()
class HistoryPrevious(Message):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
super().__init__()
pass
class HistoryNext(Message):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
super().__init__()
pass
class HistoryReset(Message):
"""Message sent when history navigation should be reset."""
@ -56,20 +57,23 @@ class ChatTextArea(TextArea):
self.mode = mode
super().__init__()
def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
def __init__(
self,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._nuage_enabled = nuage_enabled
self._input_mode: InputMode = self.DEFAULT_MODE
self._history_prefix: str | None = None
self._last_text = ""
self._navigating_history = False
self._last_cursor_col: int = 0
self._last_used_prefix: str | None = None
self._original_text: str = ""
self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False
self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
self._voice_manager = voice_manager
def on_blur(self, event: events.Blur) -> None:
if self._app_has_focus:
@ -100,7 +104,6 @@ class ChatTextArea(TextArea):
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False
@ -114,11 +117,6 @@ class ChatTextArea(TextArea):
self.get_full_text(), self._get_full_cursor_offset()
)
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
self._history_prefix = None
if clear_last_used:
self._last_used_prefix = None
def _mark_cursor_moved_if_needed(self) -> None:
if (
self._cursor_pos_after_load is not None
@ -126,20 +124,8 @@ class ChatTextArea(TextArea):
and self.cursor_location != self._cursor_pos_after_load
):
self._cursor_moved_since_load = True
self._reset_prefix(clear_last_used=False)
def _get_prefix_up_to_cursor(self) -> str:
cursor_row, cursor_col = self.cursor_location
lines = self.text.split("\n")
if cursor_row < len(lines):
visible_prefix = lines[cursor_row][:cursor_col]
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
return self._input_mode + visible_prefix
return visible_prefix
return ""
def _handle_history_up(self) -> bool:
_, cursor_col = self.cursor_location
history_loaded_and_cursor_unmoved = (
self._cursor_pos_after_load is not None
and not self._cursor_moved_since_load
@ -150,63 +136,61 @@ class ChatTextArea(TextArea):
)
if should_intercept:
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryPrevious(self._history_prefix))
self.post_message(self.HistoryPrevious())
return True
return False
def _is_on_loaded_history_entry(self) -> bool:
return self._cursor_pos_after_load is not None
def _has_history_prefix(self) -> bool:
return self._history_prefix is not None
def _has_history_navigation_context(self) -> bool:
return self._is_on_loaded_history_entry() or self._has_history_prefix()
def _should_intercept_history_down(self) -> bool:
history_loaded_and_cursor_unmoved = (
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
)
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
if self._is_on_loaded_history_entry() and not self._cursor_moved_since_load:
return True
if not self.navigator.is_last_wrapped_line(self.cursor_location):
return False
return self._has_history_navigation_context()
return self._is_on_loaded_history_entry()
def _handle_history_down(self) -> bool:
_, cursor_col = self.cursor_location
if not self._should_intercept_history_down():
return False
navigating_loaded_history = self._is_on_loaded_history_entry()
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
if not navigating_loaded_history:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
if navigating_loaded_history and self._last_used_prefix is not None:
self._history_prefix = self._last_used_prefix
else:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryNext(self._history_prefix))
self.post_message(self.HistoryNext())
return True
async def _handle_voice_key(self, event: events.Key) -> bool:
if not self._voice_manager:
return False
# Handle key pressed during audio recording
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
event.prevent_default()
event.stop()
if event.key == "ctrl+c": # Escape is handled in app.py
self._voice_manager.cancel_recording()
elif self._voice_manager.transcribe_state == TranscribeState.RECORDING:
await self._voice_manager.stop_recording()
return True
# Handle audio record keybind
if self._voice_manager.is_enabled and event.key == "ctrl+r":
event.prevent_default()
event.stop()
try:
self._voice_manager.start_recording()
except RecordingStartError as e:
self.notify(str(e), severity="warning")
return True
return False
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
if await self._handle_voice_key(event):
return
self._mark_cursor_moved_if_needed()
manager = self._completion_manager
@ -223,7 +207,6 @@ class ChatTextArea(TextArea):
event.stop()
value = self.get_full_text().strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
return
@ -232,7 +215,6 @@ class ChatTextArea(TextArea):
event.stop()
value = self.get_full_text().strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
return
@ -323,7 +305,6 @@ class ChatTextArea(TextArea):
self.move_cursor((last_row, len(lines[last_row])))
def reset_history_state(self) -> None:
self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False

View file

@ -1,6 +1,9 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from vibe.cli.textual_ui.app import ChatScroll
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
@ -67,6 +70,7 @@ class StreamingMessageBase(Static):
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
self._content_initialized = False
self._to_write_buffer = ""
def _get_markdown(self) -> Markdown:
if self._markdown is None:
@ -80,23 +84,46 @@ class StreamingMessageBase(Static):
self._stream = Markdown.get_stream(self._get_markdown())
return self._stream
def _is_chat_at_bottom(self) -> bool:
try:
chat = cast("ChatScroll", self.app.query_one("#chat"))
return chat.is_at_bottom
except Exception:
return True
async def append_content(self, content: str) -> None:
if not content:
return
self._content += content
if self._should_write_content():
if not self._should_write_content():
return
if self._is_chat_at_bottom():
to_write = self._to_write_buffer + content
self._to_write_buffer = ""
stream = self._ensure_stream()
await stream.write(content)
await stream.write(to_write)
return
self._to_write_buffer += content
async def write_initial_content(self) -> None:
if self._content_initialized:
return
self._content_initialized = True
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
self._to_write_buffer = ""
async def stop_stream(self) -> None:
if self._to_write_buffer and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._to_write_buffer)
self._to_write_buffer = ""
if self._stream is None:
return
@ -187,6 +214,7 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
await self._markdown.update("")
stream = self._ensure_stream()
await stream.write(self._content)
self._to_write_buffer = ""
class UserCommandMessage(Static):

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from vibe.cli.voice_manager.voice_manager import VoiceManager
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
VoiceManagerPort,
VoiceToggleResult,
)
__all__ = [
"RecordingStartError",
"VoiceManager",
"VoiceManagerPort",
"VoiceToggleResult",
]

View file

@ -0,0 +1,186 @@
from __future__ import annotations
from asyncio import CancelledError, Task, create_task, wait_for
from collections.abc import Callable
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,
NoAudioInputDeviceError,
RecordingMode,
)
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,
)
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
class VoiceManager:
def __init__(
self,
config_getter: Callable[[], VibeConfig],
audio_recorder: AudioRecorderPort,
transcribe_client: TranscribeClientPort | None,
) -> None:
self._config_getter = config_getter
self._audio_recorder = audio_recorder
self._transcribe_client = transcribe_client
self._transcribe_state = TranscribeState.IDLE
self._transcribe_task: Task[None] | None = None
self._listeners: list[VoiceManagerListener] = []
@property
def is_enabled(self) -> bool:
return self._config_getter().voice_mode_enabled
@property
def transcribe_state(self) -> TranscribeState:
return self._transcribe_state
@property
def peak(self) -> float:
return self._audio_recorder.peak
def toggle_voice_mode(self) -> VoiceToggleResult:
new_state = not self.is_enabled
if not new_state:
self.cancel_recording()
VibeConfig.save_updates({"voice_mode_enabled": new_state})
for listener in self._listeners:
try:
listener.on_voice_mode_change(new_state)
except Exception:
logger.error("Listener raised during voice mode change", exc_info=True)
return VoiceToggleResult(enabled=new_state)
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
if self._transcribe_state != TranscribeState.IDLE:
return
if self._transcribe_client is None:
logger.warning(
"Failed to start recording as the transcribe client is missing"
)
raise RecordingStartError("Transcribe client is not available")
model = self._config_getter().get_active_transcribe_model()
try:
self._audio_recorder.start(mode, sample_rate=model.sample_rate)
except AlreadyRecordingError:
raise RecordingStartError("Recording is already in progress")
except AudioBackendUnavailableError:
raise RecordingStartError("Audio backend is unavailable")
except NoAudioInputDeviceError:
raise RecordingStartError("No audio input device found")
self._set_state(TranscribeState.RECORDING)
self._transcribe_task = create_task(self._run_transcription())
async def stop_recording(self) -> None:
if self._transcribe_state != TranscribeState.RECORDING:
return
should_flush_queue = self._audio_recorder.mode == RecordingMode.STREAM
if should_flush_queue:
self._set_state(TranscribeState.FLUSHING)
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
if self._transcribe_task is not None:
try:
await wait_for(
self._transcribe_task, timeout=TRANSCRIPTION_DRAIN_TIMEOUT
)
except TimeoutError:
logger.warning("Transcription task timed out, cancelling")
self._transcribe_task.cancel()
except CancelledError:
pass
self._transcribe_task = None
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
def cancel_recording(self) -> None:
if self._transcribe_state == TranscribeState.IDLE:
return
self._audio_recorder.cancel()
if self._transcribe_task is not None:
self._transcribe_task.cancel()
self._transcribe_task = None
self._set_state(TranscribeState.IDLE)
def add_listener(self, listener: VoiceManagerListener) -> None:
if listener not in self._listeners:
self._listeners.append(listener)
def remove_listener(self, listener: VoiceManagerListener) -> None:
try:
self._listeners.remove(listener)
except ValueError:
pass
async def _run_transcription(self) -> None:
if self._transcribe_client is None:
return
try:
audio_stream = self._audio_recorder.audio_stream()
async for event in self._transcribe_client.transcribe(audio_stream):
match event:
case TranscribeTextDelta(text=text):
for listener in self._listeners:
try:
listener.on_transcribe_text(text)
except Exception:
logger.error(
"Listener raised during transcribe text",
exc_info=True,
)
case TranscribeDone():
pass
case TranscribeError(message=msg):
raise RuntimeError(msg)
case TranscribeSessionCreated():
pass
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
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)
def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state:
return
self._transcribe_state = state
for listener in self._listeners:
try:
listener.on_transcribe_state_change(state)
except Exception:
logger.error("Listener raised during state change", exc_info=True)

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
class TranscribeState(StrEnum):
IDLE = auto()
RECORDING = auto()
FLUSHING = auto()
@dataclass(frozen=True, slots=True)
class VoiceToggleResult:
enabled: bool
class RecordingStartError(Exception):
pass
class VoiceManagerListener:
def on_transcribe_state_change(self, state: TranscribeState) -> None:
pass
def on_voice_mode_change(self, enabled: bool) -> None:
pass
def on_transcribe_text(self, text: str) -> None:
pass
class VoiceManagerPort(Protocol):
@property
def is_enabled(self) -> bool: ...
@property
def transcribe_state(self) -> TranscribeState: ...
@property
def peak(self) -> float: ...
def toggle_voice_mode(self) -> VoiceToggleResult: ...
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None: ...
async def stop_recording(self) -> None: ...
def cancel_recording(self) -> None: ...
def add_listener(self, listener: VoiceManagerListener) -> None: ...
def remove_listener(self, listener: VoiceManagerListener) -> None: ...

View file

@ -2,13 +2,14 @@ from __future__ import annotations
import asyncio
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, cast
from typing import TYPE_CHECKING, Any, Literal
from uuid import uuid4
from pydantic import BaseModel
@ -16,7 +17,7 @@ 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, ProviderConfig, VibeConfig
from vibe.core.config import Backend, 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 (
@ -66,7 +67,6 @@ from vibe.core.types import (
ApprovalCallback,
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
@ -78,7 +78,6 @@ from vibe.core.types import (
RateLimitError,
ReasoningEvent,
Role,
SyncApprovalCallback,
ToolCall,
ToolCallEvent,
ToolResultEvent,
@ -87,6 +86,7 @@ from vibe.core.types import (
UserMessageEvent,
)
from vibe.core.utils import (
CANCELLATION_TAG,
TOOL_ERROR_TAG,
VIBE_STOP_EVENT_TAG,
CancellationReason,
@ -217,6 +217,10 @@ class AgentLoop:
def agent_profile(self) -> AgentProfile:
return self.agent_manager.active_profile
@property
def base_config(self) -> VibeConfig:
return self._base_config
@property
def config(self) -> VibeConfig:
return self.agent_manager.config
@ -239,6 +243,10 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
def refresh_config(self) -> None:
self._base_config = VibeConfig.load()
self.agent_manager.invalidate_config()
def emit_new_session_telemetry(self) -> None:
entrypoint = (
self.entrypoint_metadata.agent_entrypoint
@ -348,15 +356,9 @@ class AgentLoop:
if self._max_price is not None:
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
active_model = self.config.get_active_model()
if active_model.auto_compact_threshold > 0:
self.middleware_pipeline.add(
AutoCompactMiddleware(active_model.auto_compact_threshold)
)
if self.config.context_warnings:
self.middleware_pipeline.add(
ContextWarningMiddleware(0.5, active_model.auto_compact_threshold)
)
self.middleware_pipeline.add(AutoCompactMiddleware())
if self.config.context_warnings:
self.middleware_pipeline.add(ContextWarningMiddleware(0.5))
self.middleware_pipeline.add(
ReadOnlyAgentMiddleware(
@ -425,6 +427,10 @@ class AgentLoop:
messages=self.messages, stats=self.stats, config=self.config
)
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}
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
headers: dict[str, str] = {
"user-agent": get_user_agent(provider.backend),
@ -577,39 +583,35 @@ class AgentLoop:
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 ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, error_msg, "failure")
yield self._tool_failure_event(tool_call, error_msg)
return
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
return
self.stats.tool_calls_agreed += 1
decision: ToolDecision | None = None
try:
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
cancelled=f"<{CANCELLATION_TAG}>" in skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
return
self.stats.tool_calls_agreed += 1
start_time = time.perf_counter()
result_model = None
async for item in tool_instance.invoke(
@ -637,6 +639,9 @@ class AgentLoop:
result_dict = result_model.model_dump()
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
extra = tool_instance.get_result_extra(result_model)
if extra:
text += "\n\n" + extra
self._handle_tool_response(
tool_call, text, "success", decision, result_dict
)
@ -644,6 +649,7 @@ class AgentLoop:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
cancelled=getattr(result_model, "cancelled", False),
duration=duration,
tool_call_id=tool_call.call_id,
)
@ -653,35 +659,27 @@ class AgentLoop:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, cancel, "failure", decision)
self.stats.tool_calls_failed += 1
yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True)
raise
except (ToolError, ToolPermissionError) as exc:
except Exception as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self._handle_tool_response(tool_call, error_msg, "failure", decision)
yield self._tool_failure_event(tool_call, error_msg, decision)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
async for event in self._emit_failed_tool_events(resolved.failed_calls):
yield event
if not resolved.tool_calls:
return
for tool_call in resolved.tool_calls:
yield ToolCallEvent(
tool_name=tool_call.tool_name,
@ -689,8 +687,62 @@ class AgentLoop:
args=tool_call.validated_args,
tool_call_id=tool_call.call_id,
)
async for event in self._process_one_tool_call(tool_call):
async for event in self._run_tools_concurrently(resolved.tool_calls):
yield event
async def _execute_tool_to_queue(
self,
tc: ResolvedToolCall,
queue: asyncio.Queue[ToolCallEvent | ToolResultEvent | ToolStreamEvent | None],
) -> None:
"""Run a single tool call, sending events to the queue."""
async for event in self._process_one_tool_call(tc):
await queue.put(event)
async def _run_tools_concurrently(
self, tool_calls: list[ResolvedToolCall]
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
"""Execute multiple tool calls concurrently, yielding events as they arrive."""
queue: asyncio.Queue[
ToolCallEvent | ToolResultEvent | ToolStreamEvent | None
] = asyncio.Queue()
tasks = [
asyncio.create_task(self._execute_tool_to_queue(tc, queue))
for tc in tool_calls
]
async def _signal_when_all_done() -> None:
try:
await asyncio.gather(*tasks, return_exceptions=True)
finally:
await queue.put(None)
monitor = asyncio.create_task(_signal_when_all_done())
try:
while True:
event = await queue.get()
if event is None:
break
yield event
except GeneratorExit:
for t in tasks:
if not t.done():
t.cancel()
raise
except asyncio.CancelledError:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
finally:
if not monitor.done():
monitor.cancel()
with contextlib.suppress(asyncio.CancelledError):
await monitor
def _handle_tool_response(
self,
@ -714,8 +766,27 @@ class AgentLoop:
result=result,
)
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
active_model = self.config.get_active_model()
def _tool_failure_event(
self,
tool_call: ResolvedToolCall,
error_msg: str,
decision: ToolDecision | None = None,
cancelled: bool = False,
) -> ToolResultEvent:
"""Create a ToolResultEvent for a failed tool and record the failure."""
self._handle_tool_response(tool_call, error_msg, "failure", decision)
return ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
cancelled=cancelled,
tool_call_id=tool_call.call_id,
)
async def _chat(
self, max_tokens: int | None = None, model_override: ModelConfig | None = None
) -> LLMChunk:
active_model = model_override or self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
available_tools = self.format_handler.get_available_tools(self.tool_manager)
@ -731,9 +802,7 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
metadata=self._build_metadata(),
)
end_time = time.perf_counter()
@ -777,9 +846,7 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
metadata=self._build_metadata(),
):
processed_message = self.format_handler.process_api_response_message(
chunk.message
@ -855,26 +922,17 @@ class AgentLoop:
approval_type=ToolPermission.ASK,
feedback="Tool execution not permitted.",
)
if asyncio.iscoroutinefunction(self.approval_callback):
async_callback = cast(AsyncApprovalCallback, self.approval_callback)
response, feedback = await async_callback(tool_name, args, tool_call_id)
else:
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
response, feedback = sync_callback(tool_name, args, tool_call_id)
response, feedback = await self.approval_callback(tool_name, args, tool_call_id)
match response:
case ApprovalResponse.YES:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
case ApprovalResponse.NO:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
verdict = ToolExecutionResponse.EXECUTE
case _:
verdict = ToolExecutionResponse.SKIP
return ToolDecision(
verdict=verdict, approval_type=ToolPermission.ASK, feedback=feedback
)
def _clean_message_history(self) -> None:
ACCEPTABLE_HISTORY_SIZE = 2
@ -892,17 +950,19 @@ class AgentLoop:
expected_responses = len(msg.tool_calls)
if expected_responses > 0:
actual_responses = 0
responded_ids: set[str] = set()
j = i + 1
while j < len(self.messages) and self.messages[j].role == "tool":
actual_responses += 1
if self.messages[j].tool_call_id:
responded_ids.add(self.messages[j].tool_call_id)
j += 1
if actual_responses < expected_responses:
insertion_point = i + 1 + actual_responses
if len(responded_ids) < expected_responses:
insertion_point = j
for call_idx in range(actual_responses, expected_responses):
tool_call_data = msg.tool_calls[call_idx]
for tool_call_data in msg.tool_calls:
if (tool_call_data.id or "") in responded_ids:
continue
empty_response = LLMMessage(
role=Role.tool,
@ -990,7 +1050,9 @@ class AgentLoop:
self.messages.append(
LLMMessage(role=Role.user, content=summary_request)
)
summary_result = await self._chat()
summary_result = await self._chat(
model_override=self.config.get_compaction_model()
)
if summary_result.usage is None:
raise AgentLoopLLMResponseError(
@ -1010,9 +1072,7 @@ class AgentLoop:
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
metadata=self._build_metadata(),
)
self.stats.context_tokens = actual_context_tokens

View file

@ -50,19 +50,25 @@ class AgentManager:
@property
def available_agents(self) -> dict[str, AgentProfile]:
installed = self._config.installed_agents
base = {
name: profile
for name, profile in self._available.items()
if not profile.install_required or name in installed
}
if self._config.enabled_agents:
return {
name: profile
for name, profile in self._available.items()
for name, profile in base.items()
if name_matches(name, self._config.enabled_agents)
}
if self._config.disabled_agents:
return {
name: profile
for name, profile in self._available.items()
for name, profile in base.items()
if not name_matches(name, self._config.disabled_agents)
}
return dict(self._available)
return base
@property
def config(self) -> VibeConfig:

View file

@ -41,6 +41,7 @@ class BuiltinAgentName(StrEnum):
ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve"
EXPLORE = "explore"
LEAN = "lean"
@dataclass(frozen=True)
@ -51,6 +52,7 @@ class AgentProfile:
safety: AgentSafety
agent_type: AgentType = AgentType.AGENT
overrides: dict[str, Any] = field(default_factory=dict)
install_required: bool = False
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
from vibe.core.config import VibeConfig as VC
@ -134,10 +136,51 @@ EXPLORE = AgentProfile(
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
)
LEAN = AgentProfile(
name=BuiltinAgentName.LEAN,
display_name="Lean",
description="Specialized mode for Lean 4 code analysis, proof assistance, and theorem proving",
safety=AgentSafety.NEUTRAL,
agent_type=AgentType.AGENT,
install_required=True,
overrides={
"system_prompt_id": "lean",
"active_model": "leanstral",
"providers": [
{
"name": "mistral-testing",
"api_base": "https://api.mistral.ai/v1",
"api_key_env_var": "MISTRAL_API_KEY",
"api_style": "reasoning",
"backend": "generic",
}
],
"models": [
{
"name": "labs-leanstral-2603",
"provider": "mistral-testing",
"alias": "leanstral",
"thinking": "high",
"temperature": 1.0,
"auto_compact_threshold": 168_000,
}
],
"compaction_model": {
"name": "mistral-vibe-cli-latest",
"provider": "mistral-testing",
"alias": "devstral-compact",
"temperature": 0.2,
"thinking": "off",
},
"tools": {"bash": {"default_timeout": 1200}},
},
)
BUILTIN_AGENTS: dict[str, AgentProfile] = {
BuiltinAgentName.DEFAULT: DEFAULT,
BuiltinAgentName.PLAN: PLAN,
BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
BuiltinAgentName.EXPLORE: EXPLORE,
BuiltinAgentName.LEAN: LEAN,
}

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from vibe.core.audio_recorder.audio_recorder import AudioRecorder
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
AudioRecorderPort,
AudioRecording,
IncompatibleSampleRateError,
NoAudioInputDeviceError,
RecordingMode,
)
__all__ = [
"AlreadyRecordingError",
"AudioBackendUnavailableError",
"AudioRecorder",
"AudioRecorderPort",
"AudioRecording",
"IncompatibleSampleRateError",
"NoAudioInputDeviceError",
"RecordingMode",
]

View file

@ -0,0 +1,286 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
import io
import struct
import threading
import time
from typing import TYPE_CHECKING
import wave
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
AudioRecording,
IncompatibleSampleRateError,
NoAudioInputDeviceError,
RecordingMode,
)
from vibe.core.logger import logger
try:
import sounddevice as sd
if TYPE_CHECKING:
from sounddevice import CallbackFlags, RawInputStream
except OSError:
sd = None # type: ignore[assignment]
DEFAULT_SAMPLE_RATE = 48_000
DEFAULT_CHANNELS = 1
DTYPE = "int16"
DEFAULT_BLOCKSIZE = 4096
DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
INT16_ABS_MAX = 2**15 - 1
DRAIN_TIMEOUT = 5.0
DEFAULT_MAX_DURATION = 300.0 # 5 min
class AudioRecorder:
"""Records audio from the default microphone using sounddevice.
Supports both buffer mode (stop returns WAV bytes) and streaming
mode (async generator yields chunks).
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._mode: RecordingMode = RecordingMode.BUFFER
self._stream: RawInputStream | None = None
self._frames: list[bytes] = []
self._peak: float = 0.0
self._recording: bool = False
self._start_time: float = 0.0
self._loop: asyncio.AbstractEventLoop | None = None
self._audio_queue: asyncio.Queue[bytes | None] | None = None
self._audio_queue_drained: threading.Event | None = None
self._max_duration_timer: threading.Timer | None = None
self._on_expire: Callable[[AudioRecording], object] | None = None
@property
def is_recording(self) -> bool:
return self._recording
@property
def mode(self) -> RecordingMode:
return self._mode
@property
def peak(self) -> float:
"""Current audio peak level normalized to [0.0, 1.0], updated per audio block."""
return self._peak
def start(
self,
mode: RecordingMode,
*,
sample_rate: int = DEFAULT_SAMPLE_RATE,
channels: int = DEFAULT_CHANNELS,
max_duration: float = DEFAULT_MAX_DURATION,
on_expire: Callable[[AudioRecording], object] | None = None,
) -> None:
with self._lock:
if self._recording:
raise AlreadyRecordingError("Already recording")
if not sd:
error_message = "sounddevice is not available, audio recording disabled"
logger.error(error_message)
raise AudioBackendUnavailableError(error_message)
try:
sample_rate = self._guard_audio_input(sample_rate, channels)
except NoAudioInputDeviceError as exc:
logger.error("No audio input device available, recording disabled")
raise exc
except IncompatibleSampleRateError as exc:
logger.warning(
"Requested sample rate %d Hz not supported, falling back to %d Hz",
sample_rate,
exc.fallback_sample_rate,
)
sample_rate = exc.fallback_sample_rate
self._mode = mode
self._sample_rate = sample_rate
self._channels = channels
self._peak = 0.0
self._start_time = time.monotonic()
self._frames = []
if mode == RecordingMode.BUFFER:
self._audio_queue = None
self._loop = None
self._audio_queue_drained = None
else:
self._audio_queue_drained = threading.Event()
try:
self._loop = asyncio.get_running_loop()
self._audio_queue = asyncio.Queue()
except RuntimeError:
self._loop = None
self._audio_queue = None
self._stream = sd.RawInputStream(
samplerate=self._sample_rate,
channels=self._channels,
dtype=DTYPE,
blocksize=DEFAULT_BLOCKSIZE,
callback=self._audio_callback,
)
self._stream.start()
self._recording = True
self._on_expire = on_expire
self._start_max_duration_timer(max_duration)
def stop(self, *, wait_for_queue_drained: bool = True) -> AudioRecording:
with self._lock:
if not self._recording or self._stream is None:
return AudioRecording(data=b"", duration=0.0)
self._reset_max_duration_timer()
self._stop_stream()
self._recording = False
duration = time.monotonic() - self._start_time
if self._mode == RecordingMode.BUFFER:
wav_data = self._encode_wav()
self._frames = []
return AudioRecording(data=wav_data, duration=duration)
loop = self._loop
self._push_sentinel()
try:
on_event_loop = asyncio.get_running_loop() is loop
except RuntimeError:
on_event_loop = False
if (
wait_for_queue_drained
and self._audio_queue_drained is not None
and not on_event_loop
):
self._audio_queue_drained.wait(timeout=DRAIN_TIMEOUT)
return AudioRecording(data=b"", duration=duration)
def cancel(self) -> None:
with self._lock:
if not self._recording or self._stream is None:
return
self._reset_max_duration_timer()
self._stop_stream()
self._recording = False
if self._mode == RecordingMode.BUFFER:
self._frames = []
else:
self._push_sentinel()
async def audio_stream(self) -> AsyncGenerator[bytes, None]:
queue = self._audio_queue
if queue is None:
return
audio_queue_drained = self._audio_queue_drained
try:
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
finally:
if audio_queue_drained is not None:
audio_queue_drained.set()
def _audio_callback(
self, indata: bytes, frames: int, time_info: object, status: CallbackFlags
) -> None:
if status:
logger.warning("Audio callback status: %s", status)
raw = bytes(indata)
n_samples = frames * self._channels
if n_samples > 0:
samples = struct.unpack(f"<{n_samples}h", raw)
self._peak = min(max(abs(s) for s in samples) / INT16_ABS_MAX, 1.0)
if self._mode == RecordingMode.BUFFER:
self._frames.append(raw)
if (
self._mode == RecordingMode.STREAM
and self._loop is not None
and self._audio_queue is not None
):
self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, raw)
def _stop_stream(self) -> None:
if self._stream is not None:
self._stream.stop()
self._stream.close()
self._stream = None
def _push_sentinel(self) -> None:
"""Push None to the audio queue to signal end-of-stream to the consumer."""
if self._loop is not None and self._audio_queue is not None:
self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, None)
self._audio_queue = None
self._loop = None
@staticmethod
def _guard_audio_input(sample_rate: int, channels: int) -> int:
if sd is None:
raise RuntimeError("sounddevice is not available")
try:
device_info = sd.query_devices(kind="input")
except Exception as exc:
raise NoAudioInputDeviceError("No audio input device available") from exc
try:
sd.check_input_settings(
samplerate=sample_rate, channels=channels, dtype=DTYPE
)
except sd.PortAudioError as exc:
fallback = int(device_info["default_samplerate"])
raise IncompatibleSampleRateError(
f"Requested sample rate {sample_rate} Hz is not supported by the default "
f"input device; device default is {fallback} Hz",
fallback_sample_rate=fallback,
) from exc
return sample_rate
def _encode_wav(self) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(self._channels)
wf.setsampwidth(DEFAULT_SAMPLE_WIDTH)
wf.setframerate(self._sample_rate)
wf.writeframes(b"".join(self._frames))
return buf.getvalue()
def _on_max_duration_expired(self) -> None:
result = self.stop()
if self._on_expire is not None:
self._on_expire(result)
def _start_max_duration_timer(self, max_duration: float) -> None:
if max_duration <= 0:
return
self._max_duration_timer = threading.Timer(
max_duration, self._on_max_duration_expired
)
self._max_duration_timer.daemon = True
self._max_duration_timer.start()
def _reset_max_duration_timer(self) -> None:
if self._max_duration_timer is None:
return
self._max_duration_timer.cancel()
self._max_duration_timer = None

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
class RecordingMode(StrEnum):
BUFFER = auto()
STREAM = auto()
@dataclass(frozen=True, slots=True)
class AudioRecording:
"""Result of a completed recording."""
data: bytes
duration: float
class AlreadyRecordingError(Exception):
pass
class AudioBackendUnavailableError(Exception):
pass
class NoAudioInputDeviceError(Exception):
pass
class IncompatibleSampleRateError(Exception):
def __init__(self, message: str, fallback_sample_rate: int) -> None:
super().__init__(message)
self.fallback_sample_rate = fallback_sample_rate
class AudioRecorderPort(Protocol):
@property
def is_recording(self) -> bool: ...
@property
def peak(self) -> float: ...
@property
def mode(self) -> RecordingMode: ...
def start(
self,
mode: RecordingMode,
*,
sample_rate: int = ...,
channels: int = ...,
max_duration: float = ...,
on_expire: Callable[[AudioRecording], object] | None = ...,
) -> None: ...
def stop(self, *, wait_for_queue_drained: bool = ...) -> AudioRecording: ...
def cancel(self) -> None: ...
def audio_stream(self) -> AsyncGenerator[bytes, None]: ...

View file

@ -5,6 +5,10 @@ from pathlib import Path
from typing import NamedTuple
from vibe.core.autocompletion.file_indexer import FileIndexer, IndexEntry
from vibe.core.autocompletion.file_indexer.store import (
ASCII_CODEPOINT_LIMIT,
build_ascii_mask,
)
from vibe.core.autocompletion.fuzzy import fuzzy_match
DEFAULT_MAX_ENTRIES_TO_PROCESS = 32000
@ -67,6 +71,18 @@ class CommandCompleter(Completer):
class PathCompleter(Completer):
class MatchRank(NamedTuple):
exact_directory: int
immediate_child_of_exact_path: int
exact_filename: int
preferred_stem_match: int
exact_stem: int
stem_prefix: int
name_prefix: int
extension_match: int
fuzzy_score: float
shallow_path: int
def __init__(
self,
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
@ -82,6 +98,7 @@ class PathCompleter(Completer):
search_pattern: str
path_prefix: str
immediate_only: bool
search_pattern_ascii_mask: int | None
def _extract_partial(self, before_cursor: str) -> str | None:
if "@" not in before_cursor:
@ -97,11 +114,16 @@ class PathCompleter(Completer):
def _build_search_context(self, partial_path: str) -> _SearchContext:
suffix = partial_path.split("/")[-1]
search_pattern_ascii_mask = self._build_query_ascii_mask(partial_path)
if not partial_path:
# "@" => show top-level dir and files
return self._SearchContext(
search_pattern="", path_prefix="", suffix=suffix, immediate_only=True
search_pattern="",
path_prefix="",
suffix=suffix,
immediate_only=True,
search_pattern_ascii_mask=search_pattern_ascii_mask,
)
if partial_path.endswith("/"):
@ -111,6 +133,7 @@ class PathCompleter(Completer):
path_prefix=partial_path,
suffix=suffix,
immediate_only=True,
search_pattern_ascii_mask=search_pattern_ascii_mask,
)
return self._SearchContext(
@ -119,29 +142,40 @@ class PathCompleter(Completer):
path_prefix="",
suffix=suffix,
immediate_only=False,
search_pattern_ascii_mask=search_pattern_ascii_mask,
)
def _build_query_ascii_mask(self, pattern: str) -> int | None:
if any(ord(char) >= ASCII_CODEPOINT_LIMIT for char in pattern):
return None
return build_ascii_mask(pattern.lower())
def _is_immediate_child_of_prefix(self, path_str: str, prefix: str) -> bool:
prefix_without_slash = prefix.rstrip("/")
prefix_with_slash = f"{prefix_without_slash}/"
if path_str.startswith(prefix_with_slash):
after_prefix = path_str[len(prefix_with_slash) :]
else:
idx = path_str.find(prefix_with_slash)
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
return False
after_prefix = path_str[idx + len(prefix_with_slash) :]
return bool(after_prefix) and "/" not in after_prefix
def _matches_prefix(self, entry: IndexEntry, context: _SearchContext) -> bool:
path_str = entry.rel
if context.path_prefix:
prefix_without_slash = context.path_prefix.rstrip("/")
prefix_with_slash = f"{prefix_without_slash}/"
if path_str == prefix_without_slash and entry.is_dir:
# do not suggest the dir itself (e.g. "@src/" => don't suggest "@src/")
return False
if path_str.startswith(prefix_with_slash):
after_prefix = path_str[len(prefix_with_slash) :]
else:
idx = path_str.find(prefix_with_slash)
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
return False
after_prefix = path_str[idx + len(prefix_with_slash) :]
# only suggest files/dirs that are immediate children of the prefix
return bool(after_prefix) and "/" not in after_prefix
return self._is_immediate_child_of_prefix(path_str, context.path_prefix)
if context.immediate_only and "/" in path_str:
# when user just typed "@", only show top-level entries
@ -153,15 +187,72 @@ class PathCompleter(Completer):
def _is_visible(self, entry: IndexEntry, context: _SearchContext) -> bool:
return not (entry.name.startswith(".") and not context.suffix.startswith("."))
def _can_possibly_fuzzy_match(
self, entry: IndexEntry, context: _SearchContext
) -> bool:
if context.search_pattern_ascii_mask is None:
return True
return (
entry.ascii_mask & context.search_pattern_ascii_mask
) == context.search_pattern_ascii_mask
def _format_label(self, entry: IndexEntry) -> str:
suffix = "/" if entry.is_dir else ""
return f"@{entry.rel}{suffix}"
def _build_match_rank(
self, entry: IndexEntry, context: _SearchContext, fuzzy_score: float
) -> MatchRank:
query = context.suffix.lower()
if not query:
return self.MatchRank(
exact_directory=0,
immediate_child_of_exact_path=0,
exact_filename=0,
preferred_stem_match=0,
exact_stem=0,
stem_prefix=0,
name_prefix=0,
extension_match=0,
fuzzy_score=fuzzy_score,
shallow_path=-entry.rel.count("/"),
)
name = entry.name.lower()
rel = entry.rel.lower()
stem = Path(entry.name).stem.lower()
extension = Path(entry.name).suffix.lower()
query_extension = Path(query).suffix.lower()
query_stem = Path(query).stem.lower()
query_looks_like_filename = "." in query
query_looks_like_path = "/" in context.search_pattern
exact_directory = int(entry.is_dir and rel == context.search_pattern.lower())
immediate_child_of_exact_path = int(
query_looks_like_path
and self._is_immediate_child_of_prefix(rel, context.search_pattern.lower())
)
return self.MatchRank(
exact_directory=exact_directory,
immediate_child_of_exact_path=immediate_child_of_exact_path,
exact_filename=int(query_looks_like_filename and name == query),
preferred_stem_match=int(stem == query and extension != ".lock"),
exact_stem=int(
stem == query or (query_looks_like_filename and stem == query_stem)
),
stem_prefix=int(
stem.startswith(query_stem if query_looks_like_filename else query)
),
name_prefix=int(name.startswith(query)),
extension_match=int(bool(query_extension) and extension == query_extension),
fuzzy_score=fuzzy_score,
shallow_path=-entry.rel.count("/"),
)
def _score_matches(
self, entries: list[IndexEntry], context: _SearchContext
) -> list[tuple[str, float]]:
scored_matches: list[tuple[str, float]] = []
MAX_MATCHES = 50
) -> list[tuple[str, PathCompleter.MatchRank]]:
scored_matches: list[tuple[str, PathCompleter.MatchRank]] = []
for i, entry in enumerate(entries):
if i >= self._max_entries_to_process:
@ -176,24 +267,27 @@ class PathCompleter(Completer):
label = self._format_label(entry)
if not context.search_pattern:
scored_matches.append((label, 0.0))
rank = self._build_match_rank(entry, context, 0.0)
scored_matches.append((label, rank))
if len(scored_matches) >= self._target_matches:
break
continue
if not self._can_possibly_fuzzy_match(entry, context):
continue
match_result = fuzzy_match(
context.search_pattern, entry.rel, entry.rel_lower
)
if match_result.matched:
scored_matches.append((label, match_result.score))
if (
len(scored_matches) >= self._target_matches
and match_result.score > MAX_MATCHES
):
break
rank = self._build_match_rank(entry, context, match_result.score)
scored_matches.append((label, rank))
scored_matches.sort(key=lambda x: (-x[1], x[0]))
return scored_matches
# Sort alphabetically first, then by descending rank; Python's stable sort
# keeps the label order for entries with equal ranks.
scored_matches.sort(key=lambda x: x[0])
scored_matches.sort(key=lambda x: x[1], reverse=True)
return scored_matches[: self._target_matches]
def _collect_matches(self, text: str, cursor_pos: int) -> list[str]:
before_cursor = text[:cursor_pos]

View file

@ -8,6 +8,8 @@ from pathlib import Path
from vibe.core.autocompletion.file_indexer.ignore_rules import IgnoreRules
from vibe.core.autocompletion.file_indexer.watcher import Change
ASCII_CODEPOINT_LIMIT = 128
@dataclass(slots=True)
class FileIndexStats:
@ -22,6 +24,17 @@ class IndexEntry:
name: str
path: Path
is_dir: bool
ascii_mask: int
def build_ascii_mask(value: str) -> int:
mask = 0
for char in value:
codepoint = ord(char)
if codepoint >= ASCII_CODEPOINT_LIMIT:
continue
mask |= 1 << codepoint
return mask
class FileIndexStore:
@ -118,8 +131,14 @@ class FileIndexStore:
) -> IndexEntry | None:
if self._ignore_rules.should_ignore(rel_str, name, is_dir):
return None
rel_lower = rel_str.lower()
return IndexEntry(
rel=rel_str, rel_lower=rel_str.lower(), name=name, path=path, is_dir=is_dir
rel=rel_str,
rel_lower=rel_lower,
name=name,
path=path,
is_dir=is_dir,
ascii_mask=build_ascii_mask(rel_lower),
)
def _walk_directory(

View file

@ -4,6 +4,8 @@ from vibe.core.config._settings import (
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_TRANSCRIBE_MODELS,
DEFAULT_TRANSCRIBE_PROVIDERS,
Backend,
MCPHttp,
MCPServer,
@ -16,6 +18,9 @@ from vibe.core.config._settings import (
ProviderConfig,
SessionLoggingConfig,
TomlFileSettingsSource,
TranscribeClient,
TranscribeModelConfig,
TranscribeProviderConfig,
VibeConfig,
load_dotenv_values,
)
@ -24,6 +29,8 @@ __all__ = [
"DEFAULT_MISTRAL_API_ENV_KEY",
"DEFAULT_MODELS",
"DEFAULT_PROVIDERS",
"DEFAULT_TRANSCRIBE_MODELS",
"DEFAULT_TRANSCRIBE_PROVIDERS",
"Backend",
"MCPHttp",
"MCPServer",
@ -36,6 +43,9 @@ __all__ = [
"ProviderConfig",
"SessionLoggingConfig",
"TomlFileSettingsSource",
"TranscribeClient",
"TranscribeModelConfig",
"TranscribeProviderConfig",
"VibeConfig",
"load_dotenv_values",
]

View file

@ -93,7 +93,6 @@ class ProjectContextConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
default_commit_count: int = 5
max_doc_bytes: int = 32 * 1024
timeout_seconds: float = 2.0
@ -131,6 +130,17 @@ class ProviderConfig(BaseModel):
region: str = ""
class TranscribeClient(StrEnum):
MISTRAL = auto()
class TranscribeProviderConfig(BaseModel):
name: str
api_base: str = "wss://api.mistral.ai"
api_key_env_var: str = ""
client: TranscribeClient = TranscribeClient.MISTRAL
class _MCPBase(BaseModel):
name: str = Field(description="Short alias used to prefix tool names")
prompt: str | None = Field(
@ -229,6 +239,13 @@ MCPServer = Annotated[
]
def _default_alias_to_name(data: Any) -> Any:
if isinstance(data, dict):
if "alias" not in data or data["alias"] is None:
data["alias"] = data.get("name")
return data
class ModelConfig(BaseModel):
name: str
provider: str
@ -239,13 +256,19 @@ class ModelConfig(BaseModel):
thinking: Literal["off", "low", "medium", "high"] = "off"
auto_compact_threshold: int = 200_000
@model_validator(mode="before")
@classmethod
def _default_alias_to_name(cls, data: Any) -> Any:
if isinstance(data, dict):
if "alias" not in data or data["alias"] is None:
data["alias"] = data.get("name")
return data
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class TranscribeModelConfig(BaseModel):
name: str
provider: str
alias: str
sample_rate: int = 16000
encoding: Literal["pcm_s16le"] = "pcm_s16le"
language: str = "en"
target_streaming_delay_ms: int = 500
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
@ -289,6 +312,22 @@ DEFAULT_MODELS = [
),
]
DEFAULT_TRANSCRIBE_PROVIDERS = [
TranscribeProviderConfig(
name="mistral",
api_base="wss://api.mistral.ai",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
)
]
DEFAULT_TRANSCRIBE_MODELS = [
TranscribeModelConfig(
name="voxtral-mini-transcribe-realtime-2602",
provider="mistral",
alias="voxtral-realtime",
)
]
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
@ -298,6 +337,8 @@ class VibeConfig(BaseSettings):
file_watcher_for_autocomplete: bool = False
displayed_workdir: str = ""
context_warnings: bool = False
voice_mode_enabled: bool = False
active_transcribe_model: str = "voxtral-realtime"
auto_approve: bool = False
enable_telemetry: bool = True
system_prompt_id: str = "cli"
@ -323,6 +364,14 @@ class VibeConfig(BaseSettings):
default_factory=lambda: list(DEFAULT_PROVIDERS)
)
models: list[ModelConfig] = Field(default_factory=lambda: list(DEFAULT_MODELS))
compaction_model: ModelConfig | None = None
transcribe_providers: list[TranscribeProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)
)
transcribe_models: list[TranscribeModelConfig] = Field(
default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
)
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
@ -378,6 +427,12 @@ class VibeConfig(BaseSettings):
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
installed_agents: list[str] = Field(
default_factory=list,
description=(
"A list of opt-in builtin agent names that have been explicitly installed."
),
)
skill_paths: list[Path] = Field(
default_factory=list,
description=(
@ -405,6 +460,10 @@ class VibeConfig(BaseSettings):
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("exclude_none", True)
return super().model_dump(**kwargs)
@property
def nuage_api_key(self) -> str:
return os.getenv(self.nuage_api_key_env_var, "")
@ -437,6 +496,11 @@ class VibeConfig(BaseSettings):
f"Active model '{self.active_model}' not found in configuration."
)
def get_compaction_model(self) -> ModelConfig:
if self.compaction_model is not None:
return self.compaction_model
return self.get_active_model()
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
for provider in self.providers:
if provider.name == model.provider:
@ -445,6 +509,24 @@ class VibeConfig(BaseSettings):
f"Provider '{model.provider}' for model '{model.name}' not found in configuration."
)
def get_active_transcribe_model(self) -> TranscribeModelConfig:
for model in self.transcribe_models:
if model.alias == self.active_transcribe_model:
return model
raise ValueError(
f"Active transcribe model '{self.active_transcribe_model}' not found in configuration."
)
def get_transcribe_provider_for_model(
self, model: TranscribeModelConfig
) -> TranscribeProviderConfig:
for provider in self.transcribe_providers:
if provider.name == model.provider:
return provider
raise ValueError(
f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
)
@classmethod
def settings_customise_sources(
cls,
@ -480,6 +562,24 @@ class VibeConfig(BaseSettings):
]
return self
@model_validator(mode="after")
def _check_compaction_model_provider(self) -> VibeConfig:
if self.compaction_model is None:
return self
compaction_provider = self.get_provider_for_model(self.compaction_model)
try:
active_provider = self.get_provider_for_model(self.get_active_model())
except ValueError:
return self
if active_provider.name != compaction_provider.name:
raise ValueError(
f"Compaction model '{self.compaction_model.alias}' uses provider "
f"'{compaction_provider.name}' but active model uses provider "
f"'{active_provider.name}'. They must share the same provider."
)
return self
@model_validator(mode="after")
def _check_api_key(self) -> VibeConfig:
try:
@ -534,6 +634,17 @@ class VibeConfig(BaseSettings):
seen_aliases.add(model.alias)
return self
@model_validator(mode="after")
def _validate_transcribe_model_uniqueness(self) -> VibeConfig:
seen_aliases: set[str] = set()
for model in self.transcribe_models:
if model.alias in seen_aliases:
raise ValueError(
f"Duplicate transcribe 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
@ -558,7 +669,13 @@ class VibeConfig(BaseSettings):
and isinstance(target.get(key), list)
and isinstance(value, list)
):
if key in {"providers", "models"}:
if key in {
"providers",
"models",
"transcribe_providers",
"transcribe_models",
"installed_agents",
}:
target[key] = value
else:
target[key] = list(set(value + target[key]))
@ -566,9 +683,7 @@ class VibeConfig(BaseSettings):
target[key] = value
deep_merge(current_config, updates)
cls.dump_config(
to_jsonable_python(current_config, exclude_none=True, fallback=str)
)
cls.dump_config(current_config)
@classmethod
def dump_config(cls, config: dict[str, Any]) -> None:
@ -578,11 +693,29 @@ class VibeConfig(BaseSettings):
target = mgr.config_file or mgr.user_config_file
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as f:
tomli_w.dump(config, f)
tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f)
@classmethod
def _migrate(cls) -> None:
pass
mgr = get_harness_files_manager()
if not mgr.persist_allowed:
return
file = mgr.config_file
if file is None:
return
try:
with file.open("rb") as f:
data = tomllib.load(f)
except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
return
bash_tools = data.get("tools", {}).get("bash", {})
allowlist = bash_tools.get("allowlist")
if allowlist is None or "find" not in allowlist:
return
allowlist.remove("find")
cls.dump_config(data)
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
@ -592,7 +725,7 @@ class VibeConfig(BaseSettings):
@classmethod
def create_default(cls) -> dict[str, Any]:
config = cls.model_construct()
config_dict = config.model_dump(mode="json", exclude_none=True)
config_dict = config.model_dump(mode="json")
from vibe.core.tools.manager import ToolManager

View file

@ -10,7 +10,7 @@ from vibe.core.config.harness_files._paths import (
GLOBAL_SKILLS_DIR,
GLOBAL_TOOLS_DIR,
)
from vibe.core.paths import AGENTS_MD_FILENAMES, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.trusted_folders import trusted_folders_manager
FileSource = Literal["user", "project"]
@ -110,17 +110,85 @@ class HarnessFilesManager:
d = GLOBAL_PROMPTS_DIR.path
return [d] if d.is_dir() else []
def load_project_doc(self, max_bytes: int) -> str:
def load_user_doc(self) -> str:
if "user" not in self.sources:
return ""
path = VIBE_HOME.path / AGENTS_MD_FILENAME
try:
content = path.read_text("utf-8", errors="ignore")
stripped = content.strip()
return stripped if stripped else ""
except (FileNotFoundError, OSError):
return ""
def _collect_agents_md(
self, start: Path, stop: Path, *, stop_inclusive: bool
) -> list[tuple[Path, str]]:
"""Walk up from start toward stop, collecting non-empty AGENTS.md files.
Returns ``(directory, content)`` pairs ordered outermost-first.
When ``stop_inclusive`` is True the stop directory is included in the
walk; when False the walk stops before reaching it.
"""
if not start.is_relative_to(stop):
return []
docs: list[tuple[Path, str]] = []
current = start
while True:
if current == stop and not stop_inclusive:
break
path = current / AGENTS_MD_FILENAME
try:
content = path.read_text("utf-8", errors="ignore")
stripped = content.strip()
if stripped:
docs.append((current, stripped))
except (FileNotFoundError, OSError):
pass
if current == stop:
break
parent = current.parent
if parent == current: # fs-root safety
break
current = parent
docs.reverse() # outermost first
return docs
def find_subdirectory_agents_md(self, file_path: Path) -> list[tuple[Path, str]]:
"""Find AGENTS.md files between file_path's parent and cwd (exclusive of cwd).
For lazy injection when reading files in subdirectories below cwd.
Returns (directory, content) pairs, outermost first.
Does not overlap with load_project_docs() which covers cwd and above.
"""
workdir = self.trusted_workdir
if workdir is None:
return ""
for name in AGENTS_MD_FILENAMES:
path = workdir / name
try:
return path.read_text("utf-8", errors="ignore")[:max_bytes]
except (FileNotFoundError, OSError):
continue
return ""
return []
cwd = workdir.resolve()
try:
resolved = file_path.resolve()
except (ValueError, OSError):
return []
if not resolved.is_relative_to(cwd):
return []
start = resolved if resolved.is_dir() else resolved.parent
return self._collect_agents_md(start, cwd, stop_inclusive=False)
def load_project_docs(self) -> list[tuple[Path, str]]:
"""Walk up from cwd to the trust root, collecting AGENTS.md files.
Returns ``(directory, content)`` pairs ordered outermost-first
(trust root first, cwd last). Later entries take priority.
"""
workdir = self.trusted_workdir
if workdir is None:
return []
cwd = workdir.resolve()
trust_root = trusted_folders_manager.find_trust_root(cwd)
if trust_root is None:
return []
return self._collect_agents_md(cwd, trust_root, stop_inclusive=True)
_manager: HarnessFilesManager | None = None

View file

@ -7,8 +7,28 @@ import types
from typing import TYPE_CHECKING, NamedTuple, cast
import httpx
import mistralai
from mistralai.utils.retries import BackoffStrategy, RetryConfig
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
from mistralai.client.models import (
AssistantMessage,
AssistantMessageContent,
ChatCompletionRequestMessage,
ChatCompletionStreamRequestToolChoice,
FileChunk,
Function,
FunctionCall as MistralFunctionCall,
FunctionName,
SystemMessage,
TextChunk,
ThinkChunk,
Tool,
ToolCall as MistralToolCall,
ToolChoice,
ToolChoiceEnum,
ToolMessage,
UserMessage,
)
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
@ -35,35 +55,33 @@ class ParsedContent(NamedTuple):
class MistralMapper:
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
def prepare_message(self, msg: LLMMessage) -> ChatCompletionRequestMessage:
match msg.role:
case Role.system:
return mistralai.SystemMessage(role="system", content=msg.content or "")
return SystemMessage(role="system", content=msg.content or "")
case Role.user:
return mistralai.UserMessage(role="user", content=msg.content)
return UserMessage(role="user", content=msg.content)
case Role.assistant:
content: mistralai.AssistantMessageContent
content: AssistantMessageContent
if msg.reasoning_content:
content = [
mistralai.ThinkChunk(
ThinkChunk(
type="thinking",
thinking=[
mistralai.TextChunk(
type="text", text=msg.reasoning_content
)
TextChunk(type="text", text=msg.reasoning_content)
],
),
mistralai.TextChunk(type="text", text=msg.content or ""),
TextChunk(type="text", text=msg.content or ""),
]
else:
content = msg.content or ""
return mistralai.AssistantMessage(
return AssistantMessage(
role="assistant",
content=content,
tool_calls=[
mistralai.ToolCall(
function=mistralai.FunctionCall(
MistralToolCall(
function=MistralFunctionCall(
name=tc.function.name or "",
arguments=tc.function.arguments or "",
),
@ -75,17 +93,17 @@ class MistralMapper:
],
)
case Role.tool:
return mistralai.ToolMessage(
return ToolMessage(
role="tool",
content=msg.content,
tool_call_id=msg.tool_call_id,
name=msg.name,
)
def prepare_tool(self, tool: AvailableTool) -> mistralai.Tool:
return mistralai.Tool(
def prepare_tool(self, tool: AvailableTool) -> Tool:
return Tool(
type="function",
function=mistralai.Function(
function=Function(
name=tool.function.name,
description=tool.function.description,
parameters=tool.function.parameters,
@ -94,16 +112,15 @@ class MistralMapper:
def prepare_tool_choice(
self, tool_choice: StrToolChoice | AvailableTool
) -> mistralai.ChatCompletionStreamRequestToolChoice:
) -> ChatCompletionStreamRequestToolChoice:
if isinstance(tool_choice, str):
return cast(mistralai.ToolChoiceEnum, tool_choice)
return cast(ToolChoiceEnum, tool_choice)
return mistralai.ToolChoice(
type="function",
function=mistralai.FunctionName(name=tool_choice.function.name),
return ToolChoice(
type="function", function=FunctionName(name=tool_choice.function.name)
)
def _extract_thinking_text(self, chunk: mistralai.ThinkChunk) -> str:
def _extract_thinking_text(self, chunk: ThinkChunk) -> str:
thinking_content = getattr(chunk, "thinking", None)
if not thinking_content:
return ""
@ -115,27 +132,25 @@ class MistralMapper:
parts.append(inner)
return "".join(parts)
def parse_content(
self, content: mistralai.AssistantMessageContent
) -> ParsedContent:
def parse_content(self, content: AssistantMessageContent) -> ParsedContent:
if isinstance(content, str):
return ParsedContent(content=content, reasoning_content=None)
concat_content = ""
concat_reasoning = ""
for chunk in content:
if isinstance(chunk, mistralai.FileChunk):
if isinstance(chunk, FileChunk):
continue
if isinstance(chunk, mistralai.TextChunk):
if isinstance(chunk, TextChunk):
concat_content += chunk.text
elif isinstance(chunk, mistralai.ThinkChunk):
elif isinstance(chunk, ThinkChunk):
concat_reasoning += self._extract_thinking_text(chunk)
return ParsedContent(
content=concat_content,
reasoning_content=concat_reasoning if concat_reasoning else None,
)
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
def parse_tool_calls(self, tool_calls: list[MistralToolCall]) -> list[ToolCall]:
return [
ToolCall(
id=tool_call.id,
@ -153,7 +168,7 @@ class MistralMapper:
class MistralBackend:
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
self._client: mistralai.Mistral | None = None
self._client: Mistral | None = None
self._provider = provider
self._mapper = MistralMapper()
self._api_key = (
@ -208,15 +223,15 @@ class MistralBackend:
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
)
def _create_mistral_client(self) -> mistralai.Mistral:
return mistralai.Mistral(
def _create_mistral_client(self) -> Mistral:
return Mistral(
api_key=self._api_key,
server_url=self._server_url,
timeout_ms=int(self._timeout * 1000),
retry_config=self._retry_config,
)
def _get_client(self) -> mistralai.Mistral:
def _get_client(self) -> Mistral:
if self._client is None:
self._client = self._create_mistral_client()
return self._client
@ -273,7 +288,7 @@ class MistralBackend:
),
)
except mistralai.SDKError as e:
except SDKError as e:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,
@ -351,7 +366,7 @@ class MistralBackend:
),
)
except mistralai.SDKError as e:
except SDKError as e:
raise BackendErrorBuilder.build_http_error(
provider=self._provider.name,
endpoint=self._server_url,

View file

@ -106,6 +106,13 @@ class ReasoningAdapter(APIAdapter):
return payload
def _strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
if msg.role != Role.assistant or not msg.reasoning_content:
return msg
return msg.model_copy(
update={"reasoning_content": None, "reasoning_signature": None}
)
def prepare_request( # noqa: PLR0913
self,
*,
@ -121,6 +128,8 @@ class ReasoningAdapter(APIAdapter):
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
if thinking == "off":
merged_messages = [self._strip_reasoning(msg) for msg in merged_messages]
converted_messages = [self._convert_message(msg) for msg in merged_messages]
payload = self._build_payload(

View file

@ -79,16 +79,14 @@ class PriceLimitMiddleware:
class AutoCompactMiddleware:
def __init__(self, threshold: int) -> None:
self.threshold = threshold
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if context.stats.context_tokens >= self.threshold:
threshold = context.config.get_active_model().auto_compact_threshold
if threshold > 0 and context.stats.context_tokens >= threshold:
return MiddlewareResult(
action=MiddlewareAction.COMPACT,
metadata={
"old_tokens": context.stats.context_tokens,
"threshold": self.threshold,
"threshold": threshold,
},
)
return MiddlewareResult()
@ -98,19 +96,16 @@ class AutoCompactMiddleware:
class ContextWarningMiddleware:
def __init__(
self, threshold_percent: float = 0.5, max_context: int | None = None
) -> None:
def __init__(self, threshold_percent: float = 0.5) -> None:
self.threshold_percent = threshold_percent
self.max_context = max_context
self.has_warned = False
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if self.has_warned:
return MiddlewareResult()
max_context = self.max_context
if max_context is None:
max_context = context.config.get_active_model().auto_compact_threshold
if max_context <= 0:
return MiddlewareResult()
if context.stats.context_tokens >= max_context * self.threshold_percent:

View file

@ -13,10 +13,10 @@ from vibe.core.paths._vibe_home import (
VIBE_HOME,
GlobalPath,
)
from vibe.core.paths.conventions import AGENTS_MD_FILENAMES
from vibe.core.paths.conventions import AGENTS_MD_FILENAME
__all__ = [
"AGENTS_MD_FILENAMES",
"AGENTS_MD_FILENAME",
"DEFAULT_TOOL_DIR",
"GLOBAL_ENV_FILE",
"HISTORY_FILE",

View file

@ -1,3 +1,3 @@
from __future__ import annotations
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
AGENTS_MD_FILENAME = "AGENTS.md"

View file

@ -14,16 +14,18 @@ class Prompt(StrEnum):
return (_PROMPTS_DIR / self.value).with_suffix(".md")
def read(self) -> str:
return self.path.read_text(encoding="utf-8").strip()
return self.path.read_text(encoding="utf-8", errors="ignore").strip()
class SystemPrompt(Prompt):
CLI = auto()
EXPLORE = auto()
TESTS = auto()
LEAN = auto()
class UtilityPrompt(Prompt):
AGENTS_DOC = auto()
COMPACT = auto()
DANGEROUS_DIRECTORY = auto()
PROJECT_CONTEXT = auto()

View file

@ -0,0 +1,5 @@
Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written. When both user-level and project-level instructions are present, project instructions take priority over user instructions. When multiple project-level AGENTS.md files are present, instructions closer to the working directory take priority. Each AGENTS.md applies to its own directory and all of its descendants within the project.
$sections
IMPORTANT: this context may or may not be relevant to your tasks. You should act on these guidelines if they are relevant to your task.

View file

@ -1,5 +1,5 @@
directoryStructure: Project context scanning has been disabled because {reason}. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
directoryStructure: Project context scanning has been disabled because $reason. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
Absolute path: {abs_path}
Absolute path: $abs_path
gitStatus: Use git tools to check repository status if needed.

166
vibe/core/prompts/lean.md Normal file
View file

@ -0,0 +1,166 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
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.
Determine the task type:
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
Identify constraints: language, framework, test setup, and any user restrictions on scope.
When given a complex, multi-file architectural task: summarize your understanding and wait for user confirmation. For targeted tasks, including writing specific Lean proofs or single-file bug fixes, do not wait. Plan internally and execute immediately.
Phase 2 — Plan
State your plan before writing code:
List files to edit and the specific modifications per file.
Multi-file modifications: numbered checklist. Single-file fix: one-line plan.
No time estimates. Concrete actions only.
Phase 3 — Execute & Verify
Apply modifications, then confirm they work:
Edit one logical unit at a time.
After each unit, verify: run tests, or read back the file to confirm the edit landed.
Never claim completion without verification — a passing test, correct read-back, or successful build.
Lean Rules
Create a New Package or Project
Usually, use the mathlib4 dependency. Run `lake +leanprover-community/mathlib4:lean-toolchain new <your_project_name> math` to create a new project with mathlib4 as a dependency.
Otherwise run `lake init <your_project_name>`.
Add External Dependencies
You can add external dependencies by adding to lakefile.toml, for example:
```
[[require]]
name = "mathlib"
git = "https://github.com/leanprover-community/mathlib4.git"
```
Whenever you create a new package or add a new external dependency, run `lake exe cache get` to download cache for them. Do not build before downloading all the necessary dependencies. Never manually edit `lake-manifest.json`, use `lake` commands to update it.
Work incrementally and in blocks. Make a plan before you take on a big project.
Imports
Put imports at the beginning of a file.
Compile a Package or a File
Before compiling or building for the first time, check if external dependencies are in the cache. If not, run `lake exe cache get`.
Run `lake build` to check the entire repository's correctness or `lake build <file>` for one file. Check lakefile.toml for build targets. Prefer `lake build <file>` while developing, it is a lot faster. To check a standalone Lean file which not tracked by lake, such as a test file, use `lake env lean <file>`.
Tactics
Make use of the `grind` tactic when possible if using Lean version >= 4.22.0. It is very powerful.
Debug
View the current goal and proof state by inserting the `trace_state` tactic before the line in question.
Complete the Work
When tasked with writing code or a Lean proof, do not stop until you find the complete working solution. Do not leave incomplete code, stubs, or use sorry in Lean unless the user explicitly instructs you to.
Hard Rules
Don't be Lazy
When the user asks you to perform something, be laser-focused and do not settle for easier things.
Never Commit
Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
Respect User Constraints
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
Don't Remove What Wasn't Asked
If user asks to fix X, do not rewrite, delete, or restructure Y.
Don't Assert — Verify
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
Break Loops
If approach isn't working after 2 attempts at the same region, STOP:
Re-read the code and error output.
Identify why it failed, not just what failed.
Choose a fundamentally different strategy.
If stuck, ask the user one specific question.
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
After creating test files that are not going to be used once the task is complete, remember to remove them.
Response Format
No Noise
No greetings, outros, hedging, puffery, or tool narration.
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
No unsolicited tutorials. Do not explain concepts the user clearly knows.
Structure First
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
For modification tasks:
file_path:line_number
langcode
Prefer Brevity
State only what's necessary to complete the task. Code + file reference > explanation.
If your response exceeds 300 words, remove explanations the user didn't request.
For investigate tasks:
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
request → auth.verify() → permissions.check() → handler
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
BAD: "The authentication flow works by first checking the token…"
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
Visual Formats
Before responding with structural data, choose the right format:
BAD: Bullet lists for hierarchy/tree
GOOD: ASCII tree (├──/└──)
BAD: Prose or bullet lists for comparisons/config/options
GOOD: Markdown table
BAD: Prose for Flows/pipelines
GOOD: → A → B → C diagrams
Interaction Design
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
Good: "Apply this fix to the other 3 endpoints?"
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
Bad: "Does this look good?", "Anything else?", "Let me know"
If unambiguous and complete, end with the result.
Length
Default to minimal prose. Your conversational text should be <100 words. However, this length restriction does NOT apply to code, scripts, or Lean proofs. Code and proofs must always be fully written out and functional, no matter how many lines they require.
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
Code Modifications (Change tasks)
Read First, Edit Second
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
Minimal, Focused Changes
Only modify what was requested. No extra features, abstractions, or speculative error handling.
Match existing style: indentation, naming, comment density, error handling.
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
Security
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
Code References
Cite as file_path:line_number.
Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
No over-the-top validation.
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.

View file

@ -1,4 +1,4 @@
Absolute path: {abs_path}
Absolute path: $abs_path
gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
{git_status}
$git_status

View file

@ -3,11 +3,13 @@ from __future__ import annotations
import html
import os
from pathlib import Path
from string import Template
import subprocess
import sys
from typing import TYPE_CHECKING
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.paths import VIBE_HOME
from vibe.core.prompts import UtilityPrompt
from vibe.core.utils import is_dangerous_directory, is_windows
@ -138,7 +140,9 @@ class ProjectContextProvider:
git_status = self.get_git_status()
template = UtilityPrompt.PROJECT_CONTEXT.read()
return template.format(abs_path=self.root_path, git_status=git_status)
return Template(template).safe_substitute(
abs_path=str(self.root_path), git_status=git_status
)
def _get_platform_name() -> str:
@ -275,7 +279,7 @@ def get_universal_system_prompt(
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
template = UtilityPrompt.DANGEROUS_DIRECTORY.read()
context = template.format(
context = Template(template).safe_substitute(
reason=reason.lower(), abs_path=Path(".").resolve()
)
else:
@ -285,10 +289,25 @@ def get_universal_system_prompt(
sections.append(context)
project_doc = get_harness_files_manager().load_project_doc(
config.project_context.max_doc_bytes
)
if project_doc.strip():
sections.append(project_doc)
mgr = get_harness_files_manager()
user_doc = mgr.load_user_doc()
project_docs = mgr.load_project_docs()
doc_sections: list[str] = []
if user_doc.strip():
doc_sections.append(
f"## User instructions\n\nContents of {VIBE_HOME.path}/AGENTS.md (user-level instructions):\n\n{user_doc.strip()}"
)
if project_docs:
doc_sections.append("## Project instructions (checked into the codebase)")
for doc_dir, doc_content in project_docs:
doc_sections.append(
f"Contents of {doc_dir}/AGENTS.md:\n\n{doc_content.strip()}"
)
if doc_sections:
template = UtilityPrompt.AGENTS_DOC.read()
sections.append(
Template(template).safe_substitute(sections="\n\n".join(doc_sections))
)
return "\n\n".join(sections)

View file

@ -348,3 +348,12 @@ class BaseTool[
Override in subclasses for domain-specific rules (e.g. workdir checks).
"""
return None
def get_result_extra(self, result: ToolResult) -> str | None:
"""Optional extra context appended to the result text sent to the LLM.
Override in subclasses to inject contextual information alongside
tool results (e.g. directory-level instructions discovered during
file reads). The default returns ``None`` (no annotation).
"""
return None

View file

@ -116,7 +116,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
def _get_default_allowlist() -> list[str]:
common = ["echo", "find", "git diff", "git log", "git status", "tree", "whoami"]
common = ["echo", "git diff", "git log", "git status", "tree", "whoami"]
if is_windows():
return common + ["dir", "findstr", "more", "type", "ver", "where"]
@ -225,9 +225,6 @@ class Bash(
return "Running command"
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None
command_parts = _extract_commands(args.command)
if not command_parts:
return None

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
import anyio
from pydantic import BaseModel, Field
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -18,6 +19,7 @@ from vibe.core.tools.base import (
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 import VIBE_WARNING_TAG
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
@ -57,8 +59,12 @@ class ReadFileToolConfig(BaseToolConfig):
)
class ReadFileState(BaseToolState):
injected_agents_md: set[str] = Field(default_factory=set)
class ReadFile(
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
@ -89,6 +95,27 @@ class ReadFile(
config_permission=self.config.permission,
)
def get_result_extra(self, result: ReadFileResult) -> str | None:
try:
mgr = get_harness_files_manager()
except RuntimeError:
return None
docs = mgr.find_subdirectory_agents_md(Path(result.path))
new_docs = [
(d, c)
for d, c in docs
if str(d.resolve()) not in self.state.injected_agents_md
]
if not new_docs:
return None
for d, _ in new_docs:
self.state.injected_agents_md.add(str(d.resolve()))
sections = [
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
for d, c in new_docs
]
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
self._validate_inputs(args)

View file

@ -4,7 +4,14 @@ from collections.abc import AsyncGenerator
import os
from typing import TYPE_CHECKING, ClassVar, final
import mistralai
from mistralai.client import Mistral
from mistralai.client.errors import SDKError
from mistralai.client.models import (
ConversationResponse,
MessageOutputEntry,
TextChunk,
ToolReferenceChunk,
)
from pydantic import BaseModel, Field
from vibe.core.config import Backend
@ -67,7 +74,7 @@ class WebSearch(
if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.")
client = mistralai.Mistral(
client = Mistral(
api_key=api_key,
server_url=self._resolve_server_url(ctx),
timeout_ms=self.config.timeout * 1000,
@ -85,7 +92,7 @@ class WebSearch(
yield self._parse_response(response)
except mistralai.SDKError as exc:
except SDKError as exc:
raise ToolError(f"Mistral API error: {exc}") from exc
def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
@ -96,19 +103,17 @@ class WebSearch(
return get_server_url_from_api_base(provider.api_base)
return None
def _parse_response(
self, response: mistralai.ConversationResponse
) -> WebSearchResult:
def _parse_response(self, response: ConversationResponse) -> WebSearchResult:
text_parts: list[str] = []
sources: dict[str, WebSearchSource] = {}
for entry in response.outputs:
if not isinstance(entry, mistralai.MessageOutputEntry):
if not isinstance(entry, MessageOutputEntry):
continue
for chunk in entry.content:
if isinstance(chunk, mistralai.TextChunk):
if isinstance(chunk, TextChunk):
text_parts.append(chunk.text)
elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
elif isinstance(chunk, ToolReferenceChunk) and chunk.url:
if chunk.url not in sources:
sources[chunk.url] = WebSearchSource(
title=chunk.title, url=chunk.url

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from vibe.core.transcribe.factory import make_transcribe_client
from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
from vibe.core.transcribe.transcribe_client_port import (
TranscribeClientPort,
TranscribeDone,
TranscribeError,
TranscribeEvent,
TranscribeSessionCreated,
TranscribeTextDelta,
)
__all__ = [
"MistralTranscribeClient",
"TranscribeClientPort",
"TranscribeDone",
"TranscribeError",
"TranscribeEvent",
"TranscribeSessionCreated",
"TranscribeTextDelta",
"make_transcribe_client",
]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from vibe.core.config import (
TranscribeClient,
TranscribeModelConfig,
TranscribeProviderConfig,
)
from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
TRANSCRIBE_CLIENT_MAP: dict[TranscribeClient, type[TranscribeClientPort]] = {
TranscribeClient.MISTRAL: MistralTranscribeClient
}
def make_transcribe_client(
provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> TranscribeClientPort:
return TRANSCRIBE_CLIENT_MAP[provider.client](provider=provider, model=model)

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import os
from mistralai.client import Mistral
from mistralai.client.models import (
AudioFormat,
RealtimeTranscriptionError,
RealtimeTranscriptionSessionCreated,
TranscriptionStreamDone,
TranscriptionStreamTextDelta,
)
from mistralai.extra.realtime import UnknownRealtimeEvent
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
from vibe.core.transcribe.transcribe_client_port import (
TranscribeDone,
TranscribeError,
TranscribeEvent,
TranscribeSessionCreated,
TranscribeTextDelta,
)
class MistralTranscribeClient:
def __init__(
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> None:
self._api_key = os.getenv(provider.api_key_env_var, "")
self._server_url = provider.api_base
self._model_name = model.name
self._audio_format = AudioFormat(
encoding=model.encoding, sample_rate=model.sample_rate
)
self._target_streaming_delay_ms = model.target_streaming_delay_ms
self._client: Mistral | None = None
def _get_client(self) -> Mistral:
if self._client is None:
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
return self._client
async def transcribe(
self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]:
client = self._get_client()
async for event in client.audio.realtime.transcribe_stream(
audio_stream=audio_stream,
model=self._model_name,
audio_format=self._audio_format,
target_streaming_delay_ms=self._target_streaming_delay_ms,
):
if isinstance(event, RealtimeTranscriptionSessionCreated):
yield TranscribeSessionCreated()
elif isinstance(event, TranscriptionStreamTextDelta):
yield TranscribeTextDelta(text=event.text)
elif isinstance(event, TranscriptionStreamDone):
yield TranscribeDone()
elif isinstance(event, RealtimeTranscriptionError):
yield TranscribeError(message=str(event.error.message))
elif isinstance(event, UnknownRealtimeEvent):
continue

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Protocol
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
@dataclass(frozen=True, slots=True)
class TranscribeSessionCreated:
pass
@dataclass(frozen=True, slots=True)
class TranscribeTextDelta:
text: str
@dataclass(frozen=True, slots=True)
class TranscribeDone:
pass
@dataclass(frozen=True, slots=True)
class TranscribeError:
message: str
TranscribeEvent = (
TranscribeSessionCreated | TranscribeTextDelta | TranscribeDone | TranscribeError
)
class TranscribeClientPort(Protocol):
def __init__(
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> None: ...
def transcribe(
self, audio_stream: AsyncIterator[bytes]
) -> AsyncIterator[TranscribeEvent]: ...

View file

@ -6,14 +6,14 @@ import tomllib
import tomli_w
from vibe.core.paths import (
AGENTS_MD_FILENAMES,
AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE,
walk_local_config_dirs_all,
)
def has_agents_md_file(path: Path) -> bool:
return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
return (path / AGENTS_MD_FILENAME).exists()
def has_trustable_content(path: Path) -> bool:
@ -60,11 +60,35 @@ class TrustedFoldersManager:
pass
def is_trusted(self, path: Path) -> bool | None:
normalized = self._normalize_path(path)
if normalized in self._trusted:
return True
if normalized in self._untrusted:
return False
"""Check trust walking up from *path* to filesystem root.
The first ancestor (or *path* itself) found in either the trusted
or untrusted list wins. Returns ``None`` when no decision exists.
"""
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._trusted:
return True
if s in self._untrusted:
return False
parent = current.parent
if parent == current:
break
current = parent
return None
def find_trust_root(self, path: Path) -> Path | None:
"""Return the closest ancestor (or *path* itself) explicitly in the trusted list."""
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._trusted:
return current
parent = current.parent
if parent == current:
break
current = parent
return None
def add_trusted(self, path: Path) -> None:

View file

@ -366,6 +366,7 @@ class ToolResultEvent(BaseEvent):
error: str | None = None
skipped: bool = False
skip_reason: str | None = None
cancelled: bool = False
duration: float | None = None
tool_call_id: str
@ -403,16 +404,10 @@ class OutputFormat(StrEnum):
STREAMING = auto()
type AsyncApprovalCallback = Callable[
type ApprovalCallback = Callable[
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
]
type SyncApprovalCallback = Callable[
[str, BaseModel, str], tuple[ApprovalResponse, str | None]
]
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
type SwitchAgentCallback = Callable[[str], Awaitable[None]]

View file

@ -88,12 +88,9 @@ def get_user_cancellation_message(
def is_user_cancellation_event(event: BaseEvent) -> bool:
return (
isinstance(event, ToolResultEvent)
and event.skipped
and event.skip_reason is not None
and f"<{CANCELLATION_TAG}>" in event.skip_reason
)
if not isinstance(event, ToolResultEvent):
return False
return event.cancelled
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:

View file

@ -46,14 +46,16 @@ class TrustFolderDialog(CenterMiddle):
def compose(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic("⚠ Trust this folder?", id="trust-dialog-title")
yield NoMarkupStatic(
"⚠ Trust this folder and all its subfolders?", id="trust-dialog-title"
)
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
)
yield NoMarkupStatic(
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder?",
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
id="trust-dialog-message",
classes="trust-dialog-message",
)

View file

@ -1,4 +1,4 @@
# What's new in v2.4.2
- **Skill arguments**: Skills now extract arguments when invoked, allowing you to pass arguments
- **Auto-compact fallback**: Auto-compact threshold falls back to the global setting when not defined at model level
# 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