Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-04-09 18:40:46 +02:00 committed by GitHub
parent 90763daf81
commit e9a9217cc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 7202 additions and 541 deletions

View file

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

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from contextlib import aclosing
import os
from pathlib import Path
import sys
@ -445,14 +446,14 @@ class VibeAcpAgentLoop(AcpAgent):
await self.client.session_update(session_id=session_id, update=update)
elif msg.role == Role.assistant:
if text_update := create_assistant_message_replay(msg):
await self.client.session_update(
session_id=session_id, update=text_update
)
if reasoning_update := create_reasoning_replay(msg):
await self.client.session_update(
session_id=session_id, update=reasoning_update
)
if text_update := create_assistant_message_replay(msg):
await self.client.session_update(
session_id=session_id, update=text_update
)
await self._replay_tool_calls(session_id, msg)
elif msg.role == Role.tool:
@ -885,61 +886,64 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AsyncGenerator[SessionUpdate]:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
async for event in session.agent_loop.act(
rendered_prompt, client_message_id=client_message_id
):
if isinstance(event, AssistantEvent):
yield AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
elif isinstance(event, ReasoningEvent):
yield AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(
tool_manager=session.agent_loop.tool_manager,
client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
async with aclosing(
session.agent_loop.act(rendered_prompt, client_message_id=client_message_id)
) as events:
async for event in events:
if isinstance(event, AssistantEvent):
yield AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
session_update = tool_call_session_update(event)
if session_update:
yield session_update
elif isinstance(event, ReasoningEvent):
yield AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=event.content),
message_id=event.message_id,
)
elif isinstance(event, ToolResultEvent):
session_update = tool_result_session_update(event)
if session_update:
yield session_update
elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=event.message),
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(
tool_manager=session.agent_loop.tool_manager,
client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
)
],
)
elif isinstance(event, CompactStartEvent):
yield create_compact_start_session_update(event)
session_update = tool_call_session_update(event)
if session_update:
yield session_update
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
elif isinstance(event, ToolResultEvent):
session_update = tool_result_session_update(event)
if session_update:
yield session_update
elif isinstance(event, AgentProfileChangedEvent):
pass
elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(
type="text", text=event.message
),
)
],
)
elif isinstance(event, CompactStartEvent):
yield create_compact_start_session_update(event)
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
elif isinstance(event, AgentProfileChangedEvent):
pass
@override
async def close_session(

View file

@ -8,7 +8,7 @@ import sys
import tomli_w
from vibe import __version__
from vibe.core.config import MissingAPIKeyError, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.config.harness_files import (
get_harness_files_manager,
init_harness_files_manager,
@ -92,8 +92,8 @@ def main() -> None:
try:
config = VibeConfig.load()
setup_tracing(config)
except MissingAPIKeyError:
pass # tracing disabled, but server can still handle the error properly in new_session
except Exception:
pass # tracing disabled
run_acp_server()

View file

@ -9,8 +9,6 @@ import subprocess
import pyperclip
from textual.app import App
_PREVIEW_MAX_LENGTH = 40
def _copy_osc52(text: str) -> None:
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
@ -117,13 +115,6 @@ def _copy_to_clipboard(text: str) -> None:
raise RuntimeError("All clipboard strategies failed")
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}"
return dense_text
def _get_selected_texts(app: App) -> list[str]:
selected_texts = []
@ -156,7 +147,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None
_copy_to_clipboard(combined_text)
if show_toast:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
"Selection copied to clipboard",
severity="information",
timeout=2,
markup=False,

View file

@ -36,7 +36,7 @@ class CommandRegistry:
),
"reload": Command(
aliases=frozenset(["/reload"]),
description="Reload configuration from disk",
description="Reload configuration, agent instructions, and skills from disk",
handler="_reload_config",
),
"clear": Command(
@ -49,6 +49,11 @@ class CommandRegistry:
description="Show path to current interaction log file",
handler="_show_log_path",
),
"debug": Command(
aliases=frozenset(["/debug"]),
description="Toggle debug console",
handler="action_toggle_debug_console",
),
"compact": Command(
aliases=frozenset(["/compact"]),
description="Compact conversation history by summarizing",
@ -85,6 +90,11 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
"mcp": Command(
aliases=frozenset(["/mcp"]),
description="Display available MCP servers. Pass the name of a server to list its tools",
handler="_show_mcp",
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Configure voice settings",
@ -120,13 +130,23 @@ class CommandRegistry:
for alias in cmd.aliases:
self._alias_map[alias] = cmd_name
def find_command(self, user_input: str) -> Command | None:
cmd_name = self.get_command_name(user_input)
return self.commands.get(cmd_name) if cmd_name else None
def get_command_name(self, user_input: str) -> str | None:
return self._alias_map.get(user_input.lower().strip())
def parse_command(self, user_input: str) -> tuple[str, Command, str] | None:
parts = user_input.strip().split(None, 1)
if not parts:
return None
cmd_word = parts[0]
cmd_args = parts[1] if len(parts) > 1 else ""
cmd_name = self.get_command_name(cmd_word)
if cmd_name is None:
return None
command = self.commands[cmd_name]
return cmd_name, command, cmd_args
def get_help_text(self) -> str:
lines: list[str] = [
"### Keyboard Shortcuts",

View file

@ -44,7 +44,7 @@ class HistoryManager:
def add(self, text: str) -> None:
text = text.strip()
if not text or text.startswith("/"):
if not text:
return
if self._entries and self._entries[-1] == text:

81
vibe/cli/profiler.py Normal file
View file

@ -0,0 +1,81 @@
"""General-purpose profiler for measuring any section of the application.
Wraps pyinstrument (dev-only dependency). Silently no-ops when not installed.
Activated by the VIBE_PROFILE=1 environment variable.
Usage:
from vibe.cli import profiler
profiler.start("startup")
# ... code to profile ...
profiler.stop_and_print()
"""
from __future__ import annotations
import dataclasses
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pyinstrument import Profiler
@dataclasses.dataclass
class _State:
profiler: Profiler | None = None
label: str = "default"
_state = _State()
def is_enabled() -> bool:
"""Return True when profiling is activated via environment variable."""
return bool(os.environ.get("VIBE_PROFILE"))
def start(label: str = "default") -> None:
"""Start profiling. The label is used to name the output file.
No-op if pyinstrument is missing or env var unset.
"""
if not is_enabled():
return
try:
from pyinstrument import Profiler
except ImportError:
return
if _state.profiler is not None:
import warnings
warnings.warn(
"Profiler already running; stop it before starting a new one.", stacklevel=2
)
return
_state.label = label
_state.profiler = Profiler()
_state.profiler.start()
def stop_and_print() -> None:
"""Stop profiling, write an HTML report, and print a text summary to stderr."""
if _state.profiler is None:
return
_state.profiler.stop()
from pathlib import Path
import sys
output_path = Path(f"{_state.label}-profile.html")
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
print(
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
file=sys.stderr,
)
print(_state.profiler.output_text(color=True), file=sys.stderr)
_state.profiler = None
_state.label = "default"

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from contextlib import aclosing
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
@ -56,9 +57,11 @@ from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp
from vibe.cli.textual_ui.widgets.messages import (
BashOutputMessage,
ErrorMessage,
@ -112,6 +115,7 @@ from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.log_reader import LogReader
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
@ -171,6 +175,7 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
MCP = auto()
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
@ -184,7 +189,7 @@ class ChatScroll(VerticalScroll):
@property
def is_at_bottom(self) -> bool:
return self.scroll_target_y >= (self.max_scroll_y - 3)
return self.scroll_target_y >= self.max_scroll_y
_reanchor_pending: bool = False
_scrolling_down: bool = False
@ -281,6 +286,7 @@ class VibeApp(App): # noqa: PLR0904
Binding(
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
),
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("alt+down", "rewind_next", "Rewind Next", show=False, priority=True),
@ -354,6 +360,8 @@ class VibeApp(App): # noqa: PLR0904
self._cached_messages_area: Widget | None = None
self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None
self._log_reader = LogReader()
self._debug_console: DebugConsole | None = None
self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None
self._narrator_manager: NarratorManagerPort = (
@ -663,6 +671,10 @@ class VibeApp(App): # noqa: PLR0904
) -> None:
await self._switch_to_input_app()
async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None:
await self._mount_and_scroll(UserCommandMessage("MCP servers closed."))
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
@ -700,17 +712,17 @@ class VibeApp(App): # noqa: PLR0904
await widget.remove()
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
if cmd_name := self.commands.get_command_name(user_input):
self.agent_loop.telemetry_client.send_slash_command_used(
cmd_name, "builtin"
)
if resolved := self.commands.parse_command(user_input):
cmd_name, command, cmd_args = resolved
self.agent_loop.telemetry_client.send_slash_command_used(
cmd_name, "builtin"
)
await self._mount_and_scroll(UserMessage(user_input))
handler = getattr(self, command.handler)
if asyncio.iscoroutinefunction(handler):
await handler()
await handler(cmd_args=cmd_args)
else:
handler()
handler(cmd_args=cmd_args)
return True
return False
@ -781,17 +793,103 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
)
except subprocess.TimeoutExpired:
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
)
)
except subprocess.TimeoutExpired as error:
stdout = (
error.stdout.decode("utf-8", errors="replace")
if isinstance(error.stdout, bytes)
else (error.stdout or "")
)
stderr = (
error.stderr.decode("utf-8", errors="replace")
if isinstance(error.stderr, bytes)
else (error.stderr or "")
)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
)
)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
status=f"failed before completion: {e}",
)
)
def _get_bash_max_output_bytes(self) -> int:
from vibe.core.tools.builtins.bash import BashToolConfig
config = self.agent_loop.tool_manager.get_tool_config("bash")
if isinstance(config, BashToolConfig):
return config.max_output_bytes
return BashToolConfig().max_output_bytes
@staticmethod
def _cap_output(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[:limit] + "\n... [truncated]"
def _format_manual_command_context(
self,
*,
command: str,
cwd: str,
stdout: str = "",
stderr: str = "",
exit_code: int | None = None,
status: str | None = None,
) -> str:
limit = self._get_bash_max_output_bytes()
stdout = self._cap_output(stdout, limit)
stderr = self._cap_output(stderr, limit)
sections = [
"Manual `!` command result from the user. Use this as context only.",
f"Command: `{command}`",
f"Working directory: `{cwd}`",
]
if status is not None:
sections.append(f"Status: {status}")
if exit_code is not None:
sections.append(f"Exit code: {exit_code}")
if stdout:
sections.append(f"Stdout:\n```text\n{stdout.rstrip()}\n```")
if stderr:
sections.append(f"Stderr:\n```text\n{stderr.rstrip()}\n```")
if not stdout and not stderr:
sections.append("Output:\n```text\n(no output)\n```")
return "\n\n".join(sections)
async def _handle_user_message(self, message: str) -> None:
if self._remote_manager.is_active:
@ -967,20 +1065,21 @@ class VibeApp(App): # noqa: PLR0904
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif self._loading_widget is None and is_progress_event(event):
await self._ensure_loading_widget()
if self.event_handler:
await self.event_handler.handle_event(
event,
loading_active=self._loading_widget is not None,
loading_widget=self._loading_widget,
)
async with aclosing(self.agent_loop.act(rendered_prompt)) as events:
async for event in events:
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif self._loading_widget is None and is_progress_event(event):
await self._ensure_loading_widget()
if self.event_handler:
await self.event_handler.handle_event(
event,
loading_active=self._loading_widget is not None,
loading_widget=self._loading_widget,
)
except asyncio.CancelledError:
await self._handle_turn_error()
@ -1020,7 +1119,7 @@ class VibeApp(App): # noqa: PLR0904
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."
async def _teleport_command(self) -> None:
async def _teleport_command(self, **kwargs: Any) -> None:
await self._handle_teleport_command(show_message=False)
async def _handle_teleport_command(
@ -1172,11 +1271,39 @@ class VibeApp(App): # noqa: PLR0904
self._interrupt_requested = False
async def _show_help(self) -> None:
async def _show_help(self, **kwargs: Any) -> None:
help_text = self.commands.get_help_text()
await self._mount_and_scroll(UserCommandMessage(help_text))
async def _show_status(self) -> None:
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
mcp_servers = self.config.mcp_servers
if not mcp_servers:
await self._mount_and_scroll(
UserCommandMessage("No MCP servers configured.")
)
return
if self._current_bottom_app == BottomApp.MCP:
return
name = cmd_args.strip()
if name and not any(s.name == name for s in mcp_servers):
await self._mount_and_scroll(
ErrorMessage(
f"Unknown MCP server: {name}. Known servers: "
+ ", ".join(s.name for s in mcp_servers),
collapsed=self._tools_collapsed,
)
)
return
await self._mount_and_scroll(UserCommandMessage("MCP servers opened..."))
await self._switch_from_input(
MCPApp(
mcp_servers=mcp_servers,
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
)
)
async def _show_status(self, **kwargs: Any) -> None:
stats = self.agent_loop.stats
status_text = f"""## Agent Statistics
@ -1189,27 +1316,27 @@ class VibeApp(App): # noqa: PLR0904
"""
await self._mount_and_scroll(UserCommandMessage(status_text))
async def _show_config(self) -> None:
async def _show_config(self, **kwargs: Any) -> None:
"""Switch to the configuration app in the bottom panel."""
if self._current_bottom_app == BottomApp.Config:
return
await self._switch_to_config_app()
async def _show_model(self) -> None:
async def _show_model(self, **kwargs: Any) -> None:
"""Switch to the model picker in the bottom panel."""
if self._current_bottom_app == BottomApp.ModelPicker:
return
await self._switch_to_model_picker_app()
async def _show_proxy_setup(self) -> None:
async def _show_proxy_setup(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
await self._switch_to_proxy_setup_app()
async def _show_data_retention(self) -> None:
async def _show_data_retention(self, **kwargs: Any) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
async def _show_session_picker(self) -> None:
async def _show_session_picker(self, **kwargs: Any) -> None:
cwd = str(Path.cwd())
local_sessions = (
list_local_resume_sessions(self.config, cwd)
@ -1405,7 +1532,7 @@ class VibeApp(App): # noqa: PLR0904
def loading_widget(self) -> LoadingWidget | None:
return self._loading_widget
async def _reload_config(self) -> None:
async def _reload_config(self, **kwargs: Any) -> None:
try:
self._reset_ui_state()
await self._load_more.hide()
@ -1422,7 +1549,11 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
)
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
await self._mount_and_scroll(
UserCommandMessage(
"Configuration reloaded (includes agent instructions and skills)."
)
)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
@ -1430,7 +1561,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _install_lean(self) -> None:
async def _install_lean(self, **kwargs: Any) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" in current:
await self._mount_and_scroll(
@ -1440,7 +1571,7 @@ class VibeApp(App): # noqa: PLR0904
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
await self._reload_config()
async def _uninstall_lean(self) -> None:
async def _uninstall_lean(self, **kwargs: Any) -> None:
current = list(self.agent_loop.base_config.installed_agents)
if "lean" not in current:
await self._mount_and_scroll(
@ -1452,7 +1583,7 @@ class VibeApp(App): # noqa: PLR0904
})
await self._reload_config()
async def _clear_history(self) -> None:
async def _clear_history(self, **kwargs: Any) -> None:
try:
self._reset_ui_state()
if self._remote_manager.is_active:
@ -1482,7 +1613,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _show_log_path(self) -> None:
async def _show_log_path(self, **kwargs: Any) -> None:
if not self.agent_loop.session_logger.enabled:
await self._mount_and_scroll(
ErrorMessage(
@ -1506,7 +1637,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _compact_history(self) -> None:
async def _compact_history(self, **kwargs: Any) -> None:
if self._agent_running:
await self._mount_and_scroll(
ErrorMessage(
@ -1570,11 +1701,12 @@ class VibeApp(App): # noqa: PLR0904
return None
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self) -> None:
async def _exit_app(self, **kwargs: Any) -> None:
self._log_reader.shutdown()
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self) -> None:
async def _setup_terminal(self, **kwargs: Any) -> None:
result = setup_terminal()
if result.success:
@ -1612,7 +1744,7 @@ class VibeApp(App): # noqa: PLR0904
telemetry_client=self.agent_loop.telemetry_client,
)
async def _show_voice_settings(self) -> None:
async def _show_voice_settings(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._switch_to_voice_app()
@ -1720,6 +1852,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case BottomApp.MCP:
self.query_one(MCPApp).focus()
case BottomApp.Rewind:
self.query_one(RewindApp).focus()
case BottomApp.Voice:
@ -1794,7 +1928,7 @@ class VibeApp(App): # noqa: PLR0904
if isinstance(child, UserMessage) and child.message_index is not None
]
def _start_rewind_mode(self) -> None:
def _start_rewind_mode(self, **kwargs: Any) -> None:
self.action_rewind_prev()
def action_rewind_prev(self) -> None:
@ -1987,6 +2121,15 @@ 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 _handle_bottom_app_close_escape(
self, widget_type: type[MCPApp] | type[ProxySetupApp]
) -> None:
try:
self.query_one(widget_type).action_close()
except Exception:
pass
self._last_escape_time = None
def action_interrupt(self) -> None: # noqa: PLR0911
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
self._voice_manager.cancel_recording()
@ -2002,13 +2145,12 @@ class VibeApp(App): # noqa: PLR0904
self._handle_voice_app_escape()
return
if self._current_bottom_app == BottomApp.MCP:
self._handle_bottom_app_close_escape(MCPApp)
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
proxy_setup_app = self.query_one(ProxySetupApp)
proxy_setup_app.action_close()
except Exception:
pass
self._last_escape_time = None
self._handle_bottom_app_close_escape(ProxySetupApp)
return
if self._current_bottom_app == BottomApp.Approval:
@ -2170,6 +2312,14 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(schedule_switch)
async def action_toggle_debug_console(self) -> None:
if self._debug_console is not None:
await self._debug_console.remove()
self._debug_console = None
else:
self._debug_console = DebugConsole(log_reader=self._log_reader)
await self.mount(self._debug_console)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer)
if input_widgets:
@ -2185,6 +2335,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_task.cancel()
self._remote_manager.cancel_stream_task()
self._log_reader.shutdown()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())

View file

@ -84,10 +84,17 @@ TextArea > .text-area--cursor {
}
#completion-popup {
width: 100%;
padding: 1;
padding-left: 3;
overlay: screen;
constrain: inside inside;
display: none;
width: auto;
max-width: 80;
height: auto;
max-height: 12;
padding: 0 1;
color: ansi_default;
background: $surface;
border: solid ansi_bright_black;
}
#input-box {
@ -679,7 +686,7 @@ StatusMessage {
}
#config-app, #voice-app {
#config-app, #voice-app, #mcp-app {
width: 100%;
height: auto;
background: transparent;
@ -688,7 +695,7 @@ StatusMessage {
margin: 0;
}
#config-content, #voice-content {
#config-content, #voice-content, #mcp-content {
width: 100%;
height: auto;
}
@ -699,13 +706,13 @@ StatusMessage {
color: ansi_blue;
}
#config-options {
#config-options, #mcp-options {
width: 100%;
max-height: 50vh;
border: none;
}
#config-options:focus {
#config-options:focus, #mcp-options:focus {
border: none;
}
@ -1089,6 +1096,35 @@ NarratorStatus {
margin-top: 1;
}
/* Debug Console */
#debug-console {
dock: right;
width: 40%;
max-width: 80;
min-width: 40;
height: 100%;
background: transparent;
border-left: solid ansi_bright_black;
padding: 0 1;
}
#debug-console-header {
width: 100%;
height: auto;
color: ansi_default;
padding: 0 0 1 0;
text-align: left;
}
#debug-console-log {
width: 100%;
height: 1fr;
background: transparent;
overflow-x: hidden;
scrollbar-size: 1 1;
}
#modelpicker-app {
width: 100%;
height: auto;

View file

@ -33,15 +33,12 @@ class CompletionPopup(Static):
text.append(description, style=description_style)
self.update(text)
self.show()
self.styles.display = "block"
def hide(self) -> None:
self.update("")
self.styles.display = "none"
def show(self) -> None:
self.styles.display = "block"
def _display_label(self, label: str) -> str:
if label.startswith("@"):
return label[1:]

View file

@ -72,7 +72,6 @@ class ChatInputContainer(Vertical):
self,
),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None
def _get_slash_entries(self) -> list[tuple[str, str]]:
@ -86,8 +85,7 @@ class ChatInputContainer(Vertical):
return sorted(entries)
def compose(self) -> ComposeResult:
self._completion_popup = CompletionPopup()
yield self._completion_popup
yield CompletionPopup()
border_class = self._get_border_class()
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
@ -138,12 +136,32 @@ class ChatInputContainer(Vertical):
def render_completion_suggestions(
self, suggestions: list[tuple[str, str]], selected_index: int
) -> None:
if self._completion_popup:
self._completion_popup.update_suggestions(suggestions, selected_index)
try:
popup = self.query_one(CompletionPopup)
except Exception:
return
popup.update_suggestions(suggestions, selected_index)
self._position_popup(popup, len(suggestions))
def clear_completion_suggestions(self) -> None:
if self._completion_popup:
self._completion_popup.hide()
try:
popup = self.query_one(CompletionPopup)
except Exception:
return
popup.hide()
def _position_popup(self, popup: CompletionPopup, line_count: int) -> None:
widget = self.input_widget
if not widget:
return
cursor = widget.cursor_screen_offset
my_region = self.region
# Place popup bottom edge just above the cursor row
popup_height = line_count + 2 # +2 for solid border
popup.styles.offset = (
cursor.x - my_region.x,
cursor.y - popup_height - my_region.y,
)
def _format_insertion(self, replacement: str, suffix: str) -> str:
"""Format the insertion text with appropriate spacing.

View file

@ -0,0 +1,244 @@
from __future__ import annotations
import bisect
from collections.abc import Callable
from rich.markup import escape
from rich.text import Text
from textual import events
from textual.app import ComposeResult
from textual.cache import LRUCache
from textual.containers import Vertical
from textual.geometry import Size
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.widgets import Static
from vibe.core.log_reader import LogEntry, LogReader
from vibe.core.logger import decode_log_message
LOG_LEVEL_COLORS: dict[str, str] = {
"DEBUG": "dim",
"INFO": "cyan",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "bold red",
}
DEFAULT_LOG_PAGE_SIZE = 30
class _LogView(ScrollView, can_focus=True):
def __init__(
self,
load_page: Callable[[], None],
has_more: Callable[[], bool],
*,
id: str | None = None,
) -> None:
super().__init__(id=id)
self._lines: list[str] = []
self._wrap_counts: list[int] = []
self._wrap_prefix: list[int] = [0]
self._total_visual: int = 0
self._cached_width: int = 0
self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024)
self._load_page = load_page
self._has_more = has_more
def _wrap_markup(self, markup: str) -> int:
"""Return the number of visual lines this markup produces at current width."""
width = self._cached_width
if width <= 0:
return 1
text = Text.from_markup(markup, style=self.rich_style)
return len(text.wrap(self.app.console, width))
def _recompute_prefix(self) -> None:
self._wrap_prefix = [0]
for count in self._wrap_counts:
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
self._total_visual = self._wrap_prefix[-1]
def _reflow(self) -> None:
"""Re-wrap all lines at current widget width."""
width = self.size.width
if width <= 0:
return
self._cached_width = width
self._render_line_cache.clear()
self._wrap_counts = [self._wrap_markup(m) for m in self._lines]
self._recompute_prefix()
self.virtual_size = Size(width, self._total_visual)
def write_line(self, markup: str, scroll_end: bool | None = None) -> None:
at_bottom = self.is_vertical_scroll_end
width = self._cached_width or self.size.width
self._cached_width = width
self._lines.append(markup)
count = self._wrap_markup(markup)
self._wrap_counts.append(count)
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
self._total_visual += count
self.virtual_size = Size(width, self._total_visual)
if scroll_end or (scroll_end is None and at_bottom):
self.scroll_end(animate=False, immediate=True, x_axis=False)
def prepend_lines(self, markups: list[str]) -> None:
if not markups:
return
width = self._cached_width or self.size.width
self._cached_width = width
new_counts = [self._wrap_markup(m) for m in markups]
new_visual = sum(new_counts)
self._lines[0:0] = markups
self._wrap_counts[0:0] = new_counts
self._recompute_prefix()
self._render_line_cache.clear()
self.virtual_size = Size(width, self._total_visual)
self.scroll_to(y=self.scroll_y + new_visual, animate=False, immediate=True)
def render_line(self, y: int) -> Strip:
_, scroll_y = self.scroll_offset
abs_y = scroll_y + y
width = self.size.width
wrap_width = self._cached_width or width
rich_style = self.rich_style
if abs_y >= self._total_visual:
return Strip.blank(width, rich_style)
if abs_y in self._render_line_cache:
return self._render_line_cache[abs_y]
logical_idx = bisect.bisect_right(self._wrap_prefix, abs_y) - 1
text = Text.from_markup(self._lines[logical_idx], style=rich_style)
wrapped = text.wrap(self.app.console, wrap_width)
base = self._wrap_prefix[logical_idx]
for i, line_text in enumerate(wrapped):
strip = Strip(line_text.render(self.app.console), line_text.cell_len)
strip = strip.crop_extend(0, width, rich_style)
self._render_line_cache[base + i] = strip
try:
return self._render_line_cache[abs_y]
except KeyError:
return Strip.blank(width, rich_style)
def notify_style_update(self) -> None:
super().notify_style_update()
self._render_line_cache.clear()
def on_resize(self, event: events.Resize) -> None:
if event.size.width != self._cached_width:
self._reflow()
def on_click(self, event: events.Click) -> None:
_, scroll_y = self.scroll_offset
visual_y = scroll_y + event.y
logical_idx = bisect.bisect_right(self._wrap_prefix, visual_y) - 1
if 0 <= logical_idx < len(self._lines):
plain = Text.from_markup(self._lines[logical_idx]).plain
self.app.copy_to_clipboard(plain)
self.app.notify("Copied to clipboard", timeout=2.0)
def _try_load_previous(self) -> None:
if not self._has_more() or self.scroll_y > 0:
return
self._load_page()
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
super()._on_mouse_scroll_up(event)
self._try_load_previous()
def action_scroll_up(self) -> None:
super().action_scroll_up()
self._try_load_previous()
def action_page_up(self) -> None:
super().action_page_up()
self._try_load_previous()
def action_scroll_home(self) -> None:
super().action_scroll_home()
self._try_load_previous()
class DebugConsole(Vertical):
def __init__(
self, log_reader: LogReader, page_size: int = DEFAULT_LOG_PAGE_SIZE
) -> None:
super().__init__(id="debug-console")
self._log_reader = log_reader
self._log_view: _LogView | None = None
self._cursor: int | None = None
self._has_more: bool = True
self._page_size = page_size
def compose(self) -> ComposeResult:
yield Static(
"Debug Console [dim](ctrl+\\ to close)[/dim]", id="debug-console-header"
)
self._log_view = _LogView(
load_page=self._load_page,
has_more=lambda: self._has_more and self._cursor is not None,
id="debug-console-log",
)
yield self._log_view
def on_mount(self) -> None:
self._fill_viewport()
self._log_reader.set_consumer(self._on_log_entry)
self._log_reader.start_watching()
def on_unmount(self) -> None:
self._log_reader.set_consumer(None)
self._log_reader.stop_watching()
def _load_page(self) -> None:
if self._log_view is None:
return
result = self._log_reader.get_logs(
limit=self._page_size, offset=self._cursor or 0
)
self._cursor = result.cursor
self._has_more = result.has_more
markups = [self._format_entry(e) for e in reversed(result.entries)]
self._log_view.prepend_lines(markups)
def _fill_viewport(self) -> None:
"""Load enough logs to fill the viewport, then scroll to the bottom."""
if self._log_view is None or not self._has_more:
return
self.call_after_refresh(self._check_and_fill)
def _check_and_fill(self) -> None:
if self._log_view is None or not self._has_more:
return
if self._log_view.virtual_size.height <= self._log_view.size.height:
self._load_page()
self._fill_viewport()
else:
self._log_view.scroll_end(animate=False)
def _on_log_entry(self, entry: LogEntry) -> None:
self.app.call_from_thread(self._append_log_entry, entry)
def _append_log_entry(self, entry: LogEntry) -> None:
if self._log_view is None:
return
self._log_view.write_line(self._format_entry(entry))
@staticmethod
def _format_entry(entry: LogEntry) -> str:
color = LOG_LEVEL_COLORS.get(entry.level, "dim")
ts = entry.timestamp.astimezone().strftime("%Y-%m-%d %H:%M:%S")
message = decode_log_message(entry.message)
safe_message = escape(message)
return f"[dim]{ts}[/dim] [{color}]{entry.level:<8}[/{color}] {safe_message}"

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.events import DescendantBlur
from textual.message import Message
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.mcp.tools import MCPTool
if TYPE_CHECKING:
from vibe.core.config import MCPServer
from vibe.core.tools.manager import ToolManager
class MCPToolIndex(NamedTuple):
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
enabled_tools: dict[str, type[Any]]
def collect_mcp_tool_index(
mcp_servers: Sequence[MCPServer], tool_manager: ToolManager
) -> MCPToolIndex:
registered = tool_manager.registered_tools
available = tool_manager.available_tools
configured_servers = {server.name for server in mcp_servers}
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
for tool_name, cls in registered.items():
if not issubclass(cls, MCPTool):
continue
server_name = cls.get_server_name()
if server_name is None or server_name not in configured_servers:
continue
server_tools.setdefault(server_name, []).append((tool_name, cls))
return MCPToolIndex(server_tools, enabled_tools=available)
class MCPApp(Container):
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
Binding("backspace", "back", "Back", show=False),
]
class MCPClosed(Message):
pass
def __init__(
self,
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
initial_server: str = "",
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
self._tool_manager = tool_manager
self._index = collect_mcp_tool_index(mcp_servers, tool_manager)
self._viewing_server: str | None = initial_server.strip() or None
def compose(self) -> ComposeResult:
with Vertical(id="mcp-content"):
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(id="mcp-options")
yield NoMarkupStatic("")
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
def on_mount(self) -> None:
self._refresh_view(self._viewing_server)
self.query_one(OptionList).focus()
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id or ""
if option_id.startswith("server:"):
self._refresh_view(option_id.removeprefix("server:"))
def action_back(self) -> None:
if self._viewing_server is not None:
self._refresh_view(None)
def action_close(self) -> None:
self.post_message(self.MCPClosed())
def _refresh_view(self, server_name: str | None) -> None:
index = self._index
option_list = self.query_one(OptionList)
option_list.clear_options()
server_names = {s.name for s in self._mcp_servers}
if server_name is None or server_name not in server_names:
self._viewing_server = None
self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers")
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Enter Show tools Esc Close"
)
for srv in self._mcp_servers:
tools = index.server_tools.get(srv.name, [])
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
status = _server_status(enabled)
label = Text(no_wrap=True)
label.append(srv.name)
label.append(f" [{srv.transport}] {status}")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
if self._mcp_servers:
option_list.highlighted = 0
return
self._viewing_server = server_name
self.query_one("#mcp-title", NoMarkupStatic).update(
f"MCP Server: {server_name}"
)
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Backspace Back Esc Close"
)
enabled_tools = [
(tool_name, cls)
for tool_name, cls in sorted(
index.server_tools.get(server_name, []), key=lambda t: t[0]
)
if tool_name in index.enabled_tools
]
if not enabled_tools:
option_list.add_option(
Option("No enabled tools for this server", disabled=True)
)
return
for tool_name, cls in enabled_tools:
remote_name = cls.get_remote_name()
raw_desc = (
(cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0]
)
label = Text(no_wrap=True)
label.append(remote_name, style="bold")
if raw_desc:
label.append(f" - {raw_desc}")
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
if enabled_tools:
option_list.highlighted = 0
def _server_status(enabled: int) -> str:
if enabled == 0:
return "unavailable"
noun = "tool" if enabled == 1 else "tools"
return f"{enabled} {noun} enabled"

View file

@ -1,5 +1,15 @@
from __future__ import annotations
__all__ = ["run_programmatic"]
from typing import TYPE_CHECKING
from vibe.core.programmatic import run_programmatic
if TYPE_CHECKING:
from vibe.core.programmatic import run_programmatic as run_programmatic
def __getattr__(name: str) -> object:
if name == "run_programmatic":
from vibe.core.programmatic import run_programmatic
return run_programmatic
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -159,11 +159,14 @@ class AgentLoop:
backend: BackendLike | None = None,
enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None,
is_subagent: bool = False,
) -> None:
self._base_config = config
self.mcp_registry = MCPRegistry()
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
lambda: self._base_config,
initial_agent=agent_name,
allow_subagent=is_subagent,
)
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self.mcp_registry
@ -209,6 +212,7 @@ class AgentLoop:
self._is_user_prompt_call: bool = False
self._session_rules: list[ApprovedRule] = []
self._approval_lock = asyncio.Lock()
self.telemetry_client = TelemetryClient(
config_getter=lambda: self.config, session_id_getter=lambda: self.session_id
@ -267,7 +271,6 @@ class AgentLoop:
self.config.tools[tool_name] = {}
self.config.tools[tool_name]["permission"] = permission.value
self.tool_manager.invalidate_tool(tool_name)
def add_session_rule(self, rule: ApprovedRule) -> None:
self._session_rules.append(rule)
@ -350,6 +353,10 @@ class AgentLoop:
self.agent_profile,
)
async def inject_user_context(self, content: str) -> None:
self.messages.append(LLMMessage(role=Role.user, content=content, injected=True))
await self._save_messages()
async def act(
self, msg: str, client_message_id: str | None = None
) -> AsyncGenerator[BaseEvent, None]:
@ -1019,40 +1026,41 @@ class AgentLoop:
approval_type=ToolPermission.ALWAYS,
)
tool_name = tool.get_name()
ctx = tool.resolve_permission(args)
async with self._approval_lock:
tool_name = tool.get_name()
ctx = tool.resolve_permission(args)
if ctx is None:
config_perm = self.tool_manager.get_tool_config(tool_name).permission
ctx = PermissionContext(permission=config_perm)
if ctx is None:
config_perm = self.tool_manager.get_tool_config(tool_name).permission
ctx = PermissionContext(permission=config_perm)
match ctx.permission:
case ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
case ToolPermission.NEVER:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=ctx.reason
or f"Tool '{tool_name}' is permanently disabled",
)
case _:
uncovered = [
rp
for rp in ctx.required_permissions
if not self._is_permission_covered(tool_name, rp)
]
if ctx.required_permissions and not uncovered:
match ctx.permission:
case ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
return await self._ask_approval(
tool_name, args, tool_call_id, uncovered
)
case ToolPermission.NEVER:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=ctx.reason
or f"Tool '{tool_name}' is permanently disabled",
)
case _:
uncovered = [
rp
for rp in ctx.required_permissions
if not self._is_permission_covered(tool_name, rp)
]
if ctx.required_permissions and not uncovered:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
return await self._ask_approval(
tool_name, args, tool_call_id, uncovered
)
async def _ask_approval(
self,

View file

@ -23,6 +23,7 @@ class AgentManager:
self,
config_getter: Callable[[], VibeConfig],
initial_agent: str = BuiltinAgentName.DEFAULT,
allow_subagent: bool = False,
) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
@ -39,9 +40,18 @@ class AgentManager:
" ".join(str(p) for p in self._search_paths),
)
self.active_profile = self._available.get(
initial_agent, self._available[BuiltinAgentName.DEFAULT]
)
profile = self._available.get(initial_agent)
if (
not allow_subagent
and profile is not None
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._cached_config: VibeConfig | None = None
@property

View file

@ -177,7 +177,7 @@ LEAN = AgentProfile(
}
],
"compaction_model": {
"name": "mistral-vibe-cli-latest",
"name": "mistral-small-latest",
"provider": "mistral-testing",
"alias": "devstral-compact",
"temperature": 0.2,

View file

@ -41,6 +41,10 @@ class WatchController:
thread.start()
ready_event.wait(timeout=0.5)
@property
def is_watching(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def stop(self) -> None:
thread = self._thread
if self._stop_event:

View file

@ -15,7 +15,7 @@ from vibe.core.config._settings import (
MissingAPIKeyError,
MissingPromptFileError,
ModelConfig,
OtelExporterConfig,
OtelSpanExporterConfig,
ProjectContextConfig,
ProviderConfig,
SessionLoggingConfig,
@ -45,7 +45,7 @@ __all__ = [
"MissingAPIKeyError",
"MissingPromptFileError",
"ModelConfig",
"OtelExporterConfig",
"OtelSpanExporterConfig",
"ProjectContextConfig",
"ProviderConfig",
"SessionLoggingConfig",

View file

@ -11,6 +11,9 @@ from typing import Annotated, Any, Literal
from urllib.parse import urljoin
from dotenv import dotenv_values
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_TRACES_EXPORT_PATH,
)
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.fields import FieldInfo
from pydantic_core import to_jsonable_python
@ -291,7 +294,7 @@ class TTSModelConfig(BaseModel):
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
class OtelExporterConfig(BaseModel):
class OtelSpanExporterConfig(BaseModel):
model_config = ConfigDict(frozen=True)
endpoint: str
@ -299,7 +302,7 @@ class OtelExporterConfig(BaseModel):
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
MISTRAL_OTEL_TRACES_PATH = "/telemetry/v1/traces"
MISTRAL_OTEL_PATH = "/telemetry"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_PROVIDERS = [
@ -523,12 +526,17 @@ class VibeConfig(BaseSettings):
return os.getenv(self.nuage_api_key_env_var, "")
@property
def otel_exporter_config(self) -> OtelExporterConfig | None:
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
# When otel_endpoint is set explicitly, authentication is the user's responsibility
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
# Otherwise endpoint and API key are derived from the first MISTRAL provider.
traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
if self.otel_endpoint:
return OtelExporterConfig(endpoint=self.otel_endpoint)
return OtelSpanExporterConfig(
endpoint=urljoin(
f"{self.otel_endpoint.rstrip('/')}/", traces_export_path
)
)
provider = next(
(p for p in self.providers if p.backend == Backend.MISTRAL), None
@ -542,7 +550,8 @@ class VibeConfig(BaseSettings):
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
endpoint = urljoin(
server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_TRACES_PATH
f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
traces_export_path,
)
if not (api_key := os.getenv(api_key_env)):
@ -551,7 +560,7 @@ class VibeConfig(BaseSettings):
)
return None
return OtelExporterConfig(
return OtelSpanExporterConfig(
endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"}
)

View file

@ -11,7 +11,6 @@ import httpx
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
@ -160,14 +159,27 @@ class OpenAIAdapter(APIAdapter):
return LLMChunk(message=message, usage=usage)
ADAPTERS: dict[str, APIAdapter] = {
_ADAPTERS: dict[str, APIAdapter] = {
"openai": OpenAIAdapter(),
"anthropic": AnthropicAdapter(),
"vertex-anthropic": VertexAnthropicAdapter(),
"reasoning": ReasoningAdapter(),
}
def _get_adapter(api_style: str) -> APIAdapter:
"""Loads the appropriate adapter for the given API style,
lazily if the adapter is not already loaded.
"""
if api_style not in _ADAPTERS:
if api_style == "vertex-anthropic":
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
_ADAPTERS["vertex-anthropic"] = VertexAnthropicAdapter()
else:
raise KeyError(api_style)
return _ADAPTERS[api_style]
class GenericBackend:
def __init__(
self,
@ -232,7 +244,7 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = ADAPTERS[api_style]
adapter = _get_adapter(api_style)
req = adapter.prepare_request(
model_name=model.name,
@ -300,7 +312,7 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = ADAPTERS[api_style]
adapter = _get_adapter(api_style)
req = adapter.prepare_request(
model_name=model.name,

View file

@ -32,10 +32,7 @@ from mistralai.client.models import (
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,
strip_reasoning as strip_reasoning_message,
)
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
Content,
@ -171,9 +168,6 @@ class MistralMapper:
for tool_call in tool_calls
]
def strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
return strip_reasoning_message(msg)
ReasoningEffortValue = Literal["none", "high"]
@ -271,10 +265,6 @@ class MistralBackend:
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
if reasoning_effort is not None:
temperature = 1.0
else:
merged_messages = [
strip_reasoning_message(msg) for msg in merged_messages
]
response = await self._get_client().chat.complete_async(
model=model.name,
@ -355,10 +345,6 @@ class MistralBackend:
reasoning_effort = _THINKING_TO_REASONING_EFFORT.get(model.thinking)
if reasoning_effort is not None:
temperature = 1.0
else:
merged_messages = [
strip_reasoning_message(msg) for msg in merged_messages
]
stream = await self._get_client().chat.stream_async(
model=model.name,

View file

@ -6,7 +6,7 @@ from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.message_utils import merge_consecutive_user_messages, strip_reasoning
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
FunctionCall,
@ -122,8 +122,6 @@ class ReasoningAdapter(APIAdapter):
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
if thinking == "off":
merged_messages = [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

@ -5,14 +5,6 @@ from collections.abc import Sequence
from vibe.core.types import LLMMessage, Role
def strip_reasoning(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 merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.

224
vibe/core/log_reader.py Normal file
View file

@ -0,0 +1,224 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import re
import threading
from vibe.core.logger import logger
from vibe.core.paths import LOG_FILE
@dataclass(frozen=True, slots=True)
class LogEntry:
timestamp: datetime
ppid: int
pid: int
level: str
message: str
raw_line: str
line_number: int
@dataclass(frozen=True, slots=True)
class PaginatedLogs:
entries: list[LogEntry]
has_more: bool
cursor: int | None
LogConsumer = Callable[[LogEntry], None]
# Format: timestamp ppid pid level message [exception]
# Timestamp is ISO 8601 from datetime.isoformat(), e.g. 2026-02-21T10:28:51.529718+00:00
DEFAULT_LOG_PATTERN = re.compile(
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+(?:[+-]\d{2}:\d{2})?)\s+"
r"(\d+)\s+(\d+)\s+"
r"(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+"
r"(.+)$"
)
LOG_POLL_INTERVAL = 0.5
class LogReader:
def __init__(
self,
log_file: Path | None = None,
consumer: LogConsumer | None = None,
log_pattern: re.Pattern[str] = DEFAULT_LOG_PATTERN,
poll_interval: float = LOG_POLL_INTERVAL,
) -> None:
self._log_file = log_file if log_file is not None else LOG_FILE.path
self._consumer = consumer
self._log_pattern = log_pattern
self._lock = threading.Lock()
self._last_position: int = 0
self._new_lines_count: int = 0
self._stop_event: threading.Event | None = None
self._thread: threading.Thread | None = None
self._poll_interval = poll_interval
@property
def log_file(self) -> Path:
return self._log_file
@property
def is_watching(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def get_logs(self, limit: int = 100, offset: int = 0) -> PaginatedLogs:
if not self._log_file.exists():
return PaginatedLogs(entries=[], has_more=False, cursor=None)
entries: list[LogEntry] = []
for line, relative_position in self._read_lines_backward(start_position=offset):
if entry := self._parse_line(line, relative_position):
entries.append(entry)
if len(entries) >= limit:
return PaginatedLogs(
entries=entries, has_more=True, cursor=offset + len(entries)
)
return PaginatedLogs(entries=entries, has_more=False, cursor=None)
def _read_lines_backward(self, start_position: int) -> Iterator[tuple[str, int]]:
if not self._log_file.exists():
return
with self._lock:
# Snapshot once: file end-position is also captured once (seek EOF below),
# so new lines appended by the poll thread after this point are invisible
# to this iteration and the skip count stays consistent.
adjusted_skip = start_position + self._new_lines_count
chunk_size = 8192
relative_position = 0
skipped = 0
with self._log_file.open("rb") as f:
f.seek(0, 2)
position = f.tell()
remainder = b""
while position > 0:
read_size = min(chunk_size, position)
position -= read_size
f.seek(position)
chunk = f.read(read_size) + remainder
lines = chunk.split(b"\n")
remainder = lines[0]
for line in reversed(lines[1:]):
if not line:
continue
if skipped < adjusted_skip:
skipped += 1
continue
relative_position += 1
yield line.decode("utf-8", errors="replace"), relative_position
if remainder:
if skipped < adjusted_skip:
return
relative_position += 1
yield remainder.decode("utf-8", errors="replace"), relative_position
def set_consumer(self, consumer: LogConsumer | None) -> None:
self._consumer = consumer
def start_watching(self) -> None:
if self._thread and self._thread.is_alive():
return
with self._lock:
self._last_position = (
self._log_file.stat().st_size if self._log_file.exists() else 0
)
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._poll_log_loop, name="log-reader-poll", daemon=True
)
self._thread.start()
def stop_watching(self) -> None:
if self._stop_event:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1)
self._thread = None
self._stop_event = None
with self._lock:
self._new_lines_count = 0
def shutdown(self) -> None:
self.stop_watching()
def _poll_log_loop(self) -> None:
stop_event = self._stop_event
if stop_event is None:
return
while not stop_event.is_set():
stop_event.wait(self._poll_interval)
if stop_event.is_set():
break
self._process_new_content()
def _process_new_content(self) -> None:
consumer = self._consumer
if consumer is None:
return
lines: list[str]
with self._lock:
if not self._log_file.exists():
self._last_position = 0
return
current_size = self._log_file.stat().st_size
if current_size < self._last_position:
self._last_position = 0
self._new_lines_count = 0
if current_size == self._last_position:
return
with self._log_file.open("r") as f:
f.seek(self._last_position)
new_content = f.read()
self._last_position = f.tell()
lines = new_content.splitlines()
self._new_lines_count += len(lines)
for line in lines:
if entry := self._parse_line(line, 0):
consumer(entry)
def _parse_line(self, line: str, line_number: int) -> LogEntry | None:
try:
match = self._log_pattern.match(line)
if not match:
return None
timestamp_str, ppid_str, pid_str, level, message = match.groups()
timestamp = datetime.fromisoformat(timestamp_str)
return LogEntry(
timestamp=timestamp,
ppid=int(ppid_str),
pid=int(pid_str),
level=level,
message=message,
raw_line=line,
line_number=line_number,
)
except Exception:
logger.debug("Failed to parse log line: %s", line[:100])
return None

View file

@ -4,6 +4,7 @@ from datetime import UTC, datetime
import logging
from logging.handlers import RotatingFileHandler
import os
import re
from vibe.core.paths import LOG_DIR, LOG_FILE
@ -18,17 +19,27 @@ class StructuredLogFormatter(logging.Formatter):
ppid = os.getppid()
pid = os.getpid()
level = record.levelname
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
message = encode_log_message(record.getMessage())
line = f"{timestamp} {ppid} {pid} {level} {message}"
if record.exc_info:
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
exc_text = encode_log_message(self.formatException(record.exc_info))
line = f"{line} {exc_text}"
return line
def encode_log_message(message: str) -> str:
return message.replace("\\", "\\\\").replace("\n", "\\n")
def decode_log_message(encoded: str) -> str:
return re.sub(
r"\\(.)", lambda m: "\n" if m.group(1) == "n" else m.group(1), encoded
)
def apply_logging_config(target_logger: logging.Logger) -> None:
LOG_DIR.path.mkdir(parents=True, exist_ok=True)

View file

@ -23,37 +23,48 @@ _MAX_DIRS = 2000
def _collect_config_dirs_at(
path: Path,
entries: set[str],
entry_names: set[str],
tools: list[Path],
skills: list[Path],
agents: list[Path],
) -> None:
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
if _VIBE_DIR in entries:
if _VIBE_DIR in entry_names:
if (candidate := path / _TOOLS_SUBDIR).is_dir():
tools.append(candidate)
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents.append(candidate)
if _AGENTS_DIR in entries:
if _AGENTS_DIR in entry_names:
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
def _iter_child_dirs(path: Path, entries: set[str]) -> list[Path]:
"""Return sorted child directories to descend into, skipping ignored and dot-dirs."""
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
"""Scan a directory, returning entry names and sorted child directories to descend into.
Uses ``os.scandir`` so that ``DirEntry.is_dir()`` leverages the dirent
d_type field and avoids a separate ``stat`` syscall on most filesystems.
"""
try:
entries = list(os.scandir(path))
except OSError:
return set(), []
entry_names = {e.name for e in entries}
children: list[Path] = []
for name in sorted(entries):
for entry in entries:
name = entry.name
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
continue
child = path / name
try:
if child.is_dir():
children.append(child)
if entry.is_dir():
children.append(path / name)
except OSError:
continue
return children
children.sort()
return entry_names, children
@cache
@ -77,17 +88,16 @@ def walk_local_config_dirs_all(
current, depth = queue.popleft()
visited += 1
try:
entries = set(os.listdir(current))
except OSError:
entry_names, children = _scandir_entries(current)
if not entry_names:
continue
_collect_config_dirs_at(current, entries, tools_dirs, skills_dirs, agents_dirs)
_collect_config_dirs_at(
current, entry_names, tools_dirs, skills_dirs, agents_dirs
)
if depth < WALK_MAX_DEPTH:
queue.extend(
(child, depth + 1) for child in _iter_child_dirs(current, entries)
)
queue.extend((child, depth + 1) for child in children)
if visited >= _MAX_DIRS:
logger.warning(
@ -116,18 +126,15 @@ def has_config_dirs_nearby(
current, depth = queue.popleft()
visited += 1
try:
entries = set(os.listdir(current))
except OSError:
entry_names, children = _scandir_entries(current)
if not entry_names:
continue
_collect_config_dirs_at(current, entries, found, found, found)
_collect_config_dirs_at(current, entry_names, found, found, found)
if found:
return True
if depth < max_depth:
queue.extend(
(child, depth + 1) for child in _iter_child_dirs(current, entries)
)
queue.extend((child, depth + 1) for child in children)
return False

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from contextlib import aclosing
from vibe import __version__
from vibe.core.agent_loop import AgentLoop, TeleportError
@ -79,13 +80,14 @@ def run_programmatic(
)
formatter.on_event(next_event)
else:
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if (
isinstance(event, AssistantEvent)
and event.stopped_by_middleware
):
raise ConversationLimitException(event.content)
async with aclosing(agent_loop.act(prompt)) as events:
async for event in events:
formatter.on_event(event)
if (
isinstance(event, AssistantEvent)
and event.stopped_by_middleware
):
raise ConversationLimitException(event.content)
return formatter.finalize()
finally:

View file

@ -82,7 +82,7 @@ class GitHubPublicData(BaseModel):
class ChatAssistantPublicData(BaseModel):
chat_url: str
chat_url: str | None = None
class GetChatAssistantIntegrationResponse(BaseModel):
@ -195,7 +195,7 @@ class NuageClient:
await asyncio.sleep(min(interval, remaining))
raise ServiceTeleportError("GitHub connection timed out")
async def get_chat_assistant_url(self, execution_id: str) -> str:
async def get_chat_assistant_url(self, execution_id: str) -> str | None:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
headers=self._headers(),

View file

@ -193,6 +193,9 @@ class TeleportService:
yield TeleportFetchingUrlEvent()
chat_url = await self._nuage_client.get_chat_assistant_url(execution_id)
if not chat_url:
raise ServiceTeleportError("Chat assistant URL is not available yet")
yield TeleportCompleteEvent(url=chat_url)
async def _push_or_fail(self) -> None:

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass, field
from enum import StrEnum, auto
import functools
@ -134,10 +134,16 @@ class BaseTool[
prompt_path: ClassVar[Path] | None = None
def __init__(self, config: ToolConfig, state: ToolState) -> None:
self.config = config
def __init__(
self, config_getter: Callable[[], ToolConfig], state: ToolState
) -> None:
self._config_getter = config_getter
self.state = state
@property
def config(self) -> ToolConfig:
return self._config_getter()
@abstractmethod
async def run(
self, args: ToolArgs, ctx: InvokeContext | None = None
@ -184,11 +190,11 @@ class BaseTool[
@classmethod
def from_config(
cls, config: ToolConfig
cls, config_getter: Callable[[], ToolConfig]
) -> BaseTool[ToolArgs, ToolResult, ToolConfig, ToolState]:
state_class = cls._get_tool_state_class()
initial_state = state_class()
return cls(config=config, state=initial_state)
return cls(config_getter=config_getter, state=initial_state)
@classmethod
def _get_tool_config_class(cls) -> type[ToolConfig]:

View file

@ -4,3 +4,4 @@ Use this tool when you need to retrieve and analyze web content.
- Prefer a more specialized tool over `web_fetch` when one is available.
- URLs must be valid.
- Read-only: does not modify any files.
- Content is capped at a byte limit. If `was_truncated` is true, the page had more content that was cut off.

View file

@ -99,9 +99,10 @@ class SearchReplace(
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if isinstance(event.result, SearchReplaceResult):
path_name = Path(event.result.file).name
return ToolResultDisplay(
success=True,
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'}",
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}",
warnings=event.result.warnings,
)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import aclosing
import fnmatch
from typing import ClassVar
@ -133,6 +134,7 @@ class Task(
config=base_config,
agent_name=args.agent,
entrypoint_metadata=ctx.entrypoint_metadata,
is_subagent=True,
)
if ctx and ctx.approval_callback:
@ -141,23 +143,24 @@ class Task(
accumulated_response: list[str] = []
completed = True
try:
async for event in subagent_loop.act(args.task):
if isinstance(event, AssistantEvent) and event.content:
accumulated_response.append(event.content)
if event.stopped_by_middleware:
completed = False
elif isinstance(event, ToolResultEvent):
if event.skipped:
completed = False
elif event.result and event.tool_class:
adapter = ToolUIDataAdapter(event.tool_class)
display = adapter.get_result_display(event)
message = f"{event.tool_name}: {display.message}"
yield ToolStreamEvent(
tool_name=self.get_name(),
message=message,
tool_call_id=ctx.tool_call_id,
)
async with aclosing(subagent_loop.act(args.task)) as events:
async for event in events:
if isinstance(event, AssistantEvent) and event.content:
accumulated_response.append(event.content)
if event.stopped_by_middleware:
completed = False
elif isinstance(event, ToolResultEvent):
if event.skipped:
completed = False
elif event.result and event.tool_class:
adapter = ToolUIDataAdapter(event.tool_class)
display = adapter.get_result_display(event)
message = f"{event.tool_name}: {display.message}"
yield ToolStreamEvent(
tool_name=self.get_name(),
message=message,
tool_call_id=ctx.tool_call_id,
)
turns_used = sum(
msg.role == Role.assistant for msg in subagent_loop.messages

View file

@ -1,11 +1,11 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import functools
from typing import TYPE_CHECKING, ClassVar, final
from urllib.parse import urlparse
import httpx
from markdownify import MarkdownConverter
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
@ -32,10 +32,16 @@ _HONEST_USER_AGENT = "vibe-cli"
_HTTP_FORBIDDEN = 403
class _Converter(MarkdownConverter):
convert_script = convert_style = convert_noscript = convert_iframe = (
convert_object
) = convert_embed = lambda *_, **__: ""
@functools.cache
def _make_converter_class() -> type:
from markdownify import MarkdownConverter
class _Converter(MarkdownConverter):
convert_script = convert_style = convert_noscript = convert_iframe = (
convert_object
) = convert_embed = lambda *_, **__: ""
return _Converter
class WebFetchArgs(BaseModel):
@ -49,6 +55,7 @@ class WebFetchResult(BaseModel):
url: str
content: str
content_type: str
was_truncated: bool = False
class WebFetchConfig(BaseToolConfig):
@ -57,7 +64,8 @@ class WebFetchConfig(BaseToolConfig):
default_timeout: int = Field(default=30, description="Default timeout in seconds.")
max_timeout: int = Field(default=120, description="Maximum allowed timeout.")
max_content_bytes: int = Field(
default=512_000, description="Maximum content size to fetch."
default=120_000,
description="Maximum content size in bytes returned to the model.",
)
user_agent: str = Field(
default=(
@ -120,7 +128,20 @@ class WebFetch(
if "text/html" in content_type:
content = _html_to_markdown(content)
yield WebFetchResult(url=url, content=content, content_type=content_type)
content_bytes = content.encode("utf-8")
was_truncated = len(content_bytes) > self.config.max_content_bytes
if was_truncated:
content = content_bytes[: self.config.max_content_bytes].decode(
"utf-8", errors="ignore"
)
content += "\n\n[Content truncated due to size limit]"
yield WebFetchResult(
url=url,
content=content,
content_type=content_type,
was_truncated=was_truncated,
)
def _validate_args(self, args: WebFetchArgs) -> None:
if not args.url.strip():
@ -169,11 +190,7 @@ class WebFetch(
content_type = response.headers.get("Content-Type", "text/plain")
content_bytes = response.content[: self.config.max_content_bytes]
content = content_bytes.decode("utf-8", errors="ignore")
if len(response.content) > self.config.max_content_bytes:
content += "[Content truncated due to size limit]"
content = response.content.decode("utf-8", errors="ignore")
return content, content_type
@ -222,6 +239,8 @@ class WebFetch(
message = (
f"Fetched {content_len:,} chars ({event.result.content_type.split(';')[0]})"
)
if event.result.was_truncated:
message += " [truncated]"
return ToolResultDisplay(success=True, message=message)
@ -231,4 +250,5 @@ class WebFetch(
def _html_to_markdown(html: str) -> str:
return _Converter(heading_style="ATX", bullets="-").convert(html)
converter_class = _make_converter_class()
return converter_class(heading_style="ATX", bullets="-").convert(html)

View file

@ -174,6 +174,10 @@ class ToolManager:
continue
return defaults
@property
def registered_tools(self) -> dict[str, type[BaseTool]]:
return self._available
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
runtime_available = {
@ -241,12 +245,10 @@ class ToolManager:
)
tool_class = self._available[tool_name]
tool_config = self.get_tool_config(tool_name)
self._instances[tool_name] = tool_class.from_config(tool_config)
self._instances[tool_name] = tool_class.from_config(
lambda: self.get_tool_config(tool_name)
)
return self._instances[tool_name]
def reset_all(self) -> None:
self._instances.clear()
def invalidate_tool(self, tool_name: str) -> None:
self._instances.pop(tool_name, None)

View file

@ -71,6 +71,22 @@ class MCPToolResult(BaseModel):
structured: dict[str, Any] | None = None
class MCPTool(
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
ToolUIData[_OpenArgs, MCPToolResult],
):
_server_name: ClassVar[str] = ""
_remote_name: ClassVar[str] = ""
@classmethod
def get_server_name(cls) -> str | None:
return cls._server_name or None
@classmethod
def get_remote_name(cls) -> str:
return cls._remote_name or cls.get_name()
class RemoteTool(BaseModel):
model_config = ConfigDict(from_attributes=True)
@ -207,17 +223,16 @@ def create_mcp_http_proxy_tool_class(
port = f"_{p.port}" if p.port else ""
return f"{host}{port}"
published_name = f"{(alias or _alias_from_url(url))}_{remote.name}"
computed_alias = alias or _alias_from_url(url)
published_name = f"{computed_alias}_{remote.name}"
class MCPHttpProxyTool(
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
ToolUIData[_OpenArgs, MCPToolResult],
):
class MCPHttpProxyTool(MCPTool):
description: ClassVar[str] = (
(f"[{alias}] " if alias else "")
(f"[{computed_alias}] " if computed_alias else "")
+ (remote.description or f"MCP tool '{remote.name}' from {url}")
+ (f"\nHint: {server_hint}" if server_hint else "")
)
_server_name: ClassVar[str] = computed_alias
_mcp_url: ClassVar[str] = url
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
@ -269,7 +284,7 @@ def create_mcp_http_proxy_tool_class(
def get_status_text(cls) -> str:
return f"Calling MCP tool {remote.name}"
MCPHttpProxyTool.__name__ = f"MCP_{(alias or _alias_from_url(url))}__{remote.name}"
MCPHttpProxyTool.__name__ = f"MCP_{computed_alias}__{remote.name}"
return MCPHttpProxyTool
@ -344,10 +359,7 @@ def create_mcp_stdio_proxy_tool_class(
computed_alias = alias or _alias_from_command(command)
published_name = f"{computed_alias}_{remote.name}"
class MCPStdioProxyTool(
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
ToolUIData[_OpenArgs, MCPToolResult],
):
class MCPStdioProxyTool(MCPTool):
description: ClassVar[str] = (
(f"[{computed_alias}] " if computed_alias else "")
+ (
@ -356,6 +368,7 @@ def create_mcp_stdio_proxy_tool_class(
)
+ (f"\nHint: {server_hint}" if server_hint else "")
)
_server_name: ClassVar[str] = computed_alias
_stdio_command: ClassVar[list[str]] = command
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema

View file

@ -24,7 +24,7 @@ def setup_tracing(config: VibeConfig) -> None:
if not config.enable_otel:
return
exporter_cfg = config.otel_exporter_config
exporter_cfg = config.otel_span_exporter_config
if exporter_cfg is None:
return

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from vibe.setup.auth.browser_sign_in import BrowserSignInService
from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInGateway,
BrowserSignInPollResult,
BrowserSignInProcess,
)
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
__all__ = [
"BrowserSignInError",
"BrowserSignInErrorCode",
"BrowserSignInGateway",
"BrowserSignInPollResult",
"BrowserSignInProcess",
"BrowserSignInService",
"HttpBrowserSignInGateway",
]

View file

@ -0,0 +1,147 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
import hashlib
import secrets
import webbrowser
from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInGateway,
BrowserSignInProcess,
)
StatusCallback = Callable[[str], None]
BrowserOpener = Callable[[str], bool]
SleepFn = Callable[[float], Awaitable[None]]
NowFn = Callable[[], datetime]
class BrowserSignInService:
_max_consecutive_poll_failures = 3
def __init__(
self,
gateway: BrowserSignInGateway,
*,
open_browser: BrowserOpener | None = None,
sleep: SleepFn = asyncio.sleep,
now: NowFn | None = None,
poll_interval: float = 3.0,
) -> None:
self._gateway = gateway
self._open_browser = open_browser or webbrowser.open
self._sleep = sleep
self._now = now or (lambda: datetime.now(UTC))
self._poll_interval = poll_interval
async def aclose(self) -> None:
await self._gateway.aclose()
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
verifier, challenge = _generate_pkce_pair()
process = await self._gateway.create_process(challenge)
self._emit(status_callback, "opening_browser")
self._open_browser_or_raise(process.sign_in_url)
self._emit(status_callback, "waiting_for_browser_sign_in")
exchange_token = await self._wait_for_completion(process)
self._emit(status_callback, "exchanging")
api_key = await self._gateway.exchange(
process.process_id, exchange_token, verifier
)
self._emit(status_callback, "completed")
return api_key
async def _wait_for_completion(self, process: BrowserSignInProcess) -> str:
consecutive_poll_failures = 0
while self._now() < process.expires_at:
try:
payload = await self._gateway.poll(process.poll_url)
except BrowserSignInError as err:
if err.code is not BrowserSignInErrorCode.POLL_FAILED:
raise
consecutive_poll_failures += 1
if consecutive_poll_failures >= self._max_consecutive_poll_failures:
raise
await self._sleep_until_next_poll_or_timeout(process.expires_at)
continue
consecutive_poll_failures = 0
match payload.status:
case "pending":
await self._sleep_until_next_poll_or_timeout(process.expires_at)
case "completed":
if payload.exchange_token:
return payload.exchange_token
raise BrowserSignInError(
"Sign-in worked, but setup couldn't finish.",
code=BrowserSignInErrorCode.MISSING_EXCHANGE_TOKEN,
)
case "expired":
raise BrowserSignInError(
"Browser sign-in expired.", code=BrowserSignInErrorCode.EXPIRED
)
case "denied":
raise BrowserSignInError(
"Browser sign-in was denied.",
code=BrowserSignInErrorCode.DENIED,
)
case "error":
raise BrowserSignInError(
payload.message or "Browser sign-in failed.",
code=BrowserSignInErrorCode.PROVIDER_ERROR,
)
case _:
raise BrowserSignInError(
"Browser sign-in returned an unknown state.",
code=BrowserSignInErrorCode.UNKNOWN_STATE,
)
raise BrowserSignInError(
"Browser sign-in timed out.", code=BrowserSignInErrorCode.TIMED_OUT
)
async def _sleep_until_next_poll_or_timeout(self, expires_at: datetime) -> None:
remaining_seconds = (expires_at - self._now()).total_seconds()
if remaining_seconds <= 0:
raise BrowserSignInError(
"Browser sign-in timed out.", code=BrowserSignInErrorCode.TIMED_OUT
)
await self._sleep(min(self._poll_interval, remaining_seconds))
def _emit(self, callback: StatusCallback | None, status: str) -> None:
if callback is not None:
callback(status)
def _open_browser_or_raise(self, sign_in_url: str) -> None:
try:
browser_opened = self._open_browser(sign_in_url)
except Exception as err:
raise BrowserSignInError(
"Failed to open browser for sign-in.",
code=BrowserSignInErrorCode.OPEN_BROWSER_FAILED,
) from err
if not browser_opened:
raise BrowserSignInError(
"Failed to open browser for sign-in.",
code=BrowserSignInErrorCode.OPEN_BROWSER_FAILED,
)
def _generate_code_verifier() -> str:
return secrets.token_urlsafe(64)
def _generate_pkce_pair() -> tuple[str, str]:
verifier = _generate_code_verifier()
return verifier, _generate_code_challenge(verifier)
def _generate_code_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")

View file

@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum, auto
from typing import Literal, Protocol
class BrowserSignInErrorCode(StrEnum):
START_FAILED = auto()
POLL_FAILED = auto()
UNKNOWN_STATE = auto()
EXCHANGE_FAILED = auto()
MISSING_API_KEY = auto()
MISSING_EXCHANGE_TOKEN = auto()
EXPIRED = auto()
DENIED = auto()
PROVIDER_ERROR = auto()
TIMED_OUT = auto()
OPEN_BROWSER_FAILED = auto()
class BrowserSignInError(Exception):
def __init__(
self, message: str, *, code: BrowserSignInErrorCode | None = None
) -> None:
super().__init__(message)
self.code = code
@dataclass
class BrowserSignInProcess:
process_id: str
sign_in_url: str
poll_url: str
expires_at: datetime
@dataclass
class BrowserSignInPollResult:
status: Literal["pending", "completed", "expired", "denied", "error"]
exchange_token: str | None = None
message: str | None = None
class BrowserSignInGateway(Protocol):
async def create_process(self, code_challenge: str) -> BrowserSignInProcess: ...
async def poll(self, poll_url: str) -> BrowserSignInPollResult: ...
async def exchange(
self, process_id: str, exchange_token: str, code_verifier: str
) -> str: ...
async def aclose(self) -> None: ...

View file

@ -0,0 +1,319 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
import posixpath
import types
from typing import Any, Literal, TypedDict, cast
from urllib.parse import SplitResult, unquote, urlsplit
import httpx
from vibe.core.logger import logger
from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInGateway,
BrowserSignInPollResult,
BrowserSignInProcess,
)
class CreateProcessPayload(TypedDict):
process_id: str
sign_in_url: str
poll_url: str
expires_at: str
class PollPayload(TypedDict, total=False):
status: Literal["pending", "completed", "expired", "denied", "error"]
exchange_token: str
message: str
class ExchangePayload(TypedDict, total=False):
api_key: str
HTTP_GONE = 410
_DEFAULT_PORTS = {"http": 80, "https": 443}
class HttpBrowserSignInGateway(BrowserSignInGateway):
def __init__(
self,
browser_base_url: str,
api_base_url: str,
*,
client: httpx.AsyncClient | None = None,
) -> None:
self._browser_base_url = browser_base_url.rstrip("/")
self._api_base_url = api_base_url.rstrip("/")
self._client = client
self._should_manage_client = client is None
async def __aenter__(self) -> HttpBrowserSignInGateway:
self._ensure_client()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
) -> None:
await self.aclose()
async def aclose(self) -> None:
if self._should_manage_client and self._client is not None:
await self._client.aclose()
self._client = None
async def create_process(self, code_challenge: str) -> BrowserSignInProcess:
message = "Failed to start browser sign-in."
code = BrowserSignInErrorCode.START_FAILED
try:
response = await self._ensure_client().post(
f"{self._api_base_url}/vibe/sign-in",
json={
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
)
except httpx.HTTPError as err:
logger.warning(
"Browser sign-in start request failed for api_base_url=%s: %s",
self._api_base_url,
err,
exc_info=True,
)
raise BrowserSignInError(message, code=code) from err
if not response.is_success:
logger.warning(
"Browser sign-in start request returned status_code=%s for api_base_url=%s",
response.status_code,
self._api_base_url,
)
raise BrowserSignInError(message, code=code)
payload = _response_json_or_raise(response, message=message, code=code)
data = cast(CreateProcessPayload, payload)
try:
return BrowserSignInProcess(
process_id=data["process_id"],
sign_in_url=_validate_url_against_base_url(
data["sign_in_url"],
base_url=self._browser_base_url,
message=message,
code=code,
),
poll_url=_validate_url_against_base_url(
data["poll_url"],
base_url=self._api_base_url,
message=message,
code=code,
),
expires_at=_parse_expires_at(data["expires_at"]),
)
except BrowserSignInError:
sign_in_url_details, poll_url_details = _build_start_payload_log_details(
data
)
logger.warning(
"Browser sign-in start payload validation failed for browser_base_url=%s api_base_url=%s sign_in_url=%s poll_url=%s",
self._browser_base_url,
self._api_base_url,
sign_in_url_details,
poll_url_details,
exc_info=True,
)
raise
except (KeyError, TypeError, ValueError) as err:
sign_in_url_details, poll_url_details = _build_start_payload_log_details(
payload
)
logger.warning(
"Browser sign-in start payload parsing failed for api_base_url=%s payload_keys=%s sign_in_url=%s poll_url=%s",
self._api_base_url,
sorted(str(key) for key in payload),
sign_in_url_details,
poll_url_details,
exc_info=True,
)
raise BrowserSignInError(message, code=code) from err
async def poll(self, poll_url: str) -> BrowserSignInPollResult:
message = "Browser sign-in status could not be retrieved."
code = BrowserSignInErrorCode.POLL_FAILED
validated_poll_url = _validate_url_against_base_url(
poll_url, base_url=self._api_base_url, message=message, code=code
)
try:
response = await self._ensure_client().get(validated_poll_url)
except httpx.HTTPError as err:
raise BrowserSignInError(message, code=code) from err
if response.status_code == HTTP_GONE:
return BrowserSignInPollResult(status="expired")
if not response.is_success:
raise BrowserSignInError(message, code=code)
raw_payload = _response_json_or_raise(response, message=message, code=code)
payload = cast(PollPayload, raw_payload)
status = payload.get("status")
if status not in {"pending", "completed", "expired", "denied", "error"}:
raise BrowserSignInError(
"Browser sign-in returned an unknown state.",
code=BrowserSignInErrorCode.UNKNOWN_STATE,
)
return BrowserSignInPollResult(
status=status,
exchange_token=payload.get("exchange_token"),
message=payload.get("message"),
)
async def exchange(
self, process_id: str, exchange_token: str, code_verifier: str
) -> str:
message = "Failed to exchange browser sign-in for an API key."
code = BrowserSignInErrorCode.EXCHANGE_FAILED
try:
response = await self._ensure_client().post(
f"{self._api_base_url}/vibe/sign-in/{process_id}/exchange",
json={"exchange_token": exchange_token, "code_verifier": code_verifier},
)
except httpx.HTTPError as err:
raise BrowserSignInError(message, code=code) from err
if not response.is_success:
raise BrowserSignInError(message, code=code)
raw_payload = _response_json_or_raise(response, message=message, code=code)
payload = cast(ExchangePayload, raw_payload)
if api_key := payload.get("api_key"):
return api_key
raise BrowserSignInError(
"Browser sign-in exchange did not return an API key.",
code=BrowserSignInErrorCode.MISSING_API_KEY,
)
def _ensure_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient()
self._should_manage_client = True
return self._client
def _parse_expires_at(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
def _validate_url_against_base_url(
value: str, *, base_url: str, message: str, code: BrowserSignInErrorCode
) -> str:
current_url = urlsplit(value)
base = urlsplit(base_url)
safe_url_details = _build_safe_url_log_details(value)
try:
current_origin = _normalized_origin(current_url)
base_origin = _normalized_origin(base)
except ValueError as err:
logger.warning(
"Browser sign-in URL origin validation failed for returned_url=%s expected_base_url=%s",
safe_url_details,
base_url,
exc_info=True,
)
raise BrowserSignInError(message, code=code) from err
if current_origin != base_origin:
logger.warning(
"Browser sign-in URL host validation failed for returned_url=%s expected_base_url=%s",
safe_url_details,
base_url,
)
raise BrowserSignInError(message, code=code)
if not _is_path_under_base_path(current_url.path, base.path):
logger.warning(
"Browser sign-in URL path validation failed for returned_url=%s expected_base_url=%s",
safe_url_details,
base_url,
)
raise BrowserSignInError(message, code=code)
return value
def _is_path_under_base_path(path: str, base_path: str) -> bool:
normalized_path = _normalize_url_path(path)
normalized_base_path = _normalize_url_path(base_path).rstrip("/")
if not normalized_base_path:
return True
if normalized_path == normalized_base_path:
return True
return normalized_path.startswith(f"{normalized_base_path}/")
def _normalized_origin(parsed: SplitResult) -> tuple[str, str | None, int | None]:
scheme = parsed.scheme.lower()
port = parsed.port
return scheme, parsed.hostname, _effective_port(scheme, port)
def _effective_port(scheme: str, port: int | None) -> int | None:
if port is not None:
return port
return _DEFAULT_PORTS.get(scheme)
def _normalize_url_path(path: str) -> str:
if not path:
return "/"
decoded_path = unquote(path)
return posixpath.normpath(decoded_path)
def _build_start_payload_log_details(payload: Mapping[str, object]) -> tuple[str, str]:
return (
_build_safe_url_log_details(payload.get("sign_in_url")),
_build_safe_url_log_details(payload.get("poll_url")),
)
def _response_json_or_raise(
response: httpx.Response, *, message: str, code: BrowserSignInErrorCode
) -> dict[str, Any]:
try:
payload = response.json()
except ValueError as err:
raise BrowserSignInError(message, code=code) from err
if not isinstance(payload, Mapping):
raise BrowserSignInError(message, code=code)
return dict(payload)
def _build_safe_url_log_details(value: object) -> str:
if not isinstance(value, str):
return "unavailable"
parsed = urlsplit(value)
return (
f"scheme={parsed.scheme or None!r} "
f"hostname={parsed.hostname!r} "
f"port={_safe_port(parsed)!r} "
f"has_path={bool(parsed.path)} "
f"has_query={bool(parsed.query)} "
f"has_fragment={bool(parsed.fragment)}"
)
def _safe_port(parsed: SplitResult) -> int | None:
try:
return parsed.port
except ValueError:
return None

View file

@ -1,3 +1,4 @@
# What's new in v2.7.3
- **Data retention command**: Added a `/data-retention` slash command to view Mistral AI's data retention notice and privacy settings
# What's new in v2.7.4
- **MCP command**: New `/mcp` command to display MCP servers and their status
- **Manual command output**: Manual `!` commands now forward their output to the agent as context
- **Console view**: New `ctrl+\` keybind to toggle logs display.