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:
commit
fa15fc977b
200 changed files with 30484 additions and 0 deletions
0
vibe/acp/__init__.py
Normal file
0
vibe/acp/__init__.py
Normal file
441
vibe/acp/acp_agent.py
Normal file
441
vibe/acp/acp_agent.py
Normal 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
37
vibe/acp/entrypoint.py
Normal 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()
|
||||
0
vibe/acp/tools/__init__.py
Normal file
0
vibe/acp/tools/__init__.py
Normal file
100
vibe/acp/tools/base.py
Normal file
100
vibe/acp/tools/base.py
Normal 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}")
|
||||
144
vibe/acp/tools/builtins/bash.py
Normal file
144
vibe/acp/tools/builtins/bash.py
Normal 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",
|
||||
)
|
||||
58
vibe/acp/tools/builtins/read_file.py
Normal file
58
vibe/acp/tools/builtins/read_file.py
Normal 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
|
||||
)
|
||||
132
vibe/acp/tools/builtins/search_replace.py
Normal file
132
vibe/acp/tools/builtins/search_replace.py
Normal 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(),
|
||||
)
|
||||
65
vibe/acp/tools/builtins/todo.py
Normal file
65
vibe/acp/tools/builtins/todo.py
Normal 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
|
||||
98
vibe/acp/tools/builtins/write_file.py
Normal file
98
vibe/acp/tools/builtins/write_file.py
Normal 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(),
|
||||
)
|
||||
111
vibe/acp/tools/session_update.py
Normal file
111
vibe/acp/tools/session_update.py
Normal 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
70
vibe/acp/utils.py
Normal 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),
|
||||
),
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue