Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Jean-Baptiste Muscat <jeanbaptiste.muscatdupuis@mistral.ai>
Co-authored-by: JeroenvdV <JeroenvdV@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-05-05 14:40:11 +02:00 committed by GitHub
parent 71f373c60c
commit 4972dd5694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2892 additions and 842 deletions

View file

@ -52,6 +52,7 @@ from acp.schema import (
SessionConfigOptionSelect,
SessionForkCapabilities,
SessionInfo,
SessionInfoUpdate,
SessionListCapabilities,
SetSessionConfigOptionResponse,
SseMcpServer,
@ -122,6 +123,10 @@ from vibe.core.proxy_setup import (
set_proxy_var,
unset_proxy_var,
)
from vibe.core.session.saved_sessions import (
update_saved_session_title,
update_saved_session_title_at_path,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
@ -159,6 +164,15 @@ class ForkSessionParams(BaseModel):
message_id: str | None = Field(default=None, alias="messageId")
class SessionSetTitleRequest(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
session_id: str = Field(
validation_alias=AliasChoices("session_id", "sessionId"), min_length=1
)
title: str = Field(min_length=1)
class TelemetrySendNotification(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -167,7 +181,20 @@ class TelemetrySendNotification(BaseModel):
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {}
def _dispatch_at_mention_inserted(
client: TelemetryClient, properties: dict[str, Any]
) -> None:
client.send_at_mention_inserted(
nb_mentions=properties.get("nb_mentions", 0),
context_types=properties.get("context_types", {}),
file_extensions=properties.get("file_extensions"),
message_id=properties.get("message_id"),
)
_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {
"vibe.at_mention_inserted": _dispatch_at_mention_inserted
}
def _resolved_user_message_id(client_message_id: str | None) -> str:
@ -301,9 +328,20 @@ class VibeAcpAgentLoop(AcpAgent):
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
session.spawn(self._send_available_commands(session))
session.spawn(self._warm_up_agent_loop(agent_loop))
return session
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
without waiting for the user's first prompt. Errors are swallowed here
and will resurface on the first `act()` call via `requires_init`.
"""
try:
await agent_loop.wait_until_ready()
except Exception:
pass
def _create_agent_loop(
self, config: VibeConfig, agent_name: str, hook_config_result: Any = None
) -> AgentLoop:
@ -398,6 +436,11 @@ class VibeAcpAgentLoop(AcpAgent):
case ToolOption.ALLOW_ALWAYS:
session.agent_loop.approve_always(tool_name, required_permissions)
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS_PERMANENT:
session.agent_loop.approve_always(
tool_name, required_permissions, save_permanently=True
)
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE:
session.agent_loop.telemetry_client.send_user_cancelled_action(
"reject_approval"
@ -454,6 +497,29 @@ class VibeAcpAgentLoop(AcpAgent):
raise SessionNotFoundError(session_id)
return self.sessions[session_id]
def _find_acp_session_by_vibe_session_id(
self, session_id: str
) -> AcpSessionLoop | None:
for candidate in self.sessions.values():
if candidate.agent_loop.session_id == session_id:
return candidate
return None
def _load_session_logging_config(self) -> SessionLoggingConfig:
try:
return VibeConfig.load().session_logging
except MissingAPIKeyError:
try:
persisted_config = VibeConfig.get_persisted_config()
return SessionLoggingConfig.model_validate(
persisted_config.get("session_logging", {})
)
except Exception as e:
raise ConfigurationError(str(e)) from e
except Exception as e:
raise ConfigurationError(str(e)) from e
def _build_usage(self, session: AcpSessionLoop) -> Usage:
stats = session.agent_loop.stats
return Usage(
@ -1008,9 +1074,97 @@ class VibeAcpAgentLoop(AcpAgent):
) -> ResumeSessionResponse:
raise NotImplementedMethodError("resume_session")
async def _emit_session_info_update(
self, session_id: str, *, title: str, updated_at: str | None
) -> None:
update_kwargs: dict[str, Any] = {
"session_update": "session_info_update",
"title": title,
}
if updated_at is not None:
update_kwargs["updated_at"] = updated_at
await self.client.session_update(
session_id=session_id, update=SessionInfoUpdate(**update_kwargs)
)
async def _persist_live_session_title(
self, session: AcpSessionLoop, title: str
) -> dict[str, Any] | None:
logger = session.agent_loop.session_logger
if not logger.enabled or logger.session_dir is None:
return None
if not logger.metadata_filepath.exists():
return None
try:
return await update_saved_session_title_at_path(logger.session_dir, title)
except ValueError as exc:
raise InternalError(
f"Failed to persist title update for session {logger.session_id}: {exc}"
) from exc
def _set_live_session_title(self, session: AcpSessionLoop, title: str) -> None:
try:
session.agent_loop.session_logger.set_title(title)
except ValueError as exc:
raise InvalidRequestError(
f"Invalid ACP session title request: {exc}"
) from exc
async def _handle_session_set_title(self, params: dict[str, Any]) -> dict[str, Any]:
try:
request = SessionSetTitleRequest.model_validate(params)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP session title request: {exc}"
) from exc
live_session = self.sessions.get(
request.session_id
) or self._find_acp_session_by_vibe_session_id(request.session_id)
if live_session is None:
try:
metadata = await update_saved_session_title(
request.session_id,
request.title,
self._load_session_logging_config(),
)
except ValueError as exc:
raise SessionNotFoundError(request.session_id) from exc
await self._emit_session_info_update(
request.session_id,
title=request.title,
updated_at=metadata.get("end_time"),
)
return {}
persisted_metadata = await self._persist_live_session_title(
live_session, request.title
)
self._set_live_session_title(live_session, request.title)
updated_at = (
persisted_metadata.get("end_time")
if persisted_metadata is not None
else (
live_session.agent_loop.session_logger.session_metadata.end_time
if live_session.agent_loop.session_logger.session_metadata is not None
else None
)
)
await self._emit_session_info_update(
live_session.id, title=request.title, updated_at=updated_at
)
return {}
@override
async def ext_method(self, method: str, params: dict) -> dict:
raise NotImplementedMethodError("ext_method")
if method == "session/set_title":
return await self._handle_session_set_title(params)
raise NotImplementedMethodError(method)
@override
async def ext_notification(self, method: str, params: dict) -> None:

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from enum import StrEnum
from typing import TYPE_CHECKING, Literal, cast
from typing import TYPE_CHECKING
from acp.schema import (
AgentMessageChunk,
@ -9,6 +9,7 @@ from acp.schema import (
ContentToolCallContent,
ModelInfo,
PermissionOption,
PermissionOptionKind,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionMode,
@ -34,25 +35,31 @@ if TYPE_CHECKING:
class ToolOption(StrEnum):
ALLOW_ONCE = "allow_once"
ALLOW_ALWAYS = "allow_always"
ALLOW_ALWAYS_PERMANENT = "allow_always_permanent"
REJECT_ONCE = "reject_once"
REJECT_ALWAYS = "reject_always"
_KIND_ALLOW_ONCE: PermissionOptionKind = "allow_once"
_KIND_ALLOW_ALWAYS: PermissionOptionKind = "allow_always"
_KIND_REJECT_ONCE: PermissionOptionKind = "reject_once"
TOOL_OPTIONS = [
PermissionOption(
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
option_id=ToolOption.ALLOW_ONCE, name="Allow once", kind=_KIND_ALLOW_ONCE
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name="Allow for this session",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
name="Allow for remainder of this session",
kind=_KIND_ALLOW_ALWAYS,
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
option_id=ToolOption.ALLOW_ALWAYS_PERMANENT,
name="Always allow",
kind=_KIND_ALLOW_ALWAYS,
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE, name="Deny", kind=_KIND_REJECT_ONCE
),
]
@ -64,7 +71,6 @@ def build_permission_options(
if not required_permissions:
return TOOL_OPTIONS
labels = ", ".join(rp.label for rp in required_permissions)
permissions_meta = [
{
"scope": rp.scope,
@ -77,20 +83,22 @@ def build_permission_options(
return [
PermissionOption(
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
option_id=ToolOption.ALLOW_ONCE, name="Allow once", kind=_KIND_ALLOW_ONCE
),
PermissionOption(
option_id=ToolOption.ALLOW_ALWAYS,
name=f"Allow for this session: {labels}",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
name="Allow for remainder of this session",
kind=_KIND_ALLOW_ALWAYS,
field_meta={"required_permissions": permissions_meta},
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
option_id=ToolOption.ALLOW_ALWAYS_PERMANENT,
name="Always allow",
kind=_KIND_ALLOW_ALWAYS,
field_meta={"required_permissions": permissions_meta},
),
PermissionOption(
option_id=ToolOption.REJECT_ONCE, name="Deny", kind=_KIND_REJECT_ONCE
),
]