v2.7.6 (#600)
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:
parent
e1a25caa52
commit
95336528f6
59 changed files with 3992 additions and 403 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
5
vibe/acp/commands/__init__.py
Normal file
5
vibe/acp/commands/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.acp.commands.registry import AcpCommandRegistry
|
||||
|
||||
__all__ = ["AcpCommandRegistry"]
|
||||
88
vibe/acp/commands/registry.py
Normal file
88
vibe/acp/commands/registry.py
Normal 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",
|
||||
),
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
14
vibe/core/tools/connectors/__init__.py
Normal file
14
vibe/core/tools/connectors/__init__.py
Normal 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"]
|
||||
417
vibe/core/tools/connectors/connector_registry.py
Normal file
417
vibe/core/tools/connectors/connector_registry.py
Normal 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 = {}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
104
vibe/core/utils/merge.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue