Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-05-07 13:55:56 +02:00 committed by GitHub
parent 4972dd5694
commit b23f49e5f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2342 additions and 240 deletions

View file

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

View file

@ -7,6 +7,7 @@ import inspect
import logging
import os
from pathlib import Path
import signal
import sys
from typing import Any, cast, override
from uuid import uuid4
@ -994,11 +995,22 @@ class VibeAcpAgentLoop(AcpAgent):
session = self._get_session(session_id)
self.sessions.pop(session_id, None)
session.agent_loop.emit_session_closed_telemetry()
await session.close()
await self._close_agent_loop(session.agent_loop)
return CloseSessionResponse()
async def emit_session_closed_for_active_sessions(self) -> None:
agent_loops = [session.agent_loop for session in self.sessions.values()]
for agent_loop in agent_loops:
agent_loop.telemetry_client._client = None
agent_loop.emit_session_closed_telemetry()
await asyncio.gather(
*(agent_loop.telemetry_client.aclose() for agent_loop in agent_loops),
return_exceptions=True,
)
async def _close_agent_loop(self, agent_loop: AgentLoop) -> None:
deferred_init_thread = agent_loop._deferred_init_thread
if deferred_init_thread is not None and deferred_init_thread.is_alive():
@ -1248,6 +1260,7 @@ class VibeAcpAgentLoop(AcpAgent):
tool_call_id = str(uuid4())
old_tokens = session.agent_loop.stats.context_tokens
old_session_id = session.agent_loop.session_id
parts = text_prompt.strip().split(None, 1)
cmd_args = parts[1] if len(parts) > 1 else ""
@ -1268,6 +1281,8 @@ class VibeAcpAgentLoop(AcpAgent):
old_context_tokens=old_tokens or 0,
new_context_tokens=new_tokens or 0,
summary_length=0,
old_session_id=old_session_id,
new_session_id=session.agent_loop.session_id,
tool_call_id=tool_call_id,
)
await self.client.session_update(
@ -1419,19 +1434,48 @@ class VibeAcpAgentLoop(AcpAgent):
return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id)
SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS = 1.0
def run_acp_server() -> None:
agent = VibeAcpAgentLoop()
install_sigterm_flush = TelemetryClient(config_getter=VibeConfig.load).is_active()
received_sigterm = False
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
def _handle_sigterm(_signum: int, _frame: Any) -> None:
nonlocal received_sigterm
received_sigterm = True
raise KeyboardInterrupt
if install_sigterm_flush:
signal.signal(signal.SIGTERM, _handle_sigterm)
try:
asyncio.run(
run_agent(
agent=VibeAcpAgentLoop(),
agent=agent,
use_unstable_protocol=True,
observers=[acp_message_observer],
)
)
except KeyboardInterrupt:
if received_sigterm:
signal.signal(signal.SIGTERM, previous_sigterm_handler)
try:
asyncio.run(
asyncio.wait_for(
agent.emit_session_closed_for_active_sessions(),
timeout=SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS,
)
)
except (TimeoutError, Exception):
pass
# This is expected when the server is terminated
pass
except Exception as e:
# Log any unexpected errors
print(f"ACP Agent Server error: {e}", file=sys.stderr)
raise
finally:
if install_sigterm_flush:
signal.signal(signal.SIGTERM, previous_sigterm_handler)

View file

@ -60,6 +60,7 @@ def bootstrap_config_files() -> None:
raise
# When DEBUG_MODE=true, attaches debugpy on localhost:5678.
def handle_debug_mode() -> None:
if os.environ.get("DEBUG_MODE") != "true":
return

View file

@ -231,7 +231,10 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
type="text",
text=(
compact_reduction_display(
event.old_context_tokens, event.new_context_tokens
event.old_context_tokens,
event.new_context_tokens,
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
)
),
),

View file

@ -32,7 +32,7 @@ class SlashCommandController:
return
suggestions = self._completer.get_completion_items(text, cursor_index)
if suggestions and all(alias != text for alias, _ in suggestions):
if suggestions:
self._suggestions = suggestions
self._selected_index = 0
self._view.render_completion_suggestions(

View file

@ -42,10 +42,10 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
)
def get_initial_agent_name(args: argparse.Namespace) -> str:
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str:
if args.prompt is not None and args.agent is None:
return BuiltinAgentName.AUTO_APPROVE
return args.agent
return args.agent or config.default_agent
def get_prompt_from_stdin() -> str | None:
@ -196,9 +196,9 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(0)
try:
initial_agent_name = get_initial_agent_name(args)
is_interactive = args.prompt is None
config = load_config_or_exit(interactive=is_interactive)
initial_agent_name = get_initial_agent_name(args, config)
hook_config_result = load_hooks_from_fs(config)
setup_tracing(config)
@ -243,18 +243,22 @@ def run_cli(args: argparse.Namespace) -> None:
except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
agent_loop = AgentLoop(
config,
agent_name=initial_agent_name,
enable_streaming=True,
entrypoint_metadata=_build_cli_entrypoint_metadata(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
)
try:
agent_loop = AgentLoop(
config,
agent_name=initial_agent_name,
enable_streaming=True,
entrypoint_metadata=_build_cli_entrypoint_metadata(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
)
except ValueError as e:
rprint(f"[red]Error:[/] {e}")
sys.exit(1)
if loaded_session:
_resume_previous_session(agent_loop, *loaded_session)

View file

@ -161,6 +161,14 @@ class CommandRegistry:
description="Rewind to a previous message",
handler="_start_rewind_mode",
),
"loop": Command(
aliases=frozenset(["/loop"]),
description=(
"Schedule a recurring prompt. "
"Use `/loop <interval> <prompt>`, `/loop list`, or `/loop cancel <id|all>`"
),
handler="_loop_command",
),
"data-retention": Command(
aliases=frozenset(["/data-retention"]),
description="Show data retention information",

View file

@ -8,7 +8,6 @@ import sys
from rich import print as rprint
from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import init_harness_files_manager
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
@ -18,7 +17,18 @@ from vibe.setup.trusted_folders.trust_folder_dialog import (
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI")
parser = argparse.ArgumentParser(
description="Run the Mistral Vibe interactive CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Environment variables:\n"
" VIBE_HOME Override the Vibe home directory (default: ~/.vibe)\n"
" LOG_LEVEL Logging level: DEBUG, INFO, WARNING (default), ERROR, CRITICAL.\n"
" Logs are written to $VIBE_HOME/logs/vibe.log.\n"
" LOG_MAX_BYTES Max size of vibe.log before rotation (default: 10485760).\n"
" VIBE_* Override any config field (e.g. VIBE_ACTIVE_MODEL=local)."
),
)
parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
)
@ -72,9 +82,12 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument(
"--agent",
metavar="NAME",
default=BuiltinAgentName.DEFAULT,
default=None,
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
"or custom from ~/.vibe/agents/NAME.toml)",
"or custom from ~/.vibe/agents/NAME.toml). In interactive mode, "
"defaults to the 'default_agent' config setting. In programmatic "
"mode (-p/--prompt), defaults to auto-approve and 'default_agent' "
"is ignored.",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument(

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
from vibe.core.utils.io import read_safe
@ -22,12 +24,10 @@ class HistoryManager:
try:
text = read_safe(self.history_file).text
except OSError:
self._entries = []
return
entries = []
for raw_line in text.splitlines():
raw_line = raw_line.rstrip("\n\r")
if not raw_line:
continue
try:
@ -36,13 +36,24 @@ class HistoryManager:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
self.reset_navigation()
def _save_history(self) -> None:
try:
self.history_file.parent.mkdir(parents=True, exist_ok=True)
with self.history_file.open("w", encoding="utf-8") as f:
for entry in self._entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
fd, tmp_path = tempfile.mkstemp(
prefix=f".{self.history_file.name}.",
suffix=".tmp",
dir=self.history_file.parent,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
for entry in self._entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp_path, self.history_file)
except OSError:
Path(tmp_path).unlink(missing_ok=True)
raise
except OSError:
pass
@ -51,6 +62,8 @@ class HistoryManager:
if not text:
return
self._load_history()
if self._entries and self._entries[-1] == text:
return
@ -60,7 +73,6 @@ class HistoryManager:
self._entries = self._entries[-self.max_entries :]
self._save_history()
self.reset_navigation()
def get_previous(self, current_input: str) -> str | None:
if not self._entries:

View file

@ -51,6 +51,7 @@ from vibe.cli.textual_ui.notifications import (
)
from vibe.cli.textual_ui.quit_manager import QuitManager
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
@ -140,6 +141,7 @@ from vibe.core.session.resume_sessions import (
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
@ -339,7 +341,6 @@ class VibeApp(App): # noqa: PLR0904
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.scroll_sensitivity_y = 1.0
self.agent_loop = agent_loop
self._plan_info: PlanInfo | None = None
self._voice_manager: VoiceManagerPort = (
@ -397,7 +398,17 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False
self._force_quit_task: asyncio.Task[None] | None = None
self.commands = self._build_command_registry()
self._loop_runner = ScheduledLoopRunner(
self.agent_loop.session_logger,
can_fire=lambda: (
not self._agent_running and self._current_bottom_app == BottomApp.Input
),
fire=self._handle_user_message,
mount=self._mount_and_scroll,
tools_collapsed=lambda: self._tools_collapsed,
)
def _configure_startup_options(self, startup: StartupOptions | None) -> None:
opts = startup or StartupOptions()
@ -504,6 +515,8 @@ class VibeApp(App): # noqa: PLR0904
await self._resolve_plan()
await self._show_dangerous_directory_warning()
await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
self._loop_runner.start()
await self._check_and_show_whats_new()
self._schedule_update_notification()
if not self._is_resuming_session:
@ -1407,6 +1420,12 @@ class VibeApp(App): # noqa: PLR0904
if show_message:
await self._mount_and_scroll(UserMessage("/teleport"))
if not has_history:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="no_history",
error_class="TeleportNoHistoryError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await self._mount_and_scroll(
ErrorMessage(
"No conversation history to teleport.",
@ -1429,6 +1448,12 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(teleport_msg)
if self._remote_manager.is_active:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="remote_session",
error_class="TeleportRemoteSessionError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await loading.remove()
await self._mount_and_scroll(
ErrorMessage(
@ -1818,6 +1843,8 @@ class VibeApp(App): # noqa: PLR0904
f"Session `{short_session_id(session.session_id)}` not found."
)
self._emit_session_closed_for_active_session()
loaded_messages, metadata = SessionLoader.load_session(session_path)
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
@ -1846,6 +1873,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler:
self.event_handler.is_remote = False
await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
await self._mount_and_scroll(
UserCommandMessage(
f"Resumed session `{short_session_id(session.session_id)}`"
@ -1856,6 +1884,8 @@ class VibeApp(App): # noqa: PLR0904
await self._remote_manager.attach(
session_id=session.session_id, config=self.config
)
self._emit_session_closed_for_active_session()
self.agent_loop.session_id = session.session_id
self._refresh_profile_widgets()
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
@ -2018,6 +2048,10 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _loop_command(self, cmd_args: str = "", **kwargs: Any) -> None:
widget = await self._loop_runner.handle_command(cmd_args)
await self._mount_and_scroll(widget)
async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._agent_running:
await self._mount_and_scroll(
@ -2041,22 +2075,32 @@ class VibeApp(App): # noqa: PLR0904
return
old_tokens = self.agent_loop.stats.context_tokens
old_session_id = self.agent_loop.session_id
compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens, cmd_args.strip())
self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
)
async def _run_compact(
self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = ""
self,
compact_msg: CompactMessage,
old_tokens: int,
old_session_id: str,
extra_instructions: str = "",
) -> None:
self._agent_running = True
try:
await self.agent_loop.compact(extra_instructions=extra_instructions)
new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
compact_msg.set_complete(
old_tokens=old_tokens,
new_tokens=new_tokens,
old_session_id=old_session_id,
new_session_id=self.agent_loop.session_id,
)
except asyncio.CancelledError:
compact_msg.set_error("Compaction interrupted")
@ -2085,9 +2129,16 @@ class VibeApp(App): # noqa: PLR0904
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self, **kwargs: Any) -> None:
self._emit_session_closed_for_active_session()
await self._loop_runner.stop()
self._log_reader.shutdown()
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info())
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error("Failed to close telemetry client during exit", exc_info=exc)
finally:
self.exit(result=self._get_session_resume_info())
def _make_default_voice_manager(self) -> VoiceManager:
try:
@ -2746,7 +2797,16 @@ class VibeApp(App): # noqa: PLR0904
return
self._quit_manager.request_confirmation("Ctrl+D")
def _emit_session_closed_for_active_session(self) -> None:
self.agent_loop.emit_session_closed_telemetry()
def _force_quit(self) -> None:
if self._force_quit_task is not None and not self._force_quit_task.done():
return
self._force_quit_task = asyncio.create_task(self._force_quit_async())
async def _force_quit_async(self) -> None:
self._emit_session_closed_for_active_session()
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
@ -2755,7 +2815,14 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error(
"Failed to close telemetry client during force quit", exc_info=exc
)
finally:
self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None:
try:

View file

@ -980,7 +980,7 @@ NarratorStatus {
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
margin: 0;
}
#banner-container {

View file

@ -217,7 +217,10 @@ class EventHandler:
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
if self.current_compact:
self.current_compact.set_complete(
old_tokens=event.old_context_tokens, new_tokens=event.new_context_tokens
old_tokens=event.old_context_tokens,
new_tokens=event.new_context_tokens,
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
)
self.current_compact = None

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import time
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.logger import logger
from vibe.core.loop import (
USAGE_HINT,
LoopErrorResult,
LoopListResult,
LoopManager,
LoopOkResult,
ScheduledLoop,
format_duration,
)
from vibe.core.session.session_logger import SessionLogger
def _format_loop_list(loops: list[ScheduledLoop]) -> str:
if not loops:
return "No scheduled loops."
now = time.time()
rows = ["| Prompt | Next in | Every | ID |", "|--------|------|-------|----|"]
for loop in loops:
remaining = format_duration(max(0, int(loop.next_fire_at - now)), short=True)
interval = format_duration(loop.interval_seconds)
prompt = loop.prompt.replace("|", "\\|").replace("\n", " ")
rows.append(f"| {prompt} | {remaining} | {interval} | `{loop.id}` |")
return "\n".join(rows)
class ScheduledLoopRunner:
def __init__(
self,
session_logger: SessionLogger,
*,
can_fire: Callable[[], bool],
fire: Callable[[str], Awaitable[None]],
mount: Callable[[Widget], Awaitable[None]],
tools_collapsed: Callable[[], bool],
) -> None:
self._session_logger = session_logger
self._manager = LoopManager(session_logger)
self._can_fire = can_fire
self._fire = fire
self._mount = mount
self._tools_collapsed = tools_collapsed
self._task: asyncio.Task[None] | None = None
def restore_from_session(self) -> None:
metadata = self._session_logger.session_metadata
self._manager.restore(list(metadata.loops) if metadata is not None else [])
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._poll())
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def handle_command(self, cmd_args: str) -> Widget:
result = await self._manager.handle_command(cmd_args)
match result:
case LoopListResult(loops=loops):
return UserCommandMessage(_format_loop_list(loops))
case LoopErrorResult(message=message):
return ErrorMessage(
f"{message}\n{USAGE_HINT}", collapsed=self._tools_collapsed()
)
case LoopOkResult(message=message):
return UserCommandMessage(message)
async def _poll(self) -> None:
while True:
try:
sleep_for = min(self._manager.next_due_in(), 1.0)
await asyncio.sleep(max(0.05, sleep_for))
if not self._can_fire():
continue
due = await self._manager.pop_due()
if due is None:
continue
await self._fire(due.prompt)
await self._mount(UserCommandMessage(f"Loop `{due.id}` fired"))
except asyncio.CancelledError:
return
except Exception as e:
logger.error("Error polling scheduled loops", exc_info=e)

View file

@ -17,6 +17,8 @@ class CompactMessage(StatusMessage):
self.add_class("compact-message")
self.old_tokens: int | None = None
self.new_tokens: int | None = None
self.old_session_id: str | None = None
self.new_session_id: str | None = None
self.error_message: str | None = None
def get_content(self) -> str:
@ -26,13 +28,25 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
return compact_reduction_display(self.old_tokens, self.new_tokens)
return compact_reduction_display(
self.old_tokens,
self.new_tokens,
old_session_id=self.old_session_id,
new_session_id=self.new_session_id,
)
def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None
self,
old_tokens: int | None = None,
new_tokens: int | None = None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
) -> None:
self.old_tokens = old_tokens
self.new_tokens = new_tokens
self.old_session_id = old_session_id
self.new_session_id = new_session_id
self.stop_spinning(success=True)
self.post_message(self.Completed(self))

View file

@ -66,6 +66,9 @@ from vibe.core.telemetry.types import (
TelemetryCallType,
TelemetryRequestMetadata,
)
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.telemetry import TeleportTelemetryTracker
from vibe.core.teleport.types import TeleportCompleteEvent
from vibe.core.tools.base import (
BaseTool,
InvokeContext,
@ -174,9 +177,18 @@ def _is_context_too_long_error(e: Exception) -> bool:
def _is_non_retryable_error(e: BaseException) -> bool:
# Detect Temporal-style ``non_retryable`` flag without importing temporalio.
# Wrapping such an exception in a plain RuntimeError strips the flag, so
# Temporal's activity retry policy will retry the call until exhaustion.
return bool(getattr(e, "non_retryable", False))
# Walks ``__cause__`` so an ``ActivityError`` whose cause is a non-retryable
# ``ApplicationError`` is detected too — that's what callers driving the
# agent loop from a Temporal activity will see when a sub-activity has
# already failed terminally.
seen: set[int] = set()
current: BaseException | None = e
while current is not None and id(current) not in seen:
if getattr(current, "non_retryable", False):
return True
seen.add(id(current))
current = current.__cause__
return False
def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]:
@ -504,6 +516,9 @@ class AgentLoop:
def emit_ready_telemetry(self, init_duration_ms: int) -> None:
self.telemetry_client.send_ready(init_duration_ms=init_duration_ms)
def emit_session_closed_telemetry(self) -> None:
self.telemetry_client.send_session_closed()
def _create_connector_registry(self) -> ConnectorRegistry | None:
if not connectors_enabled():
return None
@ -592,16 +607,25 @@ class AgentLoop:
async def teleport_to_vibe_code(
self, prompt: str | None
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.nuage import TeleportSession
session_messages = [
msg.model_dump(exclude_none=True) for msg in self.messages[1:]
]
telemetry_tracker = TeleportTelemetryTracker(
telemetry_client=self.telemetry_client,
nb_session_messages=len(session_messages),
stage="no_history"
if prompt is None and not session_messages
else "git_check",
)
session = TeleportSession(
metadata={
"agent": self.agent_profile.name,
"model": self.config.active_model,
"stats": self.stats.model_dump(),
},
messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]],
messages=session_messages,
)
try:
async with self.teleport_service:
@ -610,12 +634,23 @@ class AgentLoop:
while True:
try:
event = await gen.asend(response)
telemetry_tracker.record_event(event)
if isinstance(event, TeleportCompleteEvent):
telemetry_tracker.send_success()
response = yield event
except StopAsyncIteration:
break
except ServiceTeleportError as e:
telemetry_tracker.record_service_error(e)
raise TeleportError(str(e)) from e
except (asyncio.CancelledError, GeneratorExit):
telemetry_tracker.record_cancelled()
raise
except Exception as e:
telemetry_tracker.record_unexpected_error(e)
raise
finally:
telemetry_tracker.send_failure_if_needed()
self._teleport_service = None
def _setup_middleware(self) -> None:
@ -709,6 +744,8 @@ class AgentLoop:
old_context_tokens=old_tokens,
new_context_tokens=new_tokens,
summary_length=len(summary),
old_session_id=old_session_id,
new_session_id=self.session_id,
)
case MiddlewareAction.CONTINUE:
@ -1395,8 +1432,9 @@ class AgentLoop:
responded_ids: set[str] = set()
j = i + 1
while j < len(self.messages) and self.messages[j].role == "tool":
if self.messages[j].tool_call_id:
responded_ids.add(self.messages[j].tool_call_id)
tool_call_id = self.messages[j].tool_call_id
if tool_call_id is not None:
responded_ids.add(tool_call_id)
j += 1
if len(responded_ids) < expected_responses:
@ -1431,6 +1469,7 @@ class AgentLoop:
def _reset_session(self, keep_parent: bool = True) -> None:
old_session_id = self.session_id
self.emit_session_closed_telemetry()
suffix = extract_suffix(self.session_id)
self.session_id = generate_session_id(suffix=suffix)
parent_session_id = old_session_id if keep_parent else None

View file

@ -40,18 +40,22 @@ class AgentManager:
" ".join(str(p) for p in self._search_paths),
)
profile = self._available.get(initial_agent)
if (
not allow_subagent
and profile is not None
and profile.agent_type != AgentType.AGENT
):
available = self.available_agents
profile = available.get(initial_agent)
if profile is None:
if initial_agent in self._available:
raise ValueError(
f"Agent '{initial_agent}' is not available. "
f"It may be disabled, not installed, or excluded by your config."
)
raise ValueError(f"Agent '{initial_agent}' not found.")
if not allow_subagent and profile.agent_type != AgentType.AGENT:
raise ValueError(
f"Agent '{initial_agent}' is a {profile.agent_type} and cannot be used"
f" as the primary agent. Only agents of type 'agent' can be selected"
f" with --agent."
)
self.active_profile = profile or self._available[BuiltinAgentName.DEFAULT]
self.active_profile = profile
self._cached_config: VibeConfig | None = None
@property

View file

@ -64,13 +64,16 @@ class CommandCompleter(Completer):
descriptions[alias] = description
return list(descriptions.keys()), descriptions
def _head_word(self, text: str, cursor_pos: int) -> str:
head = text.split(" ", 1)[0]
return head[1 : min(cursor_pos, len(head))].lower()
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
if not text.startswith("/"):
return []
aliases, _ = self._build_lookup()
word = text[1:cursor_pos].lower()
search_str = "/" + word
search_str = "/" + self._head_word(text, cursor_pos)
return [alias for alias in aliases if alias.lower().startswith(search_str)]
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
@ -78,8 +81,7 @@ class CommandCompleter(Completer):
return []
aliases, descriptions = self._build_lookup()
word = text[1:cursor_pos].lower()
search_str = "/" + word
search_str = "/" + self._head_word(text, cursor_pos)
items = [
(alias, descriptions.get(alias, ""))
for alias in aliases
@ -90,9 +92,11 @@ class CommandCompleter(Completer):
def get_replacement_range(
self, text: str, cursor_pos: int
) -> tuple[int, int] | None:
if text.startswith("/"):
return (0, cursor_pos)
return None
if not text.startswith("/"):
return None
first_space = text.find(" ")
end = cursor_pos if first_space == -1 else min(cursor_pos, first_space)
return (0, end)
class PathCompleter(Completer):

View file

@ -25,6 +25,7 @@ from pydantic_settings import (
)
import tomli_w
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
@ -610,6 +611,15 @@ class VibeConfig(BaseSettings):
"A list of opt-in builtin agent names that have been explicitly installed."
),
)
default_agent: str = Field(
default=BuiltinAgentName.DEFAULT,
description=(
"Agent profile to use when no --agent flag is passed in interactive "
"mode. Builtin: default, plan, accept-edits, auto-approve. "
"Ignored in programmatic mode (-p/--prompt), which falls back to "
"auto-approve when --agent is not provided."
),
)
skill_paths: list[Path] = Field(
default_factory=list,
description=(

View file

@ -45,6 +45,8 @@ def apply_logging_config(target_logger: logging.Logger) -> None:
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
# DEBUG_MODE is the debugpy switch (see vibe/acp/entrypoint.py);
# it also forces DEBUG-level logging here.
if os.environ.get("DEBUG_MODE") == "true":
log_level_str = "DEBUG"
else:

250
vibe/core/loop.py Normal file
View file

@ -0,0 +1,250 @@
from __future__ import annotations
from enum import StrEnum, auto
import math
import re
import secrets
import time
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict
from vibe.core.logger import logger
from vibe.core.types import ScheduledLoop
if TYPE_CHECKING:
from vibe.core.session.session_logger import SessionLogger
__all__ = [
"MAX_LOOPS_PER_SESSION",
"MIN_INTERVAL_SECONDS",
"USAGE_HINT",
"IntervalUnit",
"LoopCommandResult",
"LoopError",
"LoopErrorResult",
"LoopListResult",
"LoopManager",
"LoopOkResult",
"format_duration",
"parse_interval",
]
MIN_INTERVAL_SECONDS = 30
MAX_LOOPS_PER_SESSION = 50
USAGE_HINT = """\
Usage:
/loop <interval> <prompt>
/loop list
/loop cancel <id|all>
"""
_INTERVAL_RE = re.compile(r"^(\d+)([smhd])$")
_CANCEL_VERBS = frozenset({"cancel", "rm", "stop", "delete"})
_LIST_VERBS = frozenset({"list", "ls"})
class LoopError(Exception):
pass
class IntervalUnit(StrEnum):
SECOND = auto()
MINUTE = auto()
HOUR = auto()
DAY = auto()
@property
def seconds(self) -> int:
match self:
case IntervalUnit.SECOND:
return 1
case IntervalUnit.MINUTE:
return 60
case IntervalUnit.HOUR:
return 3600
case IntervalUnit.DAY:
return 86400
@property
def suffix(self) -> str:
match self:
case IntervalUnit.SECOND:
return "s"
case IntervalUnit.MINUTE:
return "m"
case IntervalUnit.HOUR:
return "h"
case IntervalUnit.DAY:
return "d"
@classmethod
def from_suffix(cls, ch: str) -> IntervalUnit:
match ch:
case "s":
return cls.SECOND
case "m":
return cls.MINUTE
case "h":
return cls.HOUR
case "d":
return cls.DAY
case _:
raise LoopError(f"Unknown interval unit `{ch}`.")
def parse_interval(text: str) -> int:
if not text:
raise LoopError("Missing interval.")
match = _INTERVAL_RE.match(text.strip().lower())
if match is None:
raise LoopError(
f"Invalid interval `{text}`. "
"Expected: <number><unit> (e.g., 30s, 5m, 2h, 1d)."
)
value = int(match.group(1))
seconds = value * IntervalUnit.from_suffix(match.group(2)).seconds
if seconds < MIN_INTERVAL_SECONDS:
raise LoopError(f"Interval must be at least {MIN_INTERVAL_SECONDS}s.")
return seconds
def format_duration(seconds: int, short: bool = False) -> str:
parts = []
for unit in (
IntervalUnit.DAY,
IntervalUnit.HOUR,
IntervalUnit.MINUTE,
IntervalUnit.SECOND,
):
value = seconds // unit.seconds
if value > 0:
parts.append(f"{value}{unit.suffix}")
seconds %= unit.seconds
if not parts:
parts = ["0s"]
if short:
return parts[0]
return "".join(parts)
class _LoopResultBase(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class LoopListResult(_LoopResultBase):
loops: list[ScheduledLoop]
class LoopOkResult(_LoopResultBase):
message: str
class LoopErrorResult(_LoopResultBase):
message: str
LoopCommandResult = LoopListResult | LoopOkResult | LoopErrorResult
class LoopManager:
def __init__(self, session_logger: SessionLogger) -> None:
self._session_logger = session_logger
self._loops: list[ScheduledLoop] = []
@property
def loops(self) -> list[ScheduledLoop]:
return list(self._loops)
def restore(self, loops: list[ScheduledLoop]) -> None:
self._loops = list(loops)
def next_due_in(self, now: float | None = None) -> float:
if not self._loops:
return math.inf
ts = now if now is not None else time.time()
return max(0.0, min(loop.next_fire_at for loop in self._loops) - ts)
async def pop_due(self, now: float | None = None) -> ScheduledLoop | None:
if not self._loops:
return None
ts = now if now is not None else time.time()
due_loops = [loop for loop in self._loops if loop.next_fire_at <= ts]
if not due_loops:
return None
due = min(due_loops, key=lambda loop: loop.next_fire_at)
due.next_fire_at = ts + due.interval_seconds
await self._persist()
return due
async def handle_command(self, args: str) -> LoopCommandResult:
text = args.strip()
if not text:
return self._list_result()
verb, _, rest = text.partition(" ")
verb_lower = verb.lower()
if verb_lower in _LIST_VERBS:
return self._list_result()
if verb_lower in _CANCEL_VERBS:
return await self._cancel(rest.strip())
return await self._add(verb, rest)
def _list_result(self) -> LoopListResult:
return LoopListResult(loops=list(self._loops))
async def _add(
self, interval_text: str, prompt: str
) -> LoopOkResult | LoopErrorResult:
try:
seconds = parse_interval(interval_text)
except LoopError as e:
return LoopErrorResult(message=str(e))
prompt = prompt.strip()
if not prompt:
return LoopErrorResult(message="Missing prompt.")
if prompt.startswith("/"):
return LoopErrorResult(message="Prompt cannot start with '/'.")
if len(self._loops) >= MAX_LOOPS_PER_SESSION:
return LoopErrorResult(
message=f"Loop limit reached ({MAX_LOOPS_PER_SESSION} per session)."
)
now = time.time()
loop = ScheduledLoop(
id=secrets.token_hex(4),
interval_seconds=seconds,
prompt=prompt,
next_fire_at=now + seconds,
created_at=now,
)
self._loops.append(loop)
await self._persist()
return LoopOkResult(
message=(
f"Scheduled loop `{loop.id}` every {format_duration(seconds)}: {prompt}"
)
)
async def _cancel(self, target: str) -> LoopOkResult | LoopErrorResult:
if not target:
return LoopErrorResult(message="Missing loop id.")
if target.lower() == "all":
count = len(self._loops)
self._loops.clear()
await self._persist()
return LoopOkResult(message=f"Cancelled {count} scheduled loop(s).")
match = next((loop for loop in self._loops if loop.id == target), None)
if match is None:
return LoopErrorResult(message=f"No scheduled loop with id `{target}`.")
self._loops.remove(match)
await self._persist()
return LoopOkResult(message=f"Cancelled loop `{match.id}`: {match.prompt}")
async def _persist(self) -> None:
metadata = self._session_logger.session_metadata
if metadata is not None:
metadata.loops = [*self._loops]
try:
await self._session_logger.persist_loops()
except Exception as e:
logger.error("Failed to persist scheduled loops", exc_info=e)

View file

@ -91,6 +91,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917
return formatter.finalize()
finally:
agent_loop.emit_session_closed_telemetry()
await agent_loop.telemetry_client.aclose()
return asyncio.run(_async_run())

View file

@ -27,8 +27,8 @@ Never claim completion without verification — a passing test, correct read-bac
Hard Rules:
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.
Never Commit Proactively
Do not proactively run `git add`, `git commit`, or `git push`. Saving files is the default — the user usually reviews and commits themselves. If the user explicitly asks you to stage, commit, or push, do it.
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.

View file

@ -4,6 +4,7 @@ from pathlib import Path
import tempfile
from vibe.core.logger import logger
from vibe.core.session.session_id import shorten_session_id
_active_scratchpads: dict[str, Path] = {}
@ -17,7 +18,11 @@ def init_scratchpad(session_id: str) -> Path | None:
return _active_scratchpads[session_id]
try:
dir_path = Path(tempfile.mkdtemp(prefix=f"vibe-scratchpad-{session_id[:8]}-"))
dir_path = Path(
tempfile.mkdtemp(
prefix=f"vibe-scratchpad-{shorten_session_id(session_id)}-"
)
)
_active_scratchpads[session_id] = dir_path
logger.debug("Scratchpad initialized at %s", dir_path)
return dir_path

View file

@ -8,17 +8,14 @@ from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.workflow import WorkflowExecutionStatus
from vibe.core.session.session_id import shorten_session_id
from vibe.core.session.session_loader import SessionLoader
ResumeSessionSource = Literal["local", "remote"]
SHORT_SESSION_ID_LEN = 8
def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str:
if source == "remote":
return session_id[-SHORT_SESSION_ID_LEN:]
return session_id[:SHORT_SESSION_ID_LEN]
return shorten_session_id(session_id, from_end=source == "remote")
_ACTIVE_STATUSES = [

View file

@ -22,3 +22,13 @@ def generate_session_id(*, suffix: str | None = None) -> str:
def extract_suffix(session_id: str) -> str:
"""Extract the stable suffix (last segment after the final hyphen)."""
return session_id.rsplit("-", 1)[-1]
_SHORT_LEN = 8
def shorten_session_id(session_id: str, *, from_end: bool = False) -> str:
"""Return a short human-readable slice of a session ID (8 chars)."""
if from_end:
return session_id[-_SHORT_LEN:]
return session_id[:_SHORT_LEN]

View file

@ -5,6 +5,7 @@ import json
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict
from vibe.core.session.session_id import shorten_session_id
from vibe.core.types import LLMMessage, SessionMetadata
from vibe.core.utils.io import read_safe
@ -129,7 +130,7 @@ class SessionLoader:
if not save_dir.exists():
return []
short_id = session_id[:8]
short_id = shorten_session_id(session_id)
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
@staticmethod

View file

@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Literal
from anyio import NamedTemporaryFile, Path as AsyncPath
from vibe.core.session.session_id import shorten_session_id
from vibe.core.session.session_loader import (
MESSAGES_FILENAME,
METADATA_FILENAME,
@ -63,9 +64,20 @@ class SessionLogger:
)
timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
folder_name = (
f"{self.session_prefix}_{timestamp}_{shorten_session_id(self.session_id)}"
)
return self.save_dir / folder_name
def _get_session_info(self) -> tuple[Path, SessionMetadata] | None:
if (
not self.enabled
or self.session_dir is None
or self.session_metadata is None
):
return None
return (self.session_dir, self.session_metadata)
@property
def metadata_filepath(self) -> Path:
if self.session_dir is None:
@ -244,34 +256,34 @@ class SessionLogger:
tool_manager: ToolManager,
agent_profile: AgentProfile,
) -> None:
if not self.enabled or self.session_dir is None:
return
if self.session_metadata is None:
session_info = self._get_session_info()
if session_info is None:
return
session_dir, session_metadata = session_info
metadata_path = session_dir / METADATA_FILENAME
if not any(msg.role != Role.system for msg in messages):
return
# If the session directory does not exist, create it
try:
self.session_dir.mkdir(parents=True, exist_ok=True)
session_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise RuntimeError(
f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}"
f"Failed to create session directory at {session_dir}: {type(e).__name__}: {e}"
) from e
# Read old metadata and get total_messages
try:
if self.metadata_filepath.exists():
raw = (await read_safe_async(self.metadata_filepath)).text
if metadata_path.exists():
raw = (await read_safe_async(metadata_path)).text
old_metadata = json.loads(raw)
old_total_messages = old_metadata["total_messages"]
else:
old_total_messages = 0
except Exception as e:
raise RuntimeError(
f"Failed to read session metadata at {self.metadata_filepath}: {e}"
f"Failed to read session metadata at {metadata_path}: {e}"
) from e
try:
@ -283,7 +295,7 @@ class SessionLogger:
return
messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
await SessionLogger.persist_messages(messages_data, self.session_dir)
await SessionLogger.persist_messages(messages_data, session_dir)
# If message update succeeded, write metadata
tools_available = [
@ -307,7 +319,7 @@ class SessionLogger:
total_messages = len(non_system_messages)
metadata_dump = {
**self.session_metadata.model_dump(),
**session_metadata.model_dump(),
"end_time": utc_now().isoformat(),
"stats": stats.model_dump(),
"title": title,
@ -321,14 +333,32 @@ class SessionLogger:
"system_prompt": system_prompt,
}
await SessionLogger.persist_metadata(metadata_dump, self.session_dir)
await SessionLogger.persist_metadata(metadata_dump, session_dir)
except Exception as e:
raise RuntimeError(
f"Failed to save session to {self.session_dir}: {e}"
) from e
raise RuntimeError(f"Failed to save session to {session_dir}: {e}") from e
finally:
self.maybe_cleanup_tmp_files()
async def persist_loops(self) -> None:
session_info = self._get_session_info()
if session_info is None:
return
session_dir, session_metadata = session_info
metadata_path = session_dir / METADATA_FILENAME
if not metadata_path.exists():
return
try:
raw = (await read_safe_async(metadata_path)).text
metadata = json.loads(raw)
except (OSError, json.JSONDecodeError) as e:
raise RuntimeError(
f"Failed to read session metadata at {metadata_path}: {e}"
) from e
metadata["loops"] = [
loop.model_dump(mode="json") for loop in session_metadata.loops
]
await SessionLogger.persist_metadata(metadata, session_dir)
def reset_session(
self, session_id: str, *, parent_session_id: str | None = None
) -> None:

View file

@ -194,6 +194,15 @@ disabled_agents = ["auto-approve"]
# Opt-in builtin agents (only affects agents with install_required=True, e.g. lean)
installed_agents = ["lean"]
# Agent profile to use when --agent is not passed in interactive mode
# (default: "default"). Valid values: "default", "plan", "accept-edits",
# "auto-approve", "lean" (only when listed in installed_agents), or any
# custom agent name from ~/.vibe/agents/ or .vibe/agents/. Subagents
# (e.g. "explore") are rejected. Ignored in programmatic mode
# (-p/--prompt), which falls back to "auto-approve" when --agent is not
# provided.
default_agent = "plan"
```
### MCP Servers
@ -321,7 +330,7 @@ Tool, skill, and agent names support three matching modes:
```
vibe [PROMPT] # Start interactive session with optional prompt
vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit)
vibe --agent NAME # Select agent profile
vibe --agent NAME # Select agent profile (falls back to `default_agent` config)
vibe --workdir DIR # Change working directory
vibe --trust # Trust cwd for this invocation only (not persisted)
vibe -c / --continue # Continue most recent session
@ -374,6 +383,15 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/mcp` - Display available MCP servers (pass a server name to list its tools)
- `/resume` (or `/continue`) - Browse and resume past sessions
- `/rewind` - Rewind to a previous message
- `/loop <interval> <prompt>` - Schedule a recurring prompt (e.g. `/loop 30s ping`).
Intervals: `Ns/Nm/Nh/Nd`, minimum 30s, max 50 loops/session.
- `/loop` (or `/loop list` / `/loop ls`) - List current scheduled loops.
- `/loop cancel <id|all>` (aliases `rm`, `stop`, `delete`) - Cancel a loop.
- Loops fire only when the agent is idle and the input bar is focused. At
most one loop fires per poll. Overdue loops fire once on the next poll
(no catch-up); `next_fire_at` advances to `now + interval`.
- Loops are persisted in the session metadata (`loops` field of `meta.json`)
and restored on `--resume`/`--continue`.
- `/terminal-setup` - Configure Shift+Enter for newlines
- `/proxy-setup` - Configure proxy and SSL certificate settings
- `/leanstall` - Install the Lean 4 agent (leanstral)
@ -415,6 +433,13 @@ Detailed instructions for the model...
- `MISTRAL_API_KEY` - API key for Mistral provider
- `VIBE_ACTIVE_MODEL` - Override active model
- `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix
- `LOG_LEVEL` - Logging level for `$VIBE_HOME/logs/vibe.log`. One of `DEBUG`,
`INFO`, `WARNING` (default), `ERROR`, `CRITICAL`. Invalid values fall back
to `WARNING`.
- `LOG_MAX_BYTES` - Max size in bytes of `vibe.log` before rotation
(default: `10485760`, i.e. 10 MiB).
- `DEBUG_MODE` - When `true`, forces `DEBUG`-level logging. Under `vibe-acp`
it also attaches `debugpy` on `localhost:5678`.
## API Keys (.env file)

View file

@ -16,6 +16,9 @@ from vibe.core.telemetry.types import (
AgentEntrypoint,
EntrypointMetadata,
TelemetryCallType,
TeleportCompletedPayload,
TeleportFailedPayload,
TeleportFailureStage,
)
from vibe.core.utils import get_server_url_from_api_base, get_user_agent
@ -63,7 +66,7 @@ class TelemetryClient:
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
try:
provider = self._config_getter().get_mistral_provider()
except ValueError:
except Exception:
return None
if provider is None:
return None
@ -77,7 +80,7 @@ class TelemetryClient:
"""Check if telemetry is enabled in the current config."""
try:
return self._config_getter().enable_telemetry
except ValueError:
except Exception:
return False
def is_active(self) -> bool:
@ -270,6 +273,9 @@ class TelemetryClient:
}
self.send_telemetry_event("vibe.new_session", payload)
def send_session_closed(self) -> None:
self.send_telemetry_event("vibe.session_closed", {})
def send_onboarding_api_key_added(self) -> None:
self.send_telemetry_event(
"vibe.onboarding_api_key_added", {"version": __version__}
@ -322,3 +328,35 @@ class TelemetryClient:
{"rating": rating, "version": __version__, "model": model},
correlation_id=self.last_correlation_id,
)
def send_teleport_completed(
self,
*,
push_required: bool,
github_auth_required: bool,
nb_session_messages: int,
) -> None:
payload: TeleportCompletedPayload = {
"push_required": push_required,
"github_auth_required": github_auth_required,
"nb_session_messages": nb_session_messages,
}
self.send_telemetry_event("vibe.teleport_completed", dict(payload))
def send_teleport_failed(
self,
*,
stage: TeleportFailureStage,
error_class: str,
push_required: bool,
github_auth_required: bool,
nb_session_messages: int,
) -> None:
payload: TeleportFailedPayload = {
"stage": stage,
"error_class": error_class,
"push_required": push_required,
"github_auth_required": github_auth_required,
"nb_session_messages": nb_session_messages,
}
self.send_telemetry_event("vibe.teleport_failed", dict(payload))

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Literal
from typing import Literal, TypedDict
from pydantic import BaseModel
@ -36,3 +36,26 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata):
call_type: TelemetryCallType
call_source: str = "vibe_code"
message_id: str | None = None
TeleportFailureStage = Literal[
"no_history",
"remote_session",
"git_check",
"push",
"workflow_start",
"github_auth",
"fetch_url",
"cancelled",
]
class TeleportCompletedPayload(TypedDict):
push_required: bool
github_auth_required: bool
nb_session_messages: int
class TeleportFailedPayload(TeleportCompletedPayload):
stage: TeleportFailureStage
error_class: str

View file

@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import TeleportFailureStage
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.types import (
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
TeleportYieldEvent,
)
def send_teleport_early_failure_telemetry(
telemetry_client: TelemetryClient,
*,
stage: TeleportFailureStage,
error_class: str,
nb_session_messages: int,
) -> None:
telemetry_client.send_teleport_failed(
stage=stage,
error_class=error_class,
push_required=False,
github_auth_required=False,
nb_session_messages=nb_session_messages,
)
@dataclass
class TeleportTelemetryTracker:
telemetry_client: TelemetryClient
nb_session_messages: int
stage: TeleportFailureStage
push_required: bool = False
github_auth_required: bool = False
success: bool = False
error_class: str | None = None
def record_event(self, event: TeleportYieldEvent) -> None:
match event:
case TeleportCheckingGitEvent():
self.stage = "git_check"
case TeleportPushRequiredEvent():
self.push_required = True
self.stage = "cancelled"
case TeleportPushingEvent():
self.stage = "push"
case TeleportStartingWorkflowEvent():
self.stage = "workflow_start"
case TeleportWaitingForGitHubEvent():
self.stage = "github_auth"
case TeleportAuthRequiredEvent():
self.github_auth_required = True
self.stage = "github_auth"
case TeleportFetchingUrlEvent():
self.stage = "fetch_url"
case TeleportCompleteEvent():
self.success = True
def record_service_error(self, error: ServiceTeleportError) -> None:
self.error_class = type(error).__name__
def record_cancelled(self) -> None:
self.stage = "cancelled"
self.error_class = "CancelledError"
def record_unexpected_error(self, error: Exception) -> None:
self.error_class = type(error).__name__
def send_success(self) -> None:
self.telemetry_client.send_teleport_completed(
push_required=self.push_required,
github_auth_required=self.github_auth_required,
nb_session_messages=self.nb_session_messages,
)
def send_failure_if_needed(self) -> None:
if self.success or self.error_class is None:
return
self.telemetry_client.send_teleport_failed(
stage=self.stage,
error_class=self.error_class,
push_required=self.push_required,
github_auth_required=self.github_auth_required,
nb_session_messages=self.nb_session_messages,
)

View file

@ -141,7 +141,7 @@ class ReadFile(
try:
raw_lines: list[bytes] = []
bytes_read = 0
was_truncated = False
was_truncated = True
async with await anyio.Path(file_path).open("rb") as f:
line_index = 0
@ -155,12 +155,13 @@ class ReadFile(
line_bytes = len(raw_line)
if bytes_read + line_bytes > self.config.max_read_bytes:
was_truncated = True
break
raw_lines.append(raw_line)
bytes_read += line_bytes
line_index += 1
else:
was_truncated = False
except OSError as exc:
raise ToolError(f"Error reading {file_path}: {exc}") from exc

View file

@ -21,7 +21,7 @@ VIBE_AGENT_NAME = "mistral-vibe"
def setup_tracing(config: VibeConfig) -> None:
if not config.enable_otel:
if not config.enable_telemetry or not config.enable_otel:
return
exporter_cfg = config.otel_span_exporter_config

View file

@ -26,6 +26,16 @@ from pydantic import (
)
class ScheduledLoop(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
interval_seconds: int
prompt: str
next_fire_at: float
created_at: float
class Backend(StrEnum):
MISTRAL = auto()
GENERIC = auto()
@ -142,6 +152,7 @@ class SessionMetadata(BaseModel):
git_branch: str | None
environment: dict[str, str | None]
username: str
loops: list[ScheduledLoop] = Field(default_factory=list)
title: str | None = None
title_source: Literal["auto", "manual"] = "auto"
@ -418,6 +429,8 @@ class CompactEndEvent(BaseEvent):
old_context_tokens: int
new_context_tokens: int
summary_length: int
old_session_id: str | None = None
new_session_id: str | None = None
# WORKAROUND: Using tool_call to communicate compact events to the client.
# This should be revisited when the ACP protocol defines how compact events
# should be represented.

View file

@ -1,13 +1,31 @@
from __future__ import annotations
from vibe.core.session.session_id import shorten_session_id
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
if old_tokens is None or new_tokens is None:
return "Compaction complete"
reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
return (
f"Compaction complete: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)
def compact_reduction_display(
old_tokens: int | None,
new_tokens: int | None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
) -> str:
message = "Compaction complete"
if old_tokens is not None and new_tokens is not None:
reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
message = (
f"{message}: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)
if old_session_id is not None and new_session_id is not None:
short_old = shorten_session_id(old_session_id)
short_new = shorten_session_id(new_session_id)
message = (
f"{message}\n"
f"session: {short_old} (before compaction) → {short_new} (after compaction)"
)
return message

View file

@ -1,5 +1,5 @@
# What's new in v2.9.4
# What's new in v2.9.5
- **`/rename` command**: Rename the current session from the slash menu.
- **Persistent "always allow"**: Tool permissions granted with "always allow" now stick across sessions.
- **Faster bash bang commands**: `!command` runs via async subprocess.
- **`/loop` command**: Run a prompt or slash command on a recurring interval.
- **`default_agent` config**: Set which agent profile starts each session.
- **Parallel-safe history**: Multiple `vibe` instances no longer clobber each other's history file.