Co-authored-by: Carlo <carloantonio.patti@mistral.ai>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin 2026-02-27 17:31:58 +01:00 committed by GitHub
parent a560a47ce8
commit 5d2e01a6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 7152 additions and 1457 deletions

View file

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

View file

@ -37,14 +37,12 @@ from acp.schema import (
Implementation,
ListSessionsResponse,
McpServerStdio,
ModelInfo,
PromptCapabilities,
ResumeSessionResponse,
SessionCapabilities,
SessionInfo,
SessionListCapabilities,
SessionModelState,
SessionModeState,
SetSessionConfigOptionResponse,
SseMcpServer,
TextContentBlock,
TextResourceContents,
@ -56,6 +54,7 @@ from acp.schema import (
from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
tool_call_session_update,
@ -71,12 +70,13 @@ from vibe.acp.utils import (
create_tool_call_replay,
create_tool_result_replay,
create_user_message_replay,
get_all_acp_session_modes,
get_proxy_help_text,
is_valid_acp_agent,
is_valid_acp_mode,
make_mode_response,
make_model_response,
)
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import (
MissingAPIKeyError,
@ -98,6 +98,7 @@ from vibe.core.types import (
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
EntrypointMetadata,
LLMMessage,
ReasoningEvent,
Role,
@ -121,7 +122,8 @@ class VibeAcpAgentLoop(AcpAgent):
def __init__(self) -> None:
self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities = None
self.client_capabilities: ClientCapabilities | None = None
self.client_info: Implementation | None = None
@override
async def initialize(
@ -132,6 +134,7 @@ class VibeAcpAgentLoop(AcpAgent):
**kwargs: Any,
) -> InitializeResponse:
self.client_capabilities = client_capabilities
self.client_info = client_info
# The ACP Agent process can be launched in 3 different ways, depending on installation
# - dev mode: `uv run vibe-acp`, ran from the project root
@ -200,6 +203,14 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
return EntrypointMetadata(
agent_entrypoint="acp",
agent_version=__version__,
client_name=self.client_info.name if self.client_info else "",
client_version=self.client_info.version if self.client_info else "",
)
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
@ -223,21 +234,6 @@ class VibeAcpAgentLoop(AcpAgent):
return session
def _build_session_model_state(self, agent_loop: AgentLoop) -> SessionModelState:
return SessionModelState(
current_model_id=agent_loop.config.active_model,
available_models=[
ModelInfo(model_id=model.alias, name=model.alias)
for model in agent_loop.config.models
],
)
def _build_session_mode_state(self, session: AcpSessionLoop) -> SessionModeState:
return SessionModeState(
current_mode_id=session.agent_loop.agent_profile.name,
available_modes=get_all_acp_session_modes(session.agent_loop.agent_manager),
)
@override
async def new_session(
self,
@ -251,19 +247,32 @@ class VibeAcpAgentLoop(AcpAgent):
config = self._load_config()
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
config=config,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
# We should just use agent_loop.session_id everywhere, but it can still change during
# session lifetime (e.g. agent_loop.compact is called).
# We should refactor agent_loop.session_id to make it immutable in ACP context.
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
agent_loop.emit_new_session_telemetry("acp")
agent_loop.emit_new_session_telemetry()
modes_state, modes_config = make_mode_response(
list(agent_loop.agent_manager.available_agents.values()),
session.agent_loop.agent_profile.name,
)
models_state, models_config = make_model_response(
agent_loop.config.models, agent_loop.config.active_model
)
return NewSessionResponse(
session_id=session.id,
models=self._build_session_model_state(agent_loop),
modes=self._build_session_mode_state(session),
models=models_state,
modes=modes_state,
config_options=[modes_config, models_config],
)
def _get_acp_tool_overrides(self) -> list[Path]:
@ -447,8 +456,12 @@ class VibeAcpAgentLoop(AcpAgent):
}) from e
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
config=config,
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
@ -460,19 +473,24 @@ class VibeAcpAgentLoop(AcpAgent):
await self._replay_conversation_history(session_id, non_system_messages)
return LoadSessionResponse(
models=self._build_session_model_state(agent_loop),
modes=self._build_session_mode_state(session),
modes_state, modes_config = make_mode_response(
list(agent_loop.agent_manager.available_agents.values()),
session.agent_loop.agent_profile.name,
)
models_state, models_config = make_model_response(
agent_loop.config.models, agent_loop.config.active_model
)
@override
async def set_session_mode(
self, mode_id: str, session_id: str, **kwargs: Any
) -> SetSessionModeResponse | None:
session = self._get_session(session_id)
return LoadSessionResponse(
models=models_state,
modes=modes_state,
config_options=[modes_config, models_config],
)
if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
return None
async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool:
profiles = list(session.agent_loop.agent_manager.available_agents.values())
if not is_valid_acp_mode(profiles, mode_id):
return False
await session.agent_loop.switch_agent(mode_id)
@ -483,17 +501,12 @@ class VibeAcpAgentLoop(AcpAgent):
self._create_approval_callback(session.id)
)
return SetSessionModeResponse()
@override
async def set_session_model(
self, model_id: str, session_id: str, **kwargs: Any
) -> SetSessionModelResponse | None:
session = self._get_session(session_id)
return True
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
model_aliases = [model.alias for model in session.agent_loop.config.models]
if model_id not in model_aliases:
return None
return False
VibeConfig.save_updates({"active_model": model_id})
@ -504,8 +517,59 @@ class VibeAcpAgentLoop(AcpAgent):
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
return True
@override
async def set_session_mode(
self, mode_id: str, session_id: str, **kwargs: Any
) -> SetSessionModeResponse | None:
session = self._get_session(session_id)
if not await self._apply_mode_change(session, mode_id):
return None
return SetSessionModeResponse()
@override
async def set_session_model(
self, model_id: str, session_id: str, **kwargs: Any
) -> SetSessionModelResponse | None:
session = self._get_session(session_id)
if not await self._apply_model_change(session, model_id):
return None
return SetSessionModelResponse()
@override
async def set_config_option(
self, config_id: str, session_id: str, value: str, **kwargs: Any
) -> SetSessionConfigOptionResponse | None:
session = self._get_session(session_id)
match config_id:
case "mode":
success = await self._apply_mode_change(session, value)
case "model":
success = await self._apply_model_change(session, value)
case _:
success = False
if not success:
return None
profiles = list(session.agent_loop.agent_manager.available_agents.values())
_, modes_config = make_mode_response(
profiles, session.agent_loop.agent_profile.name
)
_, models_config = make_model_response(
session.agent_loop.config.models, session.agent_loop.config.active_model
)
return SetSessionConfigOptionResponse(
config_options=[modes_config, models_config]
)
@override
async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
@ -736,7 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
def run_acp_server() -> None:
try:
asyncio.run(run_agent(agent=VibeAcpAgentLoop(), use_unstable_protocol=True))
asyncio.run(
run_agent(
agent=VibeAcpAgentLoop(),
use_unstable_protocol=True,
observers=[acp_message_observer],
)
)
except KeyboardInterrupt:
# This is expected when the server is terminated
pass

97
vibe/acp/acp_logger.py Normal file
View file

@ -0,0 +1,97 @@
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from logging.handlers import RotatingFileHandler
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING
from cachetools import TTLCache
if TYPE_CHECKING:
from acp.connection import StreamEvent
ACP_LOG_DIR = Path.home() / ".vibe" / "logs" / "acp"
ACP_LOG_FILE = ACP_LOG_DIR / "messages.jsonl"
MAX_LOG_SIZE_BYTES = 1_000_000
BACKUP_COUNT = 3
ACP_LOGGING_ENABLED_KEY = "VIBE_ACP_LOGGING_ENABLED"
_session_cache: TTLCache[int | str, str] = TTLCache(maxsize=1000, ttl=3600)
_current_session: str | None = None
_logger: logging.Logger | None = None
def is_acp_logging_enabled() -> bool:
return os.getenv(ACP_LOGGING_ENABLED_KEY, "").lower() in {"1", "true", "yes"}
class JsonLineFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return json.dumps(record.msg, separators=(",", ":"))
def _get_logger() -> logging.Logger:
global _logger
if _logger is not None:
return _logger
ACP_LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("acp_messages")
logger.setLevel(logging.INFO)
logger.propagate = False
handler = RotatingFileHandler(
ACP_LOG_FILE,
maxBytes=MAX_LOG_SIZE_BYTES,
backupCount=BACKUP_COUNT,
encoding="utf-8",
)
handler.setFormatter(JsonLineFormatter())
logger.addHandler(handler)
_logger = logger
return _logger
def _extract_session_id(message: dict) -> str | None:
json_str = json.dumps(message)
match = re.search(r'"(?:session_id|sessionId)":\s*"([^"]+)"', json_str)
return match.group(1) if match else None
def acp_message_observer(event: StreamEvent) -> None:
if not is_acp_logging_enabled():
return
try:
global _current_session
message = event.message
msg_id = message.get("id", "")
if msg_id in _session_cache:
session_id = _session_cache[msg_id]
else:
session_id = _extract_session_id(message) or _current_session
if session_id is not None:
_current_session = session_id
if msg_id:
_session_cache[msg_id] = session_id
log_entry: dict = {
"ts": datetime.now(UTC).isoformat(),
"dir": "in" if event.direction.value == "incoming" else "out",
"msg": message,
**({"session": session_id} if session_id else {}),
}
_get_logger().info(log_entry)
except Exception:
pass

View file

@ -7,8 +7,8 @@ import sys
from vibe import __version__
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths
from vibe.core.utils import logger
# Configure line buffering for subprocess communication
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]

View file

@ -8,10 +8,10 @@ from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import logger
@runtime_checkable

View file

@ -13,10 +13,10 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.logger import logger
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.utils import logger
class AcpBashState(BaseToolState, AcpToolState):
@ -110,7 +110,16 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
raise self._build_timeout_error(command, timeout)
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart:
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart | None:
if event.args is None:
return ToolCallStart(
session_update="tool_call",
title="bash",
tool_call_id=event.tool_call_id,
kind="execute",
content=None,
raw_input=None,
)
if not isinstance(event.args, BashArgs):
raise ValueError(f"Unexpected tool args: {event.args}")

View file

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

View file

@ -12,17 +12,16 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import ToolError
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.search_replace import (
SearchReplace as CoreSearchReplaceTool,
SearchReplaceArgs,
SearchReplaceResult,
SearchReplaceState,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
class AcpSearchReplaceState(SearchReplaceState, AcpToolState):
class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None
@ -73,6 +72,15 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args
if args is None:
return ToolCallStart(
session_update="tool_call",
title="search_replace",
tool_call_id=event.tool_call_id,
kind="edit",
content=None,
raw_input=None,
)
if not isinstance(args, SearchReplaceArgs):
return None

View file

@ -12,17 +12,16 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import ToolError
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.write_file import (
WriteFile as CoreWriteFileTool,
WriteFileArgs,
WriteFileResult,
WriteFileState,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
class AcpWriteFileState(WriteFileState, AcpToolState):
class AcpWriteFileState(BaseToolState, AcpToolState):
pass
@ -51,6 +50,15 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args
if args is None:
return ToolCallStart(
session_update="tool_call",
title="write_file",
tool_call_id=event.tool_call_id,
kind="edit",
content=None,
raw_input=None,
)
if not isinstance(args, WriteFileArgs):
return None

View file

@ -50,7 +50,7 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
content=content,
tool_call_id=event.tool_call_id,
kind=TOOL_KIND.get(event.tool_name, "other"),
raw_input=event.args.model_dump_json(),
raw_input=event.args.model_dump_json() if event.args else None,
)

View file

@ -7,8 +7,14 @@ from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
ContentToolCallContent,
ModelInfo,
PermissionOption,
SessionConfigOption,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionMode,
SessionModelState,
SessionModeState,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
@ -21,7 +27,7 @@ from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
from vibe.core.config import ModelConfig
class ToolOption(StrEnum):
@ -50,22 +56,80 @@ TOOL_OPTIONS = [
]
def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
return SessionMode(
id=profile.name, name=profile.display_name, description=profile.description
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
return any(
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
)
def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
return agent_name in agent_manager.available_agents
def make_mode_response(
profiles: list[AgentProfile], current_mode_id: str
) -> tuple[SessionModeState, SessionConfigOption]:
session_modes: list[SessionMode] = []
config_options: list[SessionConfigSelectOption] = []
for profile in profiles:
if profile.agent_type != AgentType.AGENT:
continue
session_modes.append(
SessionMode(
id=profile.name,
name=profile.display_name,
description=profile.description,
)
)
config_options.append(
SessionConfigSelectOption(
value=profile.name,
name=profile.display_name,
description=profile.description,
)
)
state = SessionModeState(
current_mode_id=current_mode_id, available_modes=session_modes
)
config = SessionConfigOption(
root=SessionConfigOptionSelect(
id="mode",
name="Session Mode",
current_value=current_mode_id,
category="mode",
type="select",
options=config_options,
)
)
return state, config
def get_all_acp_session_modes(agent_manager: AgentManager) -> list[SessionMode]:
return [
agent_profile_to_acp(profile)
for profile in agent_manager.available_agents.values()
if profile.agent_type == AgentType.AGENT
]
def make_model_response(
models: list[ModelConfig], current_model_id: str
) -> tuple[SessionModelState, SessionConfigOption]:
model_infos: list[ModelInfo] = []
config_options: list[SessionConfigSelectOption] = []
for model in models:
model_infos.append(ModelInfo(model_id=model.alias, name=model.alias))
config_options.append(
SessionConfigSelectOption(
value=model.alias, name=model.alias, description=model.name
)
)
state = SessionModelState(
current_model_id=current_model_id, available_models=model_infos
)
config_option = SessionConfigOption(
root=SessionConfigOptionSelect(
id="model",
name="Model",
current_value=current_model_id,
category="model",
type="select",
options=config_options,
)
)
return state, config_option
def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:

View file

@ -1,10 +1,12 @@
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from rich import print as rprint
from vibe import __version__
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
@ -14,11 +16,12 @@ from vibe.core.config import (
VibeConfig,
load_dotenv_values,
)
from vibe.core.logger import logger
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
@ -74,7 +77,7 @@ def bootstrap_config_files() -> None:
def load_session(
args: argparse.Namespace, config: VibeConfig
) -> list[LLMMessage] | None:
) -> tuple[list[LLMMessage], Path] | None:
if not args.continue_session and not args.resume:
return None
@ -107,18 +110,26 @@ def load_session(
try:
loaded_messages, _ = SessionLoader.load_session(session_to_load)
return loaded_messages
return loaded_messages, session_to_load
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
def _load_messages_from_previous_session(
agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
def _resume_previous_session(
agent_loop: AgentLoop, loaded_messages: list[LLMMessage], session_path: Path
) -> None:
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
agent_loop.messages.extend(non_system_messages)
logger.info("Loaded %d messages from previous session", len(non_system_messages))
_, metadata = SessionLoader.load_session(session_path)
session_id = metadata.get("session_id", agent_loop.session_id)
agent_loop.session_id = session_id
agent_loop.session_logger.resume_existing_session(session_id, session_path)
logger.info(
"Resumed session %s with %d messages", session_id, len(non_system_messages)
)
def run_cli(args: argparse.Namespace) -> None:
@ -136,7 +147,7 @@ def run_cli(args: argparse.Namespace) -> None:
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
loaded_messages = load_session(args, config)
loaded_session = load_session(args, config)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
@ -157,7 +168,7 @@ def run_cli(args: argparse.Namespace) -> None:
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
)
if final_response:
@ -171,11 +182,19 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(1)
else:
agent_loop = AgentLoop(
config, agent_name=initial_agent_name, enable_streaming=True
config,
agent_name=initial_agent_name,
enable_streaming=True,
entrypoint_metadata=EntrypointMetadata(
agent_entrypoint="cli",
agent_version=__version__,
client_name="vibe_cli",
client_version=__version__,
),
)
if loaded_messages:
_load_messages_from_previous_session(agent_loop, loaded_messages)
if loaded_session:
_resume_previous_session(agent_loop, *loaded_session)
run_textual_ui(
agent_loop=agent_loop,

View file

@ -72,6 +72,11 @@ class CommandRegistry:
description="Configure proxy and SSL certificate settings",
handler="_show_proxy_setup",
),
"resume": Command(
aliases=frozenset(["/resume", "/continue"]),
description="Browse and resume past sessions",
handler="_show_session_picker",
),
}
for command in excluded_commands:

View file

@ -14,6 +14,7 @@ class Terminal(Enum):
VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
JETBRAINS = "jetbrains"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
GHOSTTY = "ghostty"
@ -50,6 +51,16 @@ def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDE
return Terminal.VSCODE
def _detect_terminal_from_env() -> Terminal | None:
if os.environ.get("WEZTERM_PANE"):
return Terminal.WEZTERM
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
return Terminal.GHOSTTY
if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
return Terminal.JETBRAINS
return None
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
@ -66,12 +77,7 @@ def detect_terminal() -> Terminal:
if term_program in term_map:
return term_map[term_program]
if os.environ.get("WEZTERM_PANE"):
return Terminal.WEZTERM
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
return Terminal.GHOSTTY
return Terminal.UNKNOWN
return _detect_terminal_from_env() or Terminal.UNKNOWN
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
@ -303,6 +309,13 @@ def setup_terminal() -> SetupResult:
match terminal:
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
return _setup_vscode_like_terminal(terminal)
case Terminal.JETBRAINS:
return SetupResult(
success=False,
terminal=Terminal.JETBRAINS,
message="Jetbrains terminal is not supported.\n"
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
)
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:

View file

@ -33,6 +33,11 @@ from vibe.cli.plan_offer.decide_plan_offer import (
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.notifications import (
NotificationContext,
NotificationPort,
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
@ -55,6 +60,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.windowing import (
@ -84,6 +90,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import (
@ -115,7 +122,6 @@ from vibe.core.utils import (
CancellationReason,
get_user_cancellation_message,
is_dangerous_directory,
logger,
)
@ -132,11 +138,16 @@ class BottomApp(StrEnum):
Input = auto()
ProxySetup = auto()
Question = auto()
SessionPicker = auto()
class ChatScroll(VerticalScroll):
"""Optimized scroll container that skips cascading style recalculations."""
@property
def is_at_bottom(self) -> bool:
return self.scroll_offset.y >= (self.max_scroll_y - 3)
def update_node_styles(self, animate: bool = True) -> None:
pass
@ -145,34 +156,40 @@ PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
"""Remove older children to keep virtual height within bounds.
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
async def prune_oldest_children(
messages_area: Widget, low_mark: int, high_mark: int
) -> bool:
"""Remove the oldest children so the virtual height stays within bounds.
Walks children back-to-front to find how much to keep (up to *low_mark*
of visible height), then removes everything before that point.
"""
height = messages_area.virtual_size.height
if height <= high_mark:
total_height = messages_area.virtual_size.height
if total_height <= high_mark:
return False
prune_children: list[Widget] = []
bottom_margin = 0
prune_height = 0
for child in messages_area.children:
children = messages_area.children
if not children:
return False
accumulated = 0
cut = len(children)
for child in reversed(children):
if not child.display:
prune_children.append(child)
cut -= 1
continue
top, _, bottom, _ = child.styles.margin
child_height = child.outer_size.height
prune_height = (
(prune_height - bottom_margin + max(bottom_margin, top))
+ bottom
+ child_height
)
bottom_margin = bottom
if height - prune_height <= low_mark:
accumulated += child.outer_size.height
cut -= 1
if accumulated >= low_mark:
break
prune_children.append(child)
if prune_children:
await messages_area.remove_children(prune_children)
return bool(prune_children)
to_remove = list(children[:cut])
if not to_remove:
return False
await messages_area.remove_children(to_remove)
return True
class VibeApp(App): # noqa: PLR0904
@ -203,10 +220,16 @@ class VibeApp(App): # noqa: PLR0904
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.agent_loop = agent_loop
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
self,
get_enabled=lambda: self.config.enable_notifications,
default_title="Vibe",
)
self._agent_running = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
@ -246,6 +269,7 @@ 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._switch_agent_generation = 0
@property
def config(self) -> VibeConfig:
@ -279,6 +303,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_mount(self) -> None:
self.theme = "textual-ansi"
self._terminal_notifier.restore()
self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll)
@ -311,7 +336,7 @@ class VibeApp(App): # noqa: PLR0904
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
self._schedule_update_notification()
self.agent_loop.emit_new_session_telemetry("cli")
self.agent_loop.emit_new_session_telemetry()
self.call_after_refresh(self._refresh_banner)
@ -575,12 +600,16 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_loop_turn(message)
)
def _reset_ui_state(self) -> None:
self._windowing.reset()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
async def _resume_history_from_messages(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
if not should_resume_history(list(messages_area.children)):
return
self._windowing.reset()
history_messages = non_system_history_messages(self.agent_loop.messages)
if (
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
@ -592,7 +621,8 @@ class VibeApp(App): # noqa: PLR0904
plan.tool_call_map,
start_index=plan.tail_start_index,
)
self.call_after_refresh(self._scroll_chat_to_end)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
self.call_after_refresh(chat.anchor)
self._tool_call_map = plan.tool_call_map
self._windowing.set_backfill(plan.backfill_messages)
await self._load_more.set_visible(
@ -643,6 +673,7 @@ class VibeApp(App): # noqa: PLR0904
return (ApprovalResponse.YES, None)
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
result = await self._pending_approval
@ -654,6 +685,7 @@ class VibeApp(App): # noqa: PLR0904
question_args = cast(AskUserQuestionArgs, args)
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
with paused_timer(self._loading_widget):
await self._switch_to_question_app(question_args)
result = await self._pending_question
@ -714,6 +746,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler:
await self.event_handler.finalize_streaming()
await self._refresh_windowing_from_history()
self._terminal_notifier.notify(NotificationContext.COMPLETE)
async def _teleport_command(self) -> None:
await self._handle_teleport_command(show_message=False)
@ -864,11 +897,108 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_proxy_setup_app()
async def _show_session_picker(self) -> None:
session_config = self.config.session_logging
if not session_config.enabled:
await self._mount_and_scroll(
ErrorMessage(
"Session logging is disabled in configuration.",
collapsed=self._tools_collapsed,
)
)
return
cwd = str(Path.cwd())
raw_sessions = SessionLoader.list_sessions(session_config, cwd=cwd)
if not raw_sessions:
await self._mount_and_scroll(
UserCommandMessage("No sessions found for this directory.")
)
return
sessions = sorted(
raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
)
latest_messages = {
s["session_id"]: SessionLoader.get_first_user_message(
s["session_id"], session_config
)
for s in sessions
}
picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
await self._switch_from_input(picker)
async def on_session_picker_app_session_selected(
self, event: SessionPickerApp.SessionSelected
) -> None:
await self._switch_to_input_app()
session_config = self.config.session_logging
session_path = SessionLoader.find_session_by_id(
event.session_id, session_config
)
if not session_path:
await self._mount_and_scroll(
ErrorMessage(
f"Session `{event.session_id[:8]}` not found.",
collapsed=self._tools_collapsed,
)
)
return
try:
loaded_messages, _ = SessionLoader.load_session(session_path)
current_system_messages = [
msg for msg in self.agent_loop.messages if msg.role == Role.system
]
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
]
self.agent_loop.session_id = event.session_id
self.agent_loop.session_logger.resume_existing_session(
event.session_id, session_path
)
self.agent_loop.messages.reset(
current_system_messages + non_system_messages
)
self._reset_ui_state()
await self._load_more.hide()
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
await self._resume_history_from_messages()
await self._mount_and_scroll(
UserCommandMessage(f"Resumed session `{event.session_id[:8]}`")
)
except ValueError as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to load session: {e}", collapsed=self._tools_collapsed
)
)
async def on_session_picker_app_cancelled(
self, event: SessionPickerApp.Cancelled
) -> None:
await self._switch_to_input_app()
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
async def _reload_config(self) -> None:
try:
self._windowing.reset()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
self._reset_ui_state()
await self._load_more.hide()
base_config = VibeConfig.load()
@ -886,9 +1016,7 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self) -> None:
try:
self._windowing.reset()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
self._reset_ui_state()
await self.agent_loop.clear_history()
if self.event_handler:
await self.event_handler.finalize_streaming()
@ -1019,6 +1147,8 @@ class VibeApp(App): # noqa: PLR0904
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
should_scroll = scroll and chat.is_at_bottom
if self._chat_input_container:
self._chat_input_container.display = False
@ -1028,8 +1158,8 @@ class VibeApp(App): # noqa: PLR0904
await bottom_container.mount(widget)
self.call_after_refresh(widget.focus)
if scroll:
self.call_after_refresh(self._scroll_chat_to_end)
if should_scroll:
self.call_after_refresh(chat.anchor)
async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config:
@ -1069,7 +1199,9 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self.call_after_refresh(self._chat_input_container.focus_input)
self.call_after_refresh(self._scroll_chat_to_end)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.is_at_bottom:
self.call_after_refresh(chat.anchor)
def _focus_current_bottom_app(self) -> None:
try:
@ -1084,6 +1216,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ApprovalApp).focus()
case BottomApp.Question:
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case app:
assert_never(app)
except Exception:
@ -1115,6 +1249,14 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_session_picker_app_escape(self) -> None:
try:
session_picker = self.query_one(SessionPickerApp)
session_picker.post_message(SessionPickerApp.Cancelled())
except Exception:
pass
self._last_escape_time = None
def _handle_input_app_escape(self) -> None:
try:
input_widget = self.query_one(ChatInputContainer)
@ -1151,6 +1293,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape()
return
if self._current_bottom_app == BottomApp.SessionPicker:
self._handle_session_picker_app_escape()
return
if (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None
@ -1163,7 +1309,9 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_running_escape()
self._last_escape_time = current_time
self.call_after_refresh(self._scroll_chat_to_end)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.is_at_bottom:
self.call_after_refresh(chat.anchor)
self._focus_current_bottom_app()
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
@ -1235,9 +1383,32 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.agent_profile
)
self._update_profile_widgets(new_profile)
await self.agent_loop.switch_agent(new_profile.name)
self.agent_loop.set_approval_callback(self._approval_callback)
self.agent_loop.set_user_input_callback(self._user_input_callback)
if self._chat_input_container:
self._chat_input_container.switching_mode = True
def schedule_switch() -> None:
self._switch_agent_generation += 1
my_gen = self._switch_agent_generation
def switch_agent_sync() -> None:
try:
asyncio.run(self.agent_loop.switch_agent(new_profile.name))
self.agent_loop.set_approval_callback(self._approval_callback)
self.agent_loop.set_user_input_callback(self._user_input_callback)
finally:
if (
self._chat_input_container
and self._switch_agent_generation == my_gen
):
self.call_from_thread(
setattr, self._chat_input_container, "switching_mode", False
)
self.run_worker(
switch_agent_sync, group="switch_agent", exclusive=True, thread=True
)
self.call_after_refresh(schedule_switch)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer)
@ -1296,9 +1467,11 @@ class VibeApp(App): # noqa: PLR0904
whats_new_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
should_anchor = chat.is_at_bottom
await chat.mount(whats_new_message, after=messages_area)
self._whats_new_message = whats_new_message
chat.anchor()
if should_anchor:
chat.anchor()
await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _plan_offer_cta(self) -> str | None:
@ -1324,29 +1497,37 @@ class VibeApp(App): # noqa: PLR0904
)
return
def _scroll_chat_to_end(self) -> None:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_end(animate=False)
async def _mount_and_scroll(self, widget: Widget) -> None:
async def _mount_and_scroll(
self, widget: Widget, after: Widget | None = None
) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
await messages_area.mount(widget)
is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage))
should_anchor = is_user_initiated or chat.is_at_bottom
if after is not None and after.parent is messages_area:
await messages_area.mount(widget, after=after)
else:
await messages_area.mount(widget)
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
self.call_after_refresh(self._try_prune)
chat.anchor()
if should_anchor:
chat.anchor()
async def _try_prune(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
pruned = await prune_oldest_children(
messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK
)
if self._load_more.widget and not self._load_more.widget.parent:
self._load_more.widget = None
if pruned:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
self.call_later(chat.anchor)
if chat.is_at_bottom:
self.call_later(chat.anchor)
async def _refresh_windowing_from_history(self) -> None:
if self._load_more.widget is None:
@ -1425,10 +1606,12 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_app_blur(self, event: AppBlur) -> None:
self._terminal_notifier.on_blur()
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(False)
def on_app_focus(self, event: AppFocus) -> None:
self._terminal_notifier.on_focus()
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)

View file

@ -118,7 +118,8 @@ TextArea > .text-area--cursor {
height: auto;
}
#prompt {
#prompt,
#prompt-spinner {
width: auto;
background: transparent;
color: $mistral_orange;
@ -719,17 +720,6 @@ StatusMessage {
height: auto;
}
.proxy-label {
height: auto;
color: ansi_blue;
text-style: bold;
}
.proxy-description {
height: auto;
color: ansi_bright_black;
}
.proxy-label-line {
height: auto;
}
@ -739,7 +729,6 @@ StatusMessage {
height: auto;
border: none;
border-left: wide ansi_bright_black;
margin-top: 1;
padding: 0 0 0 1;
}
@ -1020,3 +1009,36 @@ ContextProgress {
.whats-new-message.after-history {
margin-top: 1;
}
#sessionpicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#sessionpicker-content {
width: 100%;
height: auto;
}
#sessionpicker-options {
width: 100%;
max-height: 50vh;
text-wrap: nowrap;
text-overflow: ellipsis;
border: none;
}
#sessionpicker-options:focus {
border: none;
}
.sessionpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
margin-top: 1;
}

View file

@ -31,7 +31,7 @@ class EventHandler:
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.current_tool_call: ToolCallMessage | None = None
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None
@ -90,30 +90,40 @@ class EventHandler:
async def _handle_tool_call(
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | None:
tool_call = ToolCallMessage(event)
tool_call_id = event.tool_call_id
existing_tool_call = self.tool_calls.get(tool_call_id) if tool_call_id else None
if existing_tool_call:
existing_tool_call.update_event(event)
tool_call = existing_tool_call
else:
tool_call = ToolCallMessage(event)
if tool_call_id:
self.tool_calls[tool_call_id] = tool_call
await self.mount_callback(tool_call)
if loading_widget and event.tool_class:
adapter = ToolUIDataAdapter(event.tool_class)
status_text = adapter.get_status_text()
loading_widget.set_status(status_text)
self.current_tool_call = tool_call
await self.mount_callback(tool_call)
loading_widget.set_status(adapter.get_status_text())
return tool_call
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
tools_collapsed = self.get_tools_collapsed()
tool_result = ToolResultMessage(
event, self.current_tool_call, collapsed=tools_collapsed
)
await self.mount_callback(tool_result)
self.current_tool_call = None
call_widget = (
self.tool_calls.get(event.tool_call_id) if event.tool_call_id else None
)
tool_result = ToolResultMessage(event, call_widget, collapsed=tools_collapsed)
await self.mount_callback(tool_result, after=call_widget)
if event.tool_call_id and event.tool_call_id in self.tool_calls:
del self.tool_calls[event.tool_call_id]
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
if self.current_tool_call:
self.current_tool_call.set_stream_message(event.message)
tool_call = self.tool_calls.get(event.tool_call_id)
if tool_call:
tool_call.set_stream_message(event.message)
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
if self.current_streaming_reasoning is not None:
@ -131,6 +141,8 @@ class EventHandler:
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
if self.current_streaming_message is not None:
await self.current_streaming_message.stop_stream()
if self.current_streaming_message.is_stripped_content_empty():
await self.current_streaming_message.remove()
self.current_streaming_message = None
if self.current_streaming_reasoning is None:
@ -166,9 +178,9 @@ class EventHandler:
self.current_streaming_message = None
def stop_current_tool_call(self, success: bool = True) -> None:
if self.current_tool_call:
self.current_tool_call.stop_spinning(success=success)
self.current_tool_call = None
for tool_call in self.tool_calls.values():
tool_call.stop_spinning(success=success)
self.tool_calls.clear()
def stop_current_compact(self) -> None:
if self.current_compact:

View file

@ -0,0 +1,11 @@
from __future__ import annotations
from vibe.cli.textual_ui.notifications.adapters.textual_notification_adapter import (
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.notifications.ports.notification_port import (
NotificationContext,
NotificationPort,
)
__all__ = ["NotificationContext", "NotificationPort", "TextualNotificationAdapter"]

View file

@ -0,0 +1,60 @@
from __future__ import annotations
from collections.abc import Callable
import time
from textual.app import App
from vibe.cli.textual_ui.notifications.ports.notification_port import (
NotificationContext,
)
NOTIFICATION_TITLE_SUFFIXES: dict[NotificationContext, str] = {
NotificationContext.ACTION_REQUIRED: "Action Required",
NotificationContext.COMPLETE: "Task Complete",
}
NOTIFICATION_THROTTLE_SECONDS: float = 1.0
class TextualNotificationAdapter:
def __init__(
self, app: App, *, get_enabled: Callable[[], bool], default_title: str = "App"
) -> None:
self._app = app
self._get_enabled = get_enabled
self._default_title = default_title
self._has_focus: bool = True
self._last_notification_time: float = 0.0
def notify(self, context: NotificationContext) -> None:
if not self._get_enabled() or self._has_focus:
return
current_time = time.monotonic()
if current_time - self._last_notification_time < NOTIFICATION_THROTTLE_SECONDS:
return
self._last_notification_time = current_time
self._app.bell()
self._set_title(self._get_notification_title(context))
def on_focus(self) -> None:
self._has_focus = True
self.restore()
def on_blur(self) -> None:
self._has_focus = False
def restore(self) -> None:
self._set_title(self._default_title)
def _get_notification_title(self, context: NotificationContext) -> str:
suffix = NOTIFICATION_TITLE_SUFFIXES.get(context)
if suffix is None:
return self._default_title
return f"{self._default_title} - {suffix}"
def _set_title(self, title: str) -> None:
if not self._app.is_headless and self._app._driver is not None:
self._app._driver.write(f"\x1b]0;{title}\x07")

View file

@ -0,0 +1,16 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import Protocol
class NotificationContext(StrEnum):
ACTION_REQUIRED = auto()
COMPLETE = auto()
class NotificationPort(Protocol):
def notify(self, context: NotificationContext) -> None: ...
def on_focus(self) -> None: ...
def on_blur(self) -> None: ...
def restore(self) -> None: ...

View file

@ -2,16 +2,31 @@ from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
from typing import Any, ClassVar
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class _PromptSpinner(SpinnerMixin, Static):
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
def __init__(self) -> None:
self._indicator_widget: Static | None = None
self.init_spinner()
super().__init__(self._spinner.current_frame(), id="prompt-spinner")
def on_mount(self) -> None:
self._indicator_widget = self
self.start_spinner_timer()
class ChatInputBody(Widget):
@ -30,6 +45,7 @@ class ChatInputBody(Widget):
self.input_widget: ChatTextArea | None = None
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
self._switching_mode = False
if history_file:
self.history = HistoryManager(history_file)
@ -159,6 +175,9 @@ class ChatInputBody(Widget):
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
event.stop()
if self._switching_mode:
return
if not self.input_widget:
return
@ -175,6 +194,25 @@ class ChatInputBody(Widget):
self.post_message(self.Submitted(value))
@property
def switching_mode(self) -> bool:
return self._switching_mode
@switching_mode.setter
def switching_mode(self, value: bool) -> None:
self._switching_mode = value
if value:
if self.prompt_widget:
self.prompt_widget.display = False
if not self.query(_PromptSpinner):
self.query_one(Horizontal).mount(_PromptSpinner(), before=0)
else:
for spinner in self.query(_PromptSpinner):
spinner.remove()
if self.prompt_widget:
self.prompt_widget.display = True
self._update_prompt()
@property
def value(self) -> str:
if not self.input_widget:

View file

@ -180,6 +180,15 @@ class ChatInputContainer(Vertical):
event.stop()
self.post_message(self.Submitted(event.value))
@property
def switching_mode(self) -> bool:
return self._body.switching_mode if self._body else False
@switching_mode.setter
def switching_mode(self, value: bool) -> None:
if self._body:
self._body.switching_mode = value
def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety

View file

@ -69,6 +69,7 @@ class LoadingWidget(SpinnerMixin, Static):
self.init_spinner()
self.status = status or self._get_default_status()
self.current_color_index = 0
self._color_direction = 1
self.transition_progress = 0
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
@ -141,11 +142,12 @@ class LoadingWidget(SpinnerMixin, Static):
return
self._update_animation()
def _next_color_index(self) -> int:
return self.current_color_index + self._color_direction
def _get_color_for_position(self, position: int) -> str:
current_color = self.TARGET_COLORS[self.current_color_index]
next_color = self.TARGET_COLORS[
(self.current_color_index + 1) % len(self.TARGET_COLORS)
]
next_color = self.TARGET_COLORS[self._next_color_index()]
if position < self.transition_progress:
return next_color
return current_color
@ -173,9 +175,9 @@ class LoadingWidget(SpinnerMixin, Static):
self.transition_progress += 1
if self.transition_progress > total_elements:
self.current_color_index = (self.current_color_index + 1) % len(
self.TARGET_COLORS
)
self.current_color_index = self._next_color_index()
if not 0 < self.current_color_index < len(self.TARGET_COLORS) - 1:
self._color_direction *= -1
self.transition_progress = 0
if self.hint_widget and self.start_time is not None:

View file

@ -106,6 +106,9 @@ class StreamingMessageBase(Static):
def _should_write_content(self) -> bool:
return True
def is_stripped_content_empty(self) -> bool:
return self._content.strip() == ""
class AssistantMessage(StreamingMessageBase):
def __init__(self, content: str) -> None:

View file

@ -43,26 +43,20 @@ class ProxySetupApp(Container):
with Vertical(id="proxysetup-content"):
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
yield NoMarkupStatic("")
for key, description in SUPPORTED_PROXY_VARS.items():
yield Static(
f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
classes="proxy-label-line",
)
yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
initial_value = self.initial_values.get(key) or ""
input_widget = Input(
value=initial_value,
placeholder="NOT SET",
placeholder=description,
id=f"proxy-input-{key}",
classes="proxy-input",
)
self.inputs[key] = input_widget
yield input_widget
yield NoMarkupStatic("")
yield NoMarkupStatic(
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
)
@ -92,7 +86,7 @@ class ProxySetupApp(Container):
prev_idx = (idx - 1) % len(inputs)
inputs[prev_idx].focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
def on_input_submitted(self, _event: Input.Submitted) -> None:
self._save_and_close()
def on_blur(self, _event: events.Blur) -> None:

View file

@ -0,0 +1,113 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.session.session_loader import SessionInfo
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 3600
_SECONDS_PER_DAY = 86400
_SECONDS_PER_WEEK = 604800
def _format_relative_time(iso_time: str | None) -> str:
if not iso_time:
return "unknown"
try:
dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
now = datetime.now(UTC)
delta = now - dt
seconds = int(delta.total_seconds())
if seconds < _SECONDS_PER_MINUTE:
return "just now"
for threshold, divisor, unit in [
(_SECONDS_PER_HOUR, _SECONDS_PER_MINUTE, "m"),
(_SECONDS_PER_DAY, _SECONDS_PER_HOUR, "h"),
(_SECONDS_PER_WEEK, _SECONDS_PER_DAY, "d"),
(float("inf"), _SECONDS_PER_WEEK, "w"),
]:
if seconds < threshold:
return f"{seconds // divisor}{unit} ago"
except (ValueError, OSError):
pass
return "unknown"
def _build_option_text(session: SessionInfo, message: str) -> Text:
text = Text(no_wrap=True)
time_str = _format_relative_time(session.get("end_time"))
session_id = session["session_id"][:8]
text.append(f"{time_str:10}", style="dim")
text.append(" ")
text.append(f"{session_id} ", style="dim")
text.append(message)
return text
class SessionPickerApp(Container):
"""Session picker for /resume command."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False)
]
class SessionSelected(Message):
def __init__(self, session_id: str) -> None:
self.session_id = session_id
super().__init__()
class Cancelled(Message):
pass
def __init__(
self,
sessions: list[SessionInfo],
latest_messages: dict[str, str],
**kwargs: Any,
) -> None:
super().__init__(id="sessionpicker-app", **kwargs)
self._sessions = sessions
self._latest_messages = latest_messages
def compose(self) -> ComposeResult:
options = [
Option(
_build_option_text(
session,
self._latest_messages.get(session["session_id"], "(empty session)"),
),
id=session["session_id"],
)
for session in self._sessions
]
with Vertical(id="sessionpicker-content"):
yield OptionList(*options, id="sessionpicker-options")
yield NoMarkupStatic(
"Up/Down Navigate Enter Select Esc Cancel",
classes="sessionpicker-help",
)
def on_mount(self) -> None:
self.query_one(OptionList).focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option.id:
self.post_message(self.SessionSelected(event.option.id))
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -42,8 +42,8 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
for i, (search_text, replace_text) in enumerate(matches):
if i > 0:
all_diff_lines.append("") # Separator between blocks
search_lines = search_text.strip().split("\n")
replace_lines = replace_text.strip().split("\n")
search_lines = search_text.strip("\n").split("\n")
replace_lines = replace_text.strip("\n").split("\n")
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
@ -74,8 +74,10 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
def compose(self) -> ComposeResult:
MAX_MSG_SIZE = 150
for field_name in type(self.args).model_fields:
value = getattr(self.args, field_name)
model_cls = type(self.args)
field_names = model_cls.model_fields or self.args.model_extra or {}
for field_name in field_names:
value = getattr(self.args, field_name, None)
if value is None or value in ("", []):
continue
value_str = str(value)

View file

@ -20,7 +20,7 @@ class ToolCallMessage(StatusMessage):
raise ValueError("Either event or tool_name must be provided")
self._event = event
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._tool_name = tool_name or (event.tool_name if event else None) or "unknown"
self._is_history = event is None
self._stream_widget: NoMarkupStatic | None = None
@ -44,6 +44,7 @@ class ToolCallMessage(StatusMessage):
yield self._stream_widget
def on_mount(self) -> None:
super().on_mount()
siblings = list(self.parent.children) if self.parent else []
idx = siblings.index(self) if self in siblings else -1
if idx > 0 and isinstance(
@ -51,13 +52,23 @@ class ToolCallMessage(StatusMessage):
):
self.add_class("no-gap")
@property
def tool_call_id(self) -> str | None:
return self._event.tool_call_id if self._event else None
def get_content(self) -> str:
if self._event and self._event.tool_class:
if self._event:
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_call_display(self._event)
return display.summary
return self._tool_name
def update_event(self, event: ToolCallEvent) -> None:
self._event = event
self._tool_name = event.tool_name
if self._text_widget:
self._text_widget.update(self.get_content())
def set_stream_message(self, message: str) -> None:
"""Update the stream message displayed below the tool call indicator."""
if self._stream_widget:
@ -151,7 +162,13 @@ class ToolResultMessage(Static):
await self._content_container.remove_children()
if self._event is None:
self.display = False
if self._content:
await self._content_container.mount(
NoMarkupStatic(self._content, classes="tool-result-detail")
)
self.display = not self.collapsed
else:
self.display = False
return
if self._event.error:

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from weakref import WeakKeyDictionary
from textual.widget import Widget
@ -13,11 +14,11 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.types import LLMMessage, Role
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
def non_system_history_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
return [msg for msg in messages if msg.role != Role.system]
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
def build_tool_call_map(messages: Sequence[LLMMessage]) -> dict[str, str]:
tool_call_map: dict[str, str] = {}
for msg in messages:
if msg.role != Role.assistant or not msg.tool_calls:
@ -29,7 +30,7 @@ def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
def build_history_widgets(
batch: list[LLMMessage],
batch: Sequence[LLMMessage],
tool_call_map: dict[str, str],
*,
start_index: int,

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator, Callable, Generator
from enum import StrEnum, auto
from http import HTTPStatus
import json
@ -13,6 +13,7 @@ from uuid import uuid4
from pydantic import BaseModel
from vibe.cli.terminal_setup import detect_terminal
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import Backend, ProviderConfig, VibeConfig
@ -26,14 +27,18 @@ from vibe.core.llm.format import (
)
from vibe.core.llm.types import BackendLike
from vibe.core.middleware import (
CHAT_AGENT_EXIT,
CHAT_AGENT_REMINDER,
PLAN_AGENT_EXIT,
PLAN_AGENT_REMINDER,
AutoCompactMiddleware,
ContextWarningMiddleware,
ConversationContext,
MiddlewareAction,
MiddlewarePipeline,
MiddlewareResult,
PlanAgentMiddleware,
PriceLimitMiddleware,
ReadOnlyAgentMiddleware,
ResetReason,
TurnLimitMiddleware,
)
@ -52,6 +57,8 @@ from vibe.core.tools.base import (
ToolPermissionError,
)
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.trusted_folders import has_agents_md_file
from vibe.core.types import (
AgentStats,
@ -62,13 +69,16 @@ from vibe.core.types import (
BaseEvent,
CompactEndEvent,
CompactStartEvent,
EntrypointMetadata,
LLMChunk,
LLMMessage,
LLMUsage,
MessageList,
RateLimitError,
ReasoningEvent,
Role,
SyncApprovalCallback,
ToolCall,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@ -139,6 +149,7 @@ class AgentLoop:
max_price: float | None = None,
backend: BackendLike | None = None,
enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> None:
self._base_config = config
self._max_turns = max_turns
@ -147,15 +158,20 @@ class AgentLoop:
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
)
self.tool_manager = ToolManager(lambda: self.config)
self._mcp_registry = MCPRegistry()
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self._mcp_registry
)
self.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler()
self.backend_factory = lambda: backend or self._select_backend()
self.backend = self.backend_factory()
self._sampling_handler = MCPSamplingHandler(
backend_getter=lambda: self.backend, config_getter=lambda: self.config
)
self.message_observer = message_observer
self._last_observed_message_index: int = 0
self.enable_streaming = enable_streaming
self.middleware_pipeline = MiddlewarePipeline()
self._setup_middleware()
@ -163,11 +179,8 @@ class AgentLoop:
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
if self.message_observer:
self.message_observer(self.messages[0])
self._last_observed_message_index = 1
system_message = LLMMessage(role=Role.system, content=system_prompt)
self.messages = MessageList(initial=[system_message], observer=message_observer)
self.stats = AgentStats()
try:
@ -180,6 +193,7 @@ class AgentLoop:
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
self.entrypoint_metadata = entrypoint_metadata
self.session_id = str(uuid4())
self._current_user_message_id: str | None = None
@ -221,19 +235,28 @@ class AgentLoop:
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
def emit_new_session_telemetry(
self, entrypoint: Literal["cli", "acp", "programmatic"]
) -> None:
def emit_new_session_telemetry(self) -> None:
entrypoint = (
self.entrypoint_metadata.agent_entrypoint
if self.entrypoint_metadata
else "unknown"
)
has_agents_md = has_agents_md_file(Path.cwd())
nb_skills = len(self.skill_manager.available_skills)
nb_mcp_servers = len(self.config.mcp_servers)
nb_models = len(self.config.models)
terminal_emulator = None
if entrypoint == "cli":
terminal_emulator = detect_terminal().value
self.telemetry_client.send_new_session(
has_agents_md=has_agents_md,
nb_skills=nb_skills,
nb_mcp_servers=nb_mcp_servers,
nb_models=nb_models,
entrypoint=entrypoint,
terminal_emulator=terminal_emulator,
)
def _select_backend(self) -> BackendLike:
@ -242,9 +265,6 @@ class AgentLoop:
timeout = self.config.api_timeout
return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout)
def add_message(self, message: LLMMessage) -> None:
self.messages.append(message)
async def _save_messages(self) -> None:
await self.session_logger.save_interaction(
self.messages,
@ -254,19 +274,6 @@ class AgentLoop:
self.agent_profile,
)
async def _flush_new_messages(self) -> None:
await self._save_messages()
if not self.message_observer:
return
if self._last_observed_message_index >= len(self.messages):
return
for msg in self.messages[self._last_observed_message_index :]:
self.message_observer(msg)
self._last_observed_message_index = len(self.messages)
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
self._clean_message_history()
async for event in self._conversation_loop(msg):
@ -345,7 +352,22 @@ class AgentLoop:
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
)
self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
self.middleware_pipeline.add(
ReadOnlyAgentMiddleware(
lambda: self.agent_profile,
BuiltinAgentName.PLAN,
PLAN_AGENT_REMINDER,
PLAN_AGENT_EXIT,
)
)
self.middleware_pipeline.add(
ReadOnlyAgentMiddleware(
lambda: self.agent_profile,
BuiltinAgentName.CHAT,
CHAT_AGENT_REMINDER,
CHAT_AGENT_EXIT,
)
)
async def _handle_middleware_result(
self, result: MiddlewareResult
@ -440,7 +462,7 @@ class AgentLoop:
if is_user_cancellation_event(event):
user_cancelled = True
yield event
await self._flush_new_messages()
await self._save_messages()
last_message = self.messages[-1]
should_break_loop = last_message.role != Role.tool
@ -449,7 +471,7 @@ class AgentLoop:
return
finally:
await self._flush_new_messages()
await self._save_messages()
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
if self.enable_streaming:
@ -471,15 +493,42 @@ class AgentLoop:
async for event in self._handle_tool_calls(resolved):
yield event
def _build_tool_call_events(
self, tool_calls: list[ToolCall] | None, emitted_ids: set[str]
) -> Generator[ToolCallEvent, None, None]:
for tc in tool_calls or []:
if tc.id is None or not tc.function.name:
continue
if tc.id in emitted_ids:
continue
tool_class = self.tool_manager.available_tools.get(tc.function.name)
if tool_class is None:
continue
yield ToolCallEvent(
tool_call_id=tc.id,
tool_call_index=tc.index,
tool_name=tc.function.name,
tool_class=tool_class,
)
async def _stream_assistant_events(
self,
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]:
message_id: str | None = None
emitted_tool_call_ids = set[str]()
async for chunk in self._chat_streaming():
if message_id is None:
message_id = chunk.message.message_id
for event in self._build_tool_call_events(
chunk.message.tool_calls, emitted_tool_call_ids
):
emitted_tool_call_ids.add(event.tool_call_id)
yield event
if chunk.message.reasoning_content:
yield ReasoningEvent(
content=chunk.message.reasoning_content, message_id=message_id
@ -560,9 +609,12 @@ class AgentLoop:
async for item in tool_instance.invoke(
ctx=InvokeContext(
tool_call_id=tool_call.call_id,
approval_callback=self.approval_callback,
agent_manager=self.agent_manager,
session_dir=self.session_logger.session_dir,
entrypoint_metadata=self.entrypoint_metadata,
approval_callback=self.approval_callback,
user_input_callback=self.user_input_callback,
sampling_callback=self._sampling_handler,
),
**tool_call.args_dict,
):
@ -671,6 +723,9 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
)
end_time = time.perf_counter()
@ -714,6 +769,9 @@ class AgentLoop:
tool_choice=tool_choice,
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
):
processed_message = self.format_handler.process_api_response_message(
chunk.message
@ -759,37 +817,26 @@ class AgentLoop:
approval_type=ToolPermission.ALWAYS,
)
allowlist_denylist_result = tool.check_allowlist_denylist(args)
if allowlist_denylist_result == ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
elif allowlist_denylist_result == ToolPermission.NEVER:
denylist_patterns = tool.config.denylist
denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns)
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]",
)
tool_name = tool.get_name()
perm = self.tool_manager.get_tool_config(tool_name).permission
effective = (
tool.resolve_permission(args)
or self.tool_manager.get_tool_config(tool_name).permission
)
if perm is ToolPermission.ALWAYS:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
if perm is ToolPermission.NEVER:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER,
feedback=f"Tool '{tool_name}' is permanently disabled",
)
return await self._ask_approval(tool_name, args, tool_call_id)
match effective:
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=f"Tool '{tool_name}' is permanently disabled",
)
case _:
return await self._ask_approval(tool_name, args, tool_call_id)
async def _ask_approval(
self, tool_name: str, args: BaseModel, tool_call_id: str
@ -852,9 +899,11 @@ class AgentLoop:
empty_response = LLMMessage(
role=Role.tool,
tool_call_id=tool_call_data.id or "",
name=(tool_call_data.function.name or "")
if tool_call_data.function
else "",
name=(
(tool_call_data.function.name or "")
if tool_call_data.function
else ""
),
content=str(
get_user_cancellation_message(
CancellationReason.TOOL_NO_RESPONSE
@ -898,7 +947,7 @@ class AgentLoop:
self.tool_manager,
self.agent_profile,
)
self.messages = self.messages[:1]
self.messages.reset(self.messages[:1])
self.stats = AgentStats.create_fresh(self.stats)
self.stats.trigger_listeners()
@ -927,10 +976,14 @@ class AgentLoop:
)
summary_request = UtilityPrompt.COMPACT.read()
self.messages.append(LLMMessage(role=Role.user, content=summary_request))
self.stats.steps += 1
summary_result = await self._chat()
with self.messages.silent():
self.messages.append(
LLMMessage(role=Role.user, content=summary_request)
)
summary_result = await self._chat()
if summary_result.usage is None:
raise AgentLoopLLMResponseError(
"Usage data missing in compaction summary response"
@ -939,8 +992,7 @@ class AgentLoop:
system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content)
self.messages = [system_message, summary_message]
self._last_observed_message_index = 1
self.messages.reset([system_message, summary_message])
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@ -950,6 +1002,9 @@ class AgentLoop:
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
metadata=self.entrypoint_metadata.model_dump()
if self.entrypoint_metadata
else None,
)
self.stats.context_tokens = actual_context_tokens
@ -1015,17 +1070,19 @@ class AgentLoop:
if max_price is not None:
self._max_price = max_price
self.tool_manager = ToolManager(lambda: self.config)
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self._mcp_registry
)
self.skill_manager = SkillManager(lambda: self.config)
new_system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages = [
self.messages.reset([
LLMMessage(role=Role.system, content=new_system_prompt),
*[msg for msg in self.messages if msg.role != Role.system],
]
])
if len(self.messages) == 1:
self.stats.reset_context_state()

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
@ -11,15 +10,14 @@ from vibe.core.agents.models import (
AgentType,
BuiltinAgentName,
)
from vibe.core.paths.config_paths import resolve_local_agents_dir
from vibe.core.logger import logger
from vibe.core.paths.config_paths import discover_local_agents_dirs
from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR
from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
logger = getLogger("vibe")
class AgentManager:
def __init__(
@ -77,6 +75,10 @@ class AgentManager:
self.active_profile = self.get_agent(name)
self._cached_config = None
def register_agent(self, profile: AgentProfile) -> None:
self._available[profile.name] = profile
self._cached_config = None
def invalidate_config(self) -> None:
self._cached_config = None
@ -86,8 +88,7 @@ class AgentManager:
for path in config.agent_paths:
if path.is_dir():
paths.append(path)
if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None:
paths.append(agents_dir)
paths.extend(discover_local_agents_dirs(Path.cwd()))
if GLOBAL_AGENTS_DIR.path.is_dir():
paths.append(GLOBAL_AGENTS_DIR.path)
unique: list[Path] = []

View file

@ -34,6 +34,7 @@ class AgentType(StrEnum):
class BuiltinAgentName(StrEnum):
DEFAULT = "default"
CHAT = "chat"
PLAN = "plan"
ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve"
@ -69,6 +70,7 @@ class AgentProfile:
)
CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
DEFAULT = AgentProfile(
@ -84,6 +86,13 @@ PLAN = AgentProfile(
AgentSafety.SAFE,
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
)
CHAT = AgentProfile(
BuiltinAgentName.CHAT,
"Chat",
"Read-only conversational mode for questions and discussions",
AgentSafety.SAFE,
overrides={"auto_approve": True, "enabled_tools": CHAT_AGENT_TOOLS},
)
ACCEPT_EDITS = AgentProfile(
BuiltinAgentName.ACCEPT_EDITS,
"Accept Edits",

View file

@ -60,6 +60,32 @@ class IgnoreRules:
self._patterns: list[CompiledPattern] | None = None
self._root: Path | None = None
def _compile_default_patterns(self) -> list[CompiledPattern]:
patterns: list[CompiledPattern] = []
for raw, is_exclude in self._defaults:
anchor_root = raw.startswith("/")
if anchor_root:
raw = raw[1:]
stripped = raw.rstrip("/")
patterns.append(
CompiledPattern(
raw=raw,
stripped=stripped,
is_exclude=is_exclude,
dir_only=raw.endswith("/"),
name_only="/" not in stripped,
anchor_root=anchor_root,
)
)
return patterns
def get_walk_skip_dir_names(self) -> frozenset[str]:
return frozenset(
p.stripped
for p in self._compile_default_patterns()
if p.dir_only and p.name_only and not p.anchor_root
)
def ensure_for_root(self, root: Path) -> None:
resolved_root = root.resolve()
if self._patterns is None or self._root != resolved_root:
@ -81,24 +107,7 @@ class IgnoreRules:
self._root = None
def _build_patterns(self, root: Path) -> list[CompiledPattern]:
patterns: list[CompiledPattern] = []
for raw, is_exclude in self._defaults:
anchor_root = raw.startswith("/")
if anchor_root:
raw = raw[1:]
stripped = raw.rstrip("/")
patterns.append(
CompiledPattern(
raw=raw,
stripped=stripped,
is_exclude=is_exclude,
dir_only=raw.endswith("/"),
name_only="/" not in stripped,
anchor_root=anchor_root,
)
)
patterns = self._compile_default_patterns()
gitignore_path = root / ".gitignore"
if gitignore_path.exists():
try:
@ -154,3 +163,6 @@ class IgnoreRules:
return False
return not pattern.dir_only or is_dir
WALK_SKIP_DIR_NAMES: frozenset[str] = IgnoreRules().get_walk_skip_dir_names()

View file

@ -165,6 +165,10 @@ class _MCPBase(BaseModel):
tool_timeout_sec: float = Field(
default=60.0, gt=0, description="Timeout in seconds for tool execution."
)
sampling_enabled: bool = Field(
default=True,
description="Allow this MCP server to request LLM completions via sampling/createMessage.",
)
@field_validator("name", mode="after")
@classmethod
@ -324,6 +328,7 @@ class VibeConfig(BaseSettings):
include_prompt_detail: bool = True
enable_update_checks: bool = True
enable_auto_update: bool = True
enable_notifications: bool = True
api_timeout: float = 720.0
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import re
from typing import Any, ClassVar
@ -22,7 +23,7 @@ class AnthropicMapper:
"""Shared mapper for converting messages to/from Anthropic API format."""
def prepare_messages(
self, messages: list[LLMMessage]
self, messages: Sequence[LLMMessage]
) -> tuple[str | None, list[dict[str, Any]]]:
system_prompt: str | None = None
converted: list[dict[str, Any]] = []
@ -457,7 +458,7 @@ class AnthropicAdapter(APIAdapter):
self,
*,
model_name: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
@ -22,7 +23,7 @@ class APIAdapter(Protocol):
self,
*,
model_name: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Sequence
import json
import os
import types
@ -82,7 +82,7 @@ class OpenAIAdapter(APIAdapter):
self,
*,
model_name: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
@ -211,12 +211,13 @@ class GenericBackend:
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float = 0.2,
tools: list[AvailableTool] | None = None,
max_tokens: int | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> LLMChunk:
api_key = (
os.getenv(self._provider.api_key_env_var)
@ -279,12 +280,13 @@ class GenericBackend:
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float = 0.2,
tools: list[AvailableTool] | None = None,
max_tokens: int | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
api_key = (
os.getenv(self._provider.api_key_env_var)
@ -395,11 +397,12 @@ class GenericBackend:
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> int:
probe_messages = list(messages)
if not probe_messages or probe_messages[-1].role != Role.user:

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Sequence
import json
import os
import re
@ -226,12 +226,13 @@ class MistralBackend:
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> LLMChunk:
try:
merged_messages = merge_consecutive_user_messages(messages)
@ -247,6 +248,7 @@ class MistralBackend:
if tool_choice
else None,
http_headers=extra_headers,
metadata=metadata,
stream=False,
)
@ -300,12 +302,13 @@ class MistralBackend:
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
try:
merged_messages = merge_consecutive_user_messages(messages)
@ -321,6 +324,7 @@ class MistralBackend:
if tool_choice
else None,
http_headers=extra_headers,
metadata=metadata,
):
parsed = (
self._mapper.parse_content(chunk.data.choices[0].delta.content)
@ -376,11 +380,12 @@ class MistralBackend:
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> int:
result = await self.complete(
model=model,
@ -390,6 +395,7 @@ class MistralBackend:
max_tokens=1,
tool_choice=tool_choice,
extra_headers=extra_headers,
metadata=metadata,
)
if result.usage is None:
raise ValueError("Missing usage in non streaming completion")

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import threading
from typing import Any, ClassVar
@ -67,7 +68,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
self,
*,
model_name: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from http import HTTPStatus
import json
from typing import Any
@ -114,7 +114,7 @@ class BackendErrorBuilder:
response: httpx.Response,
headers: Mapping[str, str] | None,
model: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,
@ -146,7 +146,7 @@ class BackendErrorBuilder:
endpoint: str,
error: httpx.RequestError,
model: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,
@ -179,7 +179,7 @@ class BackendErrorBuilder:
@staticmethod
def _payload_summary(
model_name: str,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
has_tools: bool,
tool_choice: StrToolChoice | AvailableTool | None,

View file

@ -1,9 +1,11 @@
from __future__ import annotations
from collections.abc import Sequence
from vibe.core.types import LLMMessage, Role
def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
def merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.
This handles cases where middleware injects messages resulting in

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Sequence
import types
from typing import TYPE_CHECKING, Protocol
@ -29,12 +29,13 @@ class BackendLike(Protocol):
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> LLMChunk:
"""Complete a chat conversation using the specified model and provider.
@ -46,6 +47,7 @@ class BackendLike(Protocol):
max_tokens: Maximum tokens to generate
tool_choice: How to choose tools (auto, none, or specific tool)
extra_headers: Additional HTTP headers to include
metadata: Optional metadata to attach to the request
Returns:
LLMChunk containing the response message and usage information
@ -62,12 +64,13 @@ class BackendLike(Protocol):
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
"""Equivalent of the complete method, but yields LLMEvent objects
instead of a single LLMEvent.
@ -80,6 +83,7 @@ class BackendLike(Protocol):
max_tokens: Maximum tokens to generate
tool_choice: How to choose tools (auto, none, or specific tool)
extra_headers: Additional HTTP headers to include
metadata: Optional metadata to attach to the request
Returns:
AsyncGenerator[LLMEvent, None] yielding LLMEvent objects
@ -93,11 +97,12 @@ class BackendLike(Protocol):
self,
*,
model: ModelConfig,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> int:
"""Count the number of tokens in the prompt without generating a real response.
@ -113,6 +118,7 @@ class BackendLike(Protocol):
tools: Optional list of available tools
tool_choice: How to choose tools
extra_headers: Additional HTTP headers to include
metadata: Optional metadata to attach to the request
Returns:
The number of prompt tokens

58
vibe/core/logger.py Normal file
View file

@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import UTC, datetime
import logging
from logging.handlers import RotatingFileHandler
import os
from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("vibe")
class StructuredLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
ppid = os.getppid()
pid = os.getpid()
level = record.levelname
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
line = f"{timestamp} {ppid} {pid} {level} {message}"
if record.exc_info:
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
line = f"{line} {exc_text}"
return line
def apply_logging_config(target_logger: logging.Logger) -> None:
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
if os.environ.get("DEBUG_MODE") == "true":
log_level_str = "DEBUG"
else:
log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if log_level_str not in valid_levels:
log_level_str = "WARNING"
handler = RotatingFileHandler(
LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
)
handler.setFormatter(StructuredLogFormatter())
log_level = getattr(logging, log_level_str, logging.WARNING)
handler.setLevel(log_level)
# Make sure the logger is not gating logs
target_logger.setLevel(logging.DEBUG)
target_logger.addHandler(handler)
apply_logging_config(logger)

View file

@ -6,12 +6,11 @@ from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Protocol
from vibe.core.agents import AgentProfile
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
from vibe.core.types import AgentStats, LLMMessage
from vibe.core.types import AgentStats, MessageList
class MiddlewareAction(StrEnum):
@ -28,7 +27,7 @@ class ResetReason(StrEnum):
@dataclass
class ConversationContext:
messages: list[LLMMessage]
messages: MessageList
stats: AgentStats
config: VibeConfig
@ -136,44 +135,53 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
1. Answer the user's questions directly and comprehensively
2. Explain code, concepts, or architecture as requested
3. Use read-only tools (grep, read_file) to look up relevant code when needed
4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action</{VIBE_WARNING_TAG}>"""
class PlanAgentMiddleware:
CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
class ReadOnlyAgentMiddleware:
def __init__(
self,
profile_getter: Callable[[], AgentProfile],
reminder: str = PLAN_AGENT_REMINDER,
exit_message: str = PLAN_AGENT_EXIT,
agent_name: str,
reminder: str,
exit_message: str,
) -> None:
self._profile_getter = profile_getter
self._agent_name = agent_name
self.reminder = reminder
self.exit_message = exit_message
self._was_plan_agent = False
self._was_active = False
def _is_plan_agent(self) -> bool:
return self._profile_getter().name == BuiltinAgentName.PLAN
def _is_active(self) -> bool:
return self._profile_getter().name == self._agent_name
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
is_plan = self._is_plan_agent()
was_plan = self._was_plan_agent
is_active = self._is_active()
was_active = self._was_active
if was_plan and not is_plan:
self._was_plan_agent = False
if was_active and not is_active:
self._was_active = False
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
)
if is_plan and not was_plan:
self._was_plan_agent = True
if is_active and not was_active:
self._was_active = True
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
self._was_plan_agent = is_plan
self._was_active = is_active
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
self._was_plan_agent = False
self._was_active = False
class MiddlewarePipeline:

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Literal
from vibe.core.paths.global_paths import VIBE_HOME, GlobalPath
from vibe.core.paths.local_config_walk import walk_local_config_dirs_all
from vibe.core.trusted_folders import trusted_folders_manager
_config_paths_locked: bool = True
@ -31,30 +32,24 @@ def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path:
return VIBE_HOME.path / basename
def resolve_local_tools_dir(dir: Path) -> Path | None:
if not trusted_folders_manager.is_trusted(dir):
return None
if (candidate := dir / ".vibe" / "tools").is_dir():
return candidate
return None
def _discover_local_config_dirs_all(
root: Path,
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
if not trusted_folders_manager.is_trusted(root):
return ((), (), ())
return walk_local_config_dirs_all(root)
def resolve_local_skills_dirs(dir: Path) -> list[Path]:
if not trusted_folders_manager.is_trusted(dir):
return []
return [
candidate
for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
if candidate.is_dir()
]
def discover_local_tools_dirs(root: Path) -> list[Path]:
return list(_discover_local_config_dirs_all(root)[0])
def resolve_local_agents_dir(dir: Path) -> Path | None:
if not trusted_folders_manager.is_trusted(dir):
return None
if (candidate := dir / ".vibe" / "agents").is_dir():
return candidate
return None
def discover_local_skills_dirs(root: Path) -> list[Path]:
return list(_discover_local_config_dirs_all(root)[1])
def discover_local_agents_dirs(root: Path) -> list[Path]:
return list(_discover_local_config_dirs_all(root)[2])
def unlock_config_paths() -> None:

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from functools import cache
import os
from pathlib import Path
from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
_VIBE_DIR = ".vibe"
_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
_AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
_AGENTS_DIR = ".agents"
_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
@cache
def walk_local_config_dirs_all(
root: Path,
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
tools_dirs: list[Path] = []
skills_dirs: list[Path] = []
agents_dirs: list[Path] = []
resolved_root = root.resolve()
for dirpath, dirnames, _ in os.walk(resolved_root, topdown=True):
dir_set = frozenset(dirnames)
path = Path(dirpath)
if _VIBE_DIR in dir_set:
if (candidate := path / _TOOLS_SUBDIR).is_dir():
tools_dirs.append(candidate)
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
skills_dirs.append(candidate)
if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents_dirs.append(candidate)
if _AGENTS_DIR in dir_set:
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
skills_dirs.append(candidate)
dirnames[:] = sorted(d for d in dirnames if d not in WALK_SKIP_DIR_NAMES)
return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))

View file

@ -2,12 +2,23 @@ from __future__ import annotations
import asyncio
from vibe import __version__
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.output_formatters import create_formatter
from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger
from vibe.core.types import (
AssistantEvent,
ClientMetadata,
EntrypointMetadata,
LLMMessage,
OutputFormat,
Role,
)
from vibe.core.utils import ConversationLimitException
_DEFAULT_CLIENT_METADATA = ClientMetadata(name="vibe_programmatic", version=__version__)
def run_programmatic(
@ -18,6 +29,7 @@ def run_programmatic(
output_format: OutputFormat = OutputFormat.TEXT,
previous_messages: list[LLMMessage] | None = None,
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA,
) -> str | None:
formatter = create_formatter(output_format)
@ -28,6 +40,12 @@ def run_programmatic(
max_turns=max_turns,
max_price=max_price,
enable_streaming=False,
entrypoint_metadata=EntrypointMetadata(
agent_entrypoint="programmatic",
agent_version=__version__,
client_name=client_metadata.name,
client_version=client_metadata.version,
),
)
logger.info("USER: %s", prompt)
@ -42,7 +60,7 @@ def run_programmatic(
"Loaded %d messages from previous session", len(non_system_messages)
)
agent_loop.emit_new_session_telemetry("programmatic")
agent_loop.emit_new_session_telemetry()
async for event in agent_loop.act(prompt):
formatter.on_event(event)

View file

@ -1,4 +1,13 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI, powered by the Devstral model family. You interact with a local codebase through tools.
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. You have no internet access.
CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself.
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
1. Find the matching file in your skill directories.
2. Read it with `read_file`.
3. Follow its instructions step by step. You are the executor.
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
Phase 1 — Orient
Before ANY action:
@ -10,6 +19,7 @@ If unclear, default to investigate. It is better to explain what you would do th
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
Identify constraints: language, framework, test setup, and any user restrictions on scope.
When given multiple file paths or a complex task: Do not start reading files immediately. First, summarize your understanding of the task and propose a short plan. Wait for the user to confirm before exploring any files. This prevents wasted effort on the wrong path.
Phase 2 — Plan (Change tasks only)
State your plan before writing code:
@ -24,6 +34,10 @@ After each unit, verify: run tests, or read back the file to confirm the edit la
Never claim completion without verification — a passing test, correct read-back, or successful build.
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.
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.
@ -107,5 +121,6 @@ Cite as file_path:line_number.
Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
No emojis unless requested. No over-the-top validation.
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
No over-the-top validation.
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.

View file

@ -5,13 +5,16 @@ import json
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypedDict
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
from vibe.core.types import LLMMessage
from vibe.core.types import LLMMessage, SessionMetadata
if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig
METADATA_FILENAME = "meta.json"
MESSAGES_FILENAME = "messages.jsonl"
class SessionInfo(TypedDict):
session_id: str
cwd: str
@ -171,6 +174,22 @@ class SessionLoader:
return sessions
@staticmethod
def load_metadata(session_dir: Path) -> SessionMetadata:
metadata_path = session_dir / METADATA_FILENAME
if not metadata_path.exists():
raise ValueError(f"Session metadata not found at {session_dir}")
try:
metadata_content = metadata_path.read_text()
return SessionMetadata.model_validate_json(metadata_content)
except ValueError:
raise
except Exception as e:
raise ValueError(
f"Failed to load session metadata at {session_dir}: {e}"
) from e
@staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
# Load session messages from MESSAGES_FILENAME
@ -220,3 +239,37 @@ class SessionLoader:
metadata = {}
return messages, metadata
@staticmethod
def _clean_text(text: str) -> str:
text = text.strip().replace("\n", " ")
return text or "(empty message)"
@staticmethod
def _extract_text_from_content(content: str | None) -> str | None:
if not content:
return None
return SessionLoader._clean_text(content)
@staticmethod
def get_first_user_message(session_id: str, config: SessionLoggingConfig) -> str:
"""Get the first user message from a session for preview."""
session_path = SessionLoader.find_session_by_id(session_id, config)
if not session_path:
return "(session not found)"
try:
messages, _ = SessionLoader.load_session(session_path)
for msg in messages:
if msg.role != "user":
continue
text = SessionLoader._extract_text_from_content(msg.content)
if text:
return text
return "(no user messages)"
except ValueError:
return "(corrupted session)"
except OSError:
return "(error reading session)"

View file

@ -1,15 +1,22 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
import getpass
import json
import os
from pathlib import Path
import subprocess
from threading import Lock
from typing import TYPE_CHECKING, Any
from anyio import NamedTemporaryFile, Path as AsyncPath
from vibe.core.session.session_loader import (
MESSAGES_FILENAME,
METADATA_FILENAME,
SessionLoader,
)
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows, utc_now
@ -19,14 +26,15 @@ if TYPE_CHECKING:
from vibe.core.tools.manager import ToolManager
METADATA_FILENAME = "meta.json"
MESSAGES_FILENAME = "messages.jsonl"
TMP_CLEANUP_INTERVAL = timedelta(seconds=5)
class SessionLogger:
def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
self.session_config = session_config
self.enabled = session_config.enabled
self._last_tmp_cleanup_at: datetime | None = None
self._tmp_cleanup_lock = Lock()
if not self.enabled:
self.save_dir: Path | None = None
@ -127,7 +135,7 @@ class SessionLogger:
environment={"working_directory": str(Path.cwd())},
)
def _get_title(self, messages: list[LLMMessage]) -> str:
def _get_title(self, messages: Sequence[LLMMessage]) -> str:
first_user_message = None
for message in messages:
if message.role == Role.user:
@ -196,7 +204,7 @@ class SessionLogger:
async def save_interaction(
self,
messages: list[LLMMessage],
messages: Sequence[LLMMessage],
stats: AgentStats,
base_config: VibeConfig,
tool_manager: ToolManager,
@ -208,6 +216,9 @@ class SessionLogger:
if self.session_metadata is None:
return
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)
@ -284,7 +295,7 @@ class SessionLogger:
f"Failed to save session to {self.session_dir}: {e}"
) from e
finally:
self.cleanup_tmp_files()
self.maybe_cleanup_tmp_files()
def reset_session(self, session_id: str) -> None:
"""Clear existing session info and setup a new session"""
@ -296,6 +307,17 @@ class SessionLogger:
self.session_dir = self.save_folder
self.session_metadata = self._initialize_session_metadata()
def resume_existing_session(self, session_id: str, session_dir: Path) -> None:
if not self.enabled:
return
self.session_id = session_id
self.session_dir = session_dir
self.session_metadata = SessionLoader.load_metadata(session_dir)
if self.session_metadata.start_time:
self.session_start_time = self.session_metadata.start_time
def cleanup_tmp_files(self) -> None:
"""Delete temporary files created more than 5 minutes ago"""
if not self.enabled or not self.save_dir:
@ -316,3 +338,22 @@ class SessionLogger:
file_path.unlink()
except Exception:
continue
def maybe_cleanup_tmp_files(self) -> None:
if not self.enabled or not self.save_dir:
return
if not self._tmp_cleanup_lock.acquire(blocking=False):
return
try:
now = utc_now()
if (
self._last_tmp_cleanup_at is not None
and now - self._last_tmp_cleanup_at < TMP_CLEANUP_INTERVAL
):
return
self.cleanup_tmp_files()
self._last_tmp_cleanup_at = now
finally:
self._tmp_cleanup_lock.release()

View file

@ -1,11 +1,11 @@
from __future__ import annotations
from collections.abc import Callable
from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
from vibe.core.paths.config_paths import resolve_local_skills_dirs
from vibe.core.logger import logger
from vibe.core.paths.config_paths import discover_local_skills_dirs
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
@ -14,8 +14,6 @@ from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
logger = getLogger("vibe")
class SkillManager:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
@ -58,7 +56,7 @@ class SkillManager:
if path.is_dir():
paths.append(path)
paths.extend(resolve_local_skills_dirs(Path.cwd()))
paths.extend(discover_local_skills_dirs(Path.cwd()))
if GLOBAL_SKILLS_DIR.path.is_dir():
paths.append(GLOBAL_SKILLS_DIR.path)

View file

@ -141,25 +141,25 @@ class TelemetryClient:
"nb_files_created": nb_files_created,
"nb_files_modified": nb_files_modified,
}
self.send_telemetry_event("vibe/tool_call_finished", payload)
self.send_telemetry_event("vibe.tool_call_finished", payload)
def send_user_copied_text(self, text: str) -> None:
payload = {"text_length": len(text)}
self.send_telemetry_event("vibe/user_copied_text", payload)
self.send_telemetry_event("vibe.user_copied_text", payload)
def send_user_cancelled_action(self, action: str) -> None:
payload = {"action": action}
self.send_telemetry_event("vibe/user_cancelled_action", payload)
self.send_telemetry_event("vibe.user_cancelled_action", payload)
def send_auto_compact_triggered(self) -> None:
payload = {}
self.send_telemetry_event("vibe/auto_compact_triggered", payload)
self.send_telemetry_event("vibe.auto_compact_triggered", payload)
def send_slash_command_used(
self, command: str, command_type: Literal["builtin", "skill"]
) -> None:
payload = {"command": command.lstrip("/"), "command_type": command_type}
self.send_telemetry_event("vibe/slash_command_used", payload)
self.send_telemetry_event("vibe.slash_command_used", payload)
def send_new_session(
self,
@ -167,7 +167,8 @@ class TelemetryClient:
nb_skills: int,
nb_mcp_servers: int,
nb_models: int,
entrypoint: Literal["cli", "acp", "programmatic"],
entrypoint: Literal["cli", "acp", "programmatic", "unknown"],
terminal_emulator: str | None = None,
) -> None:
payload = {
"has_agents_md": has_agents_md,
@ -176,10 +177,11 @@ class TelemetryClient:
"nb_models": nb_models,
"entrypoint": entrypoint,
"version": __version__,
"terminal_emulator": terminal_emulator,
}
self.send_telemetry_event("vibe/new_session", payload)
self.send_telemetry_event("vibe.new_session", payload)
def send_onboarding_api_key_added(self) -> None:
self.send_telemetry_event(
"vibe/onboarding_api_key_added", {"version": __version__}
"vibe.onboarding_api_key_added", {"version": __version__}
)

View file

@ -27,7 +27,8 @@ from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
from vibe.core.types import ApprovalCallback, UserInputCallback
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.types import ApprovalCallback, EntrypointMetadata, UserInputCallback
ARGS_COUNT = 4
@ -40,6 +41,9 @@ class InvokeContext:
approval_callback: ApprovalCallback | None = field(default=None)
agent_manager: AgentManager | None = field(default=None)
user_input_callback: UserInputCallback | None = field(default=None)
sampling_callback: MCPSamplingHandler | None = field(default=None)
session_dir: Path | None = field(default=None)
entrypoint_metadata: EntrypointMetadata | None = field(default=None)
class ToolError(Exception):
@ -316,6 +320,10 @@ class BaseTool[
snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
return snake_case
@classmethod
def is_available(cls) -> bool:
return True
@classmethod
def create_config_with_permission(
cls, permission: ToolPermission
@ -323,14 +331,13 @@ class BaseTool[
config_class = cls._get_tool_config_class()
return config_class(permission=permission)
def check_allowlist_denylist(self, args: ToolArgs) -> ToolPermission | None:
"""Check if args match allowlist/denylist patterns.
def resolve_permission(self, args: ToolArgs) -> ToolPermission | None:
"""Per-invocation permission override, checked before config-level permission.
Returns:
ToolPermission.ALWAYS if allowlisted
ToolPermission.NEVER if denylisted
None if no match (proceed with normal permission check)
ALWAYS if auto-approved, NEVER if blocked, ASK to force approval,
or None to fall through to config permission.
Base implementation returns None. Override in subclasses for specific logic.
Override in subclasses for domain-specific rules (e.g. workdir checks).
"""
return None

View file

@ -14,7 +14,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolResultEvent
class Choice(BaseModel):
@ -84,16 +84,10 @@ class AskUserQuestion(
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, AskUserQuestionArgs):
return ToolCallDisplay(summary="Asking user")
args = event.args
def format_call_display(cls, args: AskUserQuestionArgs) -> ToolCallDisplay:
count = len(args.questions)
if count == 1:
return ToolCallDisplay(summary=f"Asking: {args.questions[0].question}")
return ToolCallDisplay(summary=f"Asking {count} questions")
@classmethod

View file

@ -21,7 +21,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils import is_windows
@ -208,11 +208,8 @@ class Bash(
description: ClassVar[str] = "Run a one-off bash command and capture its output."
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, BashArgs):
return ToolCallDisplay(summary="bash")
return ToolCallDisplay(summary=f"bash: {event.args.command}")
def format_call_display(cls, args: BashArgs) -> ToolCallDisplay:
return ToolCallDisplay(summary=f"bash: {args.command}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
@ -227,7 +224,7 @@ class Bash(
def get_status_text(cls) -> str:
return "Running command"
def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None

View file

@ -21,7 +21,7 @@ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolResultEvent
class GrepBackend(StrEnum):
@ -75,10 +75,6 @@ class GrepToolConfig(BaseToolConfig):
)
class GrepState(BaseToolState):
search_history: list[str] = Field(default_factory=list)
class GrepArgs(BaseModel):
pattern: str
path: str = "."
@ -99,7 +95,7 @@ class GrepResult(BaseModel):
class Grep(
BaseTool[GrepArgs, GrepResult, GrepToolConfig, GrepState],
BaseTool[GrepArgs, GrepResult, GrepToolConfig, BaseToolState],
ToolUIData[GrepArgs, GrepResult],
):
description: ClassVar[str] = (
@ -122,7 +118,6 @@ class Grep(
) -> AsyncGenerator[ToolStreamEvent | GrepResult, None]:
backend = self._detect_backend()
self._validate_args(args)
self.state.search_history.append(args.pattern)
exclude_patterns = self._collect_exclude_patterns()
cmd = self._build_command(args, exclude_patterns, backend)
@ -274,18 +269,14 @@ class Grep(
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, GrepArgs):
return ToolCallDisplay(summary="grep")
summary = f"Grepping '{event.args.pattern}'"
if event.args.path != ".":
summary += f" in {event.args.path}"
if event.args.max_matches:
summary += f" (max {event.args.max_matches} matches)"
if not event.args.use_default_ignore:
def format_call_display(cls, args: GrepArgs) -> ToolCallDisplay:
summary = f"Grepping '{args.pattern}'"
if args.path != ".":
summary += f" in {args.path}"
if args.max_matches:
summary += f" (max {args.max_matches} matches)"
if not args.use_default_ignore:
summary += " [no-ignore]"
return ToolCallDisplay(summary=summary)
@classmethod

View file

@ -3,11 +3,16 @@ Use `read_file` to read the content of a file. It's designed to handle large fil
- By default, it reads from the beginning of the file.
- Use `offset` (line number) and `limit` (number of lines) to read specific parts or chunks of a file. This is efficient for exploring large files.
- The result includes `was_truncated: true` if the file content was cut short due to size limits.
- This is more efficient than using `bash` with `cat` or `wc`.
**Strategy for large files:**
1. Call `read_file` with a `limit` (e.g., 1000 lines) to get the start of the file.
2. If `was_truncated` is true, you know the file is large.
3. To read the next chunk, call `read_file` again with an `offset`. For example, `offset=1000, limit=1000`.
2. If `was_truncated` is true, the file is large. STOP and assess: do you already have enough information to answer the user's question? If yes, respond immediately — do not keep reading.
3. If you need more, prefer targeted reads (e.g., jump to a specific offset, read the last 100 lines, search for a relevant section) over reading sequentially chunk by chunk.
4. Do not call `read_file` more than 3 times on the same file without responding to the user first.
This is more efficient than using `bash` with `cat` or `wc`.
**Do not read or explore:**
- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
- Binary files of any kind
- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.

View file

@ -0,0 +1,6 @@
Fetches content from a specified URL and converts HTML to markdown for readability.
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.

View file

@ -0,0 +1,25 @@
Use `web_search` to find current information from the web.
Returns answers with cited sources. Always reference sources when presenting information to the user.
**Query Best Practices:**
- Avoid relative time terms ("latest", "today", "this week") - resolve to actual dates when possible
- Be specific and use concrete terms rather than vague queries
**When to use:**
- User asks about recent events or explicitly asks to search the web
- Documentation, APIs, or libraries may have been updated since training cutoff
- Verifying facts that could be outdated (versions, deprecations, breaking changes)
- Looking up specific error messages or issues that may have known solutions
- User mentions a library, framework, or version you're not familiar with
**When NOT to use:**
- General programming concepts and patterns (use training knowledge)
- Searching the local codebase (use `grep` or file search instead)
- Static reference information unlikely to change (math, algorithms, language syntax)
- Information you're already confident about and is unlikely to have changed
**Using results:**
- Stay critical - web content may be outdated, wrong, or misleading
- Cross-reference multiple sources when possible
- Prefer official documentation over third-party sources
- Always cite your sources so the user can verify

View file

@ -16,10 +16,11 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolResultEvent
class _ReadResult(NamedTuple):
@ -54,17 +55,10 @@ class ReadFileToolConfig(BaseToolConfig):
max_read_bytes: int = Field(
default=64_000, description="Maximum total bytes to read from a file in one go."
)
max_state_history: int = Field(
default=10, description="Number of recently read files to remember in state."
)
class ReadFileState(BaseToolState):
recently_read_files: list[str] = Field(default_factory=list)
class ReadFile(
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
@ -80,8 +74,6 @@ class ReadFile(
read_result = await self._read_file(args, file_path)
self._update_state_history(file_path)
yield ReadFileResult(
path=str(file_path),
content="".join(read_result.lines),
@ -89,23 +81,13 @@ class ReadFile(
was_truncated=read_result.was_truncated,
)
def check_allowlist_denylist(self, args: ReadFileArgs) -> ToolPermission | None:
import fnmatch
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
file_str = str(file_path)
for pattern in self.config.denylist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.NEVER
for pattern in self.config.allowlist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.ALWAYS
return None
def resolve_permission(self, args: ReadFileArgs) -> ToolPermission | None:
return resolve_file_tool_permission(
args.path,
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
)
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
self._validate_inputs(args)
@ -176,25 +158,16 @@ class ReadFile(
if resolved_path.is_dir():
raise ToolError(f"Path is a directory, not a file: {file_path}")
def _update_state_history(self, file_path: Path) -> None:
self.state.recently_read_files.append(str(file_path.resolve()))
if len(self.state.recently_read_files) > self.config.max_state_history:
self.state.recently_read_files.pop(0)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, ReadFileArgs):
return ToolCallDisplay(summary="read_file")
summary = f"Reading {event.args.path}"
if event.args.offset > 0 or event.args.limit is not None:
def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay:
summary = f"Reading {args.path}"
if args.offset > 0 or args.limit is not None:
parts = []
if event.args.offset > 0:
parts.append(f"from line {event.args.offset}")
if event.args.limit is not None:
parts.append(f"limit {event.args.limit} lines")
if args.offset > 0:
parts.append(f"from line {args.offset}")
if args.limit is not None:
parts.append(f"limit {args.limit} lines")
summary += f" ({', '.join(parts)})"
return ToolCallDisplay(summary=summary)
@classmethod

View file

@ -16,9 +16,11 @@ from vibe.core.tools.base import (
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
@ -68,13 +70,9 @@ class SearchReplaceConfig(BaseToolConfig):
fuzzy_threshold: float = 0.9
class SearchReplaceState(BaseToolState):
pass
class SearchReplace(
BaseTool[
SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, SearchReplaceState
SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, BaseToolState
],
ToolUIData[SearchReplaceArgs, SearchReplaceResult],
):
@ -85,13 +83,8 @@ class SearchReplace(
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, SearchReplaceArgs):
return ToolCallDisplay(summary="Invalid arguments")
args = event.args
def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay:
blocks = cls._parse_search_replace_blocks(args.content)
return ToolCallDisplay(
summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
content=args.content,
@ -112,6 +105,14 @@ class SearchReplace(
def get_status_text(cls) -> str:
return "Editing files"
def resolve_permission(self, args: SearchReplaceArgs) -> ToolPermission | None:
return resolve_file_tool_permission(
args.file_path,
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
)
@final
async def run(
self, args: SearchReplaceArgs, ctx: InvokeContext | None = None

View file

@ -1,12 +1,13 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import fnmatch
from typing import ClassVar
from pydantic import BaseModel, Field
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import AgentType
from vibe.core.agents.models import AgentType, BuiltinAgentName
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.tools.base import (
BaseTool,
@ -47,6 +48,7 @@ class TaskResult(BaseModel):
class TaskToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
allowlist: list[str] = Field(default=[BuiltinAgentName.EXPLORE])
class Task(
@ -56,8 +58,8 @@ class Task(
description: ClassVar[str] = (
"Delegate a task to a subagent for independent execution. "
"Useful for exploration, research, or parallel work that doesn't "
"require user interaction. The subagent runs in-memory without "
"saving interaction logs."
"require user interaction. The subagent runs in-memory and "
"saves interaction logs."
)
@classmethod
@ -87,6 +89,19 @@ class Task(
def get_status_text(cls) -> str:
return "Running subagent"
def resolve_permission(self, args: TaskArgs) -> ToolPermission | None:
agent_name = args.agent
for pattern in self.config.denylist:
if fnmatch.fnmatch(agent_name, pattern):
return ToolPermission.NEVER
for pattern in self.config.allowlist:
if fnmatch.fnmatch(agent_name, pattern):
return ToolPermission.ALWAYS
return None
async def run(
self, args: TaskArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | TaskResult, None]:
@ -107,10 +122,17 @@ class Task(
f"This is a security constraint to prevent recursive spawning."
)
base_config = VibeConfig.load(
session_logging=SessionLoggingConfig(enabled=False)
session_logging = SessionLoggingConfig(
save_dir=str(ctx.session_dir / "agents") if ctx.session_dir else "",
session_prefix=args.agent,
enabled=ctx.session_dir is not None,
)
base_config = VibeConfig.load(session_logging=session_logging)
subagent_loop = AgentLoop(
config=base_config,
agent_name=args.agent,
entrypoint_metadata=ctx.entrypoint_metadata,
)
subagent_loop = AgentLoop(config=base_config, agent_name=args.agent)
if ctx and ctx.approval_callback:
subagent_loop.set_approval_callback(ctx.approval_callback)

View file

@ -15,7 +15,7 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.types import ToolResultEvent, ToolStreamEvent
class TodoStatus(StrEnum):
@ -69,12 +69,7 @@ class Todo(
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, TodoArgs):
return ToolCallDisplay(summary="Invalid arguments")
args = event.args
def format_call_display(cls, args: TodoArgs) -> ToolCallDisplay:
match args.action:
case "read":
return ToolCallDisplay(summary="Reading todos")

View file

@ -0,0 +1,200 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
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 (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
_HONEST_USER_AGENT = "vibe-cli"
_HTTP_FORBIDDEN = 403
class _Converter(MarkdownConverter):
convert_script = convert_style = convert_noscript = convert_iframe = (
convert_object
) = convert_embed = lambda *_, **__: ""
class WebFetchArgs(BaseModel):
url: str = Field(description="URL to fetch (http/https)")
timeout: int | None = Field(
default=None, description="Timeout in seconds (max 120)"
)
class WebFetchResult(BaseModel):
url: str
content: str
content_type: str
class WebFetchConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
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."
)
user_agent: str = Field(
default=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
description="User agent string for requests.",
)
class WebFetch(
BaseTool[WebFetchArgs, WebFetchResult, WebFetchConfig, BaseToolState],
ToolUIData[WebFetchArgs, WebFetchResult],
):
description: ClassVar[str] = (
"Fetch content from a URL. Converts HTML to markdown for readability."
)
@final
async def run(
self, args: WebFetchArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | WebFetchResult, None]:
self._validate_args(args)
raw = args.url.lstrip("/") if args.url.startswith("//") else args.url
url = raw if raw.startswith(("http://", "https://")) else "https://" + raw
timeout = self._resolve_timeout(args.timeout)
content, content_type = await self._fetch_url(url, timeout)
if "text/html" in content_type:
content = _html_to_markdown(content)
yield WebFetchResult(url=url, content=content, content_type=content_type)
def _validate_args(self, args: WebFetchArgs) -> None:
if not args.url.strip():
raise ToolError("URL cannot be empty")
parsed = urlparse(args.url)
if parsed.scheme and parsed.scheme not in {"http", "https"}:
raise ToolError(
f"Invalid URL scheme: {parsed.scheme}. Must be http or https."
)
if args.timeout is not None:
if args.timeout <= 0:
raise ToolError("Timeout must be a positive number")
if args.timeout > self.config.max_timeout:
raise ToolError(
f"Timeout cannot exceed {self.config.max_timeout} seconds"
)
def _resolve_timeout(self, timeout: int | None) -> int:
if timeout is None:
return self.config.default_timeout
return min(timeout, self.config.max_timeout)
async def _fetch_url(self, url: str, timeout: int) -> tuple[str, str]:
headers = {
"User-Agent": self.config.user_agent,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Language": "en-US,en;q=0.9",
}
try:
response = await self._do_fetch(url, timeout, headers)
except httpx.TimeoutException:
raise ToolError(f"Request timed out after {timeout} seconds")
except httpx.RequestError as e:
raise ToolError(f"Failed to fetch URL: {e}")
if response.is_error:
raise ToolError(
f"HTTP error {response.status_code}: {response.reason_phrase}"
)
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]"
return content, content_type
async def _do_fetch(
self, url: str, timeout: int, headers: dict[str, str]
) -> httpx.Response:
async with httpx.AsyncClient(
follow_redirects=True, timeout=httpx.Timeout(timeout)
) as client:
response = await client.get(url, headers=headers)
# In case we are hitting bot detection retry once honestly
if (
response.status_code == _HTTP_FORBIDDEN
and response.headers.get("cf-mitigated") == "challenge"
):
headers["User-Agent"] = _HONEST_USER_AGENT
response = await client.get(url, headers=headers)
return response
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if event.args is None:
return ToolCallDisplay(summary="webfetch")
if not isinstance(event.args, WebFetchArgs):
return ToolCallDisplay(summary="webfetch")
parsed = urlparse(event.args.url)
domain = parsed.netloc or event.args.url[:50]
summary = f"Fetching: {domain}"
if event.args.timeout:
summary += f" (timeout {event.args.timeout}s)"
return ToolCallDisplay(summary=summary)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, WebFetchResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
content_len = len(event.result.content)
message = (
f"Fetched {content_len:,} chars ({event.result.content_type.split(';')[0]})"
)
return ToolResultDisplay(success=True, message=message)
@classmethod
def get_status_text(cls) -> str:
return "Fetching URL"
def _html_to_markdown(html: str) -> str:
return _Converter(heading_style="ATX", bullets="-").convert(html)

View file

@ -0,0 +1,131 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import os
from typing import TYPE_CHECKING, ClassVar, final
import mistralai
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
class WebSearchSource(BaseModel):
title: str
url: str
class WebSearchArgs(BaseModel):
query: str = Field(min_length=1)
class WebSearchResult(BaseModel):
answer: str
sources: list[WebSearchSource] = Field(default_factory=list)
class WebSearchConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
timeout: int = Field(default=120, description="HTTP timeout in seconds.")
model: str = Field(
default="mistral-vibe-cli-with-tools",
description="Mistral model to use for web search.",
)
class WebSearch(
BaseTool[WebSearchArgs, WebSearchResult, WebSearchConfig, BaseToolState],
ToolUIData[WebSearchArgs, WebSearchResult],
):
description: ClassVar[str] = (
"Search the web for current information using Mistral's web search."
)
@classmethod
def is_available(cls) -> bool:
return bool(os.getenv("MISTRAL_API_KEY"))
@final
async def run(
self, args: WebSearchArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | WebSearchResult, None]:
api_key = os.getenv("MISTRAL_API_KEY")
if not api_key:
raise ToolError("MISTRAL_API_KEY environment variable not set.")
client = mistralai.Mistral(
api_key=api_key, timeout_ms=self.config.timeout * 1000
)
try:
async with client:
response = await client.beta.conversations.start_async(
model=self.config.model,
instructions="Always use the web_search tool to answer queries. Never answer from memory alone.",
tools=[{"type": "web_search"}],
inputs=args.query,
store=False,
)
yield self._parse_response(response)
except mistralai.SDKError as exc:
raise ToolError(f"Mistral API error: {exc}") from exc
def _parse_response(
self, response: mistralai.ConversationResponse
) -> WebSearchResult:
text_parts: list[str] = []
sources: dict[str, WebSearchSource] = {}
for entry in response.outputs:
if not isinstance(entry, mistralai.MessageOutputEntry):
continue
for chunk in entry.content:
if isinstance(chunk, mistralai.TextChunk):
text_parts.append(chunk.text)
elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
if chunk.url not in sources:
sources[chunk.url] = WebSearchSource(
title=chunk.title, url=chunk.url
)
answer = "".join(text_parts).strip()
if not answer:
raise ToolError("No text in agent response.")
return WebSearchResult(answer=answer, sources=list(sources.values()))
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if event.args is None:
return ToolCallDisplay(summary="websearch")
if not isinstance(event.args, WebSearchArgs):
return ToolCallDisplay(summary="websearch")
return ToolCallDisplay(summary=f"Searching the web: '{event.args.query}'")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, WebSearchResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
return ToolResultDisplay(
success=True, message=f"{len(event.result.sources)} sources found"
)
@classmethod
def get_status_text(cls) -> str:
return "Searching the web"

View file

@ -16,7 +16,8 @@ from vibe.core.tools.base import (
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
class WriteFileArgs(BaseModel):
@ -40,12 +41,8 @@ class WriteFileConfig(BaseToolConfig):
create_parent_dirs: bool = True
class WriteFileState(BaseToolState):
recently_written_files: list[str] = Field(default_factory=list)
class WriteFile(
BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, WriteFileState],
BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, BaseToolState],
ToolUIData[WriteFileArgs, WriteFileResult],
):
description: ClassVar[str] = (
@ -53,12 +50,7 @@ class WriteFile(
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, WriteFileArgs):
return ToolCallDisplay(summary="Invalid arguments")
args = event.args
def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay:
return ToolCallDisplay(
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
content=args.content,
@ -78,23 +70,13 @@ class WriteFile(
def get_status_text(cls) -> str:
return "Writing file"
def check_allowlist_denylist(self, args: WriteFileArgs) -> ToolPermission | None:
import fnmatch
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
file_str = str(file_path)
for pattern in self.config.denylist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.NEVER
for pattern in self.config.allowlist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.ALWAYS
return None
def resolve_permission(self, args: WriteFileArgs) -> ToolPermission | None:
return resolve_file_tool_permission(
args.path,
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
)
@final
async def run(
@ -104,11 +86,6 @@ class WriteFile(
await self._write_file(args, file_path)
BUFFER_SIZE = 10
self.state.recently_written_files.append(str(file_path))
if len(self.state.recently_written_files) > BUFFER_SIZE:
self.state.recently_written_files.pop(0)
yield WriteFileResult(
path=str(file_path),
bytes_written=content_bytes,
@ -131,11 +108,6 @@ class WriteFile(
file_path = Path.cwd() / file_path
file_path = file_path.resolve()
try:
file_path.relative_to(Path.cwd().resolve())
except ValueError:
raise ToolError(f"Cannot write outside project directory: {file_path}")
file_existed = file_path.exists()
if file_existed and not args.overwrite:

View file

@ -4,28 +4,20 @@ from collections.abc import Callable, Iterator
import hashlib
import importlib.util
import inspect
from logging import getLogger
from pathlib import Path
import re
import sys
from typing import TYPE_CHECKING, Any
from vibe.core.paths.config_paths import resolve_local_tools_dir
from vibe.core.logger import logger
from vibe.core.paths.config_paths import discover_local_tools_dirs
from vibe.core.paths.global_paths import DEFAULT_TOOL_DIR, GLOBAL_TOOLS_DIR
from vibe.core.tools.base import BaseTool, BaseToolConfig
from vibe.core.tools.mcp import (
RemoteTool,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
list_tools_http,
list_tools_stdio,
)
from vibe.core.utils import name_matches, run_sync
logger = getLogger("vibe")
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
from vibe.core.config import VibeConfig
def _try_canonical_module_name(path: Path) -> str | None:
@ -73,8 +65,13 @@ class ToolManager:
should have its own ToolManager instance.
"""
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
def __init__(
self,
config_getter: Callable[[], VibeConfig],
mcp_registry: MCPRegistry | None = None,
) -> None:
self._config_getter = config_getter
self._mcp_registry = mcp_registry or MCPRegistry()
self._instances: dict[str, BaseTool] = {}
self._search_paths: list[Path] = self._compute_search_paths(self._config)
@ -92,10 +89,7 @@ class ToolManager:
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
paths.extend(config.tool_paths)
if (tools_dir := resolve_local_tools_dir(Path.cwd())) is not None:
paths.append(tools_dir)
paths.extend(discover_local_tools_dirs(Path.cwd()))
paths.append(GLOBAL_TOOLS_DIR.path)
unique: list[Path] = []
@ -180,122 +174,38 @@ class ToolManager:
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
runtime_available = {
name: cls for name, cls in self._available.items() if cls.is_available()
}
if self._config.enabled_tools:
return {
name: cls
for name, cls in self._available.items()
for name, cls in runtime_available.items()
if name_matches(name, self._config.enabled_tools)
}
if self._config.disabled_tools:
return {
name: cls
for name, cls in self._available.items()
for name, cls in runtime_available.items()
if not name_matches(name, self._config.disabled_tools)
}
return dict(self._available)
return runtime_available
def _integrate_mcp(self) -> None:
if not self._config.mcp_servers:
return
run_sync(self._integrate_mcp_async())
async def _integrate_mcp_async(self) -> None:
try:
http_count = 0
stdio_count = 0
for srv in self._config.mcp_servers:
match srv.transport:
case "http" | "streamable-http":
http_count += await self._register_http_server(srv)
case "stdio":
stdio_count += await self._register_stdio_server(srv)
case _:
logger.warning("Unsupported MCP transport: %r", srv.transport)
logger.info(
"MCP integration registered %d tools (http=%d, stdio=%d)",
http_count + stdio_count,
http_count,
stdio_count,
)
except Exception as exc:
logger.warning("Failed to integrate MCP tools: %s", exc)
async def _register_http_server(self, srv: MCPHttp | MCPStreamableHttp) -> int:
url = (srv.url or "").strip()
if not url:
logger.warning("MCP server '%s' missing url for http transport", srv.name)
return 0
headers = srv.http_headers()
try:
tools: list[RemoteTool] = await list_tools_http(
url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
)
except Exception as exc:
logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
return 0
added = 0
for remote in tools:
try:
proxy_cls = create_mcp_http_proxy_tool_class(
url=url,
remote=remote,
alias=srv.name,
server_hint=srv.prompt,
headers=headers,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
)
self._available[proxy_cls.get_name()] = proxy_cls
added += 1
except Exception as exc:
logger.warning(
"Failed to register MCP HTTP tool '%s' from %s: %r",
getattr(remote, "name", "<unknown>"),
url,
exc,
)
return added
async def _register_stdio_server(self, srv: MCPStdio) -> int:
cmd = srv.argv()
if not cmd:
logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
return 0
try:
tools: list[RemoteTool] = await list_tools_stdio(
cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
)
mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
except Exception as exc:
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
return 0
logger.warning("MCP integration failed: %s", exc)
return
added = 0
for remote in tools:
try:
proxy_cls = create_mcp_stdio_proxy_tool_class(
command=cmd,
remote=remote,
alias=srv.name,
server_hint=srv.prompt,
env=srv.env or None,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
)
self._available[proxy_cls.get_name()] = proxy_cls
added += 1
except Exception as exc:
logger.warning(
"Failed to register MCP stdio tool '%s' from %r: %r",
getattr(remote, "name", "<unknown>"),
cmd,
exc,
)
return added
self._available.update(mcp_tools)
logger.info(
"MCP integration registered %d tools (via registry)", len(mcp_tools)
)
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
tool_class = self._available.get(tool_name)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from vibe.core.tools.mcp.registry import MCPRegistry
from vibe.core.tools.mcp.tools import (
MCPToolResult,
RemoteTool,
_mcp_stderr_capture,
_parse_call_result,
_stderr_logger_thread,
call_tool_http,
call_tool_stdio,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
list_tools_http,
list_tools_stdio,
)
__all__ = [
"MCPRegistry",
"MCPToolResult",
"RemoteTool",
"_mcp_stderr_capture",
"_parse_call_result",
"_stderr_logger_thread",
"call_tool_http",
"call_tool_stdio",
"create_mcp_http_proxy_tool_class",
"create_mcp_stdio_proxy_tool_class",
"list_tools_http",
"list_tools_stdio",
]

View file

@ -0,0 +1,167 @@
from __future__ import annotations
import asyncio
import hashlib
from typing import TYPE_CHECKING, cast
from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool
from vibe.core.tools.mcp.tools import (
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
list_tools_http,
list_tools_stdio,
)
from vibe.core.utils import run_sync
if TYPE_CHECKING:
from vibe.core.config import MCPHttp, MCPServer, MCPStdio, MCPStreamableHttp
class MCPRegistry:
"""Shared cache for MCP server tool discovery.
Survives agent switches so that shift-tab does not re-discover
servers whose config has not changed. The cache is keyed by a
stable fingerprint derived from each server's full config.
"""
def __init__(self) -> None:
self._cache: dict[str, dict[str, type[BaseTool]]] = {}
@staticmethod
def _server_key(srv: MCPServer) -> str:
raw = srv.model_dump_json(exclude_none=False)
return hashlib.sha256(raw.encode()).hexdigest()
def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]:
"""Return proxy tool classes for *servers*, using cache when possible."""
result: dict[str, type[BaseTool]] = {}
to_discover: list[tuple[str, MCPServer]] = []
for srv in servers:
key = self._server_key(srv)
if key in self._cache:
result.update(self._cache[key])
else:
to_discover.append((key, srv))
if to_discover:
discovered = run_sync(self._discover_all(to_discover))
result.update(discovered)
return result
async def _discover_all(
self, servers: list[tuple[str, MCPServer]]
) -> dict[str, type[BaseTool]]:
results = await asyncio.gather(
*(self._discover_server(srv) for _, srv in servers), return_exceptions=True
)
out: dict[str, type[BaseTool]] = {}
for (key, srv), result in zip(servers, results, strict=True):
if isinstance(result, BaseException):
logger.warning(
"MCP discovery failed for server %r: %s", srv.name, result
)
continue
if result is None:
continue
self._cache[key] = result
out.update(result)
return out
async def _discover_server(
self, srv: MCPServer
) -> dict[str, type[BaseTool]] | None:
match srv.transport:
case "http" | "streamable-http":
return await self._discover_http(
cast("MCPHttp | MCPStreamableHttp", srv)
)
case "stdio":
return await self._discover_stdio(cast("MCPStdio", srv))
case _:
logger.warning("Unsupported MCP transport: %r", srv.transport)
return {}
async def _discover_http(
self, srv: MCPHttp | MCPStreamableHttp
) -> dict[str, type[BaseTool]] | None:
url = (srv.url or "").strip()
if not url:
logger.warning("MCP server '%s' missing url for http transport", srv.name)
return {}
headers = srv.http_headers()
try:
remotes = await list_tools_http(
url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
)
except Exception as exc:
logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
return None
tools: dict[str, type[BaseTool]] = {}
for remote in remotes:
try:
proxy_cls = create_mcp_http_proxy_tool_class(
url=url,
remote=remote,
alias=srv.name,
server_hint=srv.prompt,
headers=headers,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
sampling_enabled=srv.sampling_enabled,
)
tools[proxy_cls.get_name()] = proxy_cls
except Exception as exc:
logger.warning(
"Failed to register MCP HTTP tool '%s' from %s: %r",
getattr(remote, "name", "<unknown>"),
url,
exc,
)
return tools
async def _discover_stdio(self, srv: MCPStdio) -> dict[str, type[BaseTool]] | None:
cmd = srv.argv()
if not cmd:
logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
return {}
try:
remotes = await list_tools_stdio(
cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
)
except Exception as exc:
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
return None
tools: dict[str, type[BaseTool]] = {}
for remote in remotes:
try:
proxy_cls = create_mcp_stdio_proxy_tool_class(
command=cmd,
remote=remote,
alias=srv.name,
server_hint=srv.prompt,
env=srv.env or None,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
sampling_enabled=srv.sampling_enabled,
)
tools[proxy_cls.get_name()] = proxy_cls
except Exception as exc:
logger.warning(
"Failed to register MCP stdio tool '%s' from %r: %r",
getattr(remote, "name", "<unknown>"),
cmd,
exc,
)
return tools
def clear(self) -> None:
"""Drop all cached entries, forcing re-discovery on next use."""
self._cache.clear()

View file

@ -4,17 +4,17 @@ from collections.abc import AsyncGenerator
import contextlib
from datetime import timedelta
import hashlib
from logging import getLogger
import os
from pathlib import Path
import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
from pydantic import BaseModel, ConfigDict, Field, field_validator
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, ConfigDict, Field, field_validator
from vibe.core.logger import logger
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -22,13 +22,12 @@ from vibe.core.tools.base import (
InvokeContext,
ToolError,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
logger = getLogger("vibe")
from vibe.core.types import ToolResultEvent
def _stderr_logger_thread(read_fd: int) -> None:
@ -169,6 +168,7 @@ async def call_tool_http(
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
@ -176,7 +176,10 @@ async def call_tool_http(
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(
read, write, read_timeout_seconds=init_timeout
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
) as session:
await session.initialize()
result = await session.call_tool(
@ -194,6 +197,7 @@ def create_mcp_http_proxy_tool_class(
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_enabled: bool = True,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
from urllib.parse import urlparse
@ -206,7 +210,8 @@ def create_mcp_http_proxy_tool_class(
published_name = f"{(alias or _alias_from_url(url))}_{remote.name}"
class MCPHttpProxyTool(
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
ToolUIData[_OpenArgs, MCPToolResult],
):
description: ClassVar[str] = (
(f"[{alias}] " if alias else "")
@ -219,6 +224,7 @@ def create_mcp_http_proxy_tool_class(
_headers: ClassVar[dict[str, str]] = dict(headers or {})
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
_sampling_enabled: ClassVar[bool] = sampling_enabled
@classmethod
def get_name(cls) -> str:
@ -232,6 +238,9 @@ def create_mcp_http_proxy_tool_class(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
sampling_callback = (
ctx.sampling_callback if ctx and self._sampling_enabled else None
)
payload = args.model_dump(exclude_none=True)
yield await call_tool_http(
self._mcp_url,
@ -240,14 +249,11 @@ def create_mcp_http_proxy_tool_class(
headers=self._headers,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
except Exception as exc:
raise ToolError(f"MCP call failed: {exc}") from exc
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
return ToolCallDisplay(summary=f"{published_name}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
@ -293,6 +299,7 @@ async def call_tool_stdio(
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
init_timeout = (
@ -302,7 +309,12 @@ async def call_tool_stdio(
async with (
_mcp_stderr_capture() as errlog,
stdio_client(params, errlog=errlog) as (read, write),
ClientSession(read, write, read_timeout_seconds=init_timeout) as session,
ClientSession(
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
) as session,
):
await session.initialize()
result = await session.call_tool(
@ -320,6 +332,7 @@ def create_mcp_stdio_proxy_tool_class(
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_enabled: bool = True,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
def _alias_from_command(cmd: list[str]) -> str:
prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
@ -332,7 +345,8 @@ def create_mcp_stdio_proxy_tool_class(
published_name = f"{computed_alias}_{remote.name}"
class MCPStdioProxyTool(
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
ToolUIData[_OpenArgs, MCPToolResult],
):
description: ClassVar[str] = (
(f"[{computed_alias}] " if computed_alias else "")
@ -348,6 +362,7 @@ def create_mcp_stdio_proxy_tool_class(
_env: ClassVar[dict[str, str] | None] = env
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
_sampling_enabled: ClassVar[bool] = sampling_enabled
@classmethod
def get_name(cls) -> str:
@ -361,6 +376,9 @@ def create_mcp_stdio_proxy_tool_class(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
sampling_callback = (
ctx.sampling_callback if ctx and self._sampling_enabled else None
)
payload = args.model_dump(exclude_none=True)
result = await call_tool_stdio(
self._stdio_command,
@ -369,15 +387,12 @@ def create_mcp_stdio_proxy_tool_class(
env=self._env,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
yield result
except Exception as exc:
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
return ToolCallDisplay(summary=f"{published_name}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):

View file

@ -0,0 +1,115 @@
from __future__ import annotations
from collections.abc import Callable
from logging import getLogger
from typing import Any
from mcp.client.session import ClientSession
from mcp.shared.context import RequestContext
from mcp.types import (
CreateMessageRequestParams,
CreateMessageResult,
ErrorData,
TextContent,
)
from vibe.core.llm.types import BackendLike
from vibe.core.types import LLMMessage, Role
logger = getLogger("vibe")
class MCPSamplingHandler:
def __init__(
self,
backend_getter: Callable[[], BackendLike],
config_getter: Callable[[], Any],
) -> None:
self._backend_getter = backend_getter
self._config_getter = config_getter
async def __call__(
self,
context: RequestContext[ClientSession, Any],
params: CreateMessageRequestParams,
) -> CreateMessageResult | ErrorData:
try:
config = self._config_getter()
model = config.get_active_model()
messages = _map_sampling_messages(params.messages)
if params.systemPrompt:
messages.insert(
0, LLMMessage(role=Role.system, content=params.systemPrompt)
)
result = await self._backend_getter().complete(
model=model,
messages=messages,
temperature=params.temperature
if params.temperature is not None
else model.temperature,
tools=None,
max_tokens=params.maxTokens,
tool_choice=None,
extra_headers=None,
)
content_text = result.message.content or ""
return CreateMessageResult(
role="assistant",
content=TextContent(type="text", text=content_text),
model=model.name,
stopReason="endTurn",
)
except Exception as exc:
logger.warning("MCP sampling request failed: %s", exc)
return ErrorData(code=-1, message=f"Sampling failed: {exc}")
def _map_sampling_messages(messages: list[Any]) -> list[LLMMessage]:
result: list[LLMMessage] = []
for msg in messages:
match msg.role:
case "user":
role = Role.user
case "assistant":
role = Role.assistant
case _:
logger.error(
"MCP sampling: unexpected message role, treating as assistant",
extra={"role": getattr(msg, "role", None)},
)
role = Role.assistant
content = _extract_text_content(msg.content)
result.append(LLMMessage(role=role, content=content))
return result
def _extract_text_content(content: Any) -> str:
if isinstance(content, list):
parts: list[str] = []
for block in content:
if (
hasattr(block, "type")
and block.type == "text"
and hasattr(block, "text")
):
parts.append(block.text)
else:
block_type = getattr(block, "type", "unknown")
logger.debug(
"MCP sampling: skipping unsupported content block type=%s",
block_type,
)
return "\n".join(parts) if parts else ""
if hasattr(content, "type") and content.type == "text" and hasattr(content, "text"):
return content.text
block_type = getattr(content, "type", "unknown")
logger.debug("MCP sampling: unsupported single content block type=%s", block_type)
return ""

View file

@ -1,6 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel, Field
@ -19,15 +21,46 @@ class ToolResultDisplay(BaseModel):
warnings: list[str] = Field(default_factory=list)
@runtime_checkable
class ToolUIData[TArgs: BaseModel, TResult: BaseModel](Protocol):
class ToolUIData[TArgs: BaseModel, TResult: BaseModel](ABC):
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: ...
def _display_name(cls) -> str:
get_name = cast(Callable[[], str] | None, getattr(cls, "get_name", None))
return get_name() if get_name is not None else cls.__name__.lower()
@classmethod
def get_no_args_display(cls) -> ToolCallDisplay:
return ToolCallDisplay(summary=cls._display_name())
@classmethod
def get_invalid_args_display(cls) -> ToolCallDisplay:
return ToolCallDisplay(summary="Invalid Arguments")
@classmethod
def format_call_display(cls, args: TArgs) -> ToolCallDisplay:
return ToolCallDisplay(summary=cls._display_name())
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if event.args is None:
return cls.get_no_args_display()
introspect = cast(
Callable[[], tuple[type, ...]] | None,
getattr(cls, "_get_tool_args_results", None),
)
if introspect is not None:
expected_type = introspect()[0]
if not isinstance(event.args, expected_type):
return cls.get_invalid_args_display()
return cls.format_call_display(cast(TArgs, event.args))
@classmethod
@abstractmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: ...
@classmethod
@abstractmethod
def get_status_text(cls) -> str: ...
@ -42,7 +75,11 @@ class ToolUIDataAdapter:
if self.ui_data_class:
return self.ui_data_class.get_call_display(event)
args_dict = event.args.model_dump() if hasattr(event.args, "model_dump") else {}
args_dict = (
event.args.model_dump()
if event.args and hasattr(event.args, "model_dump")
else {}
)
args_str = ", ".join(f"{k}={v!r}" for k, v in list(args_dict.items())[:3])
return ToolCallDisplay(summary=f"{event.tool_name}({args_str})")

69
vibe/core/tools/utils.py Normal file
View file

@ -0,0 +1,69 @@
from __future__ import annotations
import fnmatch
from pathlib import Path
from vibe.core.tools.base import ToolPermission
def resolve_path_permission(
path_str: str, *, allowlist: list[str], denylist: list[str]
) -> ToolPermission | None:
"""Resolve permission for a file path against glob patterns.
Returns NEVER on denylist match, ALWAYS on allowlist match, None otherwise.
"""
file_path = Path(path_str).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
file_str = str(file_path.resolve())
for pattern in denylist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.NEVER
for pattern in allowlist:
if fnmatch.fnmatch(file_str, pattern):
return ToolPermission.ALWAYS
return None
def is_path_within_workdir(path_str: str) -> bool:
"""Return True if the resolved path is inside cwd."""
file_path = Path(path_str).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
try:
file_path.resolve().relative_to(Path.cwd().resolve())
return True
except ValueError:
return False
def resolve_file_tool_permission(
path_str: str,
*,
allowlist: list[str],
denylist: list[str],
config_permission: ToolPermission,
) -> ToolPermission | None:
"""Resolve permission for a file-based tool invocation.
Checks allowlist/denylist first, then escalates to ASK for paths outside
the working directory (unless the tool is configured as NEVER).
Returns None to fall back to the tool's default config permission.
"""
if (
result := resolve_path_permission(
path_str, allowlist=allowlist, denylist=denylist
)
) is not None:
return result
if not is_path_within_workdir(path_str):
if config_permission == ToolPermission.NEVER:
return ToolPermission.NEVER
return ToolPermission.ASK
return None

View file

@ -6,6 +6,7 @@ import tomllib
import tomli_w
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
from vibe.core.paths.local_config_walk import walk_local_config_dirs_all
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
@ -15,11 +16,10 @@ def has_agents_md_file(path: Path) -> bool:
def has_trustable_content(path: Path) -> bool:
return (
(path / ".vibe").exists()
or (path / ".agents").exists()
or has_agents_md_file(path)
)
if (path / ".vibe").exists():
return True
tools_dirs, skills_dirs, agents_dirs = walk_local_config_dirs_all(path)
return bool(tools_dirs or skills_dirs or agents_dirs) or has_agents_md_file(path)
class TrustedFoldersManager:

View file

@ -2,10 +2,11 @@ from __future__ import annotations
from abc import ABC
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterator, Sequence
from contextlib import contextmanager
import copy
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Annotated, Any, Literal
from typing import TYPE_CHECKING, Annotated, Any, Literal, overload
from uuid import uuid4
if TYPE_CHECKING:
@ -136,6 +137,18 @@ class SessionMetadata(BaseModel):
username: str
class ClientMetadata(BaseModel):
name: str
version: str
class EntrypointMetadata(BaseModel):
agent_entrypoint: Literal["cli", "acp", "programmatic"]
agent_version: str
client_name: str
client_version: str
StrToolChoice = Literal["auto", "none", "any", "required"]
@ -159,7 +172,7 @@ class ToolCall(BaseModel):
id: str | None = None
index: int | None = None
function: FunctionCall = Field(default_factory=FunctionCall)
type: str = "function"
type: Literal["function"] = "function"
def _content_before(v: Any) -> str:
@ -339,10 +352,11 @@ class ReasoningEvent(BaseEvent):
class ToolCallEvent(BaseEvent):
tool_call_id: str
tool_name: str
tool_class: type[BaseTool]
args: BaseModel
tool_call_id: str
tool_call_index: int | None = None
args: BaseModel | None = None
class ToolResultEvent(BaseEvent):
@ -402,6 +416,68 @@ type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
class MessageList(Sequence[LLMMessage]):
def __init__(
self,
initial: list[LLMMessage] | None = None,
observer: Callable[[LLMMessage], None] | None = None,
) -> None:
self._data: list[LLMMessage] = list(initial) if initial else []
self._observer = observer
self._silent = False
if self._observer:
for msg in self._data:
self._observer(msg)
def _notify(self, msg: LLMMessage) -> None:
if not self._silent and self._observer is not None:
self._observer(msg)
def append(self, msg: LLMMessage) -> None:
self._data.append(msg)
self._notify(msg)
def insert(self, i: int, msg: LLMMessage) -> None:
self._data.insert(i, msg)
def extend(self, msgs: list[LLMMessage]) -> None:
for msg in msgs:
self.append(msg)
def reset(self, new: list[LLMMessage]) -> None:
"""Replace contents silently (never notifies)."""
self._data = list(new)
@contextmanager
def silent(self) -> Iterator[None]:
"""Context manager that suppresses notifications."""
prev = self._silent
self._silent = True
try:
yield
finally:
self._silent = prev
def __len__(self) -> int:
return len(self._data)
@overload
def __getitem__(self, index: int) -> LLMMessage: ...
@overload
def __getitem__(self, index: slice) -> list[LLMMessage]: ...
def __getitem__(self, index: int | slice) -> LLMMessage | list[LLMMessage]:
return self._data[index]
def __iter__(self) -> Iterator[LLMMessage]:
return iter(self._data)
def __contains__(self, item: object) -> bool:
return item in self._data
def __bool__(self) -> bool:
return bool(self._data)
class RateLimitError(Exception):
def __init__(self, provider: str, model: str) -> None:
self.provider = provider

View file

@ -7,9 +7,6 @@ from datetime import UTC, datetime
from enum import Enum, auto
from fnmatch import fnmatch
import functools
import logging
from logging.handlers import RotatingFileHandler
import os
from pathlib import Path
import re
import sys
@ -19,7 +16,6 @@ import httpx
from vibe import __version__
from vibe.core.config import Backend
from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
from vibe.core.types import BaseEvent, ToolResultEvent
CANCELLATION_TAG = "user_cancellation"
@ -139,57 +135,6 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
return False, ""
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("vibe")
class StructuredLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
ppid = os.getppid()
pid = os.getpid()
level = record.levelname
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
line = f"{timestamp} {ppid} {pid} {level} {message}"
if record.exc_info:
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
line = f"{line} {exc_text}"
return line
def apply_logging_config(target_logger: logging.Logger) -> None:
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
if os.environ.get("DEBUG_MODE") == "true":
log_level_str = "DEBUG"
else:
log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if log_level_str not in valid_levels:
log_level_str = "WARNING"
handler = RotatingFileHandler(
LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
)
handler.setFormatter(StructuredLogFormatter())
log_level = getattr(logging, log_level_str, logging.WARNING)
handler.setLevel(log_level)
# Make sure the logger is not gating logs
target_logger.setLevel(logging.DEBUG)
target_logger.addHandler(handler)
apply_logging_config(logger)
def get_user_agent(backend: Backend | None) -> str:
user_agent = f"Mistral-Vibe/{__version__}"
if backend == Backend.MISTRAL:

View file

@ -1,4 +1,6 @@
# What's new
- **Performance**: general improvements (streaming, scrolling).
- **Fix**: autocopying now behaves correctly in cases where it previously failed.
- **Interactive resume**: Added a /resume command to choose which session to resume
- **Web Search & Web Fetch**: New tools to search the web and fetch content from URLs directly from your session.
- **MCP Sampling**: MCP servers can now request LLM completions through the sampling protocol.
- **Notification Indicator**: Terminal bell and window title update when Vibe needs your attention or completes a task.