Initial commit

Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai>
Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai>
Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai>
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: Valentin Berard <val@mistral.ai>
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin Torroba 2025-12-09 13:13:22 +01:00 committed by Quentin Torroba
commit fa15fc977b
200 changed files with 30484 additions and 0 deletions

0
vibe/acp/__init__.py Normal file
View file

441
vibe/acp/acp_agent.py Normal file
View file

@ -0,0 +1,441 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from pathlib import Path
import sys
from typing import Any, cast, override
from acp import (
PROTOCOL_VERSION,
Agent as AcpAgent,
AgentSideConnection,
AuthenticateRequest,
CancelNotification,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
RequestError,
RequestPermissionRequest,
SessionNotification,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
stdio_streams,
)
from acp.helpers import ContentBlock, SessionUpdate
from acp.schema import (
AgentCapabilities,
AgentMessageChunk,
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
Implementation,
ModelInfo,
PromptCapabilities,
SessionModelState,
SessionModeState,
TextContentBlock,
TextResourceContents,
ToolCall,
)
from pydantic import BaseModel, ConfigDict
from vibe import VIBE_ROOT
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.session_update import (
tool_call_session_update,
tool_result_session_update,
)
from vibe.acp.utils import TOOL_OPTIONS, ToolOption, VibeSessionMode
from vibe.core import __version__
from vibe.core.agent import Agent as VibeAgent
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
from vibe.core.types import (
AssistantEvent,
AsyncApprovalCallback,
ToolCallEvent,
ToolResultEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
class AcpSession(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
agent: VibeAgent
mode_id: VibeSessionMode = VibeSessionMode.APPROVAL_REQUIRED
task: asyncio.Task[None] | None = None
class VibeAcpAgent(AcpAgent):
def __init__(self, connection: AgentSideConnection) -> None:
self.sessions: dict[str, AcpSession] = {}
self.connection = connection
self.client_capabilities = None
@override
async def initialize(self, params: InitializeRequest) -> InitializeResponse:
self.client_capabilities = params.clientCapabilities
# 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
# - uv tool install: `vibe-acp`, similar to dev mode, but uv takes care of path resolution
# - bundled binary: `./vibe-acp` from binary location
# The 2 first modes are working similarly, under the hood uv runs `/some/python /my/entrypoint.py``
# The last mode is quite different as our bundler also includes the python install.
# So sys.executable is already /path/to/binary/vibe-acp.
# For this reason, we make a distinction in the way we call the setup command
command = sys.executable
if "python" not in Path(command).name:
# It's the case for bundled binaries, we don't need any other arguments
args = ["--setup"]
else:
script_name = sys.argv[0]
args = [script_name, "--setup"]
auth_methods = [
AuthMethod(
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
field_meta={
"terminal-auth": {
"command": command,
"args": args,
"label": "Mistral Vibe Setup",
}
},
)
]
response = InitializeResponse(
agentCapabilities=AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
),
),
protocolVersion=PROTOCOL_VERSION,
agentInfo=Implementation(
name="@mistralai/mistral-vibe",
title="Mistral Vibe",
version=__version__,
),
authMethods=auth_methods,
)
return response
@override
async def authenticate(
self, params: AuthenticateRequest
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
@override
async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
capability_disabled_tools = self._get_disabled_tools_from_capabilities()
load_api_keys_from_env()
try:
config = VibeConfig.load(
workdir=Path(params.cwd),
tool_paths=[str(VIBE_ROOT / "acp" / "tools" / "builtins")],
disabled_tools=capability_disabled_tools,
)
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a new session"
}) from e
agent = VibeAgent(config=config, auto_approve=False, enable_streaming=True)
# NOTE: For now, we pin session.id to agent.session_id right after init time.
# We should just use agent.session_id everywhere, but it can still change during
# session lifetime (e.g. agent.compact is called).
# We should refactor agent.session_id to make it immutable in ACP context.
session = AcpSession(id=agent.session_id, agent=agent)
self.sessions[session.id] = session
if not agent.auto_approve:
agent.set_approval_callback(
self._create_approval_callback(agent.session_id)
)
response = NewSessionResponse(
sessionId=agent.session_id,
models=SessionModelState(
currentModelId=agent.config.active_model,
availableModels=[
ModelInfo(modelId=model.alias, name=model.alias)
for model in agent.config.models
],
),
modes=SessionModeState(
currentModeId=session.mode_id,
availableModes=VibeSessionMode.get_all_acp_session_modes(),
),
)
return response
def _get_disabled_tools_from_capabilities(self) -> list[str]:
if not self.client_capabilities:
return []
disabled: list[str] = []
if not self.client_capabilities.terminal:
disabled.append("bash")
if fs := self.client_capabilities.fs:
if not fs.readTextFile:
disabled.append("read_file")
if not fs.writeTextFile:
disabled.append("write_file")
disabled.append("search_replace")
return disabled
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
async def approval_callback(
tool_name: str, args: dict[str, Any], tool_call_id: str
) -> tuple[str, str | None]:
# Create the tool call update
tool_call = ToolCall(toolCallId=tool_call_id)
# Request permission from the user
request = RequestPermissionRequest(
sessionId=session_id, toolCall=tool_call, options=TOOL_OPTIONS
)
response = await self.connection.requestPermission(request)
# Parse the response using isinstance for proper type narrowing
if response.outcome.outcome == "selected":
outcome = cast(AllowedOutcome, response.outcome)
return self._handle_permission_selection(outcome.optionId)
else:
return (
"n",
str(
get_user_cancellation_message(
CancellationReason.OPERATION_CANCELLED
)
),
)
return approval_callback
@staticmethod
def _handle_permission_selection(option_id: str) -> tuple[str, str | None]:
match option_id:
case ToolOption.ALLOW_ONCE:
return ("y", None)
case ToolOption.ALLOW_ALWAYS:
return ("a", None)
case ToolOption.REJECT_ONCE:
return ("n", "User rejected the tool call, provide an alternative plan")
case _:
return ("n", f"Unknown option: {option_id}")
def _get_session(self, session_id: str) -> AcpSession:
if session_id not in self.sessions:
raise RequestError.invalid_params({"session": "Not found"})
return self.sessions[session_id]
@override
async def loadSession(self, params: LoadSessionRequest) -> None:
raise NotImplementedError()
@override
async def setSessionMode(
self, params: SetSessionModeRequest
) -> SetSessionModeResponse | None:
session = self._get_session(params.sessionId)
if not VibeSessionMode.is_valid(params.modeId):
return None
session.mode_id = VibeSessionMode(params.modeId)
session.agent.auto_approve = params.modeId == VibeSessionMode.AUTO_APPROVE
return SetSessionModeResponse()
@override
async def setSessionModel(
self, params: SetSessionModelRequest
) -> SetSessionModelResponse | None:
session = self._get_session(params.sessionId)
model_aliases = [model.alias for model in session.agent.config.models]
if params.modelId not in model_aliases:
return None
VibeConfig.save_updates({"active_model": params.modelId})
new_config = VibeConfig.load(
workdir=session.agent.config.workdir,
tool_paths=session.agent.config.tool_paths,
disabled_tools=self._get_disabled_tools_from_capabilities(),
)
await session.agent.reload_with_initial_messages(config=new_config)
return SetSessionModelResponse()
@override
async def prompt(self, params: PromptRequest) -> PromptResponse:
session = self._get_session(params.sessionId)
if session.task is not None:
raise RuntimeError(
"Concurrent prompts are not supported yet, wait for agent to finish"
)
text_prompt = self._build_text_prompt(params.prompt)
async def agent_task() -> None:
async for update in self._run_agent_loop(session, text_prompt):
await self.connection.sessionUpdate(
SessionNotification(sessionId=session.id, update=update)
)
try:
session.task = asyncio.create_task(agent_task())
await session.task
except asyncio.CancelledError:
return PromptResponse(stopReason="cancelled")
except Exception as e:
await self.connection.sessionUpdate(
SessionNotification(
sessionId=params.sessionId,
update=AgentMessageChunk(
sessionUpdate="agent_message_chunk",
content=TextContentBlock(type="text", text=f"Error: {e!s}"),
),
)
)
return PromptResponse(stopReason="refusal")
finally:
session.task = None
return PromptResponse(stopReason="end_turn")
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
text_prompt = ""
for block in acp_prompt:
separator = "\n\n" if text_prompt else ""
match block.type:
# NOTE: ACP supports annotations, but we don't use them here yet.
case "text":
text_prompt = f"{text_prompt}{separator}{block.text}"
case "resource":
block_content = (
block.resource.text
if isinstance(block.resource, TextResourceContents)
else block.resource.blob
)
fields = {"path": block.resource.uri, "content": block_content}
parts = [
f"{k}: {v}"
for k, v in fields.items()
if v is not None and (v or isinstance(v, (int, float)))
]
block_prompt = "\n".join(parts)
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case "resource_link":
# NOTE: we currently keep more information than just the URI
# making it more detailed than the output of the read_file tool.
# This is OK, but might be worth testing how it affect performance.
fields = {
"uri": block.uri,
"name": block.name,
"title": block.title,
"description": block.description,
"mimeType": block.mimeType,
"size": block.size,
}
parts = [
f"{k}: {v}"
for k, v in fields.items()
if v is not None and (v or isinstance(v, (int, float)))
]
block_prompt = "\n".join(parts)
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case _:
raise ValueError(f"Unsupported content block type: {block.type}")
return text_prompt
async def _run_agent_loop(
self, session: AcpSession, prompt: str
) -> AsyncGenerator[SessionUpdate]:
rendered_prompt = render_path_prompt(
prompt, base_dir=session.agent.config.effective_workdir
)
async for event in session.agent.act(rendered_prompt):
if isinstance(event, AssistantEvent):
yield AgentMessageChunk(
sessionUpdate="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
)
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(
tool_manager=session.agent.tool_manager,
connection=self.connection,
session_id=session.id,
tool_call_id=event.tool_call_id,
)
session_update = tool_call_session_update(event)
if session_update:
yield session_update
elif isinstance(event, ToolResultEvent):
session_update = tool_result_session_update(event)
if session_update:
yield session_update
@override
async def cancel(self, params: CancelNotification) -> None:
session = self._get_session(params.sessionId)
if session.task and not session.task.done():
session.task.cancel()
session.task = None
@override
async def extMethod(self, method: str, params: dict) -> dict:
raise NotImplementedError()
@override
async def extNotification(self, method: str, params: dict) -> None:
raise NotImplementedError()
async def _run_acp_server() -> None:
reader, writer = await stdio_streams()
AgentSideConnection(lambda connection: VibeAcpAgent(connection), writer, reader)
await asyncio.Event().wait()
def run_acp_server() -> None:
try:
asyncio.run(_run_acp_server())
except KeyboardInterrupt:
# This is expected when the server is terminated
pass
except Exception as e:
# Log any unexpected errors
print(f"ACP Agent Server error: {e}", file=sys.stderr)
raise

37
vibe/acp/entrypoint.py Normal file
View file

@ -0,0 +1,37 @@
from __future__ import annotations
import argparse
from dataclasses import dataclass
import sys
from vibe.acp.acp_agent import run_acp_server
from vibe.setup.onboarding import run_onboarding
# Configure line buffering for subprocess communication
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
sys.stderr.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
sys.stdin.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
@dataclass
class Arguments:
setup: bool
def parse_arguments() -> Arguments:
parser = argparse.ArgumentParser(description="Run Mistral Vibe in ACP mode")
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
args = parser.parse_args()
return Arguments(setup=args.setup)
def main() -> None:
args = parse_arguments()
if args.setup:
run_onboarding()
sys.exit(0)
run_acp_server()
if __name__ == "__main__":
main()

View file

100
vibe/acp/tools/base.py Normal file
View file

@ -0,0 +1,100 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Protocol, cast, runtime_checkable
from acp import AgentSideConnection, SessionNotification
from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import Field
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
class ToolCallSessionUpdateProtocol(Protocol):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: ...
@runtime_checkable
class ToolResultSessionUpdateProtocol(Protocol):
@classmethod
def tool_result_session_update(
cls, event: ToolResultEvent
) -> SessionUpdate | None: ...
class AcpToolState:
connection: AgentSideConnection | None = Field(
default=None, description="ACP agent-side connection"
)
session_id: str | None = Field(default=None, description="Current ACP session ID")
tool_call_id: str | None = Field(
default=None, description="Current ACP tool call ID"
)
class BaseAcpTool[ToolState: AcpToolState](BaseTool):
state: ToolState
@classmethod
def get_tool_instance(
cls, tool_name: str, tool_manager: ToolManager
) -> BaseAcpTool[AcpToolState]:
return cast(BaseAcpTool[AcpToolState], tool_manager.get(tool_name))
@classmethod
def update_tool_state(
cls,
*,
tool_manager: ToolManager,
connection: AgentSideConnection | None,
session_id: str | None,
tool_call_id: str | None,
) -> None:
tool_instance = cls.get_tool_instance(cls.get_name(), tool_manager)
tool_instance.state.connection = connection
tool_instance.state.session_id = session_id
tool_instance.state.tool_call_id = tool_call_id
@classmethod
@abstractmethod
def _get_tool_state_class(cls) -> type[ToolState]: ...
def _load_state(self) -> tuple[AgentSideConnection, str, str | None]:
if self.state.connection is None:
raise ToolError(
"Connection not available in tool state. This tool can only be used within an ACP session."
)
if self.state.session_id is None:
raise ToolError(
"Session ID not available in tool state. This tool can only be used within an ACP session."
)
return self.state.connection, self.state.session_id, self.state.tool_call_id
async def _send_in_progress_session_update(
self, content: list[ToolCallContentVariant] | None = None
) -> None:
connection, session_id, tool_call_id = self._load_state()
if tool_call_id is None:
return
try:
await connection.sessionUpdate(
SessionNotification(
sessionId=session_id,
update=ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=tool_call_id,
status="in_progress",
content=content,
),
)
)
except Exception as e:
logger.error(f"Failed to update session: {e!r}")

View file

@ -0,0 +1,144 @@
from __future__ import annotations
import asyncio
import shlex
from acp import CreateTerminalRequest, TerminalHandle
from acp.schema import (
EnvVariable,
TerminalToolCallContent,
ToolCallProgress,
ToolCallStart,
WaitForTerminalExitResponse,
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import logger
class AcpBashState(BaseToolState, AcpToolState):
pass
class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "bash.md"
state: AcpBashState
@classmethod
def _get_tool_state_class(cls) -> type[AcpBashState]:
return AcpBashState
async def run(self, args: BashArgs) -> BashResult:
connection, session_id, _ = self._load_state()
timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes
env, command, cmd_args = self._parse_command(args.command)
create_request = CreateTerminalRequest(
sessionId=session_id,
command=command,
args=cmd_args,
env=env,
cwd=str(self.config.effective_workdir),
outputByteLimit=max_bytes,
)
try:
terminal_handle = await connection.createTerminal(create_request)
except Exception as e:
raise ToolError(f"Failed to create terminal: {e!r}") from e
await self._send_in_progress_session_update([
TerminalToolCallContent(type="terminal", terminalId=terminal_handle.id)
])
try:
exit_response = await self._wait_for_terminal_exit(
terminal_handle, timeout, args.command
)
output_response = await terminal_handle.current_output()
return self._build_result(
command=args.command,
stdout=output_response.output,
stderr="",
returncode=exit_response.exitCode or 0,
)
finally:
try:
await terminal_handle.release()
except Exception as e:
logger.error(f"Failed to release terminal: {e!r}")
def _parse_command(
self, command_str: str
) -> tuple[list[EnvVariable], str, list[str]]:
parts = shlex.split(command_str)
env: list[EnvVariable] = []
command: str = ""
args: list[str] = []
for part in parts:
if "=" in part and not command:
key, value = part.split("=", 1)
env.append(EnvVariable(name=key, value=value))
elif not command:
command = part
else:
args.append(part)
return env, command, args
@classmethod
def get_summary(cls, args: BashArgs) -> str:
summary = f"{args.command}"
if args.timeout:
summary += f" (timeout {args.timeout}s)"
return summary
async def _wait_for_terminal_exit(
self, terminal_handle: TerminalHandle, timeout: int, command: str
) -> WaitForTerminalExitResponse:
try:
return await asyncio.wait_for(
terminal_handle.wait_for_exit(), timeout=timeout
)
except TimeoutError:
try:
await terminal_handle.kill()
except Exception as e:
logger.error(f"Failed to kill terminal: {e!r}")
raise self._build_timeout_error(command, timeout)
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart:
if not isinstance(event.args, BashArgs):
raise ValueError(f"Unexpected tool args: {event.args}")
return ToolCallStart(
sessionUpdate="tool_call",
title=Bash.get_summary(event.args),
content=None,
toolCallId=event.tool_call_id,
kind="execute",
rawInput=event.args.model_dump_json(),
)
@classmethod
def tool_result_session_update(
cls, event: ToolResultEvent
) -> ToolCallProgress | None:
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status="failed" if event.error else "completed",
)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
ReadFileArgs,
ReadFileResult,
ReadFileState,
_ReadResult,
)
ReadFileResult = ReadFileResult
class AcpReadFileState(ReadFileState, AcpToolState):
pass
class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
state: AcpReadFileState
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md"
@classmethod
def _get_tool_state_class(cls) -> type[AcpReadFileState]:
return AcpReadFileState
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
connection, session_id, _ = self._load_state()
line = args.offset + 1 if args.offset > 0 else None
limit = args.limit
read_request = ReadTextFileRequest(
sessionId=session_id, path=str(file_path), line=line, limit=limit
)
await self._send_in_progress_session_update()
try:
response = await connection.readTextFile(read_request)
except Exception as e:
raise ToolError(f"Error reading {file_path}: {e}") from e
content_lines = response.content.splitlines(keepends=True)
lines_read = len(content_lines)
bytes_read = sum(len(line.encode("utf-8")) for line in content_lines)
was_truncated = args.limit is not None and lines_read >= args.limit
return _ReadResult(
lines=content_lines, bytes_read=bytes_read, was_truncated=was_truncated
)

View file

@ -0,0 +1,132 @@
from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, WriteTextFileRequest
from acp.helpers import SessionUpdate
from acp.schema import (
FileEditToolCallContent,
ToolCallLocation,
ToolCallProgress,
ToolCallStart,
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import 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):
file_backup_content: str | None = None
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
state: AcpSearchReplaceState
prompt_path = (
VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "search_replace.md"
)
@classmethod
def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]:
return AcpSearchReplaceState
async def _read_file(self, file_path: Path) -> str:
connection, session_id, _ = self._load_state()
read_request = ReadTextFileRequest(sessionId=session_id, path=str(file_path))
await self._send_in_progress_session_update()
try:
response = await connection.readTextFile(read_request)
except Exception as e:
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
self.state.file_backup_content = response.content
return response.content
async def _backup_file(self, file_path: Path) -> None:
if self.state.file_backup_content is None:
return
await self._write_file(
file_path.with_suffix(file_path.suffix + ".bak"),
self.state.file_backup_content,
)
async def _write_file(self, file_path: Path, content: str) -> None:
connection, session_id, _ = self._load_state()
write_request = WriteTextFileRequest(
sessionId=session_id, path=str(file_path), content=content
)
try:
await connection.writeTextFile(write_request)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args
if not isinstance(args, SearchReplaceArgs):
return None
blocks = cls._parse_search_replace_blocks(args.content)
return ToolCallStart(
sessionUpdate="tool_call",
title=cls.get_call_display(event).summary,
toolCallId=event.tool_call_id,
kind="edit",
content=[
FileEditToolCallContent(
type="diff",
path=args.file_path,
oldText=block.search,
newText=block.replace,
)
for block in blocks
],
locations=[ToolCallLocation(path=args.file_path)],
rawInput=args.model_dump_json(),
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if event.error:
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status="failed",
)
result = event.result
if not isinstance(result, SearchReplaceResult):
return None
blocks = cls._parse_search_replace_blocks(result.content)
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status="completed",
content=[
FileEditToolCallContent(
type="diff",
path=result.file,
oldText=block.search,
newText=block.replace,
)
for block in blocks
],
locations=[ToolCallLocation(path=result.file)],
rawOutput=result.model_dump_json(),
)

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from typing import cast
from acp.helpers import SessionUpdate
from acp.schema import AgentPlanUpdate, PlanEntry, PlanEntryPriority, PlanEntryStatus
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.builtins.todo import (
Todo as CoreTodoTool,
TodoArgs,
TodoPriority,
TodoResult,
TodoState,
TodoStatus,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
TodoArgs = TodoArgs
class AcpTodoState(TodoState, AcpToolState):
pass
class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]):
state: AcpTodoState
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "todo.md"
@classmethod
def _get_tool_state_class(cls) -> type[AcpTodoState]:
return AcpTodoState
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
return None
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
result = cast(TodoResult, event.result)
todos = [todo for todo in result.todos if todo.status != TodoStatus.CANCELLED]
matched_status: dict[TodoStatus, PlanEntryStatus] = {
TodoStatus.PENDING: "pending",
TodoStatus.IN_PROGRESS: "in_progress",
TodoStatus.COMPLETED: "completed",
}
matched_priority: dict[TodoPriority, PlanEntryPriority] = {
TodoPriority.LOW: "low",
TodoPriority.MEDIUM: "medium",
TodoPriority.HIGH: "high",
}
update = AgentPlanUpdate(
sessionUpdate="plan",
entries=[
PlanEntry(
content=todo.content,
status=matched_status[todo.status],
priority=matched_priority[todo.priority],
)
for todo in todos
],
)
return update

View file

@ -0,0 +1,98 @@
from __future__ import annotations
from pathlib import Path
from acp import WriteTextFileRequest
from acp.helpers import SessionUpdate
from acp.schema import (
FileEditToolCallContent,
ToolCallLocation,
ToolCallProgress,
ToolCallStart,
)
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.core.tools.base import 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):
pass
class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
state: AcpWriteFileState
prompt_path = (
VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "write_file.md"
)
@classmethod
def _get_tool_state_class(cls) -> type[AcpWriteFileState]:
return AcpWriteFileState
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
connection, session_id, _ = self._load_state()
write_request = WriteTextFileRequest(
sessionId=session_id, path=str(file_path), content=args.content
)
await self._send_in_progress_session_update()
try:
await connection.writeTextFile(write_request)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
args = event.args
if not isinstance(args, WriteFileArgs):
return None
return ToolCallStart(
sessionUpdate="tool_call",
title=cls.get_call_display(event).summary,
toolCallId=event.tool_call_id,
kind="edit",
content=[
FileEditToolCallContent(
type="diff", path=args.path, oldText=None, newText=args.content
)
],
locations=[ToolCallLocation(path=args.path)],
rawInput=args.model_dump_json(),
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if event.error:
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status="failed",
)
result = event.result
if not isinstance(result, WriteFileResult):
return None
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status="completed",
content=[
FileEditToolCallContent(
type="diff", path=result.path, oldText=None, newText=result.content
)
],
locations=[ToolCallLocation(path=result.path)],
rawOutput=result.model_dump_json(),
)

View file

@ -0,0 +1,111 @@
from __future__ import annotations
from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import (
ContentToolCallContent,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
ToolKind,
)
from vibe.acp.tools.base import (
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
)
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import TaggedText, is_user_cancellation_event
TOOL_KIND: dict[str, ToolKind] = {"read_file": "read", "grep": "search"}
def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
if issubclass(event.tool_class, ToolCallSessionUpdateProtocol):
return event.tool_class.tool_call_session_update(event)
adapter = ToolUIDataAdapter(event.tool_class)
display = adapter.get_call_display(event)
content: list[ToolCallContentVariant] | None = (
[
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=display.content),
)
]
if display.content
else None
)
return ToolCallStart(
sessionUpdate="tool_call",
title=display.summary,
content=content,
toolCallId=event.tool_call_id,
kind=TOOL_KIND.get(event.tool_name, "other"),
rawInput=event.args.model_dump_json(),
)
def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
if is_user_cancellation_event(event):
tool_status = "failed"
raw_output = (
TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None
)
elif event.result:
tool_status = "completed"
raw_output = event.result.model_dump_json()
else:
tool_status = "failed"
raw_output = (
TaggedText.from_string(event.error).message if event.error else None
)
if event.tool_class is None:
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status="failed",
rawOutput=raw_output,
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=raw_output or ""),
)
],
)
if issubclass(event.tool_class, ToolResultSessionUpdateProtocol):
return event.tool_class.tool_result_session_update(event)
if tool_status == "failed":
content = [
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=raw_output or ""),
)
]
else:
adapter = ToolUIDataAdapter(event.tool_class)
display = adapter.get_result_display(event)
content: list[ToolCallContentVariant] | None = (
[
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=display.message),
)
]
if display.message
else None
)
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
status=tool_status,
rawOutput=raw_output,
content=content,
)

70
vibe/acp/utils.py Normal file
View file

@ -0,0 +1,70 @@
from __future__ import annotations
import enum
from enum import StrEnum
from typing import Literal, cast
from acp.schema import PermissionOption, SessionMode
class VibeSessionMode(enum.StrEnum):
APPROVAL_REQUIRED = enum.auto()
AUTO_APPROVE = enum.auto()
def to_acp_session_mode(self) -> SessionMode:
match self:
case self.APPROVAL_REQUIRED:
return SessionMode(
id=VibeSessionMode.APPROVAL_REQUIRED,
name="Approval Required",
description="Requires user approval for tool executions",
)
case self.AUTO_APPROVE:
return SessionMode(
id=VibeSessionMode.AUTO_APPROVE,
name="Auto Approve",
description="Automatically approves all tool executions",
)
@classmethod
def from_acp_session_mode(cls, session_mode: SessionMode) -> VibeSessionMode | None:
if not cls.is_valid(session_mode.id):
return None
return cls(session_mode.id)
@classmethod
def is_valid(cls, mode_id: str) -> bool:
try:
return cls(mode_id).to_acp_session_mode() is not None
except (ValueError, KeyError):
return False
@classmethod
def get_all_acp_session_modes(cls) -> list[SessionMode]:
return [mode.to_acp_session_mode() for mode in cls]
class ToolOption(StrEnum):
ALLOW_ONCE = "allow_once"
ALLOW_ALWAYS = "allow_always"
REJECT_ONCE = "reject_once"
REJECT_ALWAYS = "reject_always"
TOOL_OPTIONS = [
PermissionOption(
optionId=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
),
PermissionOption(
optionId=ToolOption.ALLOW_ALWAYS,
name="Allow always",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
),
PermissionOption(
optionId=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
),
]