Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Pierre Rossinès 2026-04-16 11:10:26 +02:00 committed by GitHub
parent e1a25caa52
commit 95336528f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3992 additions and 403 deletions

View file

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

View file

@ -33,6 +33,7 @@ from acp.schema import (
AvailableCommandInput,
ClientCapabilities,
CloseSessionResponse,
ConfigOptionUpdate,
ContentToolCallContent,
Cost,
EnvVarAuthMethod,
@ -44,6 +45,8 @@ from acp.schema import (
PromptCapabilities,
ResumeSessionResponse,
SessionCapabilities,
SessionConfigOptionBoolean,
SessionConfigOptionSelect,
SessionInfo,
SessionListCapabilities,
SetSessionConfigOptionResponse,
@ -57,10 +60,11 @@ from acp.schema import (
Usage,
UsageUpdate,
)
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, SkipValidation
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.commands import AcpCommandRegistry
from vibe.acp.exceptions import (
ConfigurationError,
ConversationLimitError,
@ -109,6 +113,7 @@ from vibe.core.proxy_setup import (
unset_proxy_var,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import (
AgentProfileChangedEvent,
@ -143,6 +148,7 @@ class AcpSessionLoop(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
agent_loop: AgentLoop
command_registry: SkipValidation[AcpCommandRegistry]
task: asyncio.Task[None] | None = None
@ -254,9 +260,16 @@ class VibeAcpAgentLoop(AcpAgent):
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
) -> AcpSessionLoop:
session = AcpSessionLoop(id=session_id, agent_loop=agent_loop)
command_registry = AcpCommandRegistry()
session = AcpSessionLoop(
id=session_id, agent_loop=agent_loop, command_registry=command_registry
)
self.sessions[session.id] = session
command_registry.set_on_changed(
lambda: self._send_available_commands(session.id)
)
if not agent_loop.auto_approve:
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
@ -463,137 +476,41 @@ class VibeAcpAgentLoop(AcpAgent):
)
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"
)
),
),
AvailableCommand(
name="leanstall",
description="Install the Lean 4 agent (leanstral)",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
AvailableCommand(
name="data-retention",
description="Show data retention information",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
AvailableCommand(
name="unleanstall",
description="Uninstall the Lean 4 agent",
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
),
]
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, message_id: 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),
message_id=str(uuid4()),
),
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_leanstall_command(
self, session_id: str, message_id: str
) -> PromptResponse:
session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents)
if "lean" in current:
message = "Lean agent is already installed."
else:
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question"],
commands: list[AvailableCommand] = []
for cmd in session.command_registry.commands.values():
input_spec = (
AvailableCommandInput(
root=UnstructuredCommandInput(hint=cmd.input_hint)
)
if cmd.input_hint
else None
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
commands.append(
AvailableCommand(
name=cmd.name, description=cmd.description, input=input_spec
)
)
message = (
"Lean agent installed. Start a new session to switch to Lean mode."
builtin_names = set(session.command_registry.commands)
for skill in session.agent_loop.skill_manager.available_skills.values():
if not skill.user_invocable or skill.name in builtin_names:
continue
commands.append(
AvailableCommand(
name=skill.name,
description=skill.description,
input=AvailableCommandInput(
root=UnstructuredCommandInput(hint="instructions for the skill")
),
)
)
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
message_id=str(uuid4()),
),
session_id=session_id, update=update_available_commands(commands)
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_data_retention_command(
self, session_id: str, message_id: str
) -> PromptResponse:
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=DATA_RETENTION_MESSAGE),
message_id=str(uuid4()),
),
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_unleanstall_command(
self, session_id: str, message_id: str
) -> PromptResponse:
session = self._get_session(session_id)
current = list(session.agent_loop.base_config.installed_agents)
if "lean" not in current:
message = "Lean agent is not installed."
else:
VibeConfig.save_updates({
"installed_agents": [a for a in current if a != "lean"]
})
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(
base_config=new_config
)
message = "Lean agent uninstalled."
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
message_id=str(uuid4()),
),
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
@override
async def load_session(
@ -723,16 +640,8 @@ class VibeAcpAgentLoop(AcpAgent):
if not success:
return None
profiles = list(session.agent_loop.agent_manager.available_agents.values())
_, modes_config = make_mode_response(
profiles, session.agent_loop.agent_profile.name
)
_, models_config = make_model_response(
session.agent_loop.config.models, session.agent_loop.config.active_model
)
return SetSessionConfigOptionResponse(
config_options=[modes_config, models_config]
config_options=self._build_config_options(session)
)
@override
@ -779,23 +688,21 @@ class VibeAcpAgentLoop(AcpAgent):
text_prompt = self._build_text_prompt(prompt)
resolved_message_id = _resolved_user_message_id(message_id)
if text_prompt.strip().lower().startswith("/proxy-setup"):
return await self._handle_proxy_setup_command(
session_id, text_prompt, resolved_message_id
)
if command_response := await self._maybe_handle_builtin_command(
session, text_prompt, resolved_message_id
):
return command_response
if text_prompt.strip().lower().startswith("/unleanstall"):
return await self._handle_unleanstall_command(
session_id, resolved_message_id
)
try:
skill = session.agent_loop.skill_manager.parse_skill_command(text_prompt)
except OSError as e:
raise InternalError(f"Failed to read skill file: {e}") from e
if text_prompt.strip().lower().startswith("/leanstall"):
return await self._handle_leanstall_command(session_id, resolved_message_id)
if text_prompt.strip().lower().startswith("/data-retention"):
return await self._handle_data_retention_command(
session_id, resolved_message_id
if skill:
session.agent_loop.telemetry_client.send_slash_command_used(
skill.name, "skill"
)
text_prompt = SkillManager.build_skill_prompt(text_prompt, skill)
async def agent_loop_task() -> None:
async for update in self._run_agent_loop(
@ -881,6 +788,22 @@ class VibeAcpAgentLoop(AcpAgent):
)
return text_prompt
async def _maybe_handle_builtin_command(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse | None:
normalized = text_prompt.strip().lower()
parts = normalized.split(None, 1)
if not parts or not parts[0].startswith("/"):
return None
cmd_name = parts[0][1:] # strip leading "/"
command = session.command_registry.get(cmd_name)
if command is None:
return None
handler = getattr(self, command.handler)
return await handler(session, text_prompt, message_id)
async def _run_agent_loop(
self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None
) -> AsyncGenerator[SessionUpdate]:
@ -990,6 +913,218 @@ class VibeAcpAgentLoop(AcpAgent):
def on_connect(self, conn: Client) -> None:
self.client = conn
# -- Command handlers ------------------------------------------------------
async def _command_reply(
self, session: AcpSessionLoop, text: str, message_id: str
) -> PromptResponse:
"""Send a text message to the client and return an end-turn response."""
await self.client.session_update(
session_id=session.id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=text),
message_id=str(uuid4()),
),
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_help(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
lines = ["### Available Commands", ""]
for cmd in session.command_registry.commands.values():
hint = f" `<{cmd.input_hint}>`" if cmd.input_hint else ""
lines.append(f"- `/{cmd.name}`{hint}: {cmd.description}")
builtin_names = set(session.command_registry.commands)
invocable = {
n: s
for n, s in session.agent_loop.skill_manager.available_skills.items()
if s.user_invocable and n not in builtin_names
}
if invocable:
lines.extend(["", "### Available Skills", ""])
for name, info in invocable.items():
lines.append(f"- `/{name}`: {info.description}")
return await self._command_reply(session, "\n".join(lines), message_id)
async def _handle_compact(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
if len(session.agent_loop.messages) <= 1:
return await self._command_reply(
session, "No conversation history to compact yet.", message_id
)
tool_call_id = str(uuid4())
old_tokens = session.agent_loop.stats.context_tokens
start_event = CompactStartEvent(
current_context_tokens=old_tokens or 0,
threshold=0,
tool_call_id=tool_call_id,
)
await self.client.session_update(
session_id=session.id,
update=create_compact_start_session_update(start_event),
)
await session.agent_loop.compact()
new_tokens = session.agent_loop.stats.context_tokens
end_event = CompactEndEvent(
old_context_tokens=old_tokens or 0,
new_context_tokens=new_tokens or 0,
summary_length=0,
tool_call_id=tool_call_id,
)
await self.client.session_update(
session_id=session.id, update=create_compact_end_session_update(end_event)
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _reload_session_config(self, session: AcpSessionLoop) -> None:
"""Reload config from disk and reinitialize the agent loop."""
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question"],
)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
async def _handle_reload(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
try:
await self._reload_session_config(session)
except Exception as e:
return await self._command_reply(
session, f"Failed to reload config: {e}", message_id
)
try:
await session.command_registry.notify_changed()
except Exception as e:
return await self._command_reply(
session,
f"Configuration reloaded, but failed to advertise updated commands: {e}",
message_id,
)
return await self._command_reply(
session,
"Configuration reloaded (includes agent instructions and skills).",
message_id,
)
async def _handle_log(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
logger = session.agent_loop.session_logger
if not logger.enabled:
return await self._command_reply(
session, "Session logging is disabled in configuration.", message_id
)
return await self._command_reply(
session,
f"## Current Log Directory\n\n`{logger.session_dir}`\n\n"
"You can send this directory to share your interaction.",
message_id,
)
async def _handle_proxy_setup(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
parts = text_prompt.strip().split(None, 1)
args = parts[1] if len(parts) > 1 else ""
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\n"
"Please start a new chat for changes to take effect."
)
else:
unset_proxy_var(key)
message = (
f"Removed `{key}` from ~/.vibe/.env\n\n"
"Please start a new chat for changes to take effect."
)
except ProxySetupError as e:
message = f"Error: {e}"
return await self._command_reply(session, message, message_id)
def _build_config_options(
self, session: AcpSessionLoop
) -> list[SessionConfigOptionSelect | SessionConfigOptionBoolean]:
"""Build the current modes + models config options for a session."""
profiles = list(session.agent_loop.agent_manager.available_agents.values())
_, modes_config = make_mode_response(
profiles, session.agent_loop.agent_profile.name
)
_, models_config = make_model_response(
session.agent_loop.config.models, session.agent_loop.config.active_model
)
return [modes_config, models_config]
async def _send_config_option_update(self, session: AcpSessionLoop) -> None:
"""Push updated config options (modes, models) to the client."""
await self.client.session_update(
session_id=session.id,
update=ConfigOptionUpdate(
session_update="config_option_update",
config_options=self._build_config_options(session),
),
)
async def _handle_leanstall(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
current = list(session.agent_loop.base_config.installed_agents)
if "lean" in current:
return await self._command_reply(
session, "Lean agent is already installed.", message_id
)
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
await self._reload_session_config(session)
await self._send_config_option_update(session)
return await self._command_reply(
session,
"Lean agent installed. Start a new session to switch to Lean mode.",
message_id,
)
async def _handle_unleanstall(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
current = list(session.agent_loop.base_config.installed_agents)
if "lean" not in current:
return await self._command_reply(
session, "Lean agent is not installed.", message_id
)
VibeConfig.save_updates({
"installed_agents": [a for a in current if a != "lean"]
})
await self._reload_session_config(session)
await self._send_config_option_update(session)
return await self._command_reply(session, "Lean agent uninstalled.", message_id)
async def _handle_data_retention(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
return await self._command_reply(session, DATA_RETENTION_MESSAGE, message_id)
def run_acp_server() -> None:
try:

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.acp.commands.registry import AcpCommandRegistry
__all__ = ["AcpCommandRegistry"]

View file

@ -0,0 +1,88 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
type OnCommandsChanged = Callable[[], Awaitable[None]]
@dataclass(frozen=True)
class AcpCommand:
"""Command advertised to ACP clients via available_commands_update."""
name: str
description: str
handler: str
input_hint: str | None = None
@dataclass
class AcpCommandRegistry:
"""Registry of ACP commands. Notifies listeners when commands change."""
_commands: dict[str, AcpCommand] = field(default_factory=dict)
_on_changed: OnCommandsChanged | None = None
def __post_init__(self) -> None:
if not self._commands:
self._commands = _build_commands()
def set_on_changed(self, callback: OnCommandsChanged) -> None:
self._on_changed = callback
@property
def commands(self) -> dict[str, AcpCommand]:
return self._commands
def get(self, name: str) -> AcpCommand | None:
return self._commands.get(name)
async def notify_changed(self) -> None:
if self._on_changed is not None:
await self._on_changed()
def _build_commands() -> dict[str, AcpCommand]:
return {
"help": AcpCommand(
name="help",
description="Show available commands and keyboard shortcuts",
handler="_handle_help",
),
"compact": AcpCommand(
name="compact",
description="Compact conversation history by summarizing",
handler="_handle_compact",
),
"reload": AcpCommand(
name="reload",
description="Reload configuration, agent instructions, and skills from disk",
handler="_handle_reload",
),
"log": AcpCommand(
name="log",
description="Show path to current session log directory",
handler="_handle_log",
),
"proxy-setup": AcpCommand(
name="proxy-setup",
description="Configure proxy and SSL certificate settings",
handler="_handle_proxy_setup",
input_hint="KEY value to set, KEY to unset, or empty for help",
),
"leanstall": AcpCommand(
name="leanstall",
description="Install the Lean 4 agent (leanstral)",
handler="_handle_leanstall",
),
"unleanstall": AcpCommand(
name="unleanstall",
description="Uninstall the Lean 4 agent",
handler="_handle_unleanstall",
),
"data-retention": AcpCommand(
name="data-retention",
description="Show data retention information",
handler="_handle_data_retention",
),
}

View file

@ -95,6 +95,11 @@ class CommandRegistry:
description="Display available MCP servers. Pass the name of a server to list its tools",
handler="_show_mcp",
),
"connectors": Command(
aliases=frozenset(["/connectors"]),
description="Manage workspace connectors. Subcommands: refresh",
handler="_handle_connectors",
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Configure voice settings",

View file

@ -126,6 +126,7 @@ from vibe.core.session.resume_sessions import (
short_session_id,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
@ -144,6 +145,8 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR, connectors_enabled
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
@ -161,7 +164,6 @@ from vibe.core.utils import (
get_user_cancellation_message,
is_dangerous_directory,
)
from vibe.core.utils.io import read_safe
class BottomApp(StrEnum):
@ -332,6 +334,8 @@ class VibeApp(App): # noqa: PLR0904
excluded_commands = []
if not self.config.nuage_enabled:
excluded_commands.append("teleport")
if not connectors_enabled():
excluded_commands.append("connectors")
self.commands = CommandRegistry(excluded_commands=excluded_commands)
self._chat_input_container: ChatInputContainer | None = None
@ -376,10 +380,17 @@ class VibeApp(App): # noqa: PLR0904
def config(self) -> VibeConfig:
return self.agent_loop.config
@property
def _connectors_enabled(self) -> bool:
return connectors_enabled() and self.agent_loop.connector_registry is not None
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
self._banner = Banner(
self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry
config=self.config,
skill_manager=self.agent_loop.skill_manager,
mcp_registry=self.agent_loop.mcp_registry,
connector_registry=self.agent_loop.connector_registry,
)
yield self._banner
yield VerticalGroup(id="messages")
@ -774,25 +785,11 @@ class VibeApp(App): # noqa: PLR0904
]
async def _handle_skill(self, user_input: str) -> bool:
if not user_input.startswith("/"):
return False
if not self.agent_loop:
return False
parts = user_input[1:].strip().split(None, 1)
if not parts:
return False
skill_name = parts[0].lower()
skill_info = self.agent_loop.skill_manager.get_skill(skill_name)
if not skill_info:
return False
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = read_safe(skill_info.skill_path).text
skill = self.agent_loop.skill_manager.parse_skill_command(user_input)
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@ -801,10 +798,12 @@ class VibeApp(App): # noqa: PLR0904
)
return True
if len(parts) > 1:
skill_content = f"{user_input}\n\n{skill_content}"
if skill is None:
return False
await self._handle_user_message(skill_content)
self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill")
prompt = SkillManager.build_skill_prompt(user_input, skill)
await self._handle_user_message(prompt)
return True
async def _handle_bash_command(self, command: str) -> None:
@ -1038,11 +1037,14 @@ class VibeApp(App): # noqa: PLR0904
return
if before is not None:
await messages_area.mount_all(widgets, before=before)
return
if after is not None:
elif after is not None:
await messages_area.mount_all(widgets, after=after)
return
await messages_area.mount_all(widgets)
else:
await messages_area.mount_all(widgets)
for widget in widgets:
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
return tool in self.agent_loop.tool_manager.available_tools
@ -1328,19 +1330,36 @@ class VibeApp(App): # noqa: PLR0904
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
mcp_servers = self.config.mcp_servers
if not mcp_servers:
await self._mount_and_scroll(
UserCommandMessage("No MCP servers configured.")
connector_registry = (
self.agent_loop.connector_registry if self._connectors_enabled else None
)
has_connectors = (
connector_registry is not None and connector_registry.connector_count > 0
)
if not mcp_servers and not has_connectors:
msg = (
"No MCP servers or connectors configured."
if self._connectors_enabled
else "No MCP servers configured."
)
await self._mount_and_scroll(UserCommandMessage(msg))
return
if self._current_bottom_app == BottomApp.MCP:
return
name = cmd_args.strip()
if name and not any(s.name == name for s in mcp_servers):
connector_names = (
connector_registry.get_connector_names() if connector_registry else []
)
if (
name
and not any(s.name == name for s in mcp_servers)
and name not in connector_names
):
all_names = [s.name for s in mcp_servers] + connector_names
entity = "MCP server or connector" if has_connectors else "MCP server"
await self._mount_and_scroll(
ErrorMessage(
f"Unknown MCP server: {name}. Known servers: "
+ ", ".join(s.name for s in mcp_servers),
f"Unknown {entity}: {name}. Known: " + ", ".join(all_names),
collapsed=self._tools_collapsed,
)
)
@ -1351,9 +1370,52 @@ class VibeApp(App): # noqa: PLR0904
mcp_servers=mcp_servers,
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
connector_registry=connector_registry,
)
)
async def _handle_connectors(self, cmd_args: str = "", **kwargs: Any) -> None:
if not self._connectors_enabled:
await self._mount_and_scroll(
UserCommandMessage(
f"Connectors are disabled. Set {CONNECTORS_ENV_VAR}=1 to enable."
)
)
return
registry = self.agent_loop.connector_registry
assert registry is not None # guaranteed by _connectors_enabled
subcmd = cmd_args.strip().lower()
match subcmd:
case "refresh":
registry.clear()
tool_manager = self.agent_loop.tool_manager
await tool_manager.integrate_connectors_async()
self.agent_loop.refresh_system_prompt()
self._refresh_banner()
count = registry.connector_count
tools = sum(
1
for cls in tool_manager.registered_tools.values()
if issubclass(cls, MCPTool) and cls.is_connector()
)
await self._mount_and_scroll(
UserCommandMessage(
f"Connectors refreshed: {count} connectors, {tools} tools"
)
)
case "":
await self._mount_and_scroll(
UserCommandMessage("Usage: /connectors refresh")
)
case _:
await self._mount_and_scroll(
ErrorMessage(
f"Unknown subcommand: {subcmd}. Available: refresh",
collapsed=self._tools_collapsed,
)
)
async def _show_status(self, **kwargs: Any) -> None:
stats = self.agent_loop.stats
status_text = f"""## Agent Statistics
@ -1598,7 +1660,8 @@ class VibeApp(App): # noqa: PLR0904
base_config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
connector_registry=self.agent_loop.connector_registry,
plan_description=plan_title(self._plan_info),
)
await self._mount_and_scroll(
UserCommandMessage(
@ -2314,7 +2377,8 @@ class VibeApp(App): # noqa: PLR0904
self.config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
connector_registry=self.agent_loop.connector_registry,
plan_description=plan_title(self._plan_info),
)
def _update_profile_widgets(self, profile: AgentProfile) -> None:

View file

@ -13,14 +13,24 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp.registry import MCPRegistry
def _pluralize(count: int, singular: str) -> str:
return f"{count} {singular}{'s' if count != 1 else ''}"
def _connector_count(registry: ConnectorRegistry | None) -> int:
return registry.connector_count if registry else 0
@dataclass
class BannerState:
active_model: str = ""
models_count: int = 0
mcp_servers_count: int = 0
connectors_count: int = 0
skills_count: int = 0
plan_description: str | None = None
@ -33,6 +43,7 @@ class Banner(Static):
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connector_registry: ConnectorRegistry | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -41,6 +52,7 @@ class Banner(Static):
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
connectors_count=_connector_count(connector_registry),
skills_count=len(skill_manager.available_skills),
plan_description=None,
)
@ -85,22 +97,25 @@ class Banner(Static):
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connector_registry: ConnectorRegistry | None = None,
plan_description: str | None = None,
) -> None:
self.state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
connectors_count=_connector_count(connector_registry),
skills_count=len(skill_manager.available_skills),
plan_description=plan_description,
)
def _format_meta_counts(self) -> str:
return (
f"{self.state.models_count} model{'s' if self.state.models_count != 1 else ''}"
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
)
parts = [_pluralize(self.state.models_count, "model")]
if self.state.connectors_count > 0:
parts.append(_pluralize(self.state.connectors_count, "connector"))
parts.append(_pluralize(self.state.mcp_servers_count, "MCP server"))
parts.append(_pluralize(self.state.skills_count, "skill"))
return " · ".join(parts)
def _format_plan(self) -> str:
return (

View file

@ -31,8 +31,6 @@ class ChatTextArea(TextArea):
show=False,
priority=True,
),
Binding("alt+left", "cursor_word_left", "Cursor word left", show=False),
Binding("alt+right", "cursor_word_right", "Cursor word right", show=False),
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]

View file

@ -13,6 +13,7 @@ from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.mcp.tools import MCPTool
if TYPE_CHECKING:
@ -22,26 +23,34 @@ if TYPE_CHECKING:
class MCPToolIndex(NamedTuple):
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]]
enabled_tools: dict[str, type[Any]]
def collect_mcp_tool_index(
mcp_servers: Sequence[MCPServer], tool_manager: ToolManager
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
connector_names: Sequence[str] = (),
) -> MCPToolIndex:
registered = tool_manager.registered_tools
available = tool_manager.available_tools
configured_servers = {server.name for server in mcp_servers}
connector_set = set(connector_names) if connectors_enabled() else set()
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
for tool_name, cls in registered.items():
if not issubclass(cls, MCPTool):
continue
server_name = cls.get_server_name()
if server_name is None or server_name not in configured_servers:
if server_name is None:
continue
server_tools.setdefault(server_name, []).append((tool_name, cls))
if cls.is_connector() and server_name in connector_set:
connector_tools.setdefault(server_name, []).append((tool_name, cls))
elif server_name in configured_servers:
server_tools.setdefault(server_name, []).append((tool_name, cls))
return MCPToolIndex(server_tools, enabled_tools=available)
return MCPToolIndex(server_tools, connector_tools, enabled_tools=available)
class MCPApp(Container):
@ -59,12 +68,21 @@ class MCPApp(Container):
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
initial_server: str = "",
connector_registry: ConnectorRegistry | None = None,
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
self._connector_registry = connector_registry
connector_names = (
connector_registry.get_connector_names() if connector_registry else []
)
self._connector_names = connector_names
self._tool_manager = tool_manager
self._index = collect_mcp_tool_index(mcp_servers, tool_manager)
self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names)
# Track both the name and the kind ("server" or "connector") to
# disambiguate entries that share the same normalised name.
self._viewing_server: str | None = initial_server.strip() or None
self._viewing_kind: str | None = None
def compose(self) -> ComposeResult:
with Vertical(id="mcp-content"):
@ -80,8 +98,12 @@ class MCPApp(Container):
def refresh_index(self) -> None:
"""Re-snapshot the tool index (e.g. after deferred MCP discovery)."""
self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager)
self._refresh_view(self._viewing_server)
if self._connector_registry:
self._connector_names = self._connector_registry.get_connector_names()
self._index = collect_mcp_tool_index(
self._mcp_servers, self._tool_manager, self._connector_names
)
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
@ -89,7 +111,9 @@ class MCPApp(Container):
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id or ""
if option_id.startswith("server:"):
self._refresh_view(option_id.removeprefix("server:"))
self._refresh_view(option_id.removeprefix("server:"), kind="server")
elif option_id.startswith("connector:"):
self._refresh_view(option_id.removeprefix("connector:"), kind="connector")
def action_back(self) -> None:
if self._viewing_server is not None:
@ -98,50 +122,150 @@ class MCPApp(Container):
def action_close(self) -> None:
self.post_message(self.MCPClosed())
def _refresh_view(self, server_name: str | None) -> None:
# ── list view ────────────────────────────────────────────────────
def _refresh_view(
self, server_name: str | None, *, kind: str | None = None
) -> None:
index = self._index
option_list = self.query_one(OptionList)
option_list.clear_options()
server_names = {s.name for s in self._mcp_servers}
if server_name is None or server_name not in server_names:
self._viewing_server = None
self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers")
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Enter Show tools Esc Close"
all_names = server_names | set(self._connector_names)
if server_name is None or server_name not in all_names:
self._show_list_view(option_list, index)
return
# Infer kind when not provided (e.g. initial_server from /mcp <name>).
# Prefer server over connector when the name is ambiguous.
if kind is None:
if server_name in server_names:
kind = "server"
else:
kind = "connector"
self._show_detail_view(server_name, option_list, index, kind=kind)
def _show_list_view(self, option_list: OptionList, index: MCPToolIndex) -> None:
self._viewing_server = None
self._viewing_kind = None
has_connectors = connectors_enabled() and bool(self._connector_names)
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
self.query_one("#mcp-title", NoMarkupStatic).update(title)
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Enter Show tools Esc Close"
)
has_servers = bool(self._mcp_servers)
# ── Local MCP Servers ──
if has_servers:
max_name = max(len(srv.name) for srv in self._mcp_servers)
max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) # +[]
option_list.add_option(
Option(
Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True
)
)
for srv in self._mcp_servers:
tools = index.server_tools.get(srv.name, [])
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
status = _server_status(enabled)
type_tag = f"[{srv.transport}]"
label = Text(no_wrap=True)
label.append(srv.name)
label.append(f" [{srv.transport}] {status}")
label.append(f" {srv.name:<{max_name}}")
label.append(f" {type_tag:<{max_type}}", style="dim")
label.append(f" {_tool_count_text(enabled)}", style="dim")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
if self._mcp_servers:
option_list.highlighted = 0
return
# ── Workspace Connectors ──
if has_connectors:
max_name = max(len(n) for n in self._connector_names)
type_tag = "[connector]"
type_width = len(type_tag)
tool_texts = {
n: _tool_count_text(
sum(
1
for t, _ in index.connector_tools.get(n, [])
if t in index.enabled_tools
)
)
for n in self._connector_names
}
max_tools = max(len(t) for t in tool_texts.values())
if has_servers:
option_list.add_option(Option(Text("", no_wrap=True), disabled=True))
option_list.add_option(
Option(
Text("Workspace Connectors", style="bold", no_wrap=True),
disabled=True,
)
)
for cname in self._connector_names:
connected = (
self._connector_registry.is_connected(cname)
if self._connector_registry
else False
)
label = Text(no_wrap=True)
label.append(f" {cname:<{max_name}}")
label.append(f" {type_tag:<{type_width}}", style="dim")
label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim")
if connected:
label.append(" ")
label.append("", style="green")
label.append(" connected", style="dim")
else:
label.append(" ")
label.append("", style="dim")
label.append(" not connected", style="dim")
option_list.add_option(Option(label, id=f"connector:{cname}"))
if not has_servers and not has_connectors:
empty_msg = (
"No MCP servers or connectors configured"
if connectors_enabled()
else "No MCP servers configured"
)
option_list.add_option(Option(empty_msg, disabled=True))
if has_servers or has_connectors:
# Skip disabled header options (e.g. section labels).
first_enabled = next(
(i for i, opt in enumerate(option_list._options) if not opt.disabled), 0
)
option_list.highlighted = first_enabled
# ── detail view ──────────────────────────────────────────────────
def _show_detail_view(
self,
server_name: str,
option_list: OptionList,
index: MCPToolIndex,
*,
kind: str = "server",
) -> None:
self._viewing_server = server_name
self._viewing_kind = kind
is_connector = kind == "connector"
title_prefix = "Connector" if is_connector else "MCP Server"
self.query_one("#mcp-title", NoMarkupStatic).update(
f"MCP Server: {server_name}"
f"{title_prefix}: {server_name}"
)
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Backspace Back Esc Close"
)
enabled_tools = [
(tool_name, cls)
for tool_name, cls in sorted(
index.server_tools.get(server_name, []), key=lambda t: t[0]
)
if tool_name in index.enabled_tools
]
if not enabled_tools:
tools_source = index.connector_tools if is_connector else index.server_tools
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
visible_tools = [(n, c) for n, c in all_tools if n in index.enabled_tools]
if not visible_tools:
option_list.add_option(
Option("No enabled tools for this server", disabled=True)
)
return
for tool_name, cls in enabled_tools:
for tool_name, cls in visible_tools:
remote_name = cls.get_remote_name()
raw_desc = (
(cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0]
@ -151,12 +275,12 @@ class MCPApp(Container):
if raw_desc:
label.append(f" - {raw_desc}")
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
if enabled_tools:
if visible_tools:
option_list.highlighted = 0
def _server_status(enabled: int) -> str:
if enabled == 0:
return "unavailable"
noun = "tool" if enabled == 1 else "tools"
return f"{enabled} {noun} enabled"
def _tool_count_text(count: int) -> str:
if count == 0:
return "no tools"
noun = "tool" if count == 1 else "tools"
return f"{count} {noun}"

View file

@ -149,9 +149,7 @@ class AssistantMessage(StreamingMessageBase):
self.add_class("assistant-message")
def compose(self) -> ComposeResult:
if self._content:
self._content_initialized = True
markdown = Markdown(self._content)
markdown = Markdown("")
self._markdown = markdown
yield markdown

View file

@ -326,7 +326,27 @@ class QuestionApp(Container):
def _switch_question(self, new_idx: int) -> None:
self.current_question_idx = new_idx
self.selected_option = 0
self.selected_option = self._restore_cursor(new_idx)
def _restore_cursor(self, question_idx: int) -> int:
"""Return the option index to restore the cursor to for a previously answered question."""
q = self.questions[question_idx]
if q.multi_select:
if selections := self.multi_selections.get(question_idx):
return min(selections)
return 0
if question_idx not in self.answers:
return 0
answer_text, is_other = self.answers[question_idx]
if is_other:
return len(q.options)
return next(
(i for i, opt in enumerate(q.options) if opt.label == answer_text), 0
)
def action_next_question(self) -> None:
if self._is_other_selected:
@ -427,7 +447,32 @@ class QuestionApp(Container):
elif not has_text and other_idx in selections:
selections.discard(other_idx)
def _handle_number_key(self, event: events.Key) -> bool:
"""Handle number key press to quickly select an option. Returns True if handled."""
if self.other_input and self.other_input.has_focus:
return False
if not event.character or not event.character.isdigit():
return False
option_idx = int(event.character) - 1
# Only handle valid option indices, excluding submit button
if option_idx < 0 or option_idx >= len(self._current_question.options) + (
1 if self._has_other else 0
):
return False
event.stop()
event.prevent_default()
self.selected_option = option_idx
if not (self._has_other and option_idx == self._other_option_idx):
self.action_select()
return True
def on_key(self, event: events.Key) -> None:
if self._handle_number_key(event):
return
if len(self.questions) <= 1:
return
if self.other_input and self.other_input.has_focus:

View file

@ -6,6 +6,7 @@ import contextlib
import copy
from enum import StrEnum, auto
from http import HTTPStatus
import os
from pathlib import Path
import threading
from threading import Thread
@ -60,6 +61,7 @@ from vibe.core.tools.base import (
ToolPermission,
ToolPermissionError,
)
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
@ -100,6 +102,7 @@ from vibe.core.utils import (
TOOL_ERROR_TAG,
VIBE_STOP_EVENT_TAG,
CancellationReason,
get_server_url_from_api_base,
get_user_agent,
get_user_cancellation_message,
is_user_cancellation_event,
@ -170,6 +173,7 @@ class AgentLoop:
self._init_error: Exception | None = None
self.mcp_registry = MCPRegistry()
self.connector_registry = self._create_connector_registry()
self.agent_manager = AgentManager(
lambda: self._base_config,
initial_agent=agent_name,
@ -178,6 +182,7 @@ class AgentLoop:
self.tool_manager = ToolManager(
lambda: self.config,
mcp_registry=self.mcp_registry,
connector_registry=self.connector_registry,
defer_mcp=defer_heavy_init,
)
self.skill_manager = SkillManager(lambda: self.config)
@ -266,13 +271,13 @@ class AgentLoop:
return self._init_complete.is_set()
def _complete_init(self) -> None:
"""Run deferred heavy I/O: MCP discovery and git status.
"""Run deferred heavy I/O: MCP discovery, connectors, and git status.
Intended to be called from a background thread when
``defer_heavy_init=True`` was passed to ``__init__``.
"""
try:
self.tool_manager.integrate_mcp(raise_on_failure=True)
self.tool_manager.integrate_all(raise_on_mcp_failure=True)
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
@ -403,6 +408,29 @@ class AgentLoop:
terminal_emulator=terminal_emulator,
)
def _create_connector_registry(self) -> ConnectorRegistry | None:
if not connectors_enabled():
return None
provider = self._base_config.get_mistral_provider()
if provider is None:
return None
api_key_env = provider.api_key_env_var or "MISTRAL_API_KEY"
api_key = os.getenv(api_key_env, "")
if not api_key:
return None
server_url = get_server_url_from_api_base(provider.api_base)
return ConnectorRegistry(api_key=api_key, server_url=server_url)
def refresh_system_prompt(self) -> None:
"""Rebuild and replace the system prompt with current tool/skill state."""
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages.update_system_prompt(system_prompt)
def _select_backend(self) -> BackendLike:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
@ -582,6 +610,7 @@ class AgentLoop:
"call_type": (
"main_call" if self._is_user_prompt_call else "secondary_call"
),
"call_source": "vibe_code",
}
if self._current_user_message_id is not None:
metadata["message_id"] = self._current_user_message_id
@ -1342,7 +1371,9 @@ class AgentLoop:
self._max_price = max_price
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self.mcp_registry
lambda: self.config,
mcp_registry=self.mcp_registry,
connector_registry=self.connector_registry,
)
self.skill_manager = SkillManager(lambda: self.config)

View file

@ -319,7 +319,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"
thinking: Literal["off", "low", "medium", "high", "max"] = "off"
auto_compact_threshold: int = 200_000
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)

View file

@ -321,12 +321,14 @@ class AnthropicAdapter(APIAdapter):
BETA_FEATURES = (
"interleaved-thinking-2025-05-14,"
"fine-grained-tool-streaming-2025-05-14,"
"prompt-caching-2024-07-31"
"prompt-caching-2024-07-31,"
"context-1m-2025-08-07"
)
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
"low": 1024,
"medium": 10_000,
"high": 32_000,
"max": 128_000,
}
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
DEFAULT_MAX_TOKENS = 8192

View file

@ -175,6 +175,7 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = {
"low": "none",
"medium": "high",
"high": "high",
"max": "high",
}
@ -283,9 +284,10 @@ class MistralBackend:
reasoning_effort=reasoning_effort,
)
message = response.choices[0].message
parsed = (
self._mapper.parse_content(response.choices[0].message.content)
if response.choices[0].message.content
self._mapper.parse_content(message.content)
if message and message.content
else ParsedContent(content="", reasoning_content=None)
)
return LLMChunk(
@ -293,10 +295,8 @@ class MistralBackend:
role=Role.assistant,
content=parsed.content,
reasoning_content=parsed.reasoning_content,
tool_calls=self._mapper.parse_tool_calls(
response.choices[0].message.tool_calls
)
if response.choices[0].message.tool_calls
tool_calls=self._mapper.parse_tool_calls(message.tool_calls)
if message and message.tool_calls
else None,
),
usage=LLMUsage(

View file

@ -106,4 +106,5 @@ Prioritize technical accuracy over validating beliefs. Disagree when necessary.
When uncertain, investigate before confirming.
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
No over-the-top validation.
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed - the fix is better work, not more apology.
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.
Other: requests unrelated to code → respond helpfully as a general assistant.

View file

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
from vibe.core.utils import name_matches
from vibe.core.utils.io import read_safe
@ -127,3 +127,32 @@ class SkillManager:
def get_skill(self, name: str) -> SkillInfo | None:
return self.available_skills.get(name)
def parse_skill_command(self, text_prompt: str) -> ParsedSkillCommand | None:
stripped = text_prompt.strip()
if not stripped.startswith("/"):
return None
parts = stripped[1:].split(None, 1)
if not parts:
return None
skill_name = parts[0].lower()
skill_info = self.get_skill(skill_name)
if skill_info is None:
return None
skill_content = read_safe(skill_info.skill_path).text
extra_instructions = parts[1] if len(parts) > 1 else None
return ParsedSkillCommand(
name=skill_name,
content=skill_content,
extra_instructions=extra_instructions,
)
@staticmethod
def build_skill_prompt(text_prompt: str, parsed: ParsedSkillCommand) -> str:
if parsed.extra_instructions is not None:
return f"{text_prompt}\n\n{parsed.content}"
return parsed.content

View file

@ -90,3 +90,9 @@ class SkillInfo(BaseModel):
user_invocable=meta.user_invocable,
skill_path=skill_path.resolve(),
)
class ParsedSkillCommand(BaseModel):
name: str
content: str
extra_instructions: str | None = None

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import html
import os
from pathlib import Path
@ -37,85 +38,79 @@ class ProjectContextProvider:
_git_status_cache[self.root_path] = result
return result
def _run_git(
self, args: list[str], timeout: float
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
capture_output=True,
check=True,
cwd=self.root_path,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=timeout,
)
@staticmethod
def _format_git_status(status_output: str) -> str:
if not status_output:
return "(clean)"
status_lines = status_output.splitlines()
MAX_GIT_STATUS_SIZE = 50
if len(status_lines) > MAX_GIT_STATUS_SIZE:
return f"({len(status_lines)} changes - use 'git status' for details)"
return f"({len(status_lines)} changes)"
@staticmethod
def _parse_git_log(log_output: str) -> list[str]:
recent_commits: list[str] = []
for line in log_output.split("\n"):
if not (line := line.strip()):
continue
if " " in line:
commit_hash, commit_msg = line.split(" ", 1)
if (
"(" in commit_msg
and ")" in commit_msg
and (paren_index := commit_msg.rfind("(")) > 0
):
commit_msg = commit_msg[:paren_index].strip()
recent_commits.append(f"{commit_hash} {commit_msg}")
else:
recent_commits.append(line)
return recent_commits
def _fetch_git_status(self) -> str:
try:
timeout = min(self.config.timeout_seconds, 10.0)
num_commits = self.config.default_commit_count
current_branch = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True,
check=True,
cwd=self.root_path,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=timeout,
).stdout.strip()
with ThreadPoolExecutor(max_workers=4) as pool:
branch_future = pool.submit(
self._run_git, ["branch", "--show-current"], timeout
)
remote_future = pool.submit(self._run_git, ["branch", "-r"], timeout)
status_future = pool.submit(
self._run_git, ["status", "--porcelain"], timeout
)
log_future = pool.submit(
self._run_git,
["log", "--oneline", f"-{num_commits}", "--decorate"],
timeout,
)
current_branch = branch_future.result().stdout.strip()
main_branch = "main"
try:
branches_output = subprocess.run(
["git", "branch", "-r"],
capture_output=True,
check=True,
cwd=self.root_path,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=timeout,
).stdout
branches_output = remote_future.result().stdout
if "origin/master" in branches_output:
main_branch = "master"
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pass
status_output = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
check=True,
cwd=self.root_path,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=timeout,
).stdout.strip()
if status_output:
status_lines = status_output.splitlines()
MAX_GIT_STATUS_SIZE = 50
if len(status_lines) > MAX_GIT_STATUS_SIZE:
status = (
f"({len(status_lines)} changes - use 'git status' for details)"
)
else:
status = f"({len(status_lines)} changes)"
else:
status = "(clean)"
log_output = subprocess.run(
["git", "log", "--oneline", f"-{num_commits}", "--decorate"],
capture_output=True,
check=True,
cwd=self.root_path,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=timeout,
).stdout.strip()
recent_commits = []
for line in log_output.split("\n"):
if not (line := line.strip()):
continue
if " " in line:
commit_hash, commit_msg = line.split(" ", 1)
if (
"(" in commit_msg
and ")" in commit_msg
and (paren_index := commit_msg.rfind("(")) > 0
):
commit_msg = commit_msg[:paren_index].strip()
recent_commits.append(f"{commit_hash} {commit_msg}")
else:
recent_commits.append(line)
status = self._format_git_status(status_future.result().stdout.strip())
recent_commits = self._parse_git_log(log_future.result().stdout.strip())
git_info_parts = [
f"Current branch: {current_branch}",

View file

@ -16,7 +16,7 @@ from vibe.core.utils import get_server_url_from_api_base, get_user_agent
if TYPE_CHECKING:
from vibe.core.agent_loop import ToolDecision
_DEFAULT_TELEMETRY_BASE_URL = "https://codestral.mistral.ai"
_DEFAULT_TELEMETRY_BASE_URL = "https://api.mistral.ai"
_DATALAKE_EVENTS_PATH = "/v1/datalake/events"

View file

@ -0,0 +1,14 @@
from __future__ import annotations
import os
from vibe.core.tools.connectors.connector_registry import ConnectorRegistry
CONNECTORS_ENV_VAR = "EXPERIMENTAL_ENABLE_CONNECTORS"
def connectors_enabled() -> bool:
return os.getenv(CONNECTORS_ENV_VAR) == "1"
__all__ = ["CONNECTORS_ENV_VAR", "ConnectorRegistry", "connectors_enabled"]

View file

@ -0,0 +1,417 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
import re
from typing import TYPE_CHECKING, Any, ClassVar
import httpx
from mistralai.client import Mistral
from mistralai.client.models.connectorsqueryfilters import (
ConnectorsQueryFiltersTypedDict,
)
from vibe.core.logger import logger
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
)
from vibe.core.tools.mcp.tools import (
MCPTool,
MCPToolResult,
RemoteTool,
_OpenArgs,
call_tool_http,
)
from vibe.core.tools.ui import ToolResultDisplay
from vibe.core.types import ToolStreamEvent
from vibe.core.utils import run_sync
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
_LIST_PAGE_SIZE = 100
_LIST_QUERY_FILTERS: ConnectorsQueryFiltersTypedDict = {"active": True}
_TOOL_FETCH_TIMEOUT = 5.0
def _normalize_name(name: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
result = normalized.strip("_-")[:256]
return result or "unnamed"
def _connector_tool_to_remote(tool: dict[str, Any] | Any) -> RemoteTool | None:
"""Convert a ConnectorTool (SDK object or raw dict) to a RemoteTool."""
if isinstance(tool, dict):
name = tool.get("name")
description = tool.get("description")
schema = tool.get("jsonschema") or tool.get("json_schema")
else:
name = getattr(tool, "name", None)
description = getattr(tool, "description", None)
schema = getattr(tool, "jsonschema", None) or getattr(tool, "json_schema", None)
if not name:
return None
if schema is not None and not isinstance(schema, dict):
dump = getattr(schema, "model_dump", None)
schema = dump() if callable(dump) else None
return RemoteTool.model_validate({
"name": name,
"description": description,
"inputSchema": schema or {"type": "object", "properties": {}},
})
_DEFAULT_BASE_URL = "https://api.mistral.ai"
def _format_http_status_error(
exc: httpx.HTTPStatusError, connector_name: str, connector_id: str
) -> str:
"""Format an HTTP status error with response body for debugging."""
status = exc.response.status_code
connector_ref = f"'{connector_name}' (id: {connector_id})"
try:
body = exc.response.text[:500]
except Exception:
body = ""
match status:
case 401 | 403:
detail = (
f"Connector {connector_ref} authentication failed "
f"(HTTP {status}). Check your MISTRAL_API_KEY."
)
case 404:
detail = (
f"Connector {connector_ref} not found (HTTP 404). "
"It may have been deleted or is not accessible."
)
case _:
detail = f"Connector {connector_ref} request failed (HTTP {status})."
if body:
detail += f"\nServer response: {body}"
return detail
def _unwrap_http_status_error(exc: Exception) -> httpx.HTTPStatusError | None:
"""Extract an HTTPStatusError from an exception or ExceptionGroup."""
if isinstance(exc, httpx.HTTPStatusError):
return exc
if isinstance(exc, ExceptionGroup):
for inner in exc.exceptions:
if found := _unwrap_http_status_error(inner):
return found
if cause := exc.__cause__:
if isinstance(cause, httpx.HTTPStatusError):
return cause
return None
def _connector_error_message(
exc: Exception, connector_id: str, connector_name: str
) -> str:
"""Return an actionable error message for connector proxy failures."""
if http_err := _unwrap_http_status_error(exc):
return _format_http_status_error(http_err, connector_name, connector_id)
if isinstance(exc, httpx.TimeoutException):
return (
f"Connector '{connector_name}' timed out. "
"The remote service may be slow or unreachable."
)
if isinstance(exc, httpx.ConnectError):
return (
f"Cannot reach connector proxy for '{connector_name}'. "
"Check your network connection."
)
if isinstance(exc, ExceptionGroup):
messages = [str(e) for e in exc.exceptions]
return (
f"Connector '{connector_name}' call failed with multiple errors: "
+ "; ".join(messages)
)
return f"Connector '{connector_name}' call failed: {exc}"
def create_connector_proxy_tool_class(
*,
connector_name: str,
connector_alias: str,
connector_id: str,
remote: RemoteTool,
api_key: str,
server_url: str | None = None,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
alias = connector_alias
published_name = f"connector_{alias}_{remote.name}"
base_url = server_url or _DEFAULT_BASE_URL
class ConnectorProxyTool(MCPTool):
description: ClassVar[str] = f"[{alias}] " + (
remote.description or f"Connector tool '{remote.name}'"
)
_server_name: ClassVar[str] = alias
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_is_connector: ClassVar[bool] = True
_connector_id: ClassVar[str] = connector_id
_connector_name: ClassVar[str] = connector_name
_api_key: ClassVar[str] = api_key
_base_url: ClassVar[str] = base_url
@classmethod
def get_name(cls) -> str:
return published_name
@classmethod
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
async def run(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
url = (
f"{self._base_url}/v1/experimental/connectors/{self._connector_id}/mcp"
)
headers = {"Authorization": f"Bearer {self._api_key}"}
payload = args.model_dump(exclude_none=True)
try:
yield await call_tool_http(
url, self._remote_name, payload, headers=headers
)
except Exception as exc:
msg = _connector_error_message(
exc, self._connector_id, self._connector_name
)
raise ToolError(msg) from exc
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
return ToolResultDisplay(
success=False,
message=event.error or event.skip_reason or "No result",
)
message = f"Connector tool {event.result.tool} completed"
return ToolResultDisplay(success=event.result.ok, message=message)
@classmethod
def get_status_text(cls) -> str:
return f"Calling connector tool {remote.name}"
ConnectorProxyTool.__name__ = f"Connector_{alias}__{remote.name}"
return ConnectorProxyTool
class ConnectorRegistry:
"""Discovers connector tools from the Mistral API.
Fetches all connectors and their tools on first call, then caches.
"""
def __init__(self, api_key: str, server_url: str | None = None) -> None:
self._api_key = api_key
self._server_url = server_url
self._cache: dict[str, dict[str, type[BaseTool]]] | None = None
self._connector_names: list[str] = []
self._connector_connected: dict[str, bool] = {}
self._discover_lock = asyncio.Lock()
def _get_client(self) -> Mistral:
return Mistral(api_key=self._api_key, server_url=self._server_url)
def get_tools(self) -> dict[str, type[BaseTool]]:
"""Return proxy tool classes for all connectors, using cache when possible."""
return run_sync(self.get_tools_async())
async def get_tools_async(self) -> dict[str, type[BaseTool]]:
"""Return proxy tool classes for all connectors, using cache when possible."""
if self._cache is not None:
result: dict[str, type[BaseTool]] = {}
for tools in self._cache.values():
result.update(tools)
return result
return await self._discover_all()
async def _discover_all(self) -> dict[str, type[BaseTool]]:
async with self._discover_lock:
# Re-check under lock — another coroutine may have finished
# discovery while we waited.
if self._cache is not None:
result: dict[str, type[BaseTool]] = {}
for tools in self._cache.values():
result.update(tools)
return result
async with self._get_client() as client:
connectors: list[Any] = []
try:
page = await client.beta.connectors.list_async(
page_size=_LIST_PAGE_SIZE, query_filters=_LIST_QUERY_FILTERS
)
connectors.extend(page.items or [])
while page.pagination and page.pagination.next_cursor:
page = await client.beta.connectors.list_async(
page_size=_LIST_PAGE_SIZE,
cursor=page.pagination.next_cursor,
query_filters=_LIST_QUERY_FILTERS,
)
connectors.extend(page.items or [])
except Exception as exc:
logger.warning(f"Failed to list connectors: {exc}")
self._cache = {}
self._connector_names = []
self._connector_connected = {}
return {}
# Build results locally to avoid publishing incomplete state.
cache: dict[str, dict[str, type[BaseTool]]] = {}
all_tools: dict[str, type[BaseTool]] = {}
connector_names: list[str] = []
connector_connected: dict[str, bool] = {}
# Deduplicate by normalized name, preserving order.
# When two connectors share the same alias, disambiguate
# with a numeric suffix rather than silently dropping.
seen_names: set[str] = set()
unique_connectors: list[tuple[str, str, Any]] = []
for connector in connectors:
connector_id = str(connector.id)
raw_name = connector.name or connector_id
alias = _normalize_name(raw_name)
if alias in seen_names:
original = alias
suffix = 2
while f"{alias}_{suffix}" in seen_names:
suffix += 1
alias = f"{alias}_{suffix}"
logger.warning(
f"Connector {raw_name!r} alias {original!r} collides, "
f"using {alias!r}"
)
seen_names.add(alias)
unique_connectors.append((connector_id, alias, connector))
# Fetch tools for all connectors in parallel.
tasks = [
self._fetch_connector_tools(client, c, alias)
for _, alias, c in unique_connectors
]
results = await asyncio.gather(*tasks)
for (connector_id, alias, _), tools_map in zip(
unique_connectors, results, strict=True
):
connector_names.append(alias)
if tools_map is None:
# Timeout or error — show in UI but don't cache so
# a refresh will retry discovery for this connector.
connector_connected[alias] = False
continue
cache[connector_id] = tools_map
all_tools.update(tools_map)
# TODO: replace with actual API field when available
connector_connected[alias] = bool(tools_map)
# Publish atomically — concurrent callers waiting on the
# lock will see the completed cache.
self._connector_names = connector_names
self._connector_connected = connector_connected
self._cache = cache
return all_tools
async def _fetch_connector_tools(
self, client: Mistral, connector: Any, connector_alias: str
) -> dict[str, type[BaseTool]] | None:
"""Fetch tools for a single connector via ``list_tools_async``.
The list endpoint does not always include tools inline, so we
call ``list_tools_async`` per connector to get the full set.
"""
connector_id = str(connector.id)
name = connector.name or connector_id
try:
response = await asyncio.wait_for(
client.beta.connectors.list_tools_async(
connector_id_or_name=connector_id
),
timeout=_TOOL_FETCH_TIMEOUT,
)
except TimeoutError:
logger.warning(
f"Timeout fetching tools for connector {name} (>{_TOOL_FETCH_TIMEOUT}s)"
)
return None
except Exception as exc:
logger.warning(f"Failed to list tools for connector {name}: {exc}")
return None
# The SDK may return a plain list, an iterable wrapper, or an
# object with a `data`/`items` attribute. Handle all cases.
if isinstance(response, list):
raw_tools: list[Any] = response
elif hasattr(response, "data"):
raw_tools = list(response.data or [])
elif hasattr(response, "items"):
raw_tools = list(response.items or [])
else:
try:
raw_tools = list(response)
except TypeError:
logger.warning(
f"Unexpected response type from list_tools_async for {name}: "
f"{type(response).__name__}"
)
raw_tools = []
result: dict[str, type[BaseTool]] = {}
for tool in raw_tools:
remote = _connector_tool_to_remote(tool)
if remote is None:
continue
try:
proxy_cls = create_connector_proxy_tool_class(
connector_name=name,
connector_alias=connector_alias,
connector_id=connector_id,
remote=remote,
api_key=self._api_key,
server_url=self._server_url,
)
result[proxy_cls.get_name()] = proxy_cls
except Exception as exc:
tool_name = (
tool.get("name")
if isinstance(tool, dict)
else getattr(tool, "name", "<unknown>")
)
logger.warning(
f"Failed to register connector tool {tool_name} for {name}: {exc}"
)
return result
@property
def connector_count(self) -> int:
if self._cache is None:
return 0
return len(self._connector_names)
def get_connector_names(self) -> list[str]:
return list(self._connector_names)
def is_connected(self, name: str) -> bool:
return self._connector_connected.get(name, False)
def clear(self) -> None:
self._cache = None
self._connector_names = []
self._connector_connected = {}

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterator
import hashlib
import importlib.util
@ -14,8 +15,10 @@ from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import DEFAULT_TOOL_DIR
from vibe.core.tools.base import BaseTool, BaseToolConfig
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.utils import name_matches
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.utils import name_matches, run_sync
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
@ -70,11 +73,13 @@ class ToolManager:
self,
config_getter: Callable[[], VibeConfig],
mcp_registry: MCPRegistry | None = None,
connector_registry: ConnectorRegistry | None = None,
*,
defer_mcp: bool = False,
) -> None:
self._config_getter = config_getter
self._mcp_registry = mcp_registry or MCPRegistry()
self._connector_registry = connector_registry
self._instances: dict[str, BaseTool] = {}
self._search_paths: list[Path] = self._compute_search_paths(self._config)
self._lock = threading.Lock()
@ -84,7 +89,7 @@ class ToolManager:
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
}
if not defer_mcp:
self.integrate_mcp()
self.integrate_all()
@property
def _config(self) -> VibeConfig:
@ -207,18 +212,24 @@ class ToolManager:
return runtime_available
def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
"""Discover and register MCP tools.
"""Discover and register MCP tools (sync wrapper).
Idempotent: subsequent calls after a successful integration are
no-ops to avoid redundant MCP discovery.
"""
run_sync(self._integrate_mcp_async(raise_on_failure=raise_on_failure))
async def _integrate_mcp_async(self, *, raise_on_failure: bool = False) -> None:
"""Async MCP discovery — canonical implementation."""
if self._mcp_integrated:
return
if not self._config.mcp_servers:
return
try:
mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
mcp_tools = await self._mcp_registry.get_tools_async(
self._config.mcp_servers
)
except Exception as exc:
logger.warning("MCP integration failed: %s", exc)
if raise_on_failure:
@ -232,6 +243,71 @@ class ToolManager:
"MCP integration registered %d tools (via registry)", len(mcp_tools)
)
def _purge_connector_state(self) -> None:
"""Remove stale connector tool classes and cached instances."""
stale_keys = [
name
for name, cls in self._available.items()
if issubclass(cls, MCPTool) and cls.is_connector()
]
for key in stale_keys:
self._available.pop(key, None)
self._instances.pop(key, None)
def integrate_connectors(self) -> None:
"""Discover and register connector tools (sync wrapper)."""
run_sync(self.integrate_connectors_async())
async def integrate_connectors_async(self) -> None:
"""Discover and register connector tools — canonical implementation.
Thread-safe: can be called from the deferred-init background thread.
"""
if self._connector_registry is None:
return
try:
connector_tools = await self._connector_registry.get_tools_async()
except Exception as exc:
logger.warning(f"Connector integration failed: {exc}")
with self._lock:
self._purge_connector_state()
return
with self._lock:
self._purge_connector_state()
self._available.update(connector_tools)
logger.info(f"Connector integration registered {len(connector_tools)} tools")
def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None:
"""Discover MCP and connector tools in parallel.
Runs both async discovery paths concurrently via ``asyncio.gather``
inside a single ``run_sync`` call.
"""
run_sync(self._integrate_all_async(raise_on_mcp_failure=raise_on_mcp_failure))
async def _integrate_all_async(self, *, raise_on_mcp_failure: bool = False) -> None:
"""Run MCP and connector discovery concurrently.
Uses ``return_exceptions=True`` so that a failing MCP server does
not cancel in-flight connector discovery (or vice-versa).
"""
mcp_result, connector_result = await asyncio.gather(
self._integrate_mcp_async(raise_on_failure=raise_on_mcp_failure),
self.integrate_connectors_async(),
return_exceptions=True,
)
# Re-raise MCP errors when the caller asked for them.
if isinstance(mcp_result, BaseException):
if raise_on_mcp_failure:
raise mcp_result
logger.warning(f"MCP integration failed: {mcp_result}")
if isinstance(connector_result, BaseException):
logger.warning(f"Connector integration failed: {connector_result}")
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
with self._lock:
tool_class = self._available.get(tool_name)

View file

@ -36,6 +36,12 @@ class MCPRegistry:
def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]:
"""Return proxy tool classes for *servers*, using cache when possible."""
return run_sync(self.get_tools_async(servers))
async def get_tools_async(
self, servers: list[MCPServer]
) -> dict[str, type[BaseTool]]:
"""Async variant of :meth:`get_tools`."""
result: dict[str, type[BaseTool]] = {}
to_discover: list[tuple[str, MCPServer]] = []
@ -47,7 +53,7 @@ class MCPRegistry:
to_discover.append((key, srv))
if to_discover:
discovered = run_sync(self._discover_all(to_discover))
discovered = await self._discover_all(to_discover)
result.update(discovered)
return result

View file

@ -77,6 +77,7 @@ class MCPTool(
):
_server_name: ClassVar[str] = ""
_remote_name: ClassVar[str] = ""
_is_connector: ClassVar[bool] = False
@classmethod
def get_server_name(cls) -> str | None:
@ -86,6 +87,10 @@ class MCPTool(
def get_remote_name(cls) -> str:
return cls._remote_name or cls.get_name()
@classmethod
def is_connector(cls) -> bool:
return cls._is_connector
class RemoteTool(BaseModel):
model_config = ConfigDict(from_attributes=True)

View file

@ -14,6 +14,7 @@ from vibe.core.utils.concurrency import (
from vibe.core.utils.display import compact_reduction_display
from vibe.core.utils.http import get_server_url_from_api_base, get_user_agent
from vibe.core.utils.matching import name_matches
from vibe.core.utils.merge import MergeConflictError, MergeStrategy
from vibe.core.utils.paths import is_dangerous_directory
from vibe.core.utils.platform import is_windows
from vibe.core.utils.retry import async_generator_retry, async_retry
@ -39,6 +40,8 @@ __all__ = [
"AsyncExecutor",
"CancellationReason",
"ConversationLimitException",
"MergeConflictError",
"MergeStrategy",
"TaggedText",
"async_generator_retry",
"async_retry",

View file

@ -15,5 +15,5 @@ def get_user_agent(backend: Backend | None) -> str:
def get_server_url_from_api_base(api_base: str) -> str | None:
match = re.match(r"(https?://[^/]+)(/v.*)", api_base)
match = re.match(r"(https?://.+)(/v\d+.*)", api_base)
return match.group(1) if match else None

104
vibe/core/utils/merge.py Normal file
View file

@ -0,0 +1,104 @@
from __future__ import annotations
from collections.abc import Callable
from enum import StrEnum, auto
from typing import Any
class MergeConflictError(Exception):
"""Raised when CONFLICT strategy detects two layers both providing a value."""
def __init__(self, field_name: str = "") -> None:
msg = (
f"Merge conflict on field '{field_name}'"
if field_name
else "Merge conflict"
)
super().__init__(msg)
self.field_name = field_name
class MergeStrategy(StrEnum):
REPLACE = auto()
CONCAT = auto()
UNION = auto()
MERGE = auto()
CONFLICT = auto()
def apply(
self, base: Any, override: Any, *, key_fn: Callable[[Any], str] | None = None
) -> Any:
"""Combine base and override according to this strategy."""
if self is MergeStrategy.REPLACE:
return self._replace(base, override)
if self is MergeStrategy.CONCAT:
return self._concat(base, override)
if self is MergeStrategy.UNION:
return self._union(base, override, key_fn)
if self is MergeStrategy.MERGE:
return self._merge(base, override)
if self is MergeStrategy.CONFLICT:
return self._conflict(base, override)
raise NotImplementedError(f"Merge strategy {self!r} is not implemented")
def _coalesce(self, base: Any, override: Any) -> tuple[bool, Any]:
"""Return the non-None operand when at most one side is present.
Returns a (resolved, value) pair:
- (True, <value>) one or both sides are None; use value as-is.
- (False, None) both sides are present; caller must merge them.
"""
if base is None:
return True, override
if override is None:
return True, base
return False, None
def _replace(self, base: Any, override: Any) -> Any:
resolved, value = self._coalesce(base, override)
return value if resolved else override
def _concat(self, base: Any, override: Any) -> Any:
resolved, value = self._coalesce(base, override)
if resolved:
return value
if not isinstance(base, list) or not isinstance(override, list):
raise TypeError(
f"CONCAT requires list operands, got {type(base).__name__} and {type(override).__name__}"
)
return base + override
def _union(
self, base: Any, override: Any, key_fn: Callable[[Any], str] | None
) -> Any:
resolved, value = self._coalesce(base, override)
if resolved:
return value
if key_fn is None:
raise ValueError("UNION strategy requires key_fn")
if not isinstance(base, list) or not isinstance(override, list):
raise TypeError(
f"UNION requires list operands, got {type(base).__name__} and {type(override).__name__}"
)
merged: dict[str, Any] = {}
for item in base:
merged[key_fn(item)] = item
for item in override:
merged[key_fn(item)] = item
return list(merged.values())
def _merge(self, base: Any, override: Any) -> Any:
resolved, value = self._coalesce(base, override)
if resolved:
return value
if not isinstance(base, dict) or not isinstance(override, dict):
raise TypeError(
f"MERGE requires dict operands, got {type(base).__name__} and {type(override).__name__}"
)
return {**base, **override}
def _conflict(self, base: Any, override: Any) -> Any:
resolved, value = self._coalesce(base, override)
if resolved:
return value
raise MergeConflictError()

View file

@ -1,4 +1,3 @@
# What's new in v2.7.5
# What's new in v2.7.6
- **Trust dialog**: Display detected files and LLM risks in the trust folder dialog
- **Faster startup**: Deferred MCP and git I/O to a background thread for faster CLI startup
- **Streaming fix**: Fixed markdown fence context loss causing streaming rendering problems