Co-Authored-By: Quentin Torroba <quentin.torroba@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: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -2,31 +2,23 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
import os
from pathlib import Path
import sys
from typing import cast, override
from typing import Any, cast, override
from acp import (
PROTOCOL_VERSION,
Agent as AcpAgent,
AgentSideConnection,
AuthenticateRequest,
CancelNotification,
InitializeRequest,
Client,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
LoadSessionResponse,
NewSessionResponse,
PromptRequest,
PromptResponse,
RequestError,
RequestPermissionRequest,
SessionNotification,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
stdio_streams,
run_agent,
)
from acp.helpers import ContentBlock, SessionUpdate
from acp.schema import (
@ -35,14 +27,24 @@ from acp.schema import (
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
ClientCapabilities,
ContentToolCallContent,
ForkSessionResponse,
HttpMcpServer,
Implementation,
ListSessionsResponse,
McpServerStdio,
ModelInfo,
PromptCapabilities,
ResumeSessionResponse,
SessionModelState,
SessionModeState,
SseMcpServer,
TextContentBlock,
TextResourceContents,
ToolCall,
ToolCallProgress,
ToolCallUpdate,
UserMessageChunk,
)
from pydantic import BaseModel, ConfigDict
@ -55,41 +57,53 @@ from vibe.acp.tools.session_update import (
from vibe.acp.utils import (
TOOL_OPTIONS,
ToolOption,
acp_to_agent_mode,
create_compact_end_session_update,
create_compact_start_session_update,
get_all_acp_session_modes,
is_valid_acp_mode,
is_valid_acp_agent,
)
from vibe.core.agent import Agent as VibeAgent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
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.modes import AgentMode
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
ApprovalResponse,
AssistantEvent,
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
UserMessageEvent,
)
from vibe.core.utils import CancellationReason, get_user_cancellation_message
class AcpSession(BaseModel):
class AcpSessionLoop(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
agent: VibeAgent
agent_loop: AgentLoop
task: asyncio.Task[None] | None = None
class VibeAcpAgent(AcpAgent):
def __init__(self, connection: AgentSideConnection) -> None:
self.sessions: dict[str, AcpSession] = {}
self.connection = connection
class VibeAcpAgentLoop(AcpAgent):
client: Client
def __init__(self) -> None:
self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities = None
@override
async def initialize(self, params: InitializeRequest) -> InitializeResponse:
self.client_capabilities = params.clientCapabilities
async def initialize(
self,
protocol_version: int,
client_capabilities: ClientCapabilities | None = None,
client_info: Implementation | None = None,
**kwargs: Any,
) -> InitializeResponse:
self.client_capabilities = client_capabilities
# 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
@ -133,91 +147,94 @@ class VibeAcpAgent(AcpAgent):
)
response = InitializeResponse(
agentCapabilities=AgentCapabilities(
loadSession=False,
promptCapabilities=PromptCapabilities(
audio=False, embeddedContext=True, image=False
agent_capabilities=AgentCapabilities(
load_session=False,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
),
),
protocolVersion=PROTOCOL_VERSION,
agentInfo=Implementation(
protocol_version=PROTOCOL_VERSION,
agent_info=Implementation(
name="@mistralai/mistral-vibe",
title="Mistral Vibe",
version=__version__,
),
authMethods=auth_methods,
auth_methods=auth_methods,
)
return response
@override
async def authenticate(
self, params: AuthenticateRequest
self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None:
raise NotImplementedError("Not implemented yet")
@override
async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
capability_disabled_tools = self._get_disabled_tools_from_capabilities()
async def new_session(
self,
cwd: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
**kwargs: Any,
) -> NewSessionResponse:
load_api_keys_from_env()
os.chdir(cwd)
cwd = Path(params.cwd)
try:
config = VibeConfig.load(
workdir=cwd,
tool_paths=[str(VIBE_ROOT / "acp" / "tools" / "builtins")],
disabled_tools=capability_disabled_tools,
)
config = VibeConfig.load(disabled_tools=["ask_user_question"])
config.tool_paths.extend(self._get_acp_tool_overrides())
except MissingAPIKeyError as e:
raise RequestError.auth_required({
"message": "You must be authenticated before creating a new session"
}) from e
agent = VibeAgent(config=config, mode=AgentMode.DEFAULT, 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)
agent_loop = AgentLoop(
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
)
# 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 = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
self.sessions[session.id] = session
if not agent.auto_approve:
agent.set_approval_callback(
self._create_approval_callback(agent.session_id)
if not agent_loop.auto_approve:
agent_loop.set_approval_callback(
self._create_approval_callback(agent_loop.session_id)
)
response = NewSessionResponse(
sessionId=agent.session_id,
session_id=agent_loop.session_id,
models=SessionModelState(
currentModelId=agent.config.active_model,
availableModels=[
ModelInfo(modelId=model.alias, name=model.alias)
for model in agent.config.models
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
],
),
modes=SessionModeState(
currentModeId=session.agent.mode.value,
availableModes=get_all_acp_session_modes(),
current_mode_id=session.agent_loop.agent_profile.name,
available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
),
)
return response
def _get_disabled_tools_from_capabilities(self) -> list[str]:
if not self.client_capabilities:
return []
def _get_acp_tool_overrides(self) -> list[Path]:
overrides = ["todo"]
disabled: list[str] = []
if self.client_capabilities:
if self.client_capabilities.terminal:
overrides.append("bash")
if self.client_capabilities.fs:
fs = self.client_capabilities.fs
if fs.read_text_file:
overrides.append("read_file")
if fs.write_text_file:
overrides.extend(["write_file", "search_replace"])
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
return [
VIBE_ROOT / "acp" / "tools" / "builtins" / f"{override}.py"
for override in overrides
]
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
session = self._get_session(session_id)
@ -229,9 +246,9 @@ class VibeAcpAgent(AcpAgent):
case ToolOption.ALLOW_ONCE:
return (ApprovalResponse.YES, None)
case ToolOption.ALLOW_ALWAYS:
if tool_name not in session.agent.config.tools:
session.agent.config.tools[tool_name] = BaseToolConfig()
session.agent.config.tools[
if tool_name not in session.agent_loop.config.tools:
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
session.agent_loop.config.tools[
tool_name
].permission = ToolPermission.ALWAYS
return (ApprovalResponse.YES, None)
@ -247,19 +264,16 @@ class VibeAcpAgent(AcpAgent):
tool_name: str, args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
# Create the tool call update
tool_call = ToolCall(toolCallId=tool_call_id)
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
# Request permission from the user
request = RequestPermissionRequest(
sessionId=session_id, toolCall=tool_call, options=TOOL_OPTIONS
response = await self.client.request_permission(
session_id=session_id, tool_call=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 _handle_permission_selection(outcome.optionId, tool_name)
return _handle_permission_selection(outcome.option_id, tool_name)
else:
return (
ApprovalResponse.NO,
@ -272,102 +286,111 @@ class VibeAcpAgent(AcpAgent):
return approval_callback
def _get_session(self, session_id: str) -> AcpSession:
def _get_session(self, session_id: str) -> AcpSessionLoop:
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:
async def load_session(
self,
cwd: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
session_id: str,
**kwargs: Any,
) -> LoadSessionResponse | None:
raise NotImplementedError()
@override
async def setSessionMode(
self, params: SetSessionModeRequest
async def set_session_mode(
self, mode_id: str, session_id: str, **kwargs: Any
) -> SetSessionModeResponse | None:
session = self._get_session(params.sessionId)
session = self._get_session(session_id)
if not is_valid_acp_mode(params.modeId):
if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
return None
new_mode = acp_to_agent_mode(params.modeId)
if new_mode is None:
return None
await session.agent_loop.switch_agent(mode_id)
await session.agent.switch_mode(new_mode)
if new_mode.auto_approve:
session.agent.approval_callback = None
if session.agent_loop.auto_approve:
session.agent_loop.approval_callback = None
else:
session.agent.set_approval_callback(
session.agent_loop.set_approval_callback(
self._create_approval_callback(session.id)
)
return SetSessionModeResponse()
@override
async def setSessionModel(
self, params: SetSessionModelRequest
async def set_session_model(
self, model_id: str, session_id: str, **kwargs: Any
) -> SetSessionModelResponse | None:
session = self._get_session(params.sessionId)
session = self._get_session(session_id)
model_aliases = [model.alias for model in session.agent.config.models]
if params.modelId not in model_aliases:
model_aliases = [model.alias for model in session.agent_loop.config.models]
if model_id not in model_aliases:
return None
VibeConfig.save_updates({"active_model": params.modelId})
VibeConfig.save_updates({"active_model": model_id})
new_config = VibeConfig.load(
workdir=session.agent.config.workdir,
tool_paths=session.agent.config.tool_paths,
disabled_tools=self._get_disabled_tools_from_capabilities(),
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=["ask_user_question"],
)
await session.agent.reload_with_initial_messages(config=new_config)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
return SetSessionModelResponse()
@override
async def prompt(self, params: PromptRequest) -> PromptResponse:
session = self._get_session(params.sessionId)
async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
) -> ListSessionsResponse:
raise NotImplementedError()
@override
async def prompt(
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
) -> PromptResponse:
session = self._get_session(session_id)
if session.task is not None:
raise RuntimeError(
"Concurrent prompts are not supported yet, wait for agent to finish"
"Concurrent prompts are not supported yet, wait for agent loop to finish"
)
text_prompt = self._build_text_prompt(params.prompt)
text_prompt = self._build_text_prompt(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)
)
temp_user_message_id: str | None = kwargs.get("messageId")
async def agent_loop_task() -> None:
async for update in self._run_agent_loop(
session, text_prompt, temp_user_message_id
):
await self.client.session_update(session_id=session.id, update=update)
try:
session.task = asyncio.create_task(agent_task())
session.task = asyncio.create_task(agent_loop_task())
await session.task
except asyncio.CancelledError:
return PromptResponse(stopReason="cancelled")
return PromptResponse(stop_reason="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}"),
),
)
await self.client.session_update(
session_id=session_id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=f"Error: {e!s}"),
),
)
return PromptResponse(stopReason="refusal")
return PromptResponse(stop_reason="refusal")
finally:
session.task = None
return PromptResponse(stopReason="end_turn")
return PromptResponse(stop_reason="end_turn")
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
text_prompt = ""
@ -400,7 +423,7 @@ class VibeAcpAgent(AcpAgent):
"name": block.name,
"title": block.title,
"description": block.description,
"mimeType": block.mimeType,
"mime_type": block.mime_type,
"size": block.size,
}
parts = [
@ -415,23 +438,37 @@ class VibeAcpAgent(AcpAgent):
return text_prompt
async def _run_agent_loop(
self, session: AcpSession, prompt: str
self, session: AcpSessionLoop, prompt: str, user_message_id: str | None = None
) -> 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):
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
async for event in session.agent_loop.act(rendered_prompt):
if isinstance(event, UserMessageEvent):
yield UserMessageChunk(
session_update="user_message_chunk",
content=TextContentBlock(type="text", text=""),
field_meta={
"messageId": event.message_id,
**(
{"previousMessageId": user_message_id}
if user_message_id
else {}
),
},
)
elif isinstance(event, AssistantEvent):
yield AgentMessageChunk(
sessionUpdate="agent_message_chunk",
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
field_meta={"messageId": event.message_id},
)
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,
tool_manager=session.agent_loop.tool_manager,
client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
)
@ -445,32 +482,67 @@ class VibeAcpAgent(AcpAgent):
if session_update:
yield session_update
elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(type="text", text=event.message),
)
],
)
elif isinstance(event, CompactStartEvent):
yield create_compact_start_session_update(event)
elif isinstance(event, CompactEndEvent):
yield create_compact_end_session_update(event)
@override
async def cancel(self, params: CancelNotification) -> None:
session = self._get_session(params.sessionId)
async def cancel(self, session_id: str, **kwargs: Any) -> None:
session = self._get_session(session_id)
if session.task and not session.task.done():
session.task.cancel()
session.task = None
@override
async def extMethod(self, method: str, params: dict) -> dict:
async def fork_session(
self,
cwd: str,
session_id: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
raise NotImplementedError()
@override
async def extNotification(self, method: str, params: dict) -> None:
async def resume_session(
self,
cwd: str,
session_id: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ResumeSessionResponse:
raise NotImplementedError()
@override
async def ext_method(self, method: str, params: dict) -> dict:
raise NotImplementedError()
async def _run_acp_server() -> None:
reader, writer = await stdio_streams()
@override
async def ext_notification(self, method: str, params: dict) -> None:
raise NotImplementedError()
AgentSideConnection(lambda connection: VibeAcpAgent(connection), writer, reader)
await asyncio.Event().wait()
@override
def on_connect(self, conn: Client) -> None:
self.client = conn
def run_acp_server() -> None:
try:
asyncio.run(_run_acp_server())
asyncio.run(run_agent(agent=VibeAcpAgentLoop(), use_unstable_protocol=True))
except KeyboardInterrupt:
# This is expected when the server is terminated
pass

View file

@ -2,10 +2,13 @@ from __future__ import annotations
import argparse
from dataclasses import dataclass
import os
import sys
from vibe import __version__
from vibe.core.paths.config_paths import unlock_config_paths
from vibe.core.config import VibeConfig
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]
@ -28,12 +31,45 @@ def parse_arguments() -> Arguments:
return Arguments(setup=args.setup)
def bootstrap_config_files() -> None:
if not CONFIG_FILE.path.exists():
try:
VibeConfig.save_updates(VibeConfig.create_default())
except Exception as e:
logger.error(f"Could not create default config file: {e}")
raise
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
except Exception as e:
logger.error(f"Could not create history file: {e}")
raise
def handle_debug_mode() -> None:
if os.environ.get("DEBUG_MODE") != "true":
return
try:
import debugpy
except ImportError:
return
debugpy.listen(("localhost", 5678))
# uncomment this to wait for the debugger to attach
# debugpy.wait_for_client()
def main() -> None:
handle_debug_mode()
unlock_config_paths()
from vibe.acp.acp_agent import run_acp_server
from vibe.acp.acp_agent_loop import run_acp_server
from vibe.setup.onboarding import run_onboarding
bootstrap_config_files()
args = parse_arguments()
if args.setup:
run_onboarding()

View file

@ -1,12 +1,12 @@
from __future__ import annotations
from abc import abstractmethod
from typing import Protocol, cast, runtime_checkable
from typing import Annotated, Protocol, cast, runtime_checkable
from acp import AgentSideConnection, SessionNotification
from acp import Client
from acp.helpers import SessionUpdate, ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import Field
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager
@ -28,9 +28,11 @@ class ToolResultSessionUpdateProtocol(Protocol):
) -> SessionUpdate | None: ...
class AcpToolState:
connection: AgentSideConnection | None = Field(
default=None, description="ACP agent-side connection"
class AcpToolState(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
client: Annotated[Client | None, SkipValidation] = Field(
default=None, description="ACP Client"
)
session_id: str | None = Field(default=None, description="Current ACP session ID")
tool_call_id: str | None = Field(
@ -52,12 +54,12 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
cls,
*,
tool_manager: ToolManager,
connection: AgentSideConnection | None,
client: Client | 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.client = client
tool_instance.state.session_id = session_id
tool_instance.state.tool_call_id = tool_call_id
@ -65,36 +67,34 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
@abstractmethod
def _get_tool_state_class(cls) -> type[ToolState]: ...
def _load_state(self) -> tuple[AgentSideConnection, str, str | None]:
if self.state.connection is None:
def _load_state(self) -> tuple[Client, str, str | None]:
if self.state.client is None:
raise ToolError(
"Connection not available in tool state. This tool can only be used within an ACP session."
"Client 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
return self.state.client, 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()
client, 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,
),
)
await client.session_update(
session_id=session_id,
update=ToolCallProgress(
session_update="tool_call_update",
tool_call_id=tool_call_id,
status="in_progress",
content=content,
),
)
except Exception as e:
logger.error(f"Failed to update session: {e!r}")

View file

@ -1,9 +1,10 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from pathlib import Path
import shlex
from acp import CreateTerminalRequest, TerminalHandle
from acp.schema import (
EnvVariable,
TerminalToolCallContent,
@ -14,9 +15,9 @@ from acp.schema import (
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.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.utils import logger
@ -32,48 +33,54 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
def _get_tool_state_class(cls) -> type[AcpBashState]:
return AcpBashState
async def run(self, args: BashArgs) -> BashResult:
connection, session_id, _ = self._load_state()
async def run(
self, args: BashArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
client, 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)
terminal_handle = await client.create_terminal(
session_id=session_id,
command=command,
args=cmd_args,
env=env,
cwd=str(Path.cwd()),
output_byte_limit=max_bytes,
)
except Exception as e:
raise ToolError(f"Failed to create terminal: {e!r}") from e
terminal_id = terminal_handle.id
await self._send_in_progress_session_update([
TerminalToolCallContent(type="terminal", terminalId=terminal_handle.id)
TerminalToolCallContent(type="terminal", terminal_id=terminal_id)
])
try:
exit_response = await self._wait_for_terminal_exit(
terminal_handle, timeout, args.command
terminal_id=terminal_id, timeout=timeout, command=args.command
)
output_response = await terminal_handle.current_output()
output_response = await client.terminal_output(
session_id=session_id, terminal_id=terminal_id
)
return self._build_result(
yield self._build_result(
command=args.command,
stdout=output_response.output,
stderr="",
returncode=exit_response.exitCode or 0,
returncode=exit_response.exit_code or 0,
)
finally:
try:
await terminal_handle.release()
await client.release_terminal(
session_id=session_id, terminal_id=terminal_id
)
except Exception as e:
logger.error(f"Failed to release terminal: {e!r}")
@ -105,15 +112,22 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
return summary
async def _wait_for_terminal_exit(
self, terminal_handle: TerminalHandle, timeout: int, command: str
self, terminal_id: str, timeout: int, command: str
) -> WaitForTerminalExitResponse:
client, session_id, _ = self._load_state()
try:
return await asyncio.wait_for(
terminal_handle.wait_for_exit(), timeout=timeout
client.wait_for_terminal_exit(
session_id=session_id, terminal_id=terminal_id
),
timeout=timeout,
)
except TimeoutError:
try:
await terminal_handle.kill()
await client.kill_terminal(
session_id=session_id, terminal_id=terminal_id
)
except Exception as e:
logger.error(f"Failed to kill terminal: {e!r}")
@ -125,12 +139,12 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
raise ValueError(f"Unexpected tool args: {event.args}")
return ToolCallStart(
sessionUpdate="tool_call",
session_update="tool_call",
title=Bash.get_summary(event.args),
content=None,
toolCallId=event.tool_call_id,
tool_call_id=event.tool_call_id,
kind="execute",
rawInput=event.args.model_dump_json(),
raw_input=event.args.model_dump_json(),
)
@classmethod
@ -138,7 +152,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
cls, event: ToolResultEvent
) -> ToolCallProgress | None:
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="failed" if event.error else "completed",
)

View file

@ -2,8 +2,6 @@ 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
@ -31,19 +29,17 @@ class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
return AcpReadFileState
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
connection, session_id, _ = self._load_state()
client, 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)
response = await client.read_text_file(
session_id=session_id, path=str(file_path), line=line, limit=limit
)
except Exception as e:
raise ToolError(f"Error reading {file_path}: {e}") from e

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from pathlib import Path
from acp import ReadTextFileRequest, WriteTextFileRequest
from acp.helpers import SessionUpdate
from acp.schema import (
FileEditToolCallContent,
@ -38,14 +37,14 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[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))
client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
try:
response = await connection.readTextFile(read_request)
response = await client.read_text_file(
session_id=session_id, path=str(file_path)
)
except Exception as e:
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
@ -62,14 +61,12 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
)
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
)
client, session_id, _ = self._load_state()
try:
await connection.writeTextFile(write_request)
await client.write_text_file(
session_id=session_id, path=str(file_path), content=content
)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@ -82,29 +79,29 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
blocks = cls._parse_search_replace_blocks(args.content)
return ToolCallStart(
sessionUpdate="tool_call",
session_update="tool_call",
title=cls.get_call_display(event).summary,
toolCallId=event.tool_call_id,
tool_call_id=event.tool_call_id,
kind="edit",
content=[
FileEditToolCallContent(
type="diff",
path=args.file_path,
oldText=block.search,
newText=block.replace,
old_text=block.search,
new_text=block.replace,
)
for block in blocks
],
locations=[ToolCallLocation(path=args.file_path)],
rawInput=args.model_dump_json(),
raw_input=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,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="failed",
)
@ -115,18 +112,18 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
blocks = cls._parse_search_replace_blocks(result.content)
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="completed",
content=[
FileEditToolCallContent(
type="diff",
path=result.file,
oldText=block.search,
newText=block.replace,
old_text=block.search,
new_text=block.replace,
)
for block in blocks
],
locations=[ToolCallLocation(path=result.file)],
rawOutput=result.model_dump_json(),
raw_output=result.model_dump_json(),
)

View file

@ -52,7 +52,7 @@ class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]):
}
update = AgentPlanUpdate(
sessionUpdate="plan",
session_update="plan",
entries=[
PlanEntry(
content=todo.content,

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from pathlib import Path
from acp import WriteTextFileRequest
from acp.helpers import SessionUpdate
from acp.schema import (
FileEditToolCallContent,
@ -38,16 +37,14 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[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
)
client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
try:
await connection.writeTextFile(write_request)
await client.write_text_file(
session_id=session_id, path=str(file_path), content=args.content
)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@ -58,25 +55,25 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return None
return ToolCallStart(
sessionUpdate="tool_call",
session_update="tool_call",
title=cls.get_call_display(event).summary,
toolCallId=event.tool_call_id,
tool_call_id=event.tool_call_id,
kind="edit",
content=[
FileEditToolCallContent(
type="diff", path=args.path, oldText=None, newText=args.content
type="diff", path=args.path, old_text=None, new_text=args.content
)
],
locations=[ToolCallLocation(path=args.path)],
rawInput=args.model_dump_json(),
raw_input=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,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="failed",
)
@ -85,14 +82,17 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return None
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="completed",
content=[
FileEditToolCallContent(
type="diff", path=result.path, oldText=None, newText=result.content
type="diff",
path=result.path,
old_text=None,
new_text=result.content,
)
],
locations=[ToolCallLocation(path=result.path)],
rawOutput=result.model_dump_json(),
raw_output=result.model_dump_json(),
)

View file

@ -17,7 +17,14 @@ 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"}
TOOL_KIND: dict[str, ToolKind] = {
"grep": "search",
"read_file": "read",
# Right now, jetbrains implementation of "edit" tool kind is broken
# Leading to the tool not appearing in the chat
# "write_file": "edit",
# "search_replace": "edit",
}
def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
@ -38,12 +45,12 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
)
return ToolCallStart(
sessionUpdate="tool_call",
session_update="tool_call",
title=display.summary,
content=content,
toolCallId=event.tool_call_id,
tool_call_id=event.tool_call_id,
kind=TOOL_KIND.get(event.tool_name, "other"),
rawInput=event.args.model_dump_json(),
raw_input=event.args.model_dump_json(),
)
@ -66,10 +73,10 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
if event.tool_class is None:
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="failed",
rawOutput=raw_output,
raw_output=raw_output,
content=[
ContentToolCallContent(
type="content",
@ -103,9 +110,9 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
)
return ToolCallProgress(
sessionUpdate="tool_call_update",
toolCallId=event.tool_call_id,
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status=tool_status,
rawOutput=raw_output,
raw_output=raw_output,
content=content,
)

View file

@ -1,11 +1,23 @@
from __future__ import annotations
from enum import StrEnum
from typing import Literal, cast
from typing import TYPE_CHECKING, Literal, cast
from acp.schema import PermissionOption, SessionMode
from acp.schema import (
ContentToolCallContent,
PermissionOption,
SessionMode,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
)
from vibe.core.modes import MODE_CONFIGS, AgentMode
from vibe.core.agents.models import AgentProfile, AgentType
from vibe.core.types import CompactEndEvent, CompactStartEvent
from vibe.core.utils import compact_reduction_display
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
class ToolOption(StrEnum):
@ -17,37 +29,85 @@ class ToolOption(StrEnum):
TOOL_OPTIONS = [
PermissionOption(
optionId=ToolOption.ALLOW_ONCE,
option_id=ToolOption.ALLOW_ONCE,
name="Allow once",
kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE),
),
PermissionOption(
optionId=ToolOption.ALLOW_ALWAYS,
option_id=ToolOption.ALLOW_ALWAYS,
name="Allow always",
kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS),
),
PermissionOption(
optionId=ToolOption.REJECT_ONCE,
option_id=ToolOption.REJECT_ONCE,
name="Reject once",
kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE),
),
]
def agent_mode_to_acp(mode: AgentMode) -> SessionMode:
config = MODE_CONFIGS[mode]
def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
return SessionMode(
id=mode.value, name=config.display_name, description=config.description
id=profile.name, name=profile.display_name, description=profile.description
)
def acp_to_agent_mode(mode_id: str) -> AgentMode | None:
return AgentMode.from_string(mode_id)
def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
return agent_name in agent_manager.available_agents
def is_valid_acp_mode(mode_id: str) -> bool:
return AgentMode.from_string(mode_id) is not None
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 get_all_acp_session_modes() -> list[SessionMode]:
return [agent_mode_to_acp(mode) for mode in AgentMode]
def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:
# WORKAROUND: Using tool_call to communicate compact events to the client.
# This should be revisited when the ACP protocol defines how compact events
# should be represented.
# [RFD](https://agentclientprotocol.com/rfds/session-usage)
return ToolCallStart(
session_update="tool_call",
tool_call_id=event.tool_call_id,
title="Compacting conversation history...",
kind="other",
status="in_progress",
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(
type="text",
text="Automatic context management, no approval required. This may take some time...",
),
)
],
)
def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgress:
# WORKAROUND: Using tool_call_update to communicate compact events to the client.
# This should be revisited when the ACP protocol defines how compact events
# should be represented.
# [RFD](https://agentclientprotocol.com/rfds/session-usage)
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
title="Compacted conversation history",
status="completed",
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(
type="text",
text=(
compact_reduction_display(
event.old_context_tokens, event.new_context_tokens
)
),
),
)
],
)