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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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