Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-02-17 16:23:28 +01:00 committed by GitHub
parent 51fecc67d9
commit ec7f3b25ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 8002 additions and 535 deletions

View file

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

View file

@ -20,7 +20,7 @@ from acp import (
SetSessionModeResponse,
run_agent,
)
from acp.helpers import ContentBlock, SessionUpdate
from acp.helpers import ContentBlock, SessionUpdate, update_available_commands
from acp.schema import (
AgentCapabilities,
AgentMessageChunk,
@ -28,6 +28,8 @@ from acp.schema import (
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
AvailableCommand,
AvailableCommandInput,
ClientCapabilities,
ContentToolCallContent,
ForkSessionResponse,
@ -38,6 +40,9 @@ from acp.schema import (
ModelInfo,
PromptCapabilities,
ResumeSessionResponse,
SessionCapabilities,
SessionInfo,
SessionListCapabilities,
SessionModelState,
SessionModeState,
SseMcpServer,
@ -45,6 +50,7 @@ from acp.schema import (
TextResourceContents,
ToolCallProgress,
ToolCallUpdate,
UnstructuredCommandInput,
UserMessageChunk,
)
from pydantic import BaseModel, ConfigDict
@ -58,15 +64,33 @@ from vibe.acp.tools.session_update import (
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
create_assistant_message_replay,
create_compact_end_session_update,
create_compact_start_session_update,
create_reasoning_replay,
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,
)
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
from vibe.core.config import (
MissingAPIKeyError,
SessionLoggingConfig,
VibeConfig,
load_dotenv_values,
)
from vibe.core.proxy_setup import (
ProxySetupError,
parse_proxy_command,
set_proxy_var,
unset_proxy_var,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalResponse,
@ -74,7 +98,9 @@ from vibe.core.types import (
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
LLMMessage,
ReasoningEvent,
Role,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@ -150,10 +176,13 @@ class VibeAcpAgentLoop(AcpAgent):
response = InitializeResponse(
agent_capabilities=AgentCapabilities(
load_session=False,
load_session=True,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
session_capabilities=SessionCapabilities(
list=SessionListCapabilities()
),
),
protocol_version=PROTOCOL_VERSION,
agent_info=Implementation(
@ -171,6 +200,44 @@ class VibeAcpAgentLoop(AcpAgent):
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a session"
}) from e
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
) -> AcpSessionLoop:
session = AcpSessionLoop(id=session_id, agent_loop=agent_loop)
self.sessions[session.id] = session
if not agent_loop.auto_approve:
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
asyncio.create_task(self._send_available_commands(session.id))
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,
@ -181,13 +248,7 @@ class VibeAcpAgentLoop(AcpAgent):
load_dotenv_values()
os.chdir(cwd)
try:
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a new session"
}) from e
config = self._load_config()
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
@ -196,29 +257,14 @@ class VibeAcpAgentLoop(AcpAgent):
# 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 = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
self.sessions[session.id] = session
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
agent_loop.emit_new_session_telemetry("acp")
if not agent_loop.auto_approve:
agent_loop.set_approval_callback(
self._create_approval_callback(agent_loop.session_id)
)
response = NewSessionResponse(
session_id=agent_loop.session_id,
models=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
],
),
modes=SessionModeState(
current_mode_id=session.agent_loop.agent_profile.name,
available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
),
return NewSessionResponse(
session_id=session.id,
models=self._build_session_model_state(agent_loop),
modes=self._build_session_mode_state(session),
)
return response
def _get_acp_tool_overrides(self) -> list[Path]:
overrides = ["todo"]
@ -293,6 +339,85 @@ class VibeAcpAgentLoop(AcpAgent):
raise RequestError.invalid_params({"session": "Not found"})
return self.sessions[session_id]
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
if not msg.tool_calls:
return
for tool_call in msg.tool_calls:
if tool_call.id and tool_call.function.name:
update = create_tool_call_replay(
tool_call.id, tool_call.function.name, tool_call.function.arguments
)
await self.client.session_update(session_id=session_id, update=update)
async def _replay_conversation_history(
self, session_id: str, messages: list[LLMMessage]
) -> None:
for msg in messages:
if msg.role == Role.user:
update = create_user_message_replay(msg)
await self.client.session_update(session_id=session_id, update=update)
elif msg.role == Role.assistant:
if text_update := create_assistant_message_replay(msg):
await self.client.session_update(
session_id=session_id, update=text_update
)
if reasoning_update := create_reasoning_replay(msg):
await self.client.session_update(
session_id=session_id, update=reasoning_update
)
await self._replay_tool_calls(session_id, msg)
elif msg.role == Role.tool:
if result_update := create_tool_result_replay(msg):
await self.client.session_update(
session_id=session_id, update=result_update
)
async def _send_available_commands(self, session_id: str) -> None:
commands = [
AvailableCommand(
name="proxy-setup",
description="Configure proxy and SSL certificate settings",
input=AvailableCommandInput(
root=UnstructuredCommandInput(
hint="KEY value to set, KEY to unset, or empty for help"
)
),
)
]
update = update_available_commands(commands)
await self.client.session_update(session_id=session_id, update=update)
async def _handle_proxy_setup_command(
self, session_id: str, text_prompt: str
) -> PromptResponse:
args = text_prompt.strip()[len("/proxy-setup") :].strip()
try:
if not args:
message = get_proxy_help_text()
else:
key, value = parse_proxy_command(args)
if value is not None:
set_proxy_var(key, value)
message = f"Set `{key}={value}` in ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
else:
unset_proxy_var(key)
message = f"Removed `{key}` from ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
except ProxySetupError as e:
message = f"Error: {e}"
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
),
)
return PromptResponse(stop_reason="end_turn")
@override
async def load_session(
self,
@ -301,7 +426,44 @@ class VibeAcpAgentLoop(AcpAgent):
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> LoadSessionResponse | None:
raise NotImplementedError()
load_dotenv_values()
os.chdir(cwd)
config = self._load_config()
session_dir = SessionLoader.find_session_by_id(
session_id, config.session_logging
)
if session_dir is None:
raise RequestError.invalid_params({
"session_id": f"Session not found: {session_id}"
})
try:
loaded_messages, _ = SessionLoader.load_session(session_dir)
except ValueError as e:
raise RequestError.invalid_params({
"session_id": f"Failed to load session: {e}"
}) from e
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
)
non_system_messages = [
msg for msg in loaded_messages if msg.role != Role.system
]
agent_loop.messages.extend(non_system_messages)
session = await self._create_acp_session(session_id, agent_loop)
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),
)
@override
async def set_session_mode(
@ -348,7 +510,27 @@ class VibeAcpAgentLoop(AcpAgent):
async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
) -> ListSessionsResponse:
raise NotImplementedError()
try:
config = VibeConfig.load()
session_logging_config = config.session_logging
except MissingAPIKeyError:
session_logging_config = SessionLoggingConfig()
session_data = SessionLoader.list_sessions(session_logging_config, cwd=cwd)
sessions = [
SessionInfo(
session_id=s["session_id"],
cwd=s["cwd"],
title=s.get("title"),
updated_at=s.get("end_time"),
)
for s in sorted(
session_data, key=lambda s: s.get("end_time") or "", reverse=True
)
]
return ListSessionsResponse(sessions=sessions)
@override
async def prompt(
@ -363,6 +545,9 @@ class VibeAcpAgentLoop(AcpAgent):
text_prompt = self._build_text_prompt(prompt)
if text_prompt.strip().lower().startswith("/proxy-setup"):
return await self._handle_proxy_setup_command(session_id, text_prompt)
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None:

View file

@ -4,16 +4,20 @@ from enum import StrEnum
from typing import TYPE_CHECKING, Literal, cast
from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
ContentToolCallContent,
PermissionOption,
SessionMode,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
UserMessageChunk,
)
from vibe.core.agents.models import AgentProfile, AgentType
from vibe.core.types import CompactEndEvent, CompactStartEvent
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
if TYPE_CHECKING:
@ -111,3 +115,99 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
)
],
)
def get_proxy_help_text() -> str:
lines = [
"## Proxy Configuration",
"",
"Configure proxy and SSL settings for HTTP requests.",
"",
"### Usage:",
"- `/proxy-setup` - Show this help and current settings",
"- `/proxy-setup KEY value` - Set an environment variable",
"- `/proxy-setup KEY` - Remove an environment variable",
"",
"### Supported Variables:",
]
for key, description in SUPPORTED_PROXY_VARS.items():
lines.append(f"- `{key}`: {description}")
lines.extend(["", "### Current Settings:"])
current = get_current_proxy_settings()
any_set = False
for key, value in current.items():
if value:
lines.append(f"- `{key}={value}`")
any_set = True
if not any_set:
lines.append("- (none configured)")
return "\n".join(lines)
def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
content = msg.content if isinstance(msg.content, str) else ""
return UserMessageChunk(
session_update="user_message_chunk",
content=TextContentBlock(type="text", text=content),
field_meta={"messageId": msg.message_id} if msg.message_id else {},
)
def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None:
content = msg.content if isinstance(msg.content, str) else ""
if not content:
return None
return AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=content),
field_meta={"messageId": msg.message_id} if msg.message_id else {},
)
def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None:
if not isinstance(msg.reasoning_content, str) or not msg.reasoning_content:
return None
return AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=msg.reasoning_content),
field_meta={"messageId": msg.message_id} if msg.message_id else {},
)
def create_tool_call_replay(
tool_call_id: str, tool_name: str, arguments: str | None
) -> ToolCallStart:
return ToolCallStart(
session_update="tool_call",
title=tool_name,
tool_call_id=tool_call_id,
kind="other",
raw_input=arguments,
)
def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None:
if not msg.tool_call_id:
return None
content = msg.content if isinstance(msg.content, str) else ""
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=msg.tool_call_id,
status="completed",
raw_output=content,
content=[
ContentToolCallContent(
type="content", content=TextContentBlock(type="text", text=content)
)
]
if content
else None,
)

View file

@ -26,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str:
return dense_text
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
selected_texts = []
for widget in app.query("*"):
@ -48,7 +48,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
selected_texts.append(selected_text)
if not selected_texts:
return
return None
combined_text = "\n".join(selected_texts)
@ -61,7 +61,9 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
timeout=2,
markup=False,
)
return combined_text
except Exception:
app.notify(
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
return None

View file

@ -67,6 +67,11 @@ class CommandRegistry:
description="Teleport session to Vibe Nuage",
handler="_teleport_command",
),
"proxy-setup": Command(
aliases=frozenset(["/proxy-setup"]),
description="Configure proxy and SSL certificate settings",
handler="_show_proxy_setup",
),
}
for command in excluded_commands:
@ -78,9 +83,12 @@ class CommandRegistry:
self._alias_map[alias] = cmd_name
def find_command(self, user_input: str) -> Command | None:
cmd_name = self._alias_map.get(user_input.lower().strip())
cmd_name = self.get_command_name(user_input)
return self.commands.get(cmd_name) if cmd_name else None
def get_command_name(self, user_input: str) -> str | None:
return self._alias_map.get(user_input.lower().strip())
def get_help_text(self) -> str:
lines: list[str] = [
"### Keyboard Shortcuts",

View file

@ -51,6 +51,7 @@ from vibe.cli.textual_ui.widgets.messages import (
)
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.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
@ -127,6 +128,7 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
ProxySetup = auto()
Question = auto()
@ -308,6 +310,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")
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
@ -422,6 +425,24 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
if message.error:
await self._mount_and_scroll(
ErrorMessage(f"Failed to save proxy settings: {message.error}")
)
elif message.saved:
await self._mount_and_scroll(
UserCommandMessage(
"Proxy settings saved. Restart the CLI for changes to take effect."
)
)
else:
await self._mount_and_scroll(UserCommandMessage("Proxy setup cancelled."))
await self._switch_to_input_app()
async def on_compact_message_completed(
self, message: CompactMessage.Completed
) -> None:
@ -449,6 +470,10 @@ class VibeApp(App): # noqa: PLR0904
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
if cmd_name := self.commands.get_command_name(user_input):
self.agent_loop.telemetry_client.send_slash_command_used(
cmd_name, "builtin"
)
await self._mount_and_scroll(UserMessage(user_input))
handler = getattr(self, command.handler)
if asyncio.iscoroutinefunction(handler):
@ -479,6 +504,8 @@ class VibeApp(App): # noqa: PLR0904
if not skill_info:
return False
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
except OSError as e:
@ -823,6 +850,11 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_config_app()
async def _show_proxy_setup(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
await self._switch_to_proxy_setup_app()
async def _reload_config(self) -> None:
try:
self._windowing.reset()
@ -996,6 +1028,13 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
await self._switch_from_input(ConfigApp(self.config))
async def _switch_to_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
await self._mount_and_scroll(UserCommandMessage("Proxy setup opened..."))
await self._switch_from_input(ProxySetupApp())
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
) -> None:
@ -1020,6 +1059,7 @@ 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_to_bottom)
def _focus_current_bottom_app(self) -> None:
try:
@ -1028,6 +1068,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ChatInputContainer).focus_input()
case BottomApp.Config:
self.query_one(ConfigApp).focus()
case BottomApp.ProxySetup:
self.query_one(ProxySetupApp).focus()
case BottomApp.Approval:
self.query_one(ApprovalApp).focus()
case BottomApp.Question:
@ -1037,34 +1079,66 @@ class VibeApp(App): # noqa: PLR0904
except Exception:
pass
def _handle_config_app_escape(self) -> None:
try:
config_app = self.query_one(ConfigApp)
config_app.action_close()
except Exception:
pass
self._last_escape_time = None
def _handle_approval_app_escape(self) -> None:
try:
approval_app = self.query_one(ApprovalApp)
approval_app.action_reject()
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
self._last_escape_time = None
def _handle_question_app_escape(self) -> None:
try:
question_app = self.query_one(QuestionApp)
question_app.action_cancel()
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_input_app_escape(self) -> None:
try:
input_widget = self.query_one(ChatInputContainer)
input_widget.value = ""
except Exception:
pass
self._last_escape_time = None
def _handle_agent_running_escape(self) -> None:
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def action_interrupt(self) -> None:
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config:
self._handle_config_app_escape()
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
config_app = self.query_one(ConfigApp)
config_app.action_close()
proxy_setup_app = self.query_one(ProxySetupApp)
proxy_setup_app.action_close()
except Exception:
pass
self._last_escape_time = None
return
if self._current_bottom_app == BottomApp.Approval:
try:
approval_app = self.query_one(ApprovalApp)
approval_app.action_reject()
except Exception:
pass
self._last_escape_time = None
self._handle_approval_app_escape()
return
if self._current_bottom_app == BottomApp.Question:
try:
question_app = self.query_one(QuestionApp)
question_app.action_cancel()
except Exception:
pass
self._last_escape_time = None
self._handle_question_app_escape()
return
if (
@ -1072,17 +1146,11 @@ class VibeApp(App): # noqa: PLR0904
and self._last_escape_time is not None
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
):
try:
input_widget = self.query_one(ChatInputContainer)
if input_widget.value:
input_widget.value = ""
self._last_escape_time = None
return
except Exception:
pass
self._handle_input_app_escape()
return
if self._agent_running:
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
self._handle_agent_running_escape()
self._last_escape_time = current_time
self._scroll_to_bottom()
@ -1430,11 +1498,15 @@ class VibeApp(App): # noqa: PLR0904
)
def action_copy_selection(self) -> None:
copy_selection_to_clipboard(self, show_toast=False)
copied_text = copy_selection_to_clipboard(self, show_toast=False)
if copied_text is not None:
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_mouse_up(self, event: MouseUp) -> None:
if self.config.autocopy_to_clipboard:
copy_selection_to_clipboard(self, show_toast=True)
copied_text = copy_selection_to_clipboard(self, show_toast=True)
if copied_text is not None:
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
def on_app_blur(self, event: AppBlur) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:

View file

@ -703,6 +703,44 @@ StatusMessage {
color: ansi_bright_black;
}
#proxysetup-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#proxysetup-content {
width: 100%;
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;
}
.proxy-input {
width: 100%;
height: auto;
border: none;
border-left: wide ansi_bright_black;
margin-top: 1;
padding: 0 0 0 1;
}
#approval-app {
width: 100%;
height: auto;

View file

@ -0,0 +1,127 @@
from __future__ import annotations
from typing import ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import Input, Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.proxy_setup import (
SUPPORTED_PROXY_VARS,
get_current_proxy_settings,
set_proxy_var,
unset_proxy_var,
)
class ProxySetupApp(Container):
can_focus = True
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "focus_previous", "Up", show=False),
Binding("down", "focus_next", "Down", show=False),
]
class ProxySetupClosed(Message):
def __init__(self, saved: bool, error: str | None = None) -> None:
super().__init__()
self.saved = saved
self.error = error
def __init__(self) -> None:
super().__init__(id="proxysetup-app")
self.inputs: dict[str, Input] = {}
self.initial_values: dict[str, str | None] = {}
def compose(self) -> ComposeResult:
self.initial_values = get_current_proxy_settings()
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",
)
initial_value = self.initial_values.get(key) or ""
input_widget = Input(
value=initial_value,
placeholder="NOT SET",
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"
)
def focus(self, scroll_visible: bool = True) -> ProxySetupApp:
"""Override focus to focus the first input widget."""
if self.inputs:
first_input = list(self.inputs.values())[0]
first_input.focus(scroll_visible=scroll_visible)
else:
super().focus(scroll_visible=scroll_visible)
return self
def action_focus_next(self) -> None:
inputs = list(self.inputs.values())
focused = self.screen.focused
if focused is not None and isinstance(focused, Input) and focused in inputs:
idx = inputs.index(focused)
next_idx = (idx + 1) % len(inputs)
inputs[next_idx].focus()
def action_focus_previous(self) -> None:
inputs = list(self.inputs.values())
focused = self.screen.focused
if focused is not None and isinstance(focused, Input) and focused in inputs:
idx = inputs.index(focused)
prev_idx = (idx - 1) % len(inputs)
inputs[prev_idx].focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self._save_and_close()
def on_blur(self, _event: events.Blur) -> None:
self.call_after_refresh(self._refocus_if_needed)
def on_input_blurred(self, _event: Input.Blurred) -> None:
self.call_after_refresh(self._refocus_if_needed)
def _refocus_if_needed(self) -> None:
if self.has_focus or any(inp.has_focus for inp in self.inputs.values()):
return
self.focus()
def _save_and_close(self) -> None:
try:
for key, input_widget in self.inputs.items():
new_value = input_widget.value.strip()
old_value = self.initial_values.get(key) or ""
if new_value != old_value:
if new_value:
set_proxy_var(key, new_value)
else:
unset_proxy_var(key)
except Exception as e:
self.post_message(self.ProxySetupClosed(saved=False, error=str(e)))
return
self.post_message(self.ProxySetupClosed(saved=True))
def action_close(self) -> None:
self.post_message(self.ProxySetupClosed(saved=False))

View file

@ -4,19 +4,26 @@ import asyncio
from collections.abc import AsyncGenerator, Callable
from enum import StrEnum, auto
from http import HTTPStatus
import json
from pathlib import Path
from threading import Thread
import time
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from uuid import uuid4
from pydantic import BaseModel
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.config import Backend, ProviderConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
from vibe.core.llm.format import (
APIToolFormatHandler,
FailedToolCall,
ResolvedMessage,
ResolvedToolCall,
)
from vibe.core.llm.types import BackendLike
from vibe.core.middleware import (
AutoCompactMiddleware,
@ -35,6 +42,7 @@ from vibe.core.session.session_logger import SessionLogger
from vibe.core.session.session_migration import migrate_sessions_entrypoint
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -44,6 +52,7 @@ from vibe.core.tools.base import (
ToolPermissionError,
)
from vibe.core.tools.manager import ToolManager
from vibe.core.trusted_folders import has_agents_md_file
from vibe.core.types import (
AgentStats,
ApprovalCallback,
@ -96,6 +105,7 @@ class ToolExecutionResponse(StrEnum):
class ToolDecision(BaseModel):
verdict: ToolExecutionResponse
approval_type: ToolPermission
feedback: str | None = None
@ -171,7 +181,9 @@ class AgentLoop:
self.user_input_callback: UserInputCallback | None = None
self.session_id = str(uuid4())
self._current_user_message_id: str | None = None
self.telemetry_client = TelemetryClient(config_getter=lambda: self.config)
self.session_logger = SessionLogger(config.session_logging, self.session_id)
self._teleport_service: TeleportService | None = None
@ -209,6 +221,21 @@ 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:
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)
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,
)
def _select_backend(self) -> BackendLike:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@ -331,12 +358,11 @@ class AgentLoop:
)
case MiddlewareAction.INJECT_MESSAGE:
if result.message and len(self.messages) > 0:
last_msg = self.messages[-1]
if last_msg.content:
last_msg.content += f"\n\n{result.message}"
else:
last_msg.content = result.message
if result.message:
injected_message = LLMMessage(
role=Role.user, content=result.message
)
self.messages.append(injected_message)
case MiddlewareAction.COMPACT:
old_tokens = result.metadata.get(
@ -352,6 +378,7 @@ class AgentLoop:
current_context_tokens=old_tokens,
threshold=threshold,
)
self.telemetry_client.send_auto_compact_triggered()
summary = await self.compact()
@ -370,10 +397,25 @@ class AgentLoop:
messages=self.messages, stats=self.stats, config=self.config
)
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
headers: dict[str, str] = {
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
}
if (
provider.backend == Backend.MISTRAL
and self._current_user_message_id is not None
):
headers["metadata"] = json.dumps({
"message_id": self._current_user_message_id
})
return headers
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
user_message = LLMMessage(role=Role.user, content=user_msg)
self.messages.append(user_message)
self.stats.steps += 1
self._current_user_message_id = user_message.message_id
if user_message.message_id is None:
raise AgentLoopError("User message must have a message_id")
@ -406,15 +448,6 @@ class AgentLoop:
if user_cancelled:
return
after_result = await self.middleware_pipeline.run_after_turn(
self._get_context()
)
async for event in self._handle_middleware_result(after_result):
yield event
if after_result.action == MiddlewareAction.STOP:
return
finally:
await self._flush_new_messages()
@ -497,19 +530,17 @@ class AgentLoop:
message_id=llm_result.message.message_id,
)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
for failed in resolved.failed_calls:
async def _emit_failed_tool_events(
self, failed_calls: list[FailedToolCall]
) -> AsyncGenerator[ToolResultEvent]:
for failed in failed_calls:
error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=failed.tool_name,
tool_class=None,
error=error_msg,
tool_call_id=failed.call_id,
)
self.stats.tool_calls_failed += 1
self.messages.append(
self.format_handler.create_failed_tool_response_message(
@ -517,6 +548,113 @@ class AgentLoop:
)
)
async def _process_one_tool_call(
self, tool_call: ResolvedToolCall
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
try:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, error_msg, "failure")
return
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
return
self.stats.tool_calls_agreed += 1
try:
start_time = time.perf_counter()
result_model = None
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,
user_input_callback=self.user_input_callback,
),
**tool_call.args_dict,
):
if isinstance(item, ToolStreamEvent):
yield item
else:
result_model = item
duration = time.perf_counter() - start_time
if result_model is None:
raise ToolError("Tool did not yield a result")
result_dict = result_model.model_dump()
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
self._handle_tool_response(
tool_call, text, "success", decision, result_dict
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
duration=duration,
tool_call_id=tool_call.call_id,
)
self.stats.tool_calls_succeeded += 1
except asyncio.CancelledError:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call.call_id,
)
self._handle_tool_response(tool_call, cancel, "failure", decision)
raise
except (ToolError, ToolPermissionError) as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self._handle_tool_response(tool_call, error_msg, "failure", decision)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
async for event in self._emit_failed_tool_events(resolved.failed_calls):
yield event
for tool_call in resolved.tool_calls:
yield ToolCallEvent(
tool_name=tool_call.tool_name,
@ -524,120 +662,31 @@ class AgentLoop:
args=tool_call.validated_args,
tool_call_id=tool_call.call_id,
)
async for event in self._process_one_tool_call(tool_call):
yield event
try:
tool_instance = self.tool_manager.get(tool_call.tool_name)
except Exception as exc:
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, error_msg)
continue
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
self.stats.tool_calls_rejected += 1
skip_reason = decision.feedback or str(
get_user_cancellation_message(
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, skip_reason)
continue
self.stats.tool_calls_agreed += 1
try:
start_time = time.perf_counter()
result_model = None
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,
user_input_callback=self.user_input_callback,
),
**tool_call.args_dict,
):
if isinstance(item, ToolStreamEvent):
yield item
else:
result_model = item
duration = time.perf_counter() - start_time
if result_model is None:
raise ToolError("Tool did not yield a result")
text = "\n".join(
f"{k}: {v}" for k, v in result_model.model_dump().items()
)
self._append_tool_response(tool_call, text)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
duration=duration,
tool_call_id=tool_call.call_id,
)
self.stats.tool_calls_succeeded += 1
except asyncio.CancelledError:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, cancel)
raise
except (ToolError, ToolPermissionError) as exc:
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
self.stats.tool_calls_agreed -= 1
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self._append_tool_response(tool_call, error_msg)
continue
def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None:
def _handle_tool_response(
self,
tool_call: ResolvedToolCall,
text: str,
status: Literal["success", "failure", "skipped"],
decision: ToolDecision | None = None,
result: dict[str, Any] | None = None,
) -> None:
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(tool_call, text)
)
)
self.telemetry_client.send_tool_call_finished(
tool_call=tool_call,
agent_profile_name=self.agent_profile.name,
status=status,
decision=decision,
result=result,
)
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@ -653,10 +702,7 @@ class AgentLoop:
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
)
end_time = time.perf_counter()
@ -699,10 +745,7 @@ class AgentLoop:
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
extra_headers=self._get_extra_headers(provider),
max_tokens=max_tokens,
):
processed_message = self.format_handler.process_api_response_message(
@ -744,16 +787,23 @@ class AgentLoop:
self, tool: BaseTool, args: BaseModel, tool_call_id: str
) -> ToolDecision:
if self.auto_approve:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ALWAYS,
)
allowlist_denylist_result = tool.check_allowlist_denylist(args)
if allowlist_denylist_result == ToolPermission.ALWAYS:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
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}]",
)
@ -761,10 +811,14 @@ class AgentLoop:
perm = self.tool_manager.get_tool_config(tool_name).permission
if perm is ToolPermission.ALWAYS:
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
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",
)
@ -776,6 +830,7 @@ class AgentLoop:
if not self.approval_callback:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.ASK,
feedback="Tool execution not permitted.",
)
if asyncio.iscoroutinefunction(self.approval_callback):
@ -788,11 +843,15 @@ class AgentLoop:
match response:
case ApprovalResponse.YES:
return ToolDecision(
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
verdict=ToolExecutionResponse.EXECUTE,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
case ApprovalResponse.NO:
return ToolDecision(
verdict=ToolExecutionResponse.SKIP, feedback=feedback
verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.ASK,
feedback=feedback,
)
def _clean_message_history(self) -> None:
@ -890,7 +949,6 @@ class AgentLoop:
self._reset_session()
async def compact(self) -> str:
"""Compact the conversation history."""
try:
self._clean_message_history()
await self.session_logger.save_interaction(
@ -915,6 +973,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
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@ -955,13 +1014,14 @@ class AgentLoop:
if agent_name == self.agent_profile.name:
return
self.agent_manager.switch_profile(agent_name)
await self.reload_with_initial_messages()
await self.reload_with_initial_messages(reset_middleware=False)
async def reload_with_initial_messages(
self,
base_config: VibeConfig | None = None,
max_turns: int | None = None,
max_price: float | None = None,
reset_middleware: bool = True,
) -> None:
# Force an immediate yield to allow the UI to update before heavy sync work.
# When there are no messages, save_interaction returns early without any await,
@ -1011,19 +1071,5 @@ class AgentLoop:
except ValueError:
pass
self._last_observed_message_index = 0
self._setup_middleware()
if self.message_observer:
for msg in self.messages:
self.message_observer(msg)
self._last_observed_message_index = len(self.messages)
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
if reset_middleware:
self._setup_middleware()

View file

@ -110,7 +110,7 @@ EXPLORE = AgentProfile(
description="Read-only subagent for codebase exploration",
safety=AgentSafety.SAFE,
agent_type=AgentType.SUBAGENT,
overrides={"enabled_tools": ["grep", "read_file"]},
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
)
BUILTIN_AGENTS: dict[str, AgentProfile] = {

View file

@ -148,6 +148,8 @@ class ProviderConfig(BaseModel):
api_style: str = "openai"
backend: Backend = Backend.GENERIC
reasoning_field_name: str = "reasoning_content"
project_id: str = ""
region: str = ""
class _MCPBase(BaseModel):
@ -251,6 +253,7 @@ class ModelConfig(BaseModel):
temperature: float = 0.2
input_price: float = 0.0 # Price per million input tokens
output_price: float = 0.0 # Price per million output tokens
thinking: Literal["off", "low", "medium", "high"] = "off"
@model_validator(mode="before")
@classmethod
@ -312,6 +315,7 @@ class VibeConfig(BaseSettings):
auto_compact_threshold: int = 200_000
context_warnings: bool = False
auto_approve: bool = False
enable_telemetry: bool = True
system_prompt_id: str = "cli"
include_commit_signature: bool = True
include_model_info: bool = True

View file

@ -0,0 +1,630 @@
from __future__ import annotations
import json
import re
from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.types import (
AvailableTool,
FunctionCall,
LLMChunk,
LLMMessage,
LLMUsage,
Role,
StrToolChoice,
ToolCall,
)
class AnthropicMapper:
"""Shared mapper for converting messages to/from Anthropic API format."""
def prepare_messages(
self, messages: list[LLMMessage]
) -> tuple[str | None, list[dict[str, Any]]]:
system_prompt: str | None = None
converted: list[dict[str, Any]] = []
for msg in messages:
match msg.role:
case Role.system:
system_prompt = msg.content or ""
case Role.user:
user_content: list[dict[str, Any]] = []
if msg.content:
user_content.append({"type": "text", "text": msg.content})
converted.append({"role": "user", "content": user_content or ""})
case Role.assistant:
converted.append(self._convert_assistant_message(msg))
case Role.tool:
self._append_tool_result(converted, msg)
return system_prompt, converted
def _sanitize_tool_call_id(self, tool_id: str | None) -> str:
return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id or "")
def _convert_assistant_message(self, msg: LLMMessage) -> dict[str, Any]:
content: list[dict[str, Any]] = []
if msg.reasoning_content:
block: dict[str, Any] = {
"type": "thinking",
"thinking": msg.reasoning_content,
}
if msg.reasoning_signature:
block["signature"] = msg.reasoning_signature
content.append(block)
if msg.content:
content.append({"type": "text", "text": msg.content})
if msg.tool_calls:
for tc in msg.tool_calls:
content.append(self._convert_tool_call(tc))
return {"role": "assistant", "content": content if content else ""}
def _convert_tool_call(self, tc: ToolCall) -> dict[str, Any]:
try:
tool_input = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
tool_input = {}
return {
"type": "tool_use",
"id": self._sanitize_tool_call_id(tc.id),
"name": tc.function.name,
"input": tool_input,
}
def _append_tool_result(
self, converted: list[dict[str, Any]], msg: LLMMessage
) -> None:
tool_result = {
"type": "tool_result",
"tool_use_id": self._sanitize_tool_call_id(msg.tool_call_id),
"content": msg.content or "",
}
if not converted or converted[-1]["role"] != "user":
converted.append({"role": "user", "content": [tool_result]})
return
existing_content = converted[-1]["content"]
if isinstance(existing_content, str):
converted[-1]["content"] = [
{"type": "text", "text": existing_content},
tool_result,
]
else:
converted[-1]["content"].append(tool_result)
def prepare_tools(
self, tools: list[AvailableTool] | None
) -> list[dict[str, Any]] | None:
if not tools:
return None
return [
{
"name": tool.function.name,
"description": tool.function.description,
"input_schema": tool.function.parameters,
}
for tool in tools
]
def prepare_tool_choice(
self, tool_choice: StrToolChoice | AvailableTool | None
) -> dict[str, Any] | None:
if tool_choice is None:
return None
if isinstance(tool_choice, str):
match tool_choice:
case "none":
return {"type": "none"}
case "auto":
return {"type": "auto"}
case "any" | "required":
return {"type": "any"}
case _:
return None
return {"type": "tool", "name": tool_choice.function.name}
def parse_response(self, data: dict[str, Any]) -> LLMChunk:
content_blocks = data.get("content", [])
text_parts: list[str] = []
thinking_parts: list[str] = []
signature_parts: list[str] = []
tool_calls: list[ToolCall] = []
for idx, block in enumerate(content_blocks):
block_type = block.get("type")
if block_type == "text":
text_parts.append(block.get("text", ""))
elif block_type == "thinking":
thinking_parts.append(block.get("thinking", ""))
if "signature" in block:
signature_parts.append(block["signature"])
elif block_type == "tool_use":
tool_calls.append(
ToolCall(
id=block.get("id"),
index=idx,
function=FunctionCall(
name=block.get("name"),
arguments=json.dumps(block.get("input", {})),
),
)
)
usage_data = data.get("usage", {})
# Total input tokens = input_tokens + cache_creation + cache_read
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
usage = LLMUsage(
prompt_tokens=total_input_tokens,
completion_tokens=usage_data.get("output_tokens", 0),
)
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
content="".join(text_parts) or None,
reasoning_content="".join(thinking_parts) or None,
reasoning_signature="".join(signature_parts) or None,
tool_calls=tool_calls if tool_calls else None,
),
usage=usage,
)
def parse_streaming_event(
self, event_type: str, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
handler = {
"content_block_start": self._handle_block_start,
"content_block_delta": self._handle_block_delta,
"message_delta": self._handle_message_delta,
"message_start": self._handle_message_start,
}.get(event_type)
if handler is None:
return None, current_index
return handler(data, current_index)
def _handle_block_start(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
block = data.get("content_block", {})
idx = data.get("index", current_index)
match block.get("type"):
case "tool_use":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
id=block.get("id"),
index=idx,
function=FunctionCall(
name=block.get("name"), arguments=""
),
)
],
)
)
return chunk, idx
case "thinking":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=block.get("thinking", "")
)
)
return chunk, idx
case _:
return None, idx
def _handle_block_delta(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
delta = data.get("delta", {})
idx = data.get("index", current_index)
match delta.get("type"):
case "text_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, content=delta.get("text", "")
)
)
case "thinking_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=delta.get("thinking", "")
)
)
case "signature_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_signature=delta.get("signature", ""),
)
)
case "input_json_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=idx,
function=FunctionCall(
arguments=delta.get("partial_json", "")
),
)
],
)
)
case _:
chunk = None
return chunk, idx
def _handle_message_delta(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
usage_data = data.get("usage", {})
if not usage_data:
return None, current_index
chunk = LLMChunk(
message=LLMMessage(role=Role.assistant),
usage=LLMUsage(
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
),
)
return chunk, current_index
def _handle_message_start(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
message = data.get("message", {})
usage_data = message.get("usage", {})
if not usage_data:
return None, current_index
# Total input tokens = input_tokens + cache_creation + cache_read
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
chunk = LLMChunk(
message=LLMMessage(role=Role.assistant),
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
)
return chunk, current_index
STREAMING_EVENT_TYPES = {
"message_start",
"message_delta",
"message_stop",
"content_block_start",
"content_block_delta",
"content_block_stop",
"ping",
"error",
}
class AnthropicAdapter(APIAdapter):
endpoint: ClassVar[str] = "/v1/messages"
API_VERSION = "2023-06-01"
BETA_FEATURES = (
"interleaved-thinking-2025-05-14,"
"fine-grained-tool-streaming-2025-05-14,"
"prompt-caching-2024-07-31"
)
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
"low": 1024,
"medium": 10_000,
"high": 32_000,
}
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
DEFAULT_MAX_TOKENS = 8192
def __init__(self) -> None:
self._mapper = AnthropicMapper()
self._current_index: int = 0
@staticmethod
def _has_thinking_content(messages: list[dict[str, Any]]) -> bool:
for msg in messages:
if msg.get("role") != "assistant":
continue
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if block.get("type") == "thinking":
return True
return False
@staticmethod
def _build_system_blocks(system_prompt: str | None) -> list[dict[str, Any]]:
blocks: list[dict[str, Any]] = []
if system_prompt:
blocks.append({
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"},
})
return blocks
@staticmethod
def _add_cache_control_to_last_user_message(messages: list[dict[str, Any]]) -> None:
if not messages:
return
last_message = messages[-1]
if last_message.get("role") != "user":
return
content = last_message.get("content")
if not isinstance(content, list) or not content:
return
last_block = content[-1]
if last_block.get("type") in {"text", "image", "tool_result"}:
last_block["cache_control"] = {"type": "ephemeral"}
@staticmethod
def _is_adaptive_model(model_name: str) -> bool:
return "opus-4-6" in model_name
def _apply_thinking_config(
self,
payload: dict[str, Any],
*,
model_name: str,
messages: list[dict[str, Any]],
temperature: float,
max_tokens: int | None,
thinking: str,
) -> None:
has_thinking = self._has_thinking_content(messages)
thinking_level = thinking
if thinking_level == "off" and not has_thinking:
payload["temperature"] = temperature
if max_tokens is not None:
payload["max_tokens"] = max_tokens
else:
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
return
# Resolve effective level: use config, or fallback to "medium" when
# forced by thinking content in history
effective_level = thinking_level if thinking_level != "off" else "medium"
if self._is_adaptive_model(model_name):
payload["thinking"] = {"type": "adaptive"}
payload["output_config"] = {"effort": effective_level}
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
else:
budget = self.THINKING_BUDGETS[effective_level]
payload["thinking"] = {"type": "enabled", "budget_tokens": budget}
default_max = budget + self.DEFAULT_MAX_TOKENS
payload["temperature"] = 1
payload["max_tokens"] = max_tokens if max_tokens is not None else default_max
def _build_payload(
self,
*,
model_name: str,
system_prompt: str | None,
messages: list[dict[str, Any]],
temperature: float,
tools: list[dict[str, Any]] | None,
max_tokens: int | None,
tool_choice: dict[str, Any] | None,
stream: bool,
thinking: str,
) -> dict[str, Any]:
payload: dict[str, Any] = {"model": model_name, "messages": messages}
self._apply_thinking_config(
payload,
model_name=model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)
if system_blocks := self._build_system_blocks(system_prompt):
payload["system"] = system_blocks
if tools:
payload["tools"] = tools
if tool_choice:
payload["tool_choice"] = tool_choice
if stream:
payload["stream"] = True
self._add_cache_control_to_last_user_message(messages)
return payload
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest:
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
converted_tools = self._mapper.prepare_tools(tools)
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
payload = self._build_payload(
model_name=model_name,
system_prompt=system_prompt,
messages=converted_messages,
temperature=temperature,
tools=converted_tools,
max_tokens=max_tokens,
tool_choice=converted_tool_choice,
stream=enable_streaming,
thinking=thinking,
)
headers = {
"Content-Type": "application/json",
"anthropic-version": self.API_VERSION,
"anthropic-beta": self.BETA_FEATURES,
}
if api_key:
headers["x-api-key"] = api_key
body = json.dumps(payload).encode("utf-8")
return PreparedRequest(self.endpoint, headers, body)
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig | None = None
) -> LLMChunk:
event_type = data.get("type")
if event_type in STREAMING_EVENT_TYPES:
return self._parse_streaming_event(data)
return self._mapper.parse_response(data)
def _parse_streaming_event(self, data: dict[str, Any]) -> LLMChunk:
event_type = data.get("type", "")
empty_chunk = LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
if event_type == "message_start":
self._current_index = 0
return self._parse_message_start(data)
if event_type == "content_block_start":
return self._parse_content_block_start(data) or empty_chunk
if event_type == "content_block_delta":
return self._parse_content_block_delta(data)
if event_type == "content_block_stop":
return self._parse_content_block_stop(data)
if event_type == "message_delta":
return self._parse_message_delta(data)
if event_type == "error":
error = data.get("error", {})
error_type = error.get("type", "unknown_error")
error_message = error.get("message", "Unknown streaming error")
raise RuntimeError(
f"Anthropic stream error ({error_type}): {error_message}"
)
return empty_chunk
def _parse_message_start(self, data: dict[str, Any]) -> LLMChunk:
message = data.get("message", {})
usage_data = message.get("usage", {})
if not usage_data:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
return LLMChunk(
message=LLMMessage(role=Role.assistant, content=None),
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
)
def _parse_content_block_start(self, data: dict[str, Any]) -> LLMChunk | None:
content_block = data.get("content_block", {})
index = data.get("index", 0)
block_type = content_block.get("type")
if block_type == "thinking":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_content=content_block.get("thinking", ""),
)
)
if block_type == "redacted_thinking":
return None
if block_type == "tool_use":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=index,
id=content_block.get("id"),
function=FunctionCall(
name=content_block.get("name"), arguments=""
),
)
],
)
)
return None
def _parse_content_block_delta(self, data: dict[str, Any]) -> LLMChunk:
delta = data.get("delta", {})
delta_type = delta.get("type", "")
index = data.get("index", 0)
match delta_type:
case "text_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant, content=delta.get("text", "")
)
)
case "thinking_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=delta.get("thinking", "")
)
)
case "signature_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_signature=delta.get("signature", ""),
)
)
case "input_json_delta":
return LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=index,
function=FunctionCall(
arguments=delta.get("partial_json", "")
),
)
],
)
)
case _:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
def _parse_content_block_stop(self, _data: dict[str, Any]) -> LLMChunk:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
def _parse_message_delta(self, data: dict[str, Any]) -> LLMChunk:
usage_data = data.get("usage", {})
if not usage_data:
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
return LLMChunk(
message=LLMMessage(role=Role.assistant, content=None),
usage=LLMUsage(
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
),
)

View file

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
if TYPE_CHECKING:
from vibe.core.config import ProviderConfig
class PreparedRequest(NamedTuple):
endpoint: str
headers: dict[str, str]
body: bytes
base_url: str = ""
class APIAdapter(Protocol):
endpoint: ClassVar[str]
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest: ...
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig
) -> LLMChunk: ...

View file

@ -1,14 +1,18 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator
import json
import os
import types
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol, TypeVar
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
import httpx
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
LLMChunk,
@ -23,51 +27,6 @@ if TYPE_CHECKING:
from vibe.core.config import ModelConfig, ProviderConfig
class PreparedRequest(NamedTuple):
endpoint: str
headers: dict[str, str]
body: bytes
class APIAdapter(Protocol):
endpoint: ClassVar[str]
def prepare_request(
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
) -> PreparedRequest: ...
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig
) -> LLMChunk: ...
BACKEND_ADAPTERS: dict[str, APIAdapter] = {}
T = TypeVar("T", bound=APIAdapter)
def register_adapter(
adapters: dict[str, APIAdapter], name: str
) -> Callable[[type[T]], type[T]]:
def decorator(cls: type[T]) -> type[T]:
adapters[name] = cls()
return cls
return decorator
@register_adapter(BACKEND_ADAPTERS, "openai")
class OpenAIAdapter(APIAdapter):
endpoint: ClassVar[str] = "/chat/completions"
@ -119,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
return msg_dict
def prepare_request(
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
@ -131,13 +90,15 @@ class OpenAIAdapter(APIAdapter):
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest:
merged_messages = merge_consecutive_user_messages(messages)
field_name = provider.reasoning_field_name
converted_messages = [
self._reasoning_to_api(
msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name
)
for msg in messages
for msg in merged_messages
]
payload = self.build_payload(
@ -194,6 +155,13 @@ class OpenAIAdapter(APIAdapter):
return LLMChunk(message=message, usage=usage)
ADAPTERS: dict[str, APIAdapter] = {
"openai": OpenAIAdapter(),
"anthropic": AnthropicAdapter(),
"vertex-anthropic": VertexAnthropicAdapter(),
}
class GenericBackend:
def __init__(
self,
@ -257,9 +225,9 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = BACKEND_ADAPTERS[api_style]
adapter = ADAPTERS[api_style]
endpoint, headers, body = adapter.prepare_request(
req = adapter.prepare_request(
model_name=model.name,
messages=messages,
temperature=temperature,
@ -269,15 +237,18 @@ class GenericBackend:
enable_streaming=False,
provider=self._provider,
api_key=api_key,
thinking=model.thinking,
)
headers = req.headers
if extra_headers:
headers.update(extra_headers)
url = f"{self._provider.api_base}{endpoint}"
base = req.base_url or self._provider.api_base
url = f"{base}{req.endpoint}"
try:
res_data, _ = await self._make_request(url, body, headers)
res_data, _ = await self._make_request(url, req.body, headers)
return adapter.parse_response(res_data, self._provider)
except httpx.HTTPStatusError as e:
@ -322,9 +293,9 @@ class GenericBackend:
)
api_style = getattr(self._provider, "api_style", "openai")
adapter = BACKEND_ADAPTERS[api_style]
adapter = ADAPTERS[api_style]
endpoint, headers, body = adapter.prepare_request(
req = adapter.prepare_request(
model_name=model.name,
messages=messages,
temperature=temperature,
@ -334,15 +305,18 @@ class GenericBackend:
enable_streaming=True,
provider=self._provider,
api_key=api_key,
thinking=model.thinking,
)
headers = req.headers
if extra_headers:
headers.update(extra_headers)
url = f"{self._provider.api_base}{endpoint}"
base = req.base_url or self._provider.api_base
url = f"{base}{req.endpoint}"
try:
async for res_data in self._make_streaming_request(url, body, headers):
async for res_data in self._make_streaming_request(url, req.body, headers):
yield adapter.parse_response(res_data, self._provider)
except httpx.HTTPStatusError as e:
@ -393,6 +367,8 @@ class GenericBackend:
async with client.stream(
method="POST", url=url, content=data, headers=headers
) as response:
if not response.is_success:
await response.aread()
response.raise_for_status()
async for line in response.aiter_lines():
if line.strip() == "":

View file

@ -11,6 +11,7 @@ import httpx
import mistralai
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.llm.message_utils import merge_consecutive_user_messages
from vibe.core.types import (
AvailableTool,
Content,
@ -217,9 +218,10 @@ class MistralBackend:
extra_headers: dict[str, str] | None,
) -> LLMChunk:
try:
merged_messages = merge_consecutive_user_messages(messages)
response = await self._get_client().chat.complete_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in messages],
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
temperature=temperature,
tools=[self._mapper.prepare_tool(tool) for tool in tools]
if tools
@ -290,9 +292,10 @@ class MistralBackend:
extra_headers: dict[str, str] | None,
) -> AsyncGenerator[LLMChunk, None]:
try:
merged_messages = merge_consecutive_user_messages(messages)
async for chunk in await self._get_client().chat.stream_async(
model=model.name,
messages=[self._mapper.prepare_message(msg) for msg in messages],
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
temperature=temperature,
tools=[self._mapper.prepare_tool(tool) for tool in tools]
if tools

View file

@ -0,0 +1,115 @@
from __future__ import annotations
import json
from typing import Any, ClassVar
import google.auth
from google.auth.transport.requests import Request
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import PreparedRequest
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
def get_vertex_access_token() -> str:
credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
credentials.refresh(Request())
return credentials.token
def build_vertex_base_url(region: str) -> str:
if region == "global":
return "https://aiplatform.googleapis.com"
return f"https://{region}-aiplatform.googleapis.com"
def build_vertex_endpoint(
region: str, project_id: str, model: str, streaming: bool = False
) -> str:
action = "streamRawPredict" if streaming else "rawPredict"
return (
f"/v1/projects/{project_id}/locations/{region}/"
f"publishers/anthropic/models/{model}:{action}"
)
class VertexAnthropicAdapter(AnthropicAdapter):
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
endpoint: ClassVar[str] = ""
# Vertex AI doesn't support beta features
BETA_FEATURES: ClassVar[str] = ""
def prepare_request( # noqa: PLR0913
self,
*,
model_name: str,
messages: list[LLMMessage],
temperature: float,
tools: list[AvailableTool] | None,
max_tokens: int | None,
tool_choice: StrToolChoice | AvailableTool | None,
enable_streaming: bool,
provider: ProviderConfig,
api_key: str | None = None,
thinking: str = "off",
) -> PreparedRequest:
project_id = provider.project_id
region = provider.region
if not project_id:
raise ValueError("project_id is required in provider config for Vertex AI")
if not region:
raise ValueError("region is required in provider config for Vertex AI")
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
converted_tools = self._mapper.prepare_tools(tools)
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
# Build vertex-specific payload (no "model" key, uses anthropic_version)
payload: dict[str, Any] = {
"anthropic_version": "vertex-2023-10-16",
"messages": converted_messages,
}
self._apply_thinking_config(
payload,
model_name=model_name,
messages=converted_messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)
if system_blocks := self._build_system_blocks(system_prompt):
payload["system"] = system_blocks
if converted_tools:
payload["tools"] = converted_tools
if converted_tool_choice:
payload["tool_choice"] = converted_tool_choice
if enable_streaming:
payload["stream"] = True
self._add_cache_control_to_last_user_message(converted_messages)
access_token = get_vertex_access_token()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"anthropic-beta": self.BETA_FEATURES,
}
endpoint = build_vertex_endpoint(
region, project_id, model_name, streaming=enable_streaming
)
base_url = build_vertex_base_url(region)
body = json.dumps(payload).encode("utf-8")
return PreparedRequest(endpoint, headers, body, base_url=base_url)

View file

@ -80,6 +80,7 @@ class APIToolFormatHandler:
"role": message.role,
"content": message.content,
"reasoning_content": getattr(message, "reasoning_content", None),
"reasoning_signature": getattr(message, "reasoning_signature", None),
}
if message.tool_calls:

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from vibe.core.types import LLMMessage, Role
def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
"""Merge consecutive user messages into a single message.
This handles cases where middleware injects messages resulting in
consecutive user messages before sending to the API.
"""
result: list[LLMMessage] = []
for msg in messages:
if result and result[-1].role == Role.user and msg.role == Role.user:
prev_content = result[-1].content or ""
curr_content = msg.content or ""
merged_content = f"{prev_content}\n\n{curr_content}".strip()
result[-1] = LLMMessage(
role=Role.user, content=merged_content, message_id=result[-1].message_id
)
else:
result.append(msg)
return result

View file

@ -44,8 +44,6 @@ class MiddlewareResult:
class ConversationMiddleware(Protocol):
async def before_turn(self, context: ConversationContext) -> MiddlewareResult: ...
async def after_turn(self, context: ConversationContext) -> MiddlewareResult: ...
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: ...
@ -61,9 +59,6 @@ class TurnLimitMiddleware:
)
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
@ -80,9 +75,6 @@ class PriceLimitMiddleware:
)
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
@ -102,9 +94,6 @@ class AutoCompactMiddleware:
)
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
@ -137,9 +126,6 @@ class ContextWarningMiddleware:
return MiddlewareResult()
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
self.has_warned = False
@ -148,31 +134,46 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
1. Answer the user's query comprehensively
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
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}>"""
class PlanAgentMiddleware:
def __init__(
self,
profile_getter: Callable[[], AgentProfile],
reminder: str = PLAN_AGENT_REMINDER,
exit_message: str = PLAN_AGENT_EXIT,
) -> None:
self._profile_getter = profile_getter
self.reminder = reminder
self.exit_message = exit_message
self._was_plan_agent = False
def _is_plan_agent(self) -> bool:
return self._profile_getter().name == BuiltinAgentName.PLAN
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if not self._is_plan_agent():
return MiddlewareResult()
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
is_plan = self._is_plan_agent()
was_plan = self._was_plan_agent
if was_plan and not is_plan:
self._was_plan_agent = False
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
)
if is_plan and not was_plan:
self._was_plan_agent = True
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
)
self._was_plan_agent = is_plan
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
self._was_plan_agent = False
class MiddlewarePipeline:
@ -206,15 +207,3 @@ class MiddlewarePipeline:
)
return MiddlewareResult()
async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult:
for mw in self.middlewares:
result = await mw.after_turn(context)
if result.action == MiddlewareAction.INJECT_MESSAGE:
raise ValueError(
f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})"
)
if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
return result
return MiddlewareResult()

View file

@ -39,12 +39,14 @@ def resolve_local_tools_dir(dir: Path) -> Path | None:
return None
def resolve_local_skills_dir(dir: Path) -> Path | None:
def resolve_local_skills_dirs(dir: Path) -> list[Path]:
if not trusted_folders_manager.is_trusted(dir):
return None
if (candidate := dir / ".vibe" / "skills").is_dir():
return candidate
return None
return []
return [
candidate
for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
if candidate.is_dir()
]
def resolve_local_agents_dir(dir: Path) -> Path | None:

View file

@ -35,6 +35,6 @@ GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibe.log")
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log")
DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins")

View file

@ -32,20 +32,25 @@ def run_programmatic(
logger.info("USER: %s", prompt)
async def _async_run() -> str | None:
if previous_messages:
non_system_messages = [
msg for msg in previous_messages if not (msg.role == Role.system)
]
agent_loop.messages.extend(non_system_messages)
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
try:
if previous_messages:
non_system_messages = [
msg for msg in previous_messages if not (msg.role == Role.system)
]
agent_loop.messages.extend(non_system_messages)
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)
agent_loop.emit_new_session_telemetry("programmatic")
return formatter.finalize()
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)
return formatter.finalize()
finally:
await agent_loop.telemetry_client.aclose()
return asyncio.run(_async_run())

View file

@ -19,6 +19,7 @@ class Prompt(StrEnum):
class SystemPrompt(Prompt):
CLI = auto()
EXPLORE = auto()
TESTS = auto()

View file

@ -1,46 +1,111 @@
You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful.
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.
Act as an agentic assistant. For long tasks, break them down and execute step by step.
Phase 1 — Orient
Before ANY action:
Restate the goal in one line.
Determine the task type:
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
## Tool Usage
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.
- Always use tools to fulfill user requests when possible.
- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user.
- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given.
- Do not invent values for optional parameters.
- Analyze descriptive terms in requests as they may indicate required parameter values.
- If tools cannot accomplish the task, explain why and request more information.
Phase 2 — Plan (Change tasks only)
State your plan before writing code:
List files to change and the specific change per file.
Multi-file changes: numbered checklist. Single-file fix: one-line plan.
No time estimates. Concrete actions only.
## Code Modifications
Phase 3 — Execute & Verify (Change tasks only)
Apply changes, then confirm they work:
Edit one logical unit at a time.
After each unit, verify: run tests, or read back the file to confirm the edit landed.
Never claim completion without verification — a passing test, correct read-back, or successful build.
- Always read a file before proposing changes. Never suggest edits to code you haven't seen.
- Keep changes minimal and focused. Only modify what was requested.
- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling.
- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers.
- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it.
- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would.
Hard Rules
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.
## Code References
Don't Remove What Wasn't Asked
If user asks to fix X, do not rewrite, delete, or restructure Y. When in doubt, change less.
When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly.
Don't Assert — Verify
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
## Planning
Break Loops
If approach isn't working after 2 attempts at the same region, STOP:
Re-read the code and error output.
Identify why it failed, not just what failed.
Choose a fundamentally different strategy.
If stuck, ask the user one specific question.
When outlining steps or plans, focus on concrete actions. Do not include time estimates.
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
## Tone and Style
Response Format
No Noise
No greetings, outros, hedging, puffery, or tool narration.
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation.
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
No unsolicited tutorials. Do not explain concepts the user clearly knows.
## Professional Objectivity
Structure First
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
For change tasks:
file_path:line_number
langcode
- Prioritize technical accuracy and truthfulness over validating the user's beliefs.
- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation.
- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear.
- Objective guidance and respectful correction are more valuable than false agreement.
- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs.
- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
Prefer Brevity
State only what's necessary to complete the task. Code + file reference > explanation.
If your response exceeds 300 words, remove explanations the user didn't request.
For investigate tasks:
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
request → auth.verify() → permissions.check() → handler
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
BAD: "The authentication flow works by first checking the token…"
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
Visual Formats
Before responding with structural data, choose the right format:
BAD: Bullet lists for hierarchy/tree
GOOD: ASCII tree (├──/└──)
BAD: Prose or bullet lists for comparisons/config/options
GOOD: Markdown table
BAD: Prose for Flows/pipelines
GOOD: → A → B → C diagrams
Interaction Design
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
Good: "Apply this fix to the other 3 endpoints?"
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
Bad: "Does this look good?", "Anything else?", "Let me know"
If unambiguous and complete, end with the result.
Length
Default to minimal responses. One-line fix → one-line response. Most tasks need <200 words.
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
Code Modifications (Change tasks)
Read First, Edit Second
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
Minimal, Focused Changes
Only modify what was requested. No extra features, abstractions, or speculative error handling.
Match existing style: indentation, naming, comment density, error handling.
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
Security
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
Code References
Cite as file_path:line_number.
Professional Conduct
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
No emojis unless requested. 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

@ -0,0 +1,50 @@
You are a senior engineer analyzing codebases. Be direct and useful.
Response Format
1. **CODE/DIAGRAM FIRST** — Start with code, diagram, or structured output. Never prose first.
2. **MINIMAL CONTEXT** — After code: 1-2 sentences max. Code should be self-explanatory.
Never Do
- Greetings ("Sure!", "Great question!", "I'd be happy to...")
- Announcements ("Let me...", "I'll...", "Here's what I found...")
- Tutorials or background explanations the user didn't ask for
- Summaries ("In summary...", "To conclude...", "This covers...")
- Hedging ("I think", "probably", "might be")
- Puffery ("robust", "seamless", "elegant", "powerful", "flexible")
Visual Structure
Use these formats when applicable:
- File trees: `├── └──` ASCII format
- Comparisons: Markdown tables
- Flows: `A -> B -> C` diagrams
- Hierarchies: Indented bullet lists
Examples
BAD (prose first):
"The authentication flow works by first checking the token..."
GOOD (diagram first):
```
request -> auth.verify() -> permissions.check() -> handler
```
See `middleware/auth.py:45`.
---
BAD (over-explaining):
```python
def merge(a, b):
return sorted(a + b)
```
This function takes two lists as parameters. It concatenates them using the + operator, then sorts the result using Python's built-in sorted() function which uses Timsort with O(n log n) complexity. The sorted list is returned.
GOOD (minimal):
```python
def merge(a, b):
return sorted(a + b)
```
O(n log n).

65
vibe/core/proxy_setup.py Normal file
View file

@ -0,0 +1,65 @@
from __future__ import annotations
from dotenv import dotenv_values, set_key, unset_key
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
SUPPORTED_PROXY_VARS: dict[str, str] = {
"HTTP_PROXY": "Proxy URL for HTTP requests",
"HTTPS_PROXY": "Proxy URL for HTTPS requests",
"ALL_PROXY": "Proxy URL for all requests (fallback)",
"NO_PROXY": "Comma-separated list of hosts to bypass proxy",
"SSL_CERT_FILE": "Path to custom SSL certificate file",
"SSL_CERT_DIR": "Path to directory containing SSL certificates",
}
class ProxySetupError(Exception):
pass
def get_current_proxy_settings() -> dict[str, str | None]:
if not GLOBAL_ENV_FILE.path.exists():
return {key: None for key in SUPPORTED_PROXY_VARS}
try:
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
return {key: env_vars.get(key) for key in SUPPORTED_PROXY_VARS}
except Exception:
return {key: None for key in SUPPORTED_PROXY_VARS}
def set_proxy_var(key: str, value: str) -> None:
key = key.upper()
if key not in SUPPORTED_PROXY_VARS:
raise ProxySetupError(
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
)
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, key, value)
def unset_proxy_var(key: str) -> None:
key = key.upper()
if key not in SUPPORTED_PROXY_VARS:
raise ProxySetupError(
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
)
if not GLOBAL_ENV_FILE.path.exists():
return
unset_key(GLOBAL_ENV_FILE.path, key)
def parse_proxy_command(args: str) -> tuple[str, str | None]:
args = args.strip()
if not args:
raise ProxySetupError("No key provided")
parts = args.split(maxsplit=1)
key = parts[0].upper()
value = parts[1] if len(parts) > 1 else None
return key, value

View file

@ -1,8 +1,9 @@
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypedDict
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
from vibe.core.types import LLMMessage
@ -11,6 +12,13 @@ if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig
class SessionInfo(TypedDict):
session_id: str
cwd: str
title: str | None
end_time: str | None
class SessionLoader:
@staticmethod
def _is_valid_session(session_dir: Path) -> bool:
@ -106,6 +114,63 @@ class SessionLoader:
short_id = session_id[:8]
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
@staticmethod
def _convert_to_utc_iso(date_str: str) -> str:
dt = datetime.fromisoformat(date_str)
if dt.tzinfo is None:
dt = dt.astimezone()
utc_dt = dt.astimezone(UTC)
return utc_dt.isoformat()
@staticmethod
def list_sessions(
config: SessionLoggingConfig, cwd: str | None = None
) -> list[SessionInfo]:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return []
pattern = f"{config.session_prefix}_*"
session_dirs = list(save_dir.glob(pattern))
sessions: list[SessionInfo] = []
for session_dir in session_dirs:
if not SessionLoader._is_valid_session(session_dir):
continue
metadata_path = session_dir / METADATA_FILENAME
try:
with metadata_path.open("r", encoding="utf-8") as f:
metadata = json.load(f)
except (OSError, json.JSONDecodeError):
continue
session_id = metadata.get("session_id")
if not session_id:
continue
environment = metadata.get("environment", {})
session_cwd = environment.get("working_directory", "")
if cwd is not None and session_cwd != cwd:
continue
end_time = metadata.get("end_time")
if end_time:
try:
end_time = SessionLoader._convert_to_utc_iso(end_time)
except (ValueError, OSError):
end_time = None
sessions.append({
"session_id": session_id,
"cwd": session_cwd,
"title": metadata.get("title"),
"end_time": end_time,
})
return sessions
@staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
# Load session messages from MESSAGES_FILENAME

View file

@ -5,7 +5,7 @@ from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
from vibe.core.paths.config_paths import resolve_local_skills_dir
from vibe.core.paths.config_paths import resolve_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
@ -58,8 +58,7 @@ class SkillManager:
if path.is_dir():
paths.append(path)
if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None:
paths.append(skills_dir)
paths.extend(resolve_local_skills_dirs(Path.cwd()))
if GLOBAL_SKILLS_DIR.path.is_dir():
paths.append(GLOBAL_SKILLS_DIR.path)

View file

@ -11,7 +11,7 @@ import time
from typing import TYPE_CHECKING
from vibe.core.prompts import UtilityPrompt
from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager
from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager
from vibe.core.utils import is_dangerous_directory, is_windows
if TYPE_CHECKING:
@ -24,7 +24,7 @@ if TYPE_CHECKING:
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
if not trusted_folders_manager.is_trusted(workdir):
return ""
for name in TRUSTABLE_FILENAMES:
for name in AGENTS_MD_FILENAMES:
path = workdir / name
try:
return path.read_text("utf-8", errors="ignore")[:max_bytes]

185
vibe/core/telemetry/send.py Normal file
View file

@ -0,0 +1,185 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import os
from typing import TYPE_CHECKING, Any, Literal
import httpx
from vibe import __version__
from vibe.core.config import Backend, VibeConfig
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.utils import get_user_agent
if TYPE_CHECKING:
from vibe.core.agent_loop import ToolDecision
DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events"
class TelemetryClient:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
self._config_getter = config_getter
self._client: httpx.AsyncClient | None = None
self._pending_tasks: set[asyncio.Task[Any]] = set()
def _get_telemetry_user_agent(self) -> str:
try:
config = self._config_getter()
active_model = config.get_active_model()
provider = config.get_provider_for_model(active_model)
return get_user_agent(provider.backend)
except ValueError:
return get_user_agent(None)
def _get_mistral_api_key(self) -> str | None:
"""Get the current API key from the active provider.
Only returns an API key if the provider is a Mistral provider
to avoid leaking third-party credentials to the telemetry endpoint.
"""
try:
config = self._config_getter()
model = config.get_active_model()
provider = config.get_provider_for_model(model)
if provider.backend != Backend.MISTRAL:
return None
env_var = provider.api_key_env_var
return os.getenv(env_var) if env_var else None
except ValueError:
return None
def _is_enabled(self) -> bool:
"""Check if telemetry is enabled in the current config."""
try:
return self._config_getter().enable_telemetry
except ValueError:
return False
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0),
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
)
return self._client
def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None:
mistral_api_key = self._get_mistral_api_key()
if mistral_api_key is None or not self._is_enabled():
return
user_agent = self._get_telemetry_user_agent()
async def _send() -> None:
try:
await self.client.post(
DATALAKE_EVENTS_URL,
json={"event": event_name, "properties": properties},
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {mistral_api_key}",
"User-Agent": user_agent,
},
)
except Exception:
pass # Silently swallow all exceptions for fire-and-forget telemetry
task = asyncio.create_task(_send())
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
async def aclose(self) -> None:
if self._pending_tasks:
await asyncio.gather(*self._pending_tasks, return_exceptions=True)
if self._client is not None:
await self._client.aclose()
self._client = None
def _calculate_file_metrics(
self,
tool_call: ResolvedToolCall,
status: Literal["success", "failure", "skipped"],
result: dict[str, Any] | None = None,
) -> tuple[int, int]:
nb_files_created = 0
nb_files_modified = 0
if status == "success" and result is not None:
if tool_call.tool_name == "write_file":
file_existed = result.get("file_existed", False)
if file_existed:
nb_files_modified = 1
else:
nb_files_created = 1
elif tool_call.tool_name == "search_replace":
nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0
return nb_files_created, nb_files_modified
def send_tool_call_finished(
self,
*,
tool_call: ResolvedToolCall,
status: Literal["success", "failure", "skipped"],
decision: ToolDecision | None,
agent_profile_name: str,
result: dict[str, Any] | None = None,
) -> None:
verdict_value = decision.verdict.value if decision else None
approval_type_value = decision.approval_type.value if decision else None
nb_files_created, nb_files_modified = self._calculate_file_metrics(
tool_call, status, result
)
payload = {
"tool_name": tool_call.tool_name,
"status": status,
"decision": verdict_value,
"approval_type": approval_type_value,
"agent_profile_name": agent_profile_name,
"nb_files_created": nb_files_created,
"nb_files_modified": nb_files_modified,
}
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)
def send_user_cancelled_action(self, action: str) -> None:
payload = {"action": action}
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)
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)
def send_new_session(
self,
has_agents_md: bool,
nb_skills: int,
nb_mcp_servers: int,
nb_models: int,
entrypoint: Literal["cli", "acp", "programmatic"],
) -> None:
payload = {
"has_agents_md": has_agents_md,
"nb_skills": nb_skills,
"nb_mcp_servers": nb_mcp_servers,
"nb_models": nb_models,
"entrypoint": entrypoint,
"version": __version__,
}
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__}
)

View file

@ -1,10 +1,14 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import contextlib
from datetime import timedelta
import hashlib
from logging import getLogger
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
@ -24,6 +28,37 @@ from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
logger = getLogger("vibe")
def _stderr_logger_thread(read_fd: int) -> None:
with open(read_fd, "rb") as f:
for line in iter(f.readline, b""):
decoded = line.decode("utf-8", errors="replace").rstrip()
if decoded:
logger.debug(f"[MCP stderr] {decoded}")
@contextlib.asynccontextmanager
async def _mcp_stderr_capture() -> AsyncGenerator[TextIO, None]:
r, w = os.pipe()
errlog = None
thread_started = False
try:
thread = threading.Thread(target=_stderr_logger_thread, args=(r,), daemon=True)
thread.start()
thread_started = True
errlog = os.fdopen(w, "w")
yield errlog
finally:
if errlog is not None:
errlog.close()
elif thread_started:
os.close(w)
else:
os.close(r)
os.close(w)
class _OpenArgs(BaseModel):
model_config = ConfigDict(extra="allow")
@ -240,11 +275,14 @@ async def list_tools_stdio(
) -> list[RemoteTool]:
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with stdio_client(params) as (read, write):
async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async with (
_mcp_stderr_capture() as errlog,
stdio_client(params, errlog=errlog) as (read, write),
ClientSession(read, write, read_timeout_seconds=timeout) as session,
):
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_stdio(
@ -261,15 +299,16 @@ async def call_tool_stdio(
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with stdio_client(params) as (read, write):
async with ClientSession(
read, write, read_timeout_seconds=init_timeout
) as session:
await session.initialize()
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
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,
):
await session.initialize()
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
def create_mcp_stdio_proxy_tool_class(

View file

@ -7,16 +7,19 @@ import tomli_w
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
TRUSTABLE_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
def has_agents_md_file(path: Path) -> bool:
return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
def has_trustable_content(path: Path) -> bool:
if (path / ".vibe").exists():
return True
for name in TRUSTABLE_FILENAMES:
if (path / name).exists():
return True
return False
return (
(path / ".vibe").exists()
or (path / ".agents").exists()
or has_agents_md_file(path)
)
class TrustedFoldersManager:

View file

@ -191,6 +191,7 @@ class LLMMessage(BaseModel):
role: Role
content: Content | None = None
reasoning_content: Content | None = None
reasoning_signature: str | None = None
tool_calls: list[ToolCall] | None = None
name: str | None = None
tool_call_id: str | None = None
@ -210,6 +211,7 @@ class LLMMessage(BaseModel):
"role": role,
"content": getattr(v, "content", ""),
"reasoning_content": getattr(v, "reasoning_content", None),
"reasoning_signature": getattr(v, "reasoning_signature", None),
"tool_calls": getattr(v, "tool_calls", None),
"name": getattr(v, "name", None),
"tool_call_id": getattr(v, "tool_call_id", None),
@ -238,6 +240,12 @@ class LLMMessage(BaseModel):
if not reasoning_content:
reasoning_content = None
reasoning_signature = (self.reasoning_signature or "") + (
other.reasoning_signature or ""
)
if not reasoning_signature:
reasoning_signature = None
tool_calls_map = OrderedDict[int, ToolCall]()
for tool_calls in [self.tool_calls or [], other.tool_calls or []]:
for tc in tool_calls:
@ -263,6 +271,7 @@ class LLMMessage(BaseModel):
role=self.role,
content=content,
reasoning_content=reasoning_content,
reasoning_signature=reasoning_signature,
tool_calls=list(tool_calls_map.values()) or None,
name=self.name,
tool_call_id=self.tool_call_id,

View file

@ -8,6 +8,8 @@ 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
@ -139,16 +141,56 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.FileHandler(LOG_FILE.path, "a", "utf-8")],
)
logger = logging.getLogger("vibe")
def get_user_agent(backend: Backend) -> str:
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:
mistral_sdk_prefix = "mistral-client-python/"

View file

@ -13,8 +13,9 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.config import Backend, VibeConfig
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {
@ -129,6 +130,12 @@ class ApiKeyScreen(OnboardingScreen):
except OSError as err:
self.app.exit(f"save_error:{err}")
return
if self.provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(config_getter=VibeConfig)
telemetry.send_onboarding_api_key_added()
except Exception:
pass # Telemetry is fire-and-forget; don't fail onboarding
self.app.exit("completed")
def on_mouse_up(self, event: MouseUp) -> None:

View file

@ -1,9 +1,5 @@
# What's New in 2.1.0
# What's New in 2.2.0
- **UI redesign** — Refreshed interface with a cleaner layout and better performance when streaming.
- **Agent Skills standard** — Vibe now discovers skills from `.agents/skills/` (agentskills.io) as well as `.vibe/skills/`.
- **Themes removed** — Application themes have been removed; the UI now follows your terminals theme (colors and appearance).
- **Clipboard** — You can now disable autocopying in /config, and copy with Ctrl+Shift+C or Cmd+C (in terminals that support it).
- **Performance** — Various optimizations throughout the app for a more responsive UI.
Optional: usage and tool events are sent to our datalake to improve the product if you have a valid Mistral API key; set `disable_telemetry = true` in config to opt out.