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

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "1.3.5"
__version__ = "2.0.0"

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
)
),
),
)
],
)

View file

@ -6,29 +6,26 @@ import sys
from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
)
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.modes import AgentMode
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.types import LLMMessage, OutputFormat
from vibe.core.utils import ConversationLimitException
from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger
from vibe.setup.onboarding import run_onboarding
def get_initial_mode(args: argparse.Namespace) -> AgentMode:
if args.plan:
return AgentMode.PLAN
if args.auto_approve:
return AgentMode.AUTO_APPROVE
if args.prompt is not None:
return AgentMode.AUTO_APPROVE
return AgentMode.DEFAULT
def get_initial_agent_name(args: argparse.Namespace) -> str:
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
return BuiltinAgentName.AUTO_APPROVE
return args.agent
def get_prompt_from_stdin() -> str | None:
@ -46,14 +43,12 @@ def get_prompt_from_stdin() -> str | None:
return None
def load_config_or_exit(
agent: str | None = None, mode: AgentMode = AgentMode.DEFAULT
) -> VibeConfig:
def load_config_or_exit() -> VibeConfig:
try:
return VibeConfig.load(agent, **mode.config_overrides)
return VibeConfig.load()
except MissingAPIKeyError:
run_onboarding()
return VibeConfig.load(agent, **mode.config_overrides)
return VibeConfig.load()
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
@ -69,13 +64,6 @@ def bootstrap_config_files() -> None:
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
if not INSTRUCTIONS_FILE.path.exists():
try:
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.path.touch()
except Exception as e:
rprint(f"[yellow]Could not create instructions file: {e}[/]")
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
@ -99,7 +87,7 @@ def load_session(
session_to_load = None
if args.continue_session:
session_to_load = InteractionLogger.find_latest_session(config.session_logging)
session_to_load = SessionLoader.find_latest_session(config.session_logging)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
@ -107,7 +95,7 @@ def load_session(
)
sys.exit(1)
else:
session_to_load = InteractionLogger.find_session_by_id(
session_to_load = SessionLoader.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
@ -118,25 +106,32 @@ def load_session(
sys.exit(1)
try:
loaded_messages, _ = InteractionLogger.load_session(session_to_load)
loaded_messages, _ = SessionLoader.load_session(session_to_load)
return loaded_messages
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
def _load_messages_from_previous_session(
agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
) -> None:
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
agent_loop.messages.extend(non_system_messages)
logger.info("Loaded %d messages from previous session", len(non_system_messages))
def run_cli(args: argparse.Namespace) -> None:
load_api_keys_from_env()
bootstrap_config_files()
if args.setup:
run_onboarding()
sys.exit(0)
try:
bootstrap_config_files()
initial_mode = get_initial_mode(args)
config = load_config_or_exit(args.agent, initial_mode)
initial_agent_name = get_initial_agent_name(args)
config = load_config_or_exit()
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
@ -163,7 +158,7 @@ def run_cli(args: argparse.Namespace) -> None:
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
mode=initial_mode,
agent_name=initial_agent_name,
)
if final_response:
print(final_response)
@ -175,12 +170,16 @@ def run_cli(args: argparse.Namespace) -> None:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
agent_loop = AgentLoop(
config, agent_name=initial_agent_name, enable_streaming=True
)
if loaded_messages:
_load_messages_from_previous_session(agent_loop, loaded_messages)
run_textual_ui(
config,
initial_mode=initial_mode,
enable_streaming=True,
agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
loaded_messages=loaded_messages,
)
except (KeyboardInterrupt, EOFError):

View file

@ -100,6 +100,7 @@ def copy_selection_to_clipboard(app: App) -> None:
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
else:
app.notify(

View file

@ -84,6 +84,7 @@ class CommandRegistry:
"- `Ctrl+J` / `Shift+Enter` Insert newline",
"- `Escape` Interrupt agent or close dialogs",
"- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Ctrl+T` Toggle todo view",
"- `Shift+Tab` Toggle auto-approve mode",

View file

@ -1,12 +1,14 @@
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
from rich import print as rprint
from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.paths.config_paths import unlock_config_paths
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
@ -35,18 +37,6 @@ def parse_arguments() -> argparse.Namespace:
help="Run in programmatic mode: send prompt, auto-approve all tools, "
"output response, and exit.",
)
parser.add_argument(
"--auto-approve",
action="store_true",
default=False,
help="Start in auto-approve mode: never ask for approval before running tools.",
)
parser.add_argument(
"--plan",
action="store_true",
default=False,
help="Start in plan mode: read-only tools for exploration and planning.",
)
parser.add_argument(
"--max-turns",
type=int,
@ -82,10 +72,17 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument(
"--agent",
metavar="NAME",
default=None,
help="Load agent configuration from ~/.vibe/agents/NAME.toml",
default=BuiltinAgentName.DEFAULT,
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
"or custom from ~/.vibe/agents/NAME.toml)",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument(
"--workdir",
type=Path,
metavar="DIR",
help="Change to this directory before running",
)
continuation_group = parser.add_mutually_exclusive_group()
continuation_group.add_argument(
@ -104,7 +101,17 @@ def parse_arguments() -> argparse.Namespace:
def check_and_resolve_trusted_folder() -> None:
cwd = Path.cwd()
try:
cwd = Path.cwd()
except FileNotFoundError:
rprint(
"[red]Error: Current working directory no longer exists.[/]\n"
"[yellow]The directory you started vibe from has been deleted. "
"Please change to an existing directory and try again, "
"or use --workdir to specify a working directory.[/]"
)
sys.exit(1)
if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
return
@ -130,6 +137,15 @@ def check_and_resolve_trusted_folder() -> None:
def main() -> None:
args = parse_arguments()
if args.workdir:
workdir = args.workdir.expanduser().resolve()
if not workdir.is_dir():
rprint(
f"[red]Error: --workdir does not exist or is not a directory: {workdir}[/]"
)
sys.exit(1)
os.chdir(workdir)
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder()

View file

@ -38,7 +38,7 @@ class HistoryManager:
self.history_file.parent.mkdir(parents=True, exist_ok=True)
with self.history_file.open("w", encoding="utf-8") as f:
for entry in self._entries:
f.write(json.dumps(entry) + "\n")
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError:
pass

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import cast
import httpx
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
BASE_URL = "https://console.mistral.ai"
WHOAMI_PATH = "/api/vibe/whoami"
class HttpWhoAmIGateway:
def __init__(self, base_url: str = BASE_URL) -> None:
self._base_url = base_url.rstrip("/")
async def whoami(self, api_key: str) -> WhoAmIResponse:
url = f"{self._base_url}{WHOAMI_PATH}"
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
except httpx.RequestError as exc:
raise WhoAmIGatewayError() from exc
if response.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
raise WhoAmIGatewayUnauthorized()
if not response.is_success:
raise WhoAmIGatewayError(f"Unexpected status {response.status_code}")
payload = _safe_json(response) or {}
return WhoAmIResponse(
is_pro_plan=_parse_bool(payload.get("is_pro_plan")),
advertise_pro_plan=_parse_bool(payload.get("advertise_pro_plan")),
prompt_switching_to_pro_plan=_parse_bool(
payload.get("prompt_switching_to_pro_plan")
),
)
def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
try:
data = response.json()
except ValueError:
return None
return cast(Mapping[str, object], data) if isinstance(data, dict) else None
def _parse_bool(value: object | None) -> bool:
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, str):
match value.strip().lower():
case "true":
return True
case "false":
return False
case _:
raise WhoAmIGatewayError("Invalid boolean string in whoami response")
raise WhoAmIGatewayError("Invalid boolean value in whoami response")

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from enum import StrEnum
import logging
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGateway,
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
logger = logging.getLogger(__name__)
CONSOLE_CLI_URL = "https://console.mistral.ai/codestral/cli"
UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
class PlanOfferAction(StrEnum):
NONE = "none"
UPGRADE = "upgrade"
SWITCH_TO_PRO_KEY = "switch_to_pro_key"
ACTION_TO_URL: dict[PlanOfferAction, str] = {
PlanOfferAction.UPGRADE: UPGRADE_URL,
PlanOfferAction.SWITCH_TO_PRO_KEY: SWITCH_TO_PRO_KEY_URL,
}
async def decide_plan_offer(
api_key: str | None, gateway: WhoAmIGateway
) -> PlanOfferAction:
if not api_key:
return PlanOfferAction.UPGRADE
try:
response = await gateway.whoami(api_key)
except WhoAmIGatewayUnauthorized:
return PlanOfferAction.UPGRADE
except WhoAmIGatewayError:
logger.warning("Failed to fetch plan status.", exc_info=True)
return PlanOfferAction.NONE
return _action_from_response(response)
def _action_from_response(response: WhoAmIResponse) -> PlanOfferAction:
match response:
case WhoAmIResponse(is_pro_plan=True):
return PlanOfferAction.NONE
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
return PlanOfferAction.SWITCH_TO_PRO_KEY
case WhoAmIResponse(advertise_pro_plan=True):
return PlanOfferAction.UPGRADE
case _:
return PlanOfferAction.NONE

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, slots=True)
class WhoAmIResponse:
is_pro_plan: bool
advertise_pro_plan: bool
prompt_switching_to_pro_plan: bool
class WhoAmIGatewayUnauthorized(Exception):
pass
class WhoAmIGatewayError(Exception):
pass
class WhoAmIGateway(Protocol):
async def whoami(self, api_key: str) -> WhoAmIResponse: ...

View file

@ -7,11 +7,12 @@ import os
from pathlib import Path
import platform
import subprocess
from typing import Any
from typing import Any, Literal
class Terminal(Enum):
VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
@ -41,13 +42,21 @@ def _is_cursor() -> bool:
return False
def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]:
term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
if term_version.endswith("-insider"):
return Terminal.VSCODE_INSIDERS
return Terminal.VSCODE
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
if term_program == "vscode":
if _is_cursor():
return Terminal.CURSOR
return Terminal.VSCODE
return _detect_vscode_terminal()
term_map = {
"iterm.app": Terminal.ITERM2,
@ -65,17 +74,19 @@ def detect_terminal() -> Terminal:
return Terminal.UNKNOWN
def _get_vscode_keybindings_path() -> Path | None:
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
system = platform.system()
app_name = "Code" if is_stable else "Code - Insiders"
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Code" / "User"
base = Path.home() / "Library" / "Application Support" / app_name / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Code" / "User"
base = Path.home() / ".config" / app_name / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Code" / "User"
base = Path(appdata) / app_name / "User"
else:
return None
else:
@ -118,13 +129,13 @@ def _parse_keybindings(content: str) -> list[dict[str, Any]]:
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
"""Setup keybindings for VSCode or Cursor."""
"""Setup keybindings for VS Code or Cursor."""
if terminal == Terminal.CURSOR:
keybindings_path = _get_cursor_keybindings_path()
editor_name = "Cursor"
else:
keybindings_path = _get_vscode_keybindings_path()
editor_name = "VSCode"
keybindings_path = _get_vscode_keybindings_path(terminal == Terminal.VSCODE)
editor_name = "VS Code" if terminal == Terminal.VSCODE else "VS Code Insiders"
if keybindings_path is None:
return SetupResult(
@ -151,7 +162,9 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
)
keybindings.append(new_binding)
keybindings_path.write_text(json.dumps(keybindings, indent=2) + "\n")
keybindings_path.write_text(
json.dumps(keybindings, indent=2, ensure_ascii=False) + "\n"
)
return SetupResult(
success=True,
@ -376,10 +389,8 @@ def setup_terminal() -> SetupResult:
terminal = detect_terminal()
match terminal:
case Terminal.VSCODE:
return _setup_vscode_like_terminal(Terminal.VSCODE)
case Terminal.CURSOR:
return _setup_vscode_like_terminal(Terminal.CURSOR)
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
return _setup_vscode_like_terminal(terminal)
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
@ -391,7 +402,7 @@ def setup_terminal() -> SetupResult:
success=False,
terminal=Terminal.UNKNOWN,
message="Could not detect terminal. Supported terminals:\n"
"- VSCode\n"
"- VS Code\n"
"- Cursor\n"
"- iTerm2\n"
"- WezTerm\n"

File diff suppressed because it is too large Load diff

View file

@ -133,7 +133,7 @@ Screen {
#input {
width: 1fr;
height: auto;
max-height: 16;
max-height: 50vh;
background: transparent;
color: $text;
border: none;
@ -523,6 +523,18 @@ StatusMessage {
margin-top: 1;
}
.tool-call-container {
width: 100%;
height: auto;
}
.tool-stream-message {
width: 100%;
height: auto;
color: $text-muted;
padding-left: 2;
}
.tool-result {
width: 100%;
height: auto;
@ -852,6 +864,81 @@ WelcomeBanner {
color: $foreground;
}
#question-app {
width: 100%;
height: auto;
max-height: 16;
background: transparent;
border: round $foreground-muted;
padding: 0 1;
margin: 0 0 1 0;
}
#question-content {
width: 100%;
height: auto;
}
.question-tabs {
height: auto;
color: $primary;
margin-bottom: 1;
}
.question-title {
height: auto;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.question-option {
height: auto;
color: $foreground;
}
.question-option-selected {
color: $primary;
text-style: bold;
}
.question-other-row {
width: 100%;
height: auto;
}
.question-other-prefix {
width: auto;
height: auto;
color: $foreground;
}
.question-other-input {
width: 1fr;
height: auto;
background: transparent;
border: none;
padding: 0;
}
.question-other-static {
width: auto;
height: auto;
color: $foreground;
}
.question-submit {
height: auto;
color: $foreground-muted;
margin-top: 1;
}
.question-help {
height: auto;
color: $foreground-muted;
margin-top: 1;
}
.code-block {
height: auto;
color: $foreground;
@ -859,6 +946,28 @@ WelcomeBanner {
padding: 1;
}
.whats-new-message {
background: $surface;
border-left: solid $warning;
}
.plan-offer-message {
background: $surface;
border-left: solid $warning;
height: auto;
text-align: center;
content-align: center middle;
padding: 1 2;
margin-top: 1;
Markdown > *:last-child {
margin-bottom: 0;
link-style: bold underline;
link-background-hover: transparent;
link-color-hover: $warning;
}
}
Horizontal {
width: 100%;
height: auto;
@ -870,7 +979,7 @@ ExpandingBorder {
content-align: left bottom;
}
ModeIndicator {
AgentIndicator {
width: auto;
height: auto;
background: transparent;
@ -879,19 +988,19 @@ ModeIndicator {
color: $text-muted;
align: left middle;
&.mode-safe {
&.agent-safe {
color: $success;
}
&.mode-neutral {
&.agent-neutral {
color: $text-muted;
}
&.mode-destructive {
&.agent-destructive {
color: $warning;
}
&.mode-yolo {
&.agent-yolo {
color: $error;
}
}

View file

@ -0,0 +1,32 @@
from __future__ import annotations
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
class ExternalEditor:
"""Handles opening an external editor to edit prompt content."""
@staticmethod
def get_editor() -> str:
return os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
def edit(self, initial_content: str = "") -> str | None:
editor = self.get_editor()
fd, filepath = tempfile.mkstemp(suffix=".md", prefix="vibe_")
try:
with os.fdopen(fd, "w") as f:
f.write(initial_content)
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = Path(filepath).read_text().rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return
finally:
Path(filepath).unlink(missing_ok=True)

View file

@ -15,6 +15,8 @@ from vibe.core.types import (
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
UserMessageEvent,
)
from vibe.core.utils import TaggedText
@ -51,6 +53,8 @@ class EventHandler:
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
case ToolStreamEvent():
await self._handle_tool_stream(event)
case ReasoningEvent():
await self._handle_reasoning_message(event)
case AssistantEvent():
@ -59,6 +63,8 @@ class EventHandler:
await self._handle_compact_start()
case CompactEndEvent():
await self._handle_compact_end(event)
case UserMessageEvent():
pass
case _:
await self._handle_unknown_event(event)
return None
@ -119,6 +125,10 @@ class EventHandler:
self.current_tool_call = None
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
if self.current_tool_call:
self.current_tool_call.set_stream_message(event.message)
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
await self.mount_callback(AssistantMessage(event.content))

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from textual.widgets import Static
from vibe.core.agents import AgentProfile, AgentSafety, BuiltinAgentName
AGENT_ICONS: dict[str, str] = {
BuiltinAgentName.DEFAULT: "",
BuiltinAgentName.PLAN: "",
BuiltinAgentName.ACCEPT_EDITS: "⏵⏵",
BuiltinAgentName.AUTO_APPROVE: "⏵⏵⏵",
}
SAFETY_CLASSES: dict[AgentSafety, str] = {
AgentSafety.SAFE: "agent-safe",
AgentSafety.NEUTRAL: "agent-neutral",
AgentSafety.DESTRUCTIVE: "agent-destructive",
AgentSafety.YOLO: "agent-yolo",
}
class AgentIndicator(Static):
def __init__(self, profile: AgentProfile) -> None:
super().__init__(markup=False)
self.can_focus = False
self.profile = profile
self._update_display()
def _update_display(self) -> None:
icon = AGENT_ICONS.get(self.profile.name, "")
name = self.profile.display_name.lower()
self.update(f"{icon}{' ' if icon else ''}{name} agent (shift+tab to cycle)")
for safety_class in SAFETY_CLASSES.values():
self.remove_class(safety_class)
self.add_class(SAFETY_CLASSES[self.profile.safety])
def set_profile(self, profile: AgentProfile) -> None:
self.profile = profile
self._update_display()

View file

@ -52,12 +52,11 @@ class ApprovalApp(Container):
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: BaseModel, workdir: str, config: VibeConfig
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
self.tool_args = tool_args
self.workdir = workdir
self.config = config
self.selected_option = 0
self.content_container: Vertical | None = None

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
@ -16,13 +17,13 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.core.agents import AgentSafety
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
from vibe.core.modes import ModeSafety
SAFETY_BORDER_CLASSES: dict[ModeSafety, str] = {
ModeSafety.SAFE: "border-safe",
ModeSafety.DESTRUCTIVE: "border-warning",
ModeSafety.YOLO: "border-error",
SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
AgentSafety.SAFE: "border-safe",
AgentSafety.DESTRUCTIVE: "border-warning",
AgentSafety.YOLO: "border-error",
}
@ -38,27 +39,33 @@ class ChatInputContainer(Vertical):
self,
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
safety: ModeSafety = ModeSafety.NEUTRAL,
safety: AgentSafety = AgentSafety.NEUTRAL,
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._safety = safety
command_entries = [
(alias, command.description)
for command in self._command_registry.commands.values()
for alias in sorted(command.aliases)
]
self._skill_entries_getter = skill_entries_getter
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(command_entries), self),
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
PathCompletionController(PathCompleter(), self),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None
def _get_slash_entries(self) -> list[tuple[str, str]]:
entries = [
(alias, command.description)
for command in self._command_registry.commands.values()
for alias in sorted(command.aliases)
]
if self._skill_entries_getter:
entries.extend(self._skill_entries_getter())
return sorted(entries)
def compose(self) -> ComposeResult:
self._completion_popup = CompletionPopup()
yield self._completion_popup
@ -155,7 +162,7 @@ class ChatInputContainer(Vertical):
event.stop()
self.post_message(self.Submitted(event.value))
def set_safety(self, safety: ModeSafety) -> None:
def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety
try:

View file

@ -8,6 +8,7 @@ from textual.message import Message
from textual.widgets import TextArea
from vibe.cli.autocompletion.base import CompletionResult
from vibe.cli.textual_ui.external_editor import ExternalEditor
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
@ -23,7 +24,8 @@ class ChatTextArea(TextArea):
"New Line",
show=False,
priority=True,
)
),
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
@ -84,6 +86,17 @@ class ChatTextArea(TextArea):
def action_insert_newline(self) -> None:
self.insert("\n")
def action_open_external_editor(self) -> None:
editor = ExternalEditor()
current_text = self.get_full_text()
with self.app.suspend():
result = editor.edit(current_text)
if result is not None:
self.clear()
self.insert(result)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
self._reset_prefix()

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from textual.message import Message
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.core.utils import compact_reduction_display
class CompactMessage(StatusMessage):
@ -25,17 +26,7 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
if self.old_tokens is not None and self.new_tokens is not None:
reduction = self.old_tokens - self.new_tokens
reduction_pct = (
(reduction / self.old_tokens * 100) if self.old_tokens > 0 else 0
)
return (
f"Compaction complete: {self.old_tokens:,}"
f"{self.new_tokens:,} tokens (-{reduction_pct:.1f}%)"
)
return "Compaction complete"
return compact_reduction_display(self.old_tokens, self.new_tokens)
def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None

View file

@ -26,7 +26,6 @@ class SettingDefinition(TypedDict):
label: str
type: str
options: list[str]
value: str
class ConfigApp(Container):
@ -69,14 +68,12 @@ class ConfigApp(Container):
"label": "Model",
"type": "cycle",
"options": [m.alias for m in self.config.models],
"value": self.config.active_model,
},
{
"key": "textual_theme",
"label": "Theme",
"type": "cycle",
"options": themes,
"value": self.config.textual_theme,
},
]
@ -115,7 +112,9 @@ class ConfigApp(Container):
cursor = " " if is_selected else " "
label: str = setting["label"]
value: str = self.changes.get(setting["key"], setting["value"])
value: str = self.changes.get(
setting["key"], getattr(self.config, setting["key"], "")
)
text = f"{cursor}{label}: {value}"
@ -141,7 +140,7 @@ class ConfigApp(Container):
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self.changes.get(key, setting["value"])
current: str = self.changes.get(key, getattr(self.config, key)) or ""
options: list[str] = setting["options"]
new_value = ""

View file

@ -25,8 +25,6 @@ class ContextProgress(NoMarkupStatic):
self.update("")
return
percentage = min(
100, int((new_state.current_tokens / new_state.max_tokens) * 100)
)
text = f"{percentage}% of {new_state.max_tokens // 1000}k tokens"
ratio = min(1, new_state.current_tokens / new_state.max_tokens)
text = f"{ratio:.0%} of {new_state.max_tokens // 1000}k tokens"
self.update(text)

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
import random
from time import time
@ -60,6 +62,8 @@ class LoadingWidget(SpinnerMixin, Static):
self.hint_widget: Static | None = None
self.start_time: float | None = None
self._last_elapsed: int = -1
self._paused_total: float = 0.0
self._pause_start: float | None = None
def _get_easter_egg(self) -> str | None:
EASTER_EGG_PROBABILITY = 0.10
@ -84,6 +88,15 @@ class LoadingWidget(SpinnerMixin, Static):
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
def pause_timer(self) -> None:
if self._pause_start is None:
self._pause_start = time()
def resume_timer(self) -> None:
if self._pause_start is not None:
self._paused_total += time() - self._pause_start
self._pause_start = None
def set_status(self, status: str) -> None:
self.status = self._apply_easter_egg(status)
self._update_animation()
@ -154,7 +167,21 @@ class LoadingWidget(SpinnerMixin, Static):
self.transition_progress = 0
if self.hint_widget and self.start_time is not None:
elapsed = int(time() - self.start_time)
paused = self._paused_total + (
time() - self._pause_start if self._pause_start else 0
)
elapsed = int(time() - self.start_time - paused)
if elapsed != self._last_elapsed:
self._last_elapsed = elapsed
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
@contextmanager
def paused_timer(loading_widget: LoadingWidget | None) -> Iterator[None]:
if loading_widget:
loading_widget.pause_timer()
try:
yield
finally:
if loading_widget:
loading_widget.resume_timer()

View file

@ -197,6 +197,29 @@ class UserCommandMessage(Static):
yield Markdown(self._content)
class WhatsNewMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("whats-new-message")
self._content = content
def compose(self) -> ComposeResult:
yield Markdown(self._content)
class PlanOfferMessage(Static):
def __init__(self, text: str) -> None:
super().__init__()
self.add_class("plan-offer-message")
self._text = text
def compose(self) -> ComposeResult:
yield Markdown(self._text)
def get_text(self) -> str:
return self._text
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__()

View file

@ -1,47 +0,0 @@
from __future__ import annotations
from textual.widgets import Static
from vibe.core.modes import AgentMode, ModeSafety
MODE_ICONS: dict[AgentMode, str] = {
AgentMode.DEFAULT: "",
AgentMode.PLAN: "⏸︎",
AgentMode.ACCEPT_EDITS: "⏵⏵",
AgentMode.AUTO_APPROVE: "⏵⏵⏵",
}
SAFETY_CLASSES: dict[ModeSafety, str] = {
ModeSafety.SAFE: "mode-safe",
ModeSafety.NEUTRAL: "mode-neutral",
ModeSafety.DESTRUCTIVE: "mode-destructive",
ModeSafety.YOLO: "mode-yolo",
}
class ModeIndicator(Static):
"""Displays the current agent mode with safety-colored indicator."""
def __init__(self, mode: AgentMode = AgentMode.DEFAULT) -> None:
super().__init__()
self.can_focus = False
self._mode = mode
self._update_display()
def _update_display(self) -> None:
icon = MODE_ICONS.get(self._mode, "??")
name = self._mode.display_name.lower()
self.update(f"{icon} {name} mode (shift+tab to cycle)")
for safety_class in SAFETY_CLASSES.values():
self.remove_class(safety_class)
self.add_class(SAFETY_CLASSES[self._mode.safety])
@property
def mode(self) -> AgentMode:
return self._mode
def set_mode(self, mode: AgentMode) -> None:
self._mode = mode
self._update_display()

View file

@ -0,0 +1,479 @@
from __future__ import annotations
import itertools
from typing import TYPE_CHECKING, ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Horizontal, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Input
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
from vibe.core.tools.builtins.ask_user_question import Answer
class QuestionApp(Container):
MAX_OPTIONS: ClassVar[int] = 4
can_focus = True
can_focus_children = False
current_question_idx: reactive[int] = reactive(0)
selected_option: reactive[int] = reactive(0)
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("enter", "select", "Select", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
class Answered(Message):
def __init__(self, answers: list[Answer]) -> None:
super().__init__()
self.answers = answers
class Cancelled(Message):
pass
def __init__(self, args: AskUserQuestionArgs) -> None:
super().__init__(id="question-app")
self.args = args
self.questions = args.questions
self.answers: dict[int, tuple[str, bool]] = {}
self.multi_selections: dict[int, set[int]] = {}
self.other_texts: dict[int, str] = {}
self.option_widgets: list[NoMarkupStatic] = []
self.title_widget: NoMarkupStatic | None = None
self.other_prefix: NoMarkupStatic | None = None
self.other_input: Input | None = None
self.other_static: NoMarkupStatic | None = None
self.submit_widget: NoMarkupStatic | None = None
self.help_widget: NoMarkupStatic | None = None
self.tabs_widget: NoMarkupStatic | None = None
@property
def _current_question(self) -> Question:
return self.questions[self.current_question_idx]
@property
def _total_options(self) -> int:
base = len(self._current_question.options) + 1
if self._current_question.multi_select:
return base + 1
return base
@property
def _other_option_idx(self) -> int:
return len(self._current_question.options)
@property
def _submit_option_idx(self) -> int:
if not self._current_question.multi_select:
return -1
return self._other_option_idx + 1
@property
def _is_other_selected(self) -> bool:
return self.selected_option == self._other_option_idx
@property
def _is_submit_selected(self) -> bool:
return (
self._current_question.multi_select
and self.selected_option == self._submit_option_idx
)
def compose(self) -> ComposeResult:
with Vertical(id="question-content"):
if len(self.questions) > 1:
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
yield self.tabs_widget
self.title_widget = NoMarkupStatic("", classes="question-title")
yield self.title_widget
for _ in range(self.MAX_OPTIONS):
widget = NoMarkupStatic("", classes="question-option")
self.option_widgets.append(widget)
yield widget
with Horizontal(classes="question-other-row"):
self.other_prefix = NoMarkupStatic("", classes="question-other-prefix")
yield self.other_prefix
self.other_input = Input(
placeholder="Type your answer...", classes="question-other-input"
)
yield self.other_input
self.other_static = NoMarkupStatic(
"Type your answer...", classes="question-other-static"
)
yield self.other_static
self.submit_widget = NoMarkupStatic("", classes="question-submit")
yield self.submit_widget
self.help_widget = NoMarkupStatic("", classes="question-help")
yield self.help_widget
async def on_mount(self) -> None:
self._update_display()
self.focus()
def _watch_current_question_idx(self) -> None:
self._update_display()
def _watch_selected_option(self) -> None:
self._update_display()
def _update_display(self) -> None:
self._update_tabs()
self._update_title()
self._update_options()
self._update_other_row()
self._update_submit()
self._update_help()
def _update_tabs(self) -> None:
if not self.tabs_widget or len(self.questions) <= 1:
return
tabs = []
for i, question in enumerate(self.questions):
header = question.header or f"Q{i + 1}"
if i in self.answers:
header += ""
if i == self.current_question_idx:
tabs.append(f"[{header}]")
else:
tabs.append(f" {header} ")
self.tabs_widget.update(" ".join(tabs))
def _update_title(self) -> None:
if self.title_widget:
self.title_widget.update(self._current_question.question)
def _update_options(self) -> None:
q = self._current_question
options = q.options
is_multi = q.multi_select
multi_selected = self.multi_selections.get(self.current_question_idx, set())
for i, widget in enumerate(self.option_widgets):
if i < len(options):
is_focused = i == self.selected_option
is_selected = i in multi_selected
self._render_option(
widget, i, options[i], is_multi, is_focused, is_selected
)
else:
widget.update("")
widget.display = False
def _format_option_prefix(
self, idx: int, is_focused: bool, is_multi: bool, is_selected: bool
) -> str:
"""Format the prefix for an option line (cursor + number + checkbox if multi)."""
cursor = " " if is_focused else " "
if is_multi:
check = "[x]" if is_selected else "[ ]"
return f"{cursor}{idx + 1}. {check} "
return f"{cursor}{idx + 1}. "
def _render_option(
self,
widget: NoMarkupStatic,
idx: int,
opt: Choice,
is_multi: bool,
is_focused: bool,
is_selected: bool,
) -> None:
prefix = self._format_option_prefix(idx, is_focused, is_multi, is_selected)
text = f"{prefix}{opt.label}"
if opt.description:
text += f" - {opt.description}"
widget.update(text)
widget.display = True
widget.remove_class("question-option-selected")
if is_focused:
widget.add_class("question-option-selected")
def _update_other_row(self) -> None:
if not self.other_prefix or not self.other_input or not self.other_static:
return
q = self._current_question
is_multi = q.multi_select
multi_selected = self.multi_selections.get(self.current_question_idx, set())
other_idx = self._other_option_idx
is_focused = self._is_other_selected
is_selected = other_idx in multi_selected
prefix = self._format_option_prefix(
other_idx, is_focused, is_multi, is_selected
)
self.other_prefix.update(prefix)
stored_text = self.other_texts.get(self.current_question_idx, "")
if self.other_input.value != stored_text:
self.other_input.value = stored_text
show_input = is_focused or bool(stored_text)
self.other_input.display = show_input
self.other_static.display = not show_input
self.other_prefix.remove_class("question-option-selected")
if is_focused:
self.other_prefix.add_class("question-option-selected")
if is_focused and show_input:
self.other_input.focus()
elif not is_focused and not self._is_submit_selected:
self.focus()
def _update_submit(self) -> None:
if not self.submit_widget:
return
q = self._current_question
if not q.multi_select:
self.submit_widget.display = False
return
self.submit_widget.display = True
is_focused = self._is_submit_selected
cursor = " " if is_focused else " "
text = (
"Submit"
if len(set(self.answers.keys()) | {self.current_question_idx})
== len(self.questions)
else "Next"
)
self.submit_widget.update(f"{cursor} {text}")
self.submit_widget.remove_class("question-option-selected")
if is_focused:
self.submit_widget.add_class("question-option-selected")
self.focus()
def _update_help(self) -> None:
if not self.help_widget:
return
if self._current_question.multi_select:
help_text = "↑↓ navigate Enter toggle Esc cancel"
else:
help_text = "↑↓ navigate Enter select Esc cancel"
if len(self.questions) > 1:
help_text = "←→ questions " + help_text
self.help_widget.update(help_text)
def _store_other_text(self) -> None:
if self.other_input:
self.other_texts[self.current_question_idx] = self.other_input.value
def _get_other_text(self, idx: int) -> str:
return self.other_texts.get(idx, "")
def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % self._total_options
def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % self._total_options
def _switch_question(self, new_idx: int) -> None:
self.current_question_idx = new_idx
self.selected_option = 0
def action_next_question(self) -> None:
if self._is_other_selected:
other_text = self.other_texts.get(self.current_question_idx, "").strip()
if not other_text:
return
new_idx = (self.current_question_idx + 1) % len(self.questions)
self._switch_question(new_idx)
def action_prev_question(self) -> None:
new_idx = (self.current_question_idx - 1) % len(self.questions)
self._switch_question(new_idx)
def action_select(self) -> None:
if self._current_question.multi_select:
self._handle_multi_select_action()
else:
self._handle_single_select_action()
def _handle_multi_select_action(self) -> None:
"""Handle Enter key in multi-select mode: toggle option or submit."""
if self._is_submit_selected:
self._save_current_answer()
self._advance_or_submit()
elif self._is_other_selected:
if self.other_input:
self.other_input.focus()
else:
self._toggle_selection(self.selected_option)
def _handle_single_select_action(self) -> None:
"""Handle Enter key in single-select mode: select and advance."""
if self._is_other_selected:
if self.other_input:
other_text = self.other_texts.get(self.current_question_idx, "").strip()
if other_text:
self._save_current_answer()
self._advance_or_submit()
else:
self.other_input.focus()
else:
self._save_current_answer()
self._advance_or_submit()
def _toggle_selection(self, option_idx: int) -> None:
"""Toggle an option's selection state (multi-select only)."""
selections = self.multi_selections.setdefault(self.current_question_idx, set())
if option_idx in selections:
selections.discard(option_idx)
else:
selections.add(option_idx)
self._update_display()
def _advance_or_submit(self) -> None:
if self._all_answered():
self._submit()
else:
new_idx = next(
i
for i in itertools.chain(
range(self.current_question_idx + 1, len(self.questions)),
range(self.current_question_idx),
)
if i not in self.answers
)
self._switch_question(new_idx)
def action_cancel(self) -> None:
self.post_message(self.Cancelled())
def on_input_submitted(self, _event: Input.Submitted) -> None:
if not self.other_input or not self.other_input.value.strip():
return
q = self._current_question
if q.multi_select:
self.selected_option = self._submit_option_idx
else:
self._save_current_answer()
self._advance_or_submit()
def on_input_changed(self, _event: Input.Changed) -> None:
self._store_other_text()
self._sync_other_selection_with_text()
self._update_display()
def _sync_other_selection_with_text(self) -> None:
"""Auto-select/deselect 'Other' option based on whether text is entered (multi-select only)."""
if not self._current_question.multi_select or not self.other_input:
return
other_idx = self._other_option_idx
selections = self.multi_selections.setdefault(self.current_question_idx, set())
has_text = bool(self.other_input.value.strip())
if has_text and other_idx not in selections:
selections.add(other_idx)
elif not has_text and other_idx in selections:
selections.discard(other_idx)
def on_key(self, event: events.Key) -> None:
if len(self.questions) <= 1:
return
if self.other_input and self.other_input.has_focus:
return
if event.key == "left":
self.action_prev_question()
event.stop()
elif event.key == "right":
self.action_next_question()
event.stop()
def _save_current_answer(self) -> None:
if self._current_question.multi_select:
self._save_multi_select_answer()
else:
self._save_single_select_answer()
def _save_multi_select_answer(self) -> None:
"""Save answer for multi-select question (combines all selected options)."""
q = self._current_question
idx = self.current_question_idx
selections = self.multi_selections.get(idx, set())
if not selections:
return
other_text = self.other_texts.get(idx, "").strip()
answers = []
has_other = False
other_idx = len(q.options)
for sel_idx in sorted(selections):
if sel_idx < len(q.options):
answers.append(q.options[sel_idx].label)
elif sel_idx == other_idx and other_text:
answers.append(other_text)
has_other = True
if answers:
self.answers[idx] = (", ".join(answers), has_other)
def _save_single_select_answer(self) -> None:
"""Save answer for single-select question."""
idx = self.current_question_idx
if self._is_other_selected:
other_text = self.other_texts.get(idx, "").strip()
if other_text:
self.answers[idx] = (other_text, True)
else:
self.answers[idx] = (
self._current_question.options[self.selected_option].label,
False,
)
def _all_answered(self) -> bool:
return all(i in self.answers for i in range(len(self.questions)))
def _submit(self) -> None:
result: list[Answer] = []
for i, q in enumerate(self.questions):
answer_text, is_other = self.answers.get(i, ("", False))
result.append(
Answer(question=q.question, answer=answer_text, is_other=is_other)
)
self.post_message(self.Answered(answers=result))
def on_blur(self, _event: events.Blur) -> None:
self.call_after_refresh(self._refocus_if_needed)
def on_input_blurred(self, _event: Input.Blurred) -> None:
self.call_after_refresh(self._refocus_if_needed)
def _refocus_if_needed(self) -> None:
if self.has_focus or (self.other_input and self.other_input.has_focus):
return
self.focus()

View file

@ -10,6 +10,7 @@ from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
from vibe.core.tools.builtins.bash import BashArgs, BashResult
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
@ -321,6 +322,19 @@ class GrepResultWidget(ToolResultWidget[GrepResult]):
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
def compose(self) -> ComposeResult:
if self.collapsed or not self.result:
yield from self._header()
return
for answer in self.result.answers:
if len(self.result.answers) > 1:
yield NoMarkupStatic(answer.question, classes="tool-result-detail")
prefix = "(Other) " if answer.is_other else ""
yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer")
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
"bash": BashApprovalWidget,
"read_file": ReadFileApprovalWidget,
@ -337,6 +351,7 @@ RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
"search_replace": SearchReplaceResultWidget,
"grep": GrepResultWidget,
"todo": TodoResultWidget,
"ask_user_question": AskUserQuestionResultWidget,
}

View file

@ -4,7 +4,7 @@ from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableStatic
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
@ -23,6 +23,7 @@ class ToolCallMessage(StatusMessage):
self._event = event
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._is_history = event is None
self._stream_widget: NoMarkupStatic | None = None
super().__init__()
self.add_class("tool-call")
@ -30,6 +31,19 @@ class ToolCallMessage(StatusMessage):
if self._is_history:
self._is_spinning = False
def compose(self) -> ComposeResult:
with Vertical(classes="tool-call-container"):
with Horizontal():
self._indicator_widget = NonSelectableStatic(
self._spinner.current_frame(), classes="status-indicator-icon"
)
yield self._indicator_widget
self._text_widget = NoMarkupStatic("", classes="status-indicator-text")
yield self._text_widget
self._stream_widget = NoMarkupStatic("", classes="tool-stream-message")
self._stream_widget.display = False
yield self._stream_widget
def get_content(self) -> str:
if self._event and self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
@ -37,6 +51,18 @@ class ToolCallMessage(StatusMessage):
return display.summary
return self._tool_name
def set_stream_message(self, message: str) -> None:
"""Update the stream message displayed below the tool call indicator."""
if self._stream_widget:
self._stream_widget.update(f"{message}")
self._stream_widget.display = True
def stop_spinning(self, success: bool = True) -> None:
"""Stop the spinner and hide the stream widget."""
if self._stream_widget:
self._stream_widget.display = False
super().stop_spinning(success)
class ToolResultMessage(Static):
def __init__(
@ -80,12 +106,21 @@ class ToolResultMessage(Static):
async def on_mount(self) -> None:
if self._call_widget:
success = self._event is None or (
not self._event.error and not self._event.skipped
)
success = self._determine_success()
self._call_widget.stop_spinning(success=success)
await self._render_result()
def _determine_success(self) -> bool:
if self._event is None:
return True
if self._event.error or self._event.skipped:
return False
if self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
return display.success
return True
async def _render_result(self) -> None:
if self._content_container is None:
return

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from time import monotonic
from rich.align import Align
@ -102,7 +103,7 @@ class WelcomeBanner(Static):
model_count = len(self.config.models)
self._static_line3_suffix = f"{self.LOGO_TEXT_GAP}[dim]{model_count} models · {mcp_count} MCP servers[/]"
self._static_line5_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
f"{self.LOGO_TEXT_GAP}[dim]{self.config.displayed_workdir or Path.cwd()}[/]"
)
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"

View file

@ -3,41 +3,45 @@ from __future__ import annotations
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
FileSystemUpdateCacheRepository,
)
from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
GitHubVersionUpdateGateway,
)
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
PyPIVersionUpdateGateway,
)
from vibe.cli.update_notifier.adapters.github_update_gateway import GitHubUpdateGateway
from vibe.cli.update_notifier.adapters.pypi_update_gateway import PyPIUpdateGateway
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.cli.update_notifier.ports.version_update_gateway import (
from vibe.cli.update_notifier.ports.update_gateway import (
DEFAULT_GATEWAY_MESSAGES,
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
Update,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateAvailability,
VersionUpdateError,
from vibe.cli.update_notifier.update import (
UpdateAvailability,
UpdateError,
get_update_if_available,
)
from vibe.cli.update_notifier.whats_new import (
load_whats_new_content,
mark_version_as_seen,
should_show_whats_new,
)
__all__ = [
"DEFAULT_GATEWAY_MESSAGES",
"FileSystemUpdateCacheRepository",
"GitHubVersionUpdateGateway",
"PyPIVersionUpdateGateway",
"GitHubUpdateGateway",
"PyPIUpdateGateway",
"Update",
"UpdateAvailability",
"UpdateCache",
"UpdateCacheRepository",
"VersionUpdate",
"VersionUpdateAvailability",
"VersionUpdateError",
"VersionUpdateGateway",
"VersionUpdateGatewayCause",
"VersionUpdateGatewayError",
"UpdateError",
"UpdateGateway",
"UpdateGatewayCause",
"UpdateGatewayError",
"get_update_if_available",
"load_whats_new_content",
"mark_version_as_seen",
"should_show_whats_new",
]

View file

@ -26,6 +26,7 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
data = json.loads(content)
latest_version = data.get("latest_version")
stored_at_timestamp = data.get("stored_at_timestamp")
seen_whats_new_version = data.get("seen_whats_new_version")
except (TypeError, json.JSONDecodeError):
return None
@ -34,16 +35,28 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
):
return None
if (
not isinstance(seen_whats_new_version, str)
and seen_whats_new_version is not None
):
seen_whats_new_version = None
return UpdateCache(
latest_version=latest_version, stored_at_timestamp=stored_at_timestamp
latest_version=latest_version,
stored_at_timestamp=stored_at_timestamp,
seen_whats_new_version=seen_whats_new_version,
)
async def set(self, update_cache: UpdateCache) -> None:
try:
payload = json.dumps({
"latest_version": update_cache.latest_version,
"stored_at_timestamp": update_cache.stored_at_timestamp,
})
payload = json.dumps(
{
"latest_version": update_cache.latest_version,
"stored_at_timestamp": update_cache.stored_at_timestamp,
"seen_whats_new_version": update_cache.seen_whats_new_version,
},
ensure_ascii=False,
)
await asyncio.to_thread(self._cache_file.write_text, payload)
except OSError:
return None

View file

@ -2,15 +2,15 @@ from __future__ import annotations
import httpx
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
from vibe.cli.update_notifier.ports.update_gateway import (
Update,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
class GitHubVersionUpdateGateway(VersionUpdateGateway):
class GitHubUpdateGateway(UpdateGateway):
def __init__(
self,
owner: str,
@ -28,7 +28,7 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
self._timeout = timeout
self._base_url = base_url.rstrip("/")
async def fetch_update(self) -> VersionUpdate | None:
async def fetch_update(self) -> Update | None:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "mistral-vibe-update-notifier",
@ -51,38 +51,30 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
) as client:
response = await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.REQUEST_FAILED
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
if response.status_code == httpx.codes.TOO_MANY_REQUESTS or (
rate_limit_remaining is not None and rate_limit_remaining == "0"
):
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.TOO_MANY_REQUESTS
)
raise UpdateGatewayError(cause=UpdateGatewayCause.TOO_MANY_REQUESTS)
if response.status_code == httpx.codes.FORBIDDEN:
raise VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.FORBIDDEN)
raise UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN)
if response.status_code == httpx.codes.NOT_FOUND:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.NOT_FOUND,
raise UpdateGatewayError(
cause=UpdateGatewayCause.NOT_FOUND,
message="Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
)
if response.is_error:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
)
raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
try:
data = response.json()
except ValueError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
if not data:
return None
@ -95,7 +87,7 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
if release.get("prerelease") or release.get("draft"):
continue
if version := _extract_version(release.get("tag_name")):
return VersionUpdate(latest_version=version)
return Update(latest_version=version)
return None

View file

@ -4,21 +4,21 @@ import httpx
from packaging.utils import parse_sdist_filename, parse_wheel_filename
from packaging.version import InvalidVersion, Version
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
from vibe.cli.update_notifier.ports.update_gateway import (
Update,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
_STATUS_CAUSES: dict[int, VersionUpdateGatewayCause] = {
httpx.codes.NOT_FOUND: VersionUpdateGatewayCause.NOT_FOUND,
httpx.codes.FORBIDDEN: VersionUpdateGatewayCause.FORBIDDEN,
httpx.codes.TOO_MANY_REQUESTS: VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
_STATUS_CAUSES: dict[int, UpdateGatewayCause] = {
httpx.codes.NOT_FOUND: UpdateGatewayCause.NOT_FOUND,
httpx.codes.FORBIDDEN: UpdateGatewayCause.FORBIDDEN,
httpx.codes.TOO_MANY_REQUESTS: UpdateGatewayCause.TOO_MANY_REQUESTS,
}
class PyPIVersionUpdateGateway(VersionUpdateGateway):
class PyPIUpdateGateway(UpdateGateway):
def __init__(
self,
project_name: str,
@ -32,16 +32,14 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
self._timeout = timeout
self._base_url = base_url.rstrip("/")
async def fetch_update(self) -> VersionUpdate | None:
async def fetch_update(self) -> Update | None:
response = await self._fetch()
self._raise_gateway_error_if_any(response)
try:
data = response.json()
except ValueError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
versions = data.get("versions") or []
files = data.get("files") or []
@ -66,7 +64,7 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
for version in sorted(valid_versions, reverse=True):
if version in non_yanked_versions:
return VersionUpdate(latest_version=str(version))
return Update(latest_version=str(version))
return None
@ -87,18 +85,14 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
) as client:
return await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.REQUEST_FAILED
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
if response.status_code in _STATUS_CAUSES:
raise VersionUpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
raise UpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
if response.is_error:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
)
raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
def _parse_filename_version(filename: str) -> Version | None:

View file

@ -8,6 +8,7 @@ from typing import Protocol
class UpdateCache:
latest_version: str
stored_at_timestamp: int
seen_whats_new_version: str | None = None
class UpdateCacheRepository(Protocol):

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
@dataclass(frozen=True, slots=True)
class Update:
latest_version: str
class UpdateGatewayCause(StrEnum):
@staticmethod
def _generate_next_value_(
name: str, start: int, count: int, last_values: list[str]
) -> str:
return name.lower()
TOO_MANY_REQUESTS = auto()
FORBIDDEN = auto()
NOT_FOUND = auto()
REQUEST_FAILED = auto()
ERROR_RESPONSE = auto()
INVALID_RESPONSE = auto()
UNKNOWN = auto()
DEFAULT_GATEWAY_MESSAGES: dict[UpdateGatewayCause, str] = {
UpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
UpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
UpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
UpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
UpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
UpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
UpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
}
class UpdateGatewayError(Exception):
def __init__(
self, *, cause: UpdateGatewayCause, message: str | None = None
) -> None:
self.cause = cause
self.user_message = message
detail = message or DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
)
super().__init__(detail)
class UpdateGateway(Protocol):
async def fetch_update(self) -> Update | None: ...

View file

@ -1,53 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
@dataclass(frozen=True, slots=True)
class VersionUpdate:
latest_version: str
class VersionUpdateGatewayCause(StrEnum):
@staticmethod
def _generate_next_value_(
name: str, start: int, count: int, last_values: list[str]
) -> str:
return name.lower()
TOO_MANY_REQUESTS = auto()
FORBIDDEN = auto()
NOT_FOUND = auto()
REQUEST_FAILED = auto()
ERROR_RESPONSE = auto()
INVALID_RESPONSE = auto()
UNKNOWN = auto()
DEFAULT_GATEWAY_MESSAGES: dict[VersionUpdateGatewayCause, str] = {
VersionUpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
VersionUpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
VersionUpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
VersionUpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
VersionUpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
VersionUpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
VersionUpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
}
class VersionUpdateGatewayError(Exception):
def __init__(
self, *, cause: VersionUpdateGatewayCause, message: str | None = None
) -> None:
self.cause = cause
self.user_message = message
detail = message or DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
)
super().__init__(detail)
class VersionUpdateGateway(Protocol):
async def fetch_update(self) -> VersionUpdate | None: ...

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import time
@ -10,21 +11,21 @@ from vibe.cli.update_notifier import (
DEFAULT_GATEWAY_MESSAGES,
UpdateCache,
UpdateCacheRepository,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
@dataclass(frozen=True, slots=True)
class VersionUpdateAvailability:
class UpdateAvailability:
latest_version: str
should_notify: bool
class VersionUpdateError(Exception):
class UpdateError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
@ -37,17 +38,17 @@ def _parse_version(raw: str) -> Version | None:
return None
def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
def _describe_gateway_error(error: UpdateGatewayError) -> str:
if message := getattr(error, "user_message", None):
return message
cause = getattr(error, "cause", VersionUpdateGatewayCause.UNKNOWN)
if isinstance(cause, VersionUpdateGatewayCause):
cause = getattr(error, "cause", UpdateGatewayCause.UNKNOWN)
if isinstance(cause, UpdateGatewayCause):
return DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
)
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
return DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
def _is_cache_fresh(
@ -60,14 +61,12 @@ def _is_cache_fresh(
def _get_cached_update_if_any(
cache: UpdateCache, current: Version
) -> VersionUpdateAvailability | None:
) -> UpdateAvailability | None:
latest_version_in_cache = _parse_version(cache.latest_version)
if latest_version_in_cache is None or latest_version_in_cache <= current:
return None
return VersionUpdateAvailability(
latest_version=cache.latest_version, should_notify=False
)
return UpdateAvailability(latest_version=cache.latest_version, should_notify=False)
async def _write_update_cache(
@ -81,11 +80,11 @@ async def _write_update_cache(
async def get_update_if_available(
version_update_notifier: VersionUpdateGateway,
update_notifier: UpdateGateway,
current_version: str,
update_cache_repository: UpdateCacheRepository,
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
) -> VersionUpdateAvailability | None:
) -> UpdateAvailability | None:
if not (current := _parse_version(current_version)):
return None
@ -94,12 +93,12 @@ async def get_update_if_available(
return _get_cached_update_if_any(update_cache, current)
try:
update = await version_update_notifier.fetch_update()
except VersionUpdateGatewayError as error:
update = await update_notifier.fetch_update()
except UpdateGatewayError as error:
await _write_update_cache(
update_cache_repository, current_version, get_current_timestamp
)
raise VersionUpdateError(_describe_gateway_error(error)) from error
raise UpdateError(_describe_gateway_error(error)) from error
if not update:
await _write_update_cache(
@ -120,6 +119,21 @@ async def get_update_if_available(
update_cache_repository, update.latest_version, get_current_timestamp
)
return VersionUpdateAvailability(
latest_version=update.latest_version, should_notify=True
)
return UpdateAvailability(latest_version=update.latest_version, should_notify=True)
UPDATE_COMMANDS = ["uv tool upgrade mistral-vibe", "brew upgrade mistral-vibe"]
async def do_update() -> bool:
for command in UPDATE_COMMANDS:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
await process.wait()
if process.returncode == 0:
return True
return False

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import time
from vibe import VIBE_ROOT
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
async def should_show_whats_new(
current_version: str, repository: UpdateCacheRepository
) -> bool:
cache = await repository.get()
if cache is None:
return False
return cache.seen_whats_new_version != current_version
def load_whats_new_content() -> str | None:
whats_new_file = VIBE_ROOT / "whats_new.md"
if not whats_new_file.exists():
return None
try:
content = whats_new_file.read_text(encoding="utf-8").strip()
return content if content else None
except OSError:
return None
async def mark_version_as_seen(version: str, repository: UpdateCacheRepository) -> None:
cache = await repository.get()
if cache is None:
await repository.set(
UpdateCache(
latest_version=version,
stored_at_timestamp=int(time.time()),
seen_whats_new_version=version,
)
)
else:
await repository.set(
UpdateCache(
latest_version=cache.latest_version,
stored_at_timestamp=cache.stored_at_timestamp,
seen_whats_new_version=version,
)
)

View file

@ -3,16 +3,18 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from enum import StrEnum, auto
from threading import Thread
import time
from typing import cast
from uuid import uuid4
from pydantic import BaseModel
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
from vibe.core.llm.types import BackendLike
from vibe.core.middleware import (
AutoCompactMiddleware,
@ -21,17 +23,20 @@ from vibe.core.middleware import (
MiddlewareAction,
MiddlewarePipeline,
MiddlewareResult,
PlanModeMiddleware,
PlanAgentMiddleware,
PriceLimitMiddleware,
ResetReason,
TurnLimitMiddleware,
)
from vibe.core.modes import AgentMode
from vibe.core.prompts import UtilityPrompt
from vibe.core.session.session_logger import SessionLogger
from vibe.core.session.session_migration import migrate_sessions_entrypoint
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
InvokeContext,
ToolError,
ToolPermission,
ToolPermissionError,
@ -54,6 +59,9 @@ from vibe.core.types import (
SyncApprovalCallback,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
UserInputCallback,
UserMessageEvent,
)
from vibe.core.utils import (
TOOL_ERROR_TAG,
@ -75,35 +83,36 @@ class ToolDecision(BaseModel):
feedback: str | None = None
class AgentError(Exception):
"""Base exception for Agent errors."""
class AgentLoopError(Exception):
"""Base exception for AgentLoop errors."""
class AgentStateError(AgentError):
"""Raised when agent is in an invalid state."""
class AgentLoopStateError(AgentLoopError):
"""Raised when agent loop is in an invalid state."""
class LLMResponseError(AgentError):
class AgentLoopLLMResponseError(AgentLoopError):
"""Raised when LLM response is malformed or missing expected data."""
class Agent:
class AgentLoop:
def __init__(
self,
config: VibeConfig,
mode: AgentMode = AgentMode.DEFAULT,
agent_name: str = BuiltinAgentName.DEFAULT,
message_observer: Callable[[LLMMessage], None] | None = None,
max_turns: int | None = None,
max_price: float | None = None,
backend: BackendLike | None = None,
enable_streaming: bool = False,
) -> None:
"""Initialize the agent with configuration and mode."""
self.config = config
self._mode = mode
self._base_config = config
self._max_turns = max_turns
self._max_price = max_price
self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name
)
self.tool_manager = ToolManager(lambda: self.config)
self.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler()
@ -113,12 +122,12 @@ class Agent:
self.message_observer = message_observer
self._last_observed_message_index: int = 0
self.middleware_pipeline = MiddlewarePipeline()
self.enable_streaming = enable_streaming
self.middleware_pipeline = MiddlewarePipeline()
self._setup_middleware()
system_prompt = get_universal_system_prompt(
self.tool_manager, config, self.skill_manager
self.tool_manager, config, self.skill_manager, self.agent_manager
)
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
@ -135,23 +144,45 @@ class Agent:
pass
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
self.session_id = str(uuid4())
self.interaction_logger = InteractionLogger(
config.session_logging,
self.session_id,
self.auto_approve,
config.effective_workdir,
self.session_logger = SessionLogger(config.session_logging, self.session_id)
thread = Thread(
target=migrate_sessions_entrypoint,
args=(config.session_logging,),
daemon=True,
name="migrate_sessions",
)
thread.start()
@property
def mode(self) -> AgentMode:
return self._mode
def agent_profile(self) -> AgentProfile:
return self.agent_manager.active_profile
@property
def config(self) -> VibeConfig:
return self.agent_manager.config
@property
def auto_approve(self) -> bool:
return self._mode.auto_approve
return self.config.auto_approve
def set_tool_permission(
self, tool_name: str, permission: ToolPermission, save_permanently: bool = False
) -> None:
if save_permanently:
VibeConfig.save_updates({
"tools": {tool_name: {"permission": permission.value}}
})
if tool_name not in self.config.tools:
self.config.tools[tool_name] = BaseToolConfig()
self.config.tools[tool_name].permission = permission
self.tool_manager.invalidate_tool(tool_name)
def _select_backend(self) -> BackendLike:
active_model = self.config.get_active_model()
@ -197,7 +228,7 @@ class Agent:
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
)
self.middleware_pipeline.add(PlanModeMiddleware(lambda: self._mode))
self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
async def _handle_middleware_result(
self, result: MiddlewareResult
@ -224,14 +255,18 @@ class Agent:
threshold = result.metadata.get(
"threshold", self.config.auto_compact_threshold
)
tool_call_id = str(uuid4())
yield CompactStartEvent(
current_context_tokens=old_tokens, threshold=threshold
tool_call_id=tool_call_id,
current_context_tokens=old_tokens,
threshold=threshold,
)
summary = await self.compact()
yield CompactEndEvent(
tool_call_id=tool_call_id,
old_context_tokens=old_tokens,
new_context_tokens=self.stats.context_tokens,
summary_length=len(summary),
@ -246,9 +281,15 @@ class Agent:
)
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
self.messages.append(LLMMessage(role=Role.user, content=user_msg))
user_message = LLMMessage(role=Role.user, content=user_msg)
self.messages.append(user_message)
self.stats.steps += 1
if user_message.message_id is None:
raise AgentLoopError("User message must have a message_id")
yield UserMessageEvent(content=user_msg, message_id=user_message.message_id)
try:
should_break_loop = False
while not should_break_loop:
@ -287,8 +328,12 @@ class Agent:
finally:
self._flush_new_messages()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
@ -303,9 +348,7 @@ class Agent:
last_message = self.messages[-1]
parsed = self.format_handler.parse_message(last_message)
resolved = self.format_handler.resolve_tool_calls(
parsed, self.tool_manager, self.config
)
resolved = self.format_handler.resolve_tool_calls(parsed, self.tool_manager)
if not resolved.tool_calls and not resolved.failed_calls:
return
@ -320,12 +363,16 @@ class Agent:
reasoning_buffer = ""
chunks_with_content = 0
chunks_with_reasoning = 0
message_id: str | None = None
BATCH_SIZE = 5
async for chunk in self._chat_streaming():
if message_id is None:
message_id = chunk.message.message_id
if chunk.message.reasoning_content:
if content_buffer:
yield AssistantEvent(content=content_buffer)
yield AssistantEvent(content=content_buffer, message_id=message_id)
content_buffer = ""
chunks_with_content = 0
@ -333,13 +380,17 @@ class Agent:
chunks_with_reasoning += 1
if chunks_with_reasoning >= BATCH_SIZE:
yield ReasoningEvent(content=reasoning_buffer)
yield ReasoningEvent(
content=reasoning_buffer, message_id=message_id
)
reasoning_buffer = ""
chunks_with_reasoning = 0
if chunk.message.content:
if reasoning_buffer:
yield ReasoningEvent(content=reasoning_buffer)
yield ReasoningEvent(
content=reasoning_buffer, message_id=message_id
)
reasoning_buffer = ""
chunks_with_reasoning = 0
@ -347,23 +398,26 @@ class Agent:
chunks_with_content += 1
if chunks_with_content >= BATCH_SIZE:
yield AssistantEvent(content=content_buffer)
yield AssistantEvent(content=content_buffer, message_id=message_id)
content_buffer = ""
chunks_with_content = 0
if reasoning_buffer:
yield ReasoningEvent(content=reasoning_buffer)
yield ReasoningEvent(content=reasoning_buffer, message_id=message_id)
if content_buffer:
yield AssistantEvent(content=content_buffer)
yield AssistantEvent(content=content_buffer, message_id=message_id)
async def _get_assistant_event(self) -> AssistantEvent:
llm_result = await self._chat()
return AssistantEvent(content=llm_result.message.content or "")
return AssistantEvent(
content=llm_result.message.content or "",
message_id=llm_result.message.message_id,
)
async def _handle_tool_calls(
self, resolved: ResolvedMessage
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent]:
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
for failed in resolved.failed_calls:
error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}</{TOOL_ERROR_TAG}>"
@ -382,13 +436,11 @@ class Agent:
)
for tool_call in resolved.tool_calls:
tool_call_id = tool_call.call_id
yield ToolCallEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
args=tool_call.validated_args,
tool_call_id=tool_call_id,
tool_call_id=tool_call.call_id,
)
try:
@ -399,19 +451,13 @@ class Agent:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call_id,
)
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(
tool_call, error_msg
)
)
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, error_msg)
continue
decision = await self._should_execute_tool(
tool_instance, tool_call.validated_args, tool_call_id
tool_instance, tool_call.validated_args, tool_call.call_id
)
if decision.verdict == ToolExecutionResponse.SKIP:
@ -427,43 +473,47 @@ class Agent:
tool_class=tool_call.tool_class,
skipped=True,
skip_reason=skip_reason,
tool_call_id=tool_call_id,
)
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(
tool_call, skip_reason
)
)
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, skip_reason)
continue
self.stats.tool_calls_agreed += 1
try:
start_time = time.perf_counter()
result_model = await tool_instance.invoke(**tool_call.args_dict)
result_model = None
async for item in tool_instance.invoke(
ctx=InvokeContext(
tool_call_id=tool_call.call_id,
approval_callback=self.approval_callback,
agent_manager=self.agent_manager,
user_input_callback=self.user_input_callback,
),
**tool_call.args_dict,
):
if isinstance(item, ToolStreamEvent):
yield item
else:
result_model = item
duration = time.perf_counter() - start_time
if result_model is None:
raise ToolError("Tool did not yield a result")
text = "\n".join(
f"{k}: {v}" for k, v in result_model.model_dump().items()
)
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(
tool_call, text
)
)
)
self._append_tool_response(tool_call, text)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
result=result_model,
duration=duration,
tool_call_id=tool_call_id,
tool_call_id=tool_call.call_id,
)
self.stats.tool_calls_succeeded += 1
@ -476,34 +526,9 @@ class Agent:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call_id,
)
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(
tool_call, cancel
)
)
)
raise
except KeyboardInterrupt:
cancel = str(
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
)
yield ToolResultEvent(
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=cancel,
tool_call_id=tool_call_id,
)
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(
tool_call, cancel
)
)
tool_call_id=tool_call.call_id,
)
self._append_tool_response(tool_call, cancel)
raise
except (ToolError, ToolPermissionError) as exc:
@ -513,7 +538,7 @@ class Agent:
tool_name=tool_call.tool_name,
tool_class=tool_call.tool_class,
error=error_msg,
tool_call_id=tool_call_id,
tool_call_id=tool_call.call_id,
)
if isinstance(exc, ToolPermissionError):
@ -521,22 +546,21 @@ class Agent:
self.stats.tool_calls_rejected += 1
else:
self.stats.tool_calls_failed += 1
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(
tool_call, error_msg
)
)
)
self._append_tool_response(tool_call, error_msg)
continue
def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None:
self.messages.append(
LLMMessage.model_validate(
self.format_handler.create_tool_response_message(tool_call, text)
)
)
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
available_tools = self.format_handler.get_available_tools(
self.tool_manager, self.config
)
available_tools = self.format_handler.get_available_tools(self.tool_manager)
tool_choice = self.format_handler.get_tool_choice()
try:
@ -557,7 +581,7 @@ class Agent:
end_time = time.perf_counter()
if result.usage is None:
raise LLMResponseError(
raise AgentLoopLLMResponseError(
"Usage data missing in non-streaming completion response"
)
self._update_stats(usage=result.usage, time_seconds=end_time - start_time)
@ -579,9 +603,7 @@ class Agent:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
available_tools = self.format_handler.get_available_tools(
self.tool_manager, self.config
)
available_tools = self.format_handler.get_available_tools(self.tool_manager)
tool_choice = self.format_handler.get_tool_choice()
try:
start_time = time.perf_counter()
@ -612,7 +634,7 @@ class Agent:
end_time = time.perf_counter()
if chunk_agg.usage is None:
raise LLMResponseError(
raise AgentLoopLLMResponseError(
"Usage data missing in final chunk of streamed completion"
)
self._update_stats(usage=usage, time_seconds=end_time - start_time)
@ -750,18 +772,26 @@ class Agent:
def _reset_session(self) -> None:
self.session_id = str(uuid4())
self.interaction_logger.reset_session(self.session_id)
self.session_logger.reset_session(self.session_id)
def set_approval_callback(self, callback: ApprovalCallback) -> None:
self.approval_callback = callback
def set_user_input_callback(self, callback: UserInputCallback) -> None:
self.user_input_callback = callback
async def clear_history(self) -> None:
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
self.messages = self.messages[:1]
self.stats = AgentStats()
self.stats.trigger_listeners()
try:
active_model = self.config.get_active_model()
@ -779,32 +809,25 @@ class Agent:
"""Compact the conversation history."""
try:
self._clean_message_history()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
last_user_message = None
for msg in reversed(self.messages):
if msg.role == Role.user:
last_user_message = msg.content
break
summary_request = UtilityPrompt.COMPACT.read()
self.messages.append(LLMMessage(role=Role.user, content=summary_request))
self.stats.steps += 1
summary_result = await self._chat()
if summary_result.usage is None:
raise LLMResponseError(
raise AgentLoopLLMResponseError(
"Usage data missing in compaction summary response"
)
summary_content = summary_result.message.content or ""
if last_user_message:
summary_content += (
f"\n\nLast request from user was: {last_user_message}"
)
system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content)
self.messages = [system_message, summary_message]
@ -816,17 +839,19 @@ class Agent:
actual_context_tokens = await backend.count_tokens(
model=active_model,
messages=self.messages,
tools=self.format_handler.get_available_tools(
self.tool_manager, self.config
),
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
)
self.stats.context_tokens = actual_context_tokens
self._reset_session()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
self.middleware_pipeline.reset(reset_reason=ResetReason.COMPACT)
@ -834,36 +859,40 @@ class Agent:
return summary_content or ""
except Exception:
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
raise
async def switch_mode(self, new_mode: AgentMode) -> None:
if new_mode == self._mode:
async def switch_agent(self, agent_name: str) -> None:
if agent_name == self.agent_profile.name:
return
new_config = VibeConfig.load(
workdir=self.config.workdir, **new_mode.config_overrides
)
await self.reload_with_initial_messages(config=new_config)
self._mode = new_mode
self.agent_manager.switch_profile(agent_name)
await self.reload_with_initial_messages()
async def reload_with_initial_messages(
self,
config: VibeConfig | None = None,
base_config: VibeConfig | None = None,
max_turns: int | None = None,
max_price: float | None = None,
) -> None:
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)
preserved_messages = self.messages[1:] if len(self.messages) > 1 else []
if base_config is not None:
self._base_config = base_config
self.agent_manager.invalidate_config()
if config is not None:
self.config = config
self.backend = self.backend_factory()
self.backend = self.backend_factory()
if max_turns is not None:
self._max_turns = max_turns
@ -874,12 +903,13 @@ class Agent:
self.skill_manager = SkillManager(lambda: self.config)
new_system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages = [LLMMessage(role=Role.system, content=new_system_prompt)]
if preserved_messages:
self.messages.extend(preserved_messages)
self.messages = [
LLMMessage(role=Role.system, content=new_system_prompt),
*[msg for msg in self.messages if msg.role != Role.system],
]
if len(self.messages) == 1:
self.stats.reset_context_state()
@ -901,8 +931,10 @@ class Agent:
self.message_observer(msg)
self._last_observed_message_index = len(self.messages)
self.tool_manager.reset_all()
await self.interaction_logger.save_interaction(
self.messages, self.stats, self.config, self.tool_manager
await self.session_logger.save_interaction(
self.messages,
self.stats,
self._base_config,
self.tool_manager,
self.agent_profile,
)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import (
ACCEPT_EDITS,
AUTO_APPROVE,
BUILTIN_AGENTS,
DEFAULT,
EXPLORE,
PLAN,
PLAN_AGENT_TOOLS,
AgentProfile,
AgentSafety,
AgentType,
BuiltinAgentName,
)
__all__ = [
"ACCEPT_EDITS",
"AUTO_APPROVE",
"BUILTIN_AGENTS",
"DEFAULT",
"EXPLORE",
"PLAN",
"PLAN_AGENT_TOOLS",
"AgentManager",
"AgentProfile",
"AgentSafety",
"AgentType",
"BuiltinAgentName",
]

165
vibe/core/agents/manager.py Normal file
View file

@ -0,0 +1,165 @@
from __future__ import annotations
from collections.abc import Callable
from logging import getLogger
from pathlib import Path
from typing import TYPE_CHECKING
from vibe.core.agents.models import (
BUILTIN_AGENTS,
AgentProfile,
AgentType,
BuiltinAgentName,
)
from vibe.core.paths.config_paths import resolve_local_agents_dir
from vibe.core.paths.global_paths import GLOBAL_AGENTS_DIR
from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
logger = getLogger("vibe")
class AgentManager:
def __init__(
self,
config_getter: Callable[[], VibeConfig],
initial_agent: str = BuiltinAgentName.DEFAULT,
) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
self._available: dict[str, AgentProfile] = self._discover_agents()
custom_count = len(self._available) - len(BUILTIN_AGENTS)
if custom_count > 0:
custom_names = [
name for name in self._available if name not in BUILTIN_AGENTS
]
logger.info(
"Discovered custom agents %s in %s",
" ".join(custom_names),
" ".join(str(p) for p in self._search_paths),
)
self.active_profile = self._available.get(
initial_agent, self._available[BuiltinAgentName.DEFAULT]
)
self._cached_config: VibeConfig | None = None
@property
def _config(self) -> VibeConfig:
return self._config_getter()
@property
def available_agents(self) -> dict[str, AgentProfile]:
if self._config.enabled_agents:
return {
name: profile
for name, profile in self._available.items()
if name_matches(name, self._config.enabled_agents)
}
if self._config.disabled_agents:
return {
name: profile
for name, profile in self._available.items()
if not name_matches(name, self._config.disabled_agents)
}
return dict(self._available)
@property
def config(self) -> VibeConfig:
if self._cached_config is None:
self._cached_config = self.active_profile.apply_to_config(self._config)
return self._cached_config
def switch_profile(self, name: str) -> None:
self.active_profile = self.get_agent(name)
self._cached_config = None
def invalidate_config(self) -> None:
self._cached_config = None
@staticmethod
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = []
for path in config.agent_paths:
if path.is_dir():
paths.append(path)
if (agents_dir := resolve_local_agents_dir(Path.cwd())) is not None:
paths.append(agents_dir)
if GLOBAL_AGENTS_DIR.path.is_dir():
paths.append(GLOBAL_AGENTS_DIR.path)
unique: list[Path] = []
for p in paths:
rp = p.resolve()
if rp not in unique:
unique.append(rp)
return unique
def _discover_agents(self) -> dict[str, AgentProfile]:
agents: dict[str, AgentProfile] = dict(BUILTIN_AGENTS)
for base in self._search_paths:
if not base.is_dir():
continue
for agent_file in base.glob("*.toml"):
if not agent_file.is_file():
continue
if (agent := self._try_load_agent(agent_file)) is not None:
if agent.name in BUILTIN_AGENTS:
logger.info(
"Custom agent '%s' overrides builtin agent", agent.name
)
elif agent.name in agents:
logger.debug(
"Skipping duplicate agent '%s' at %s",
agent.name,
agent_file,
)
continue
agents[agent.name] = agent
return agents
def _try_load_agent(self, agent_file: Path) -> AgentProfile | None:
try:
agent = AgentProfile.from_toml(agent_file)
agent.apply_to_config(self._config)
return agent
except Exception as e:
logger.warning("Failed to load agent at %s: %s", agent_file, e)
return None
def get_agent(self, name: str) -> AgentProfile:
if agent := self.available_agents.get(name):
return agent
raise ValueError(f"Agent '{name}' not found")
def get_subagents(self) -> list[AgentProfile]:
return [
a
for a in self.available_agents.values()
if a.agent_type == AgentType.SUBAGENT
]
def get_agent_order(self) -> list[str]:
builtin_order: list[str] = [
BuiltinAgentName.DEFAULT,
BuiltinAgentName.PLAN,
BuiltinAgentName.ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE,
]
primary_agents = [
name
for name, agent in self.available_agents.items()
if agent.agent_type == AgentType.AGENT
]
order = [name for name in builtin_order if name in primary_agents]
custom = sorted(name for name in primary_agents if name not in builtin_order)
return order + custom
def next_agent(self, current: AgentProfile) -> AgentProfile:
order = self.get_agent_order()
idx = order.index(current.name) if current.name in order else -1
return self.available_agents[order[(idx + 1) % len(order)]]

122
vibe/core/agents/models.py Normal file
View file

@ -0,0 +1,122 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum, auto
from pathlib import Path
import tomllib
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
class AgentSafety(StrEnum):
SAFE = auto()
NEUTRAL = auto()
DESTRUCTIVE = auto()
YOLO = auto()
class AgentType(StrEnum):
AGENT = auto()
SUBAGENT = auto()
class BuiltinAgentName(StrEnum):
DEFAULT = "default"
PLAN = "plan"
ACCEPT_EDITS = "accept-edits"
AUTO_APPROVE = "auto-approve"
EXPLORE = "explore"
@dataclass(frozen=True)
class AgentProfile:
name: str
display_name: str
description: str
safety: AgentSafety
agent_type: AgentType = AgentType.AGENT
overrides: dict[str, Any] = field(default_factory=dict)
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
from vibe.core.config import VibeConfig as VC
merged = _deep_merge(base.model_dump(), self.overrides)
return VC.model_validate(merged)
@classmethod
def from_toml(cls, path: Path) -> AgentProfile:
with path.open("rb") as f:
data = tomllib.load(f)
return cls(
name=path.stem,
display_name=data.pop("display_name", path.stem.replace("-", " ").title()),
description=data.pop("description", ""),
safety=AgentSafety(data.pop("safety", AgentSafety.NEUTRAL)),
agent_type=AgentType(data.pop("agent_type", AgentType.AGENT)),
overrides=data,
)
PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
DEFAULT = AgentProfile(
BuiltinAgentName.DEFAULT,
"Default",
"Requires approval for tool executions",
AgentSafety.NEUTRAL,
)
PLAN = AgentProfile(
BuiltinAgentName.PLAN,
"Plan",
"Read-only agent for exploration and planning",
AgentSafety.SAFE,
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
)
ACCEPT_EDITS = AgentProfile(
BuiltinAgentName.ACCEPT_EDITS,
"Accept Edits",
"Auto-approves file edits only",
AgentSafety.DESTRUCTIVE,
overrides={
"tools": {
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
}
},
)
AUTO_APPROVE = AgentProfile(
BuiltinAgentName.AUTO_APPROVE,
"Auto Approve",
"Auto-approves all tool executions",
AgentSafety.YOLO,
overrides={"auto_approve": True},
)
EXPLORE = AgentProfile(
name=BuiltinAgentName.EXPLORE,
display_name="Explore",
description="Read-only subagent for codebase exploration",
safety=AgentSafety.SAFE,
agent_type=AgentType.SUBAGENT,
overrides={"enabled_tools": ["grep", "read_file"]},
)
BUILTIN_AGENTS: dict[str, AgentProfile] = {
BuiltinAgentName.DEFAULT: DEFAULT,
BuiltinAgentName.PLAN: PLAN,
BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
BuiltinAgentName.EXPLORE: EXPLORE,
}

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import NamedTuple
@ -26,28 +27,37 @@ class Completer:
class CommandCompleter(Completer):
def __init__(self, commands: list[tuple[str, str]]) -> None:
aliases_with_descriptions: dict[str, str] = {}
for alias, description in commands:
aliases_with_descriptions[alias] = description
def __init__(self, entries: Callable[[], list[tuple[str, str]]]) -> None:
self._get_entries = entries
self._descriptions = aliases_with_descriptions
self._aliases: list[str] = list(aliases_with_descriptions.keys())
def _build_lookup(self) -> tuple[list[str], dict[str, str]]:
descriptions: dict[str, str] = {}
for alias, description in self._get_entries():
descriptions[alias] = description
return list(descriptions.keys()), descriptions
def get_completions(self, text: str, cursor_pos: int) -> list[str]:
if not text.startswith("/"):
return []
aliases, _ = self._build_lookup()
word = text[1:cursor_pos].lower()
search_str = "/" + word
return [alias for alias in aliases if alias.lower().startswith(search_str)]
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
if not text.startswith("/"):
return []
aliases, descriptions = self._build_lookup()
word = text[1:cursor_pos].lower()
search_str = "/" + word
return [
alias for alias in self._aliases if alias.lower().startswith(search_str)
(alias, descriptions.get(alias, ""))
for alias in aliases
if alias.lower().startswith(search_str)
]
def get_completion_items(self, text: str, cursor_pos: int) -> list[tuple[str, str]]:
completions = self.get_completions(text, cursor_pos)
return [(alias, self._descriptions.get(alias, "")) for alias in completions]
def get_replacement_range(
self, text: str, cursor_pos: int
) -> tuple[int, int] | None:

View file

@ -102,7 +102,7 @@ def _format_content_block(block: ResourceBlock) -> str | None:
"name": block.get("name"),
"title": block.get("title"),
"description": block.get("description"),
"mimeType": block.get("mimeType"),
"mime_type": block.get("mime_type"),
"size": block.get("size"),
}
parts = [

View file

@ -19,7 +19,7 @@ from pydantic_settings import (
)
import tomli_w
from vibe.core.paths.config_paths import AGENT_DIR, CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
from vibe.core.paths.config_paths import CONFIG_DIR, CONFIG_FILE, PROMPT_DIR
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import SystemPrompt
from vibe.core.tools.base import BaseToolConfig
@ -137,6 +137,14 @@ class _MCPBase(BaseModel):
prompt: str | None = Field(
default=None, description="Optional usage hint appended to tool descriptions"
)
startup_timeout_sec: float = Field(
default=10.0,
gt=0,
description="Timeout in seconds for the server to start and initialize.",
)
tool_timeout_sec: float = Field(
default=60.0, gt=0, description="Timeout in seconds for tool execution."
)
@field_validator("name", mode="after")
@classmethod
@ -199,6 +207,10 @@ class MCPStdio(_MCPBase):
transport: Literal["stdio"]
command: str | list[str]
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(
default_factory=dict,
description="Environment variables to set for the MCP server process.",
)
def argv(self) -> list[str]:
base = (
@ -278,14 +290,14 @@ class VibeConfig(BaseSettings):
displayed_workdir: str = ""
auto_compact_threshold: int = 200_000
context_warnings: bool = False
instructions: str = ""
workdir: Path | None = Field(default=None, exclude=True)
auto_approve: bool = False
system_prompt_id: str = "cli"
include_commit_signature: bool = True
include_model_info: bool = True
include_project_context: bool = True
include_prompt_detail: bool = True
enable_update_checks: bool = True
enable_auto_update: bool = True
api_timeout: float = 720.0
providers: list[ProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_PROVIDERS)
@ -298,8 +310,10 @@ class VibeConfig(BaseSettings):
tool_paths: list[Path] = Field(
default_factory=list,
description=(
"Additional directories to search for custom tools. "
"Each path may be absolute or relative to the current working directory."
"Additional directories or files to explore for custom tools. "
"Paths may be absolute or relative to the current working directory. "
"Directories are shallow-searched for tool definition files, "
"while files are loaded directly if valid."
),
)
@ -311,20 +325,39 @@ class VibeConfig(BaseSettings):
default_factory=list,
description=(
"An explicit list of tool names/patterns to enable. If set, only these"
" tools will be active. Supports exact names, glob patterns (e.g.,"
" 'serena_*'), and regex with 're:' prefix or regex-like patterns (e.g.,"
" 're:^serena_.*' or 'serena.*')."
" tools will be active. Supports glob patterns (e.g., 'serena_*') and"
" regex with 're:' prefix (e.g., 're:^serena_.*')."
),
)
disabled_tools: list[str] = Field(
default_factory=list,
description=(
"A list of tool names/patterns to disable. Ignored if 'enabled_tools'"
" is set. Supports exact names, glob patterns (e.g., 'bash*'), and"
" regex with 're:' prefix or regex-like patterns."
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
agent_paths: list[Path] = Field(
default_factory=list,
description=(
"Additional directories to search for custom agent profiles. "
"Each path may be absolute or relative to the current working directory."
),
)
enabled_agents: list[str] = Field(
default_factory=list,
description=(
"An explicit list of agent names/patterns to enable. If set, only these"
" agents will be available. Supports glob patterns (e.g., 'custom-*')"
" and regex with 're:' prefix."
),
)
disabled_agents: list[str] = Field(
default_factory=list,
description=(
"A list of agent names/patterns to disable. Ignored if 'enabled_agents'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
skill_paths: list[Path] = Field(
default_factory=list,
description=(
@ -332,15 +365,26 @@ class VibeConfig(BaseSettings):
"Each path may be absolute or relative to the current working directory."
),
)
enabled_skills: list[str] = Field(
default_factory=list,
description=(
"An explicit list of skill names/patterns to enable. If set, only these"
" skills will be active. Supports glob patterns (e.g., 'search-*') and"
" regex with 're:' prefix."
),
)
disabled_skills: list[str] = Field(
default_factory=list,
description=(
"A list of skill names/patterns to disable. Ignored if 'enabled_skills'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
model_config = SettingsConfigDict(
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
@property
def effective_workdir(self) -> Path:
return self.workdir if self.workdir is not None else Path.cwd()
@property
def system_prompt(self) -> str:
try:
@ -439,22 +483,6 @@ class VibeConfig(BaseSettings):
return []
return [Path(p).expanduser().resolve() for p in v]
@field_validator("workdir", mode="before")
@classmethod
def _expand_workdir(cls, v: Any) -> Path | None:
if v is None or (isinstance(v, str) and not v.strip()):
return None
if isinstance(v, str):
v = Path(v).expanduser().resolve()
elif isinstance(v, Path):
v = v.expanduser().resolve()
if not v.is_dir():
raise ValueError(
f"Tried to set {v} as working directory, path doesn't exist"
)
return v
@field_validator("tools", mode="before")
@classmethod
def _normalize_tool_configs(cls, v: Any) -> dict[str, BaseToolConfig]:
@ -523,29 +551,14 @@ class VibeConfig(BaseSettings):
with CONFIG_FILE.path.open("wb") as f:
tomli_w.dump(config, f)
@classmethod
def _get_agent_config(cls, agent: str | None) -> dict[str, Any] | None:
if agent is None:
return None
agent_config_path = (AGENT_DIR.path / agent).with_suffix(".toml")
try:
return tomllib.load(agent_config_path.open("rb"))
except FileNotFoundError:
raise ValueError(
f"Config '{agent}.toml' for agent not found in {AGENT_DIR.path}"
)
@classmethod
def _migrate(cls) -> None:
pass
@classmethod
def load(cls, agent: str | None = None, **overrides: Any) -> VibeConfig:
def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate()
agent_config = cls._get_agent_config(agent)
init_data = {**(agent_config or {}), **overrides}
return cls(**init_data)
return cls(**(overrides or {}))
@classmethod
def create_default(cls) -> dict[str, Any]:

View file

@ -1,245 +0,0 @@
from __future__ import annotations
from datetime import datetime
import getpass
import json
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING, Any
import aiofiles
from vibe.core.llm.format import get_active_tool_classes
from vibe.core.types import AgentStats, LLMMessage, SessionInfo, SessionMetadata
from vibe.core.utils import is_windows
if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.tools.manager import ToolManager
class InteractionLogger:
def __init__(
self,
session_config: SessionLoggingConfig,
session_id: str,
auto_approve: bool = False,
workdir: Path | None = None,
) -> None:
if workdir is None:
workdir = Path.cwd()
self.session_config = session_config
self.enabled = session_config.enabled
self.auto_approve = auto_approve
self.workdir = workdir
if not self.enabled:
self.save_dir: Path | None = None
self.session_prefix: str | None = None
self.session_id: str = "disabled"
self.session_start_time: str = "N/A"
self.filepath: Path | None = None
self.session_metadata: SessionMetadata | None = None
return
self.save_dir = Path(session_config.save_dir)
self.session_prefix = session_config.session_prefix
self.session_id = session_id
self.session_start_time = datetime.now().isoformat()
self.save_dir.mkdir(parents=True, exist_ok=True)
self.filepath = self._get_save_filepath()
self.session_metadata = self._initialize_session_metadata()
def _get_save_filepath(self) -> Path:
if self.save_dir is None or self.session_prefix is None:
raise RuntimeError("Cannot get filepath when logging is disabled")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}.json"
return self.save_dir / filename
def _get_git_commit(self) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
cwd=self.workdir,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=5.0,
)
if result.returncode == 0 and result.stdout:
return result.stdout.strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
return None
def _get_git_branch(self) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
cwd=self.workdir,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=5.0,
)
if result.returncode == 0 and result.stdout:
return result.stdout.strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
return None
def _get_username(self) -> str:
try:
return getpass.getuser()
except Exception:
return "unknown"
def _initialize_session_metadata(self) -> SessionMetadata:
git_commit = self._get_git_commit()
git_branch = self._get_git_branch()
user_name = self._get_username()
return SessionMetadata(
session_id=self.session_id,
start_time=self.session_start_time,
end_time=None,
git_commit=git_commit,
git_branch=git_branch,
auto_approve=self.auto_approve,
username=user_name,
environment={"working_directory": str(self.workdir)},
)
async def save_interaction(
self,
messages: list[LLMMessage],
stats: AgentStats,
config: VibeConfig,
tool_manager: ToolManager,
) -> str | None:
if not self.enabled or self.filepath is None:
return None
if self.session_metadata is None:
return None
active_tools = get_active_tool_classes(tool_manager, config)
tools_available = [
{
"type": "function",
"function": {
"name": tool_class.get_name(),
"description": tool_class.description,
"parameters": tool_class.get_parameters(),
},
}
for tool_class in active_tools
]
interaction_data = {
"metadata": {
**self.session_metadata.model_dump(),
"end_time": datetime.now().isoformat(),
"stats": stats.model_dump(),
"total_messages": len(messages),
"tools_available": tools_available,
"agent_config": config.model_dump(mode="json"),
},
"messages": [m.model_dump(exclude_none=True) for m in messages],
}
try:
json_content = json.dumps(interaction_data, indent=2, ensure_ascii=False)
async with aiofiles.open(self.filepath, "w", encoding="utf-8") as f:
await f.write(json_content)
return str(self.filepath)
except Exception:
return None
def reset_session(self, session_id: str) -> None:
if not self.enabled:
return
self.session_id = session_id
self.session_start_time = datetime.now().isoformat()
self.filepath = self._get_save_filepath()
self.session_metadata = self._initialize_session_metadata()
def get_session_info(
self, messages: list[dict[str, Any]], stats: AgentStats
) -> SessionInfo:
if not self.enabled or self.save_dir is None:
return SessionInfo(
session_id="disabled",
start_time="N/A",
message_count=len(messages),
stats=stats,
save_dir="N/A",
)
return SessionInfo(
session_id=self.session_id,
start_time=self.session_start_time,
message_count=len(messages),
stats=stats,
save_dir=str(self.save_dir),
)
@staticmethod
def find_latest_session(config: SessionLoggingConfig) -> Path | None:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return None
pattern = f"{config.session_prefix}_*.json"
session_files = list(save_dir.glob(pattern))
if not session_files:
return None
return max(session_files, key=lambda p: p.stat().st_mtime)
@staticmethod
def find_session_by_id(
session_id: str, config: SessionLoggingConfig
) -> Path | None:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return None
# If it's a full UUID, extract the short form (first 8 chars)
short_id = session_id.split("-")[0] if "-" in session_id else session_id
# Try exact match first, then partial
patterns = [
f"{config.session_prefix}_*_{short_id}.json", # Exact short UUID
f"{config.session_prefix}_*_{short_id}*.json", # Partial UUID
]
for pattern in patterns:
matches = list(save_dir.glob(pattern))
if matches:
return (
max(matches, key=lambda p: p.stat().st_mtime)
if len(matches) > 1
else matches[0]
)
return None
@staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
with filepath.open("r", encoding="utf-8") as f:
content = f.read()
data = json.loads(content)
messages = [LLMMessage.model_validate(msg) for msg in data.get("messages", [])]
metadata = data.get("metadata", {})
return messages, metadata

View file

@ -134,7 +134,9 @@ class OpenAIAdapter(APIAdapter):
) -> PreparedRequest:
field_name = provider.reasoning_field_name
converted_messages = [
self._reasoning_to_api(msg.model_dump(exclude_none=True), field_name)
self._reasoning_to_api(
msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name
)
for msg in messages
]
@ -150,7 +152,7 @@ class OpenAIAdapter(APIAdapter):
payload["stream_options"] = stream_options
headers = self.build_headers(api_key)
body = json.dumps(payload).encode("utf-8")
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
return PreparedRequest(self.endpoint, headers, body)

View file

@ -141,7 +141,7 @@ class MistralMapper:
name=tool_call.function.name,
arguments=tool_call.function.arguments
if isinstance(tool_call.function.arguments, str)
else json.dumps(tool_call.function.arguments),
else json.dumps(tool_call.function.arguments, ensure_ascii=False),
),
index=tool_call.index,
)

View file

@ -1,9 +1,6 @@
from __future__ import annotations
from fnmatch import fnmatch
from functools import lru_cache
import json
import re
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field, ValidationError
@ -18,90 +15,9 @@ from vibe.core.types import (
)
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
from vibe.core.tools.manager import ToolManager
def _is_regex_hint(pattern: str) -> bool:
"""Heuristically detect whether a pattern looks like a regex.
- Explicit regex: starts with 're:'
- Heuristic regex: contains common regex metachars or '.*'
"""
if pattern.startswith("re:"):
return True
return bool(re.search(r"[().+|^$]", pattern) or ".*" in pattern)
@lru_cache(maxsize=256)
def _compile_icase(expr: str) -> re.Pattern | None:
try:
return re.compile(expr, re.IGNORECASE)
except re.error:
return None
def _regex_match_icase(expr: str, s: str) -> bool:
rx = _compile_icase(expr)
return rx is not None and rx.fullmatch(s) is not None
def _name_matches(name: str, patterns: list[str]) -> bool:
"""Check if a tool name matches any of the provided patterns.
Supports three forms (case-insensitive):
- Exact names (no wildcards/regex tokens)
- Glob wildcards using fnmatch (e.g., 'serena_*')
- Regex when prefixed with 're:'
or when the pattern looks regex-y (e.g., 'serena.*')
"""
n = name.lower()
for raw in patterns:
if not (p := (raw or "").strip()):
continue
match p:
case _ if p.startswith("re:"):
if _regex_match_icase(p.removeprefix("re:"), name):
return True
case _ if _is_regex_hint(p):
if _regex_match_icase(p, name):
return True
case _:
if fnmatch(n, p.lower()):
return True
return False
def get_active_tool_classes(
tool_manager: ToolManager, config: VibeConfig
) -> list[type[BaseTool]]:
"""Returns a list of active tool classes based on the configuration.
Args:
tool_manager: ToolManager instance with discovered tools
config: VibeConfig with enabled_tools/disabled_tools settings
"""
all_tools = list(tool_manager.available_tools().values())
if config.enabled_tools:
return [
tool_class
for tool_class in all_tools
if _name_matches(tool_class.get_name(), config.enabled_tools)
]
if config.disabled_tools:
return [
tool_class
for tool_class in all_tools
if not _name_matches(tool_class.get_name(), config.disabled_tools)
]
return all_tools
class ParsedToolCall(BaseModel):
model_config = ConfigDict(frozen=True)
tool_name: str
@ -144,11 +60,7 @@ class APIToolFormatHandler:
def name(self) -> str:
return "api"
def get_available_tools(
self, tool_manager: ToolManager, config: VibeConfig
) -> list[AvailableTool]:
active_tools = get_active_tool_classes(tool_manager, config)
def get_available_tools(self, tool_manager: ToolManager) -> list[AvailableTool]:
return [
AvailableTool(
function=AvailableFunction(
@ -157,7 +69,7 @@ class APIToolFormatHandler:
parameters=tool_class.get_parameters(),
)
)
for tool_class in active_tools
for tool_class in tool_manager.available_tools.values()
]
def get_tool_choice(self) -> StrToolChoice | AvailableTool:
@ -209,15 +121,12 @@ class APIToolFormatHandler:
return ParsedMessage(tool_calls=tool_calls)
def resolve_tool_calls(
self, parsed: ParsedMessage, tool_manager: ToolManager, config: VibeConfig
self, parsed: ParsedMessage, tool_manager: ToolManager
) -> ResolvedMessage:
resolved_calls = []
failed_calls = []
active_tools = {
tool_class.get_name(): tool_class
for tool_class in get_active_tool_classes(tool_manager, config)
}
active_tools = tool_manager.available_tools
for parsed_call in parsed.tool_calls:
tool_class = active_tools.get(parsed_call.tool_name)

View file

@ -13,7 +13,7 @@ if TYPE_CHECKING:
class BackendLike(Protocol):
"""Port protocol for dependency-injectable LLM backends.
Any backend used by Agent should implement this async context manager
Any backend used by AgentLoop should implement this async context manager
interface with `complete`, `complete_streaming` and `count_tokens` methods.
"""

View file

@ -5,7 +5,8 @@ from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Protocol
from vibe.core.modes import AgentMode
from vibe.core.agents import AgentProfile
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.utils import VIBE_WARNING_TAG
if TYPE_CHECKING:
@ -143,25 +144,25 @@ class ContextWarningMiddleware:
self.has_warned = False
PLAN_MODE_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). Instead, you should:
PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). Instead, you should:
1. Answer the user's query comprehensively
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
class PlanModeMiddleware:
"""Injects plan mode reminder after each assistant turn when plan mode is active."""
class PlanAgentMiddleware:
def __init__(
self, mode_getter: Callable[[], AgentMode], reminder: str = PLAN_MODE_REMINDER
self,
profile_getter: Callable[[], AgentProfile],
reminder: str = PLAN_AGENT_REMINDER,
) -> None:
self._mode_getter = mode_getter
self._profile_getter = profile_getter
self.reminder = reminder
def _is_plan_mode(self) -> bool:
return self._mode_getter() == AgentMode.PLAN
def _is_plan_agent(self) -> bool:
return self._profile_getter().name == BuiltinAgentName.PLAN
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if not self._is_plan_mode():
if not self._is_plan_agent():
return MiddlewareResult()
return MiddlewareResult(
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder

View file

@ -1,108 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import Any
class ModeSafety(StrEnum):
SAFE = auto()
NEUTRAL = auto()
DESTRUCTIVE = auto()
YOLO = auto()
class AgentMode(StrEnum):
DEFAULT = auto()
AUTO_APPROVE = auto()
PLAN = auto()
ACCEPT_EDITS = auto()
@property
def display_name(self) -> str:
return MODE_CONFIGS[self].display_name
@property
def description(self) -> str:
return MODE_CONFIGS[self].description
@property
def config_overrides(self) -> dict[str, Any]:
return MODE_CONFIGS[self].config_overrides
@property
def auto_approve(self) -> bool:
return MODE_CONFIGS[self].auto_approve
@property
def safety(self) -> ModeSafety:
return MODE_CONFIGS[self].safety
@classmethod
def from_string(cls, value: str) -> AgentMode | None:
try:
return cls(value.lower())
except ValueError:
return None
@dataclass(frozen=True)
class ModeConfig:
display_name: str
description: str
safety: ModeSafety = ModeSafety.NEUTRAL
auto_approve: bool = False
config_overrides: dict[str, Any] = field(default_factory=dict)
PLAN_MODE_TOOLS = ["grep", "read_file", "todo"]
ACCEPT_EDITS_TOOLS = ["write_file", "search_replace"]
MODE_CONFIGS: dict[AgentMode, ModeConfig] = {
AgentMode.DEFAULT: ModeConfig(
display_name="Default",
description="Requires approval for tool executions",
safety=ModeSafety.NEUTRAL,
auto_approve=False,
),
AgentMode.PLAN: ModeConfig(
display_name="Plan",
description="Read-only mode for exploration and planning",
safety=ModeSafety.SAFE,
auto_approve=True,
config_overrides={"enabled_tools": PLAN_MODE_TOOLS},
),
AgentMode.ACCEPT_EDITS: ModeConfig(
display_name="Accept Edits",
description="Auto-approves file edits only",
safety=ModeSafety.DESTRUCTIVE,
auto_approve=False,
config_overrides={
"tools": {
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
}
},
),
AgentMode.AUTO_APPROVE: ModeConfig(
display_name="Auto Approve",
description="Auto-approves all tool executions",
safety=ModeSafety.YOLO,
auto_approve=True,
),
}
def get_mode_order() -> list[AgentMode]:
return [
AgentMode.DEFAULT,
AgentMode.PLAN,
AgentMode.ACCEPT_EDITS,
AgentMode.AUTO_APPROVE,
]
def next_mode(current: AgentMode) -> AgentMode:
order = get_mode_order()
idx = order.index(current)
return order[(idx + 1) % len(order)]

View file

@ -53,7 +53,7 @@ class JsonOutputFormatter(OutputFormatter):
def finalize(self) -> str | None:
messages_data = [msg.model_dump(mode="json") for msg in self._messages]
json.dump(messages_data, self.stream, indent=2)
json.dump(messages_data, self.stream, indent=2, ensure_ascii=False)
self.stream.write("\n")
self.stream.flush()
return None
@ -61,7 +61,7 @@ class JsonOutputFormatter(OutputFormatter):
class StreamingJsonOutputFormatter(OutputFormatter):
def on_message_added(self, message: LLMMessage) -> None:
json.dump(message.model_dump(mode="json"), self.stream)
json.dump(message.model_dump(mode="json"), self.stream, ensure_ascii=False)
self.stream.write("\n")
self.stream.flush()

View file

@ -47,6 +47,14 @@ def resolve_local_skills_dir(dir: Path) -> Path | None:
return None
def resolve_local_agents_dir(dir: Path) -> Path | None:
if not trusted_folders_manager.is_trusted(dir):
return None
if (candidate := dir / ".vibe" / "agents").is_dir():
return candidate
return None
def unlock_config_paths() -> None:
global _config_paths_locked
_config_paths_locked = False
@ -54,7 +62,5 @@ def unlock_config_paths() -> None:
CONFIG_FILE = ConfigPath(lambda: _resolve_config_path("config.toml", "file"))
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
AGENT_DIR = ConfigPath(lambda: _resolve_config_path("agents", "dir"))
PROMPT_DIR = ConfigPath(lambda: _resolve_config_path("prompts", "dir"))
INSTRUCTIONS_FILE = ConfigPath(lambda: _resolve_config_path("instructions.md", "file"))
HISTORY_FILE = ConfigPath(lambda: _resolve_config_path("vibehistory", "file"))

View file

@ -30,6 +30,7 @@ GLOBAL_CONFIG_FILE = GlobalPath(lambda: VIBE_HOME.path / "config.toml")
GLOBAL_ENV_FILE = GlobalPath(lambda: VIBE_HOME.path / ".env")
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")

View file

@ -2,9 +2,9 @@ from __future__ import annotations
import asyncio
from vibe.core.agent import Agent
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
from vibe.core.modes import AgentMode
from vibe.core.output_formatters import create_formatter
from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger
@ -17,27 +17,13 @@ def run_programmatic(
max_price: float | None = None,
output_format: OutputFormat = OutputFormat.TEXT,
previous_messages: list[LLMMessage] | None = None,
mode: AgentMode = AgentMode.AUTO_APPROVE,
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
) -> str | None:
"""Run in programmatic mode: execute prompt and return the assistant response.
Args:
config: Configuration for the Vibe agent
prompt: The user prompt to process
max_turns: Maximum number of assistant turns (LLM calls) to allow
max_price: Maximum cost in dollars before stopping
output_format: Format for the output
previous_messages: Optional messages from a previous session to continue
mode: Operational mode (defaults to AUTO_APPROVE for programmatic use)
Returns:
The final assistant response text, or None if no response
"""
formatter = create_formatter(output_format)
agent = Agent(
agent_loop = AgentLoop(
config,
mode=mode,
agent_name=agent_name,
message_observer=formatter.on_message_added,
max_turns=max_turns,
max_price=max_price,
@ -50,12 +36,12 @@ def run_programmatic(
non_system_messages = [
msg for msg in previous_messages if not (msg.role == Role.system)
]
agent.messages.extend(non_system_messages)
agent_loop.messages.extend(non_system_messages)
logger.info(
"Loaded %d messages from previous session", len(non_system_messages)
)
async for event in agent.act(prompt):
async for event in agent_loop.act(prompt):
formatter.on_event(event)
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
raise ConversationLimitException(event.content)

View file

@ -1,13 +1,46 @@
You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful.
You can:
Act as an agentic assistant. For long tasks, break them down and execute step by step.
- Receive user prompts, project context, and files.
- Send responses and emit function calls (e.g., shell commands, code edits).
- Apply patches, run commands, based on user approvals.
## Tool Usage
Answer the user's request using the relevant tool(s), if they are available. Check that all the required parameters for each tool call are provided or can reasonably be inferred from context. IF there are no relevant tools or there are missing values for required parameters, ask the user to supply these values; otherwise proceed with the tool calls. If the user provides a specific value for a parameter (for example provided in quotes), make sure to use that value EXACTLY. DO NOT make up values for or ask about optional parameters. Carefully analyze descriptive terms in the request as they may indicate required parameter values that should be included even if not explicitly quoted.
- Always use tools to fulfill user requests when possible.
- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user.
- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given.
- Do not invent values for optional parameters.
- Analyze descriptive terms in requests as they may indicate required parameter values.
- If tools cannot accomplish the task, explain why and request more information.
Always try your hardest to use the tools to answer the user's request. If you can't use the tools, explain why and ask the user for more information.
## Code Modifications
Act as an agentic assistant, if a user asks for a long task, break it down and do it step by step.
- Always read a file before proposing changes. Never suggest edits to code you haven't seen.
- Keep changes minimal and focused. Only modify what was requested.
- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling.
- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers.
- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it.
- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would.
## Code References
When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly.
## Planning
When outlining steps or plans, focus on concrete actions. Do not include time estimates.
## Tone and Style
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation.
## Professional Objectivity
- Prioritize technical accuracy and truthfulness over validating the user's beliefs.
- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation.
- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear.
- Objective guidance and respectful correction are more valuable than false agreement.
- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs.
- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.

View file

@ -0,0 +1,130 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Any
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
from vibe.core.types import LLMMessage
if TYPE_CHECKING:
from vibe.core.config import SessionLoggingConfig
class SessionLoader:
@staticmethod
def _is_valid_session(session_dir: Path) -> bool:
"""Check if a session directory contains valid metadata and messages."""
metadata_path = session_dir / METADATA_FILENAME
messages_path = session_dir / MESSAGES_FILENAME
if not metadata_path.is_file() or not messages_path.is_file():
return False
try:
with open(metadata_path) as f:
metadata = json.load(f)
if not isinstance(metadata, dict):
return False
with open(messages_path) as f:
lines = f.readlines()
if not lines:
return False
messages = [json.loads(line) for line in lines]
if not isinstance(messages, list) or not all(
isinstance(msg, dict) for msg in messages
):
return False
except json.JSONDecodeError:
return False
return True
@staticmethod
def latest_session(session_dirs: list[Path]) -> Path | None:
latest_dir = None
latest_mtime = 0
for session in session_dirs:
if not SessionLoader._is_valid_session(session):
continue
messages_path = session / MESSAGES_FILENAME
mtime = messages_path.stat().st_mtime
if mtime > latest_mtime:
latest_mtime = mtime
latest_dir = session
return latest_dir
@staticmethod
def find_latest_session(config: SessionLoggingConfig) -> Path | None:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return None
pattern = f"{config.session_prefix}_*"
session_dirs = list(save_dir.glob(pattern))
return SessionLoader.latest_session(session_dirs)
@staticmethod
def find_session_by_id(
session_id: str, config: SessionLoggingConfig
) -> Path | None:
save_dir = Path(config.save_dir)
if not save_dir.exists():
return None
short_id = session_id[:8]
matches = list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
return SessionLoader.latest_session(matches)
@staticmethod
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
# Load session messages from MESSAGES_FILENAME
messages_filepath = filepath / MESSAGES_FILENAME
try:
with messages_filepath.open("r", encoding="utf-8") as f:
content = f.readlines()
except Exception as e:
raise ValueError(
f"Error reading session messages at {filepath}: {e}"
) from e
if not len(content):
raise ValueError(
f"Session messages file is empty (may have been corrupted by interruption): "
f"{filepath}"
)
try:
data = [json.loads(line) for line in content]
except json.JSONDecodeError as e:
raise ValueError(
f"Session messages contain invalid JSON (may have been corrupted): "
f"{filepath}\nDetails: {e}"
) from e
messages = [
LLMMessage.model_validate(msg) for msg in data if msg["role"] != "system"
]
# Load session metadata from METADATA_FILENAME
metadata_filepath = filepath / METADATA_FILENAME
if metadata_filepath.exists():
try:
with metadata_filepath.open("r", encoding="utf-8") as f:
metadata = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(
f"Session metadata contains invalid JSON (may have been corrupted): "
f"{filepath}\nDetails: {e}"
) from e
else:
metadata = {}
return messages, metadata

View file

@ -0,0 +1,314 @@
from __future__ import annotations
from datetime import datetime, timedelta
import getpass
import json
import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING, Any
from anyio import NamedTemporaryFile, Path as AsyncPath
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows
if TYPE_CHECKING:
from vibe.core.agents.models import AgentProfile
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.tools.manager import ToolManager
METADATA_FILENAME = "meta.json"
MESSAGES_FILENAME = "messages.jsonl"
class SessionLogger:
def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
self.session_config = session_config
self.enabled = session_config.enabled
if not self.enabled:
self.save_dir: Path | None = None
self.session_prefix: str | None = None
self.session_id: str = "disabled"
self.session_start_time: str = "N/A"
self.session_dir: Path | None = None
self.session_metadata: SessionMetadata | None = None
return
self.save_dir = Path(session_config.save_dir)
self.session_prefix = session_config.session_prefix
self.session_id = session_id
self.session_start_time = datetime.now().isoformat()
self.save_dir.mkdir(parents=True, exist_ok=True)
self.session_dir = self.save_folder
self.session_metadata = self._initialize_session_metadata()
@property
def save_folder(self) -> Path:
if self.save_dir is None or self.session_prefix is None:
raise RuntimeError(
"Cannot get session save folder when logging is disabled"
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
return self.save_dir / folder_name
@property
def metadata_filepath(self) -> Path:
if self.session_dir is None:
raise RuntimeError(
"Cannot get session metadata filepath when logging is disabled"
)
return self.session_dir / METADATA_FILENAME
@property
def messages_filepath(self) -> Path:
if self.session_dir is None:
raise RuntimeError(
"Cannot get session messages filepath when logging is disabled"
)
return self.session_dir / MESSAGES_FILENAME
@property
def git_commit(self) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=5.0,
)
if result.returncode == 0 and result.stdout:
return result.stdout.strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
return None
@property
def git_branch(self) -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
stdin=subprocess.DEVNULL if is_windows() else None,
text=True,
timeout=5.0,
)
if result.returncode == 0 and result.stdout:
return result.stdout.strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
return None
@property
def username(self) -> str:
try:
return getpass.getuser()
except Exception:
return "unknown"
def _initialize_session_metadata(self) -> SessionMetadata:
git_commit = self.git_commit
git_branch = self.git_branch
user_name = self.username
return SessionMetadata(
session_id=self.session_id,
start_time=self.session_start_time,
end_time=None,
git_commit=git_commit,
git_branch=git_branch,
username=user_name,
environment={"working_directory": str(Path.cwd())},
)
def _get_title(self, messages: list[LLMMessage]) -> str:
first_user_message = None
for message in messages:
if message.role == Role.user:
first_user_message = message
break
if first_user_message is None:
title = "Untitled session"
else:
MAX_TITLE_LENGTH = 50
text = str(first_user_message.content)
title = text[:MAX_TITLE_LENGTH]
if len(text) > MAX_TITLE_LENGTH:
title += ""
return title
@staticmethod
async def persist_metadata(metadata: Any, session_dir: Path) -> None:
temp_metadata_filepath = None
metadata_filepath = session_dir / "meta.json"
try:
async with NamedTemporaryFile(
mode="w",
suffix=".json.tmp",
dir=str(session_dir),
delete=False,
encoding="utf-8",
) as f:
temp_metadata_filepath = Path(str(f.name))
await f.write(json.dumps(metadata, indent=2, ensure_ascii=False))
await f.flush()
os.fsync(f.wrapped.fileno())
os.replace(temp_metadata_filepath, str(metadata_filepath))
except Exception as e:
raise RuntimeError(
f"Failed to persist session metadata to {metadata_filepath}: {e}"
) from e
finally:
if (
temp_metadata_filepath
and temp_metadata_filepath.exists()
and temp_metadata_filepath.is_file()
):
temp_metadata_filepath.unlink()
@staticmethod
async def persist_messages(messages: list[dict], session_dir: Path) -> None:
messages_filepath = session_dir / "messages.jsonl"
try:
if not messages_filepath.exists():
messages_filepath.touch()
async with await AsyncPath(messages_filepath).open("a") as f:
for message in messages:
await f.write(json.dumps(message, ensure_ascii=False) + "\n")
await f.flush()
os.fsync(f.wrapped.fileno())
except Exception as e:
raise RuntimeError(
f"Failed to persist session messages to {messages_filepath}: {e}"
) from e
async def save_interaction(
self,
messages: list[LLMMessage],
stats: AgentStats,
base_config: VibeConfig,
tool_manager: ToolManager,
agent_profile: AgentProfile,
) -> str | None:
if not self.enabled or self.session_dir is None:
return None
if self.session_metadata is None:
return None
# If the session directory does not exist, create it
try:
self.session_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise RuntimeError(
f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}"
) from e
# Read old metadata and get total_messages
try:
if self.metadata_filepath.exists():
async with await AsyncPath(self.metadata_filepath).open() as f:
old_metadata = json.loads(await f.read())
old_total_messages = old_metadata["total_messages"]
else:
old_total_messages = 0
except Exception as e:
raise RuntimeError(
f"Failed to read session metadata at {self.metadata_filepath}: {e}"
) from e
try:
# Append new messages
new_messages = messages[old_total_messages:]
messages_data = [
m.model_dump(exclude_none=True)
for m in new_messages
if m.role != Role.system
]
await SessionLogger.persist_messages(messages_data, self.session_dir)
# If message update succeeded, write metadata
tools_available = [
{
"type": "function",
"function": {
"name": tool_class.get_name(),
"description": tool_class.description,
"parameters": tool_class.get_parameters(),
},
}
for tool_class in tool_manager.available_tools.values()
]
title = self._get_title(messages)
if messages[0].role == Role.system:
system_prompt = messages[0].model_dump()
total_messages = len(messages[1:])
else:
system_prompt = None
total_messages = len(messages)
metadata_dump = {
**self.session_metadata.model_dump(),
"end_time": datetime.now().isoformat(),
"stats": stats.model_dump(),
"title": title,
"total_messages": total_messages,
"tools_available": tools_available,
"config": base_config.model_dump(mode="json"),
"agent_profile": {
"name": agent_profile.name,
"overrides": agent_profile.overrides,
},
"system_prompt": system_prompt,
}
await SessionLogger.persist_metadata(metadata_dump, self.session_dir)
except Exception as e:
raise RuntimeError(
f"Failed to save session to {self.session_dir}: {e}"
) from e
finally:
self.cleanup_tmp_files()
return str(self.session_dir)
def reset_session(self, session_id: str) -> None:
"""Clear existing session info and setup a new session"""
if not self.enabled:
return
self.session_id = session_id
self.session_start_time = datetime.now().isoformat()
self.session_dir = self.save_folder
self.session_metadata = self._initialize_session_metadata()
def cleanup_tmp_files(self) -> None:
"""Delete temporary files created more than 5 minutes ago"""
if not self.enabled or not self.save_dir:
return
now = datetime.now()
ago = now - timedelta(minutes=5)
tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search
for file_path in tmp_files:
if file_path.is_file():
try:
file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
if file_mtime < ago:
file_path.unlink()
except Exception:
continue

View file

@ -0,0 +1,41 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_logger import SessionLogger
def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int:
return asyncio.run(migrate_sessions(session_config))
async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
"""Helper for migrating session data from singular JSON files to the format introduced in Vibe 2.0 with per-session folders with split metadata and message files."""
save_dir = session_config.save_dir
if not save_dir or not session_config.enabled:
return 0
successful_migrations = 0
session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
for session_file in session_files:
try:
with open(session_file) as f:
session_data = f.read()
session_json = json.loads(session_data)
metadata = session_json["metadata"]
messages = session_json["messages"]
session_dir = Path(save_dir) / session_file.stem
session_dir.mkdir()
await SessionLogger.persist_metadata(metadata, session_dir)
await SessionLogger.persist_messages(messages, session_dir)
session_file.unlink()
successful_migrations += 1
except Exception:
continue
return successful_migrations

View file

@ -9,6 +9,7 @@ from vibe.core.paths.config_paths import resolve_local_skills_dir
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
from vibe.core.utils import name_matches
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
@ -20,12 +21,12 @@ class SkillManager:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
self.available_skills = self._discover_skills()
self._available: dict[str, SkillInfo] = self._discover_skills()
if self.available_skills:
if self._available:
logger.info(
"Discovered %d skill(s) from %d search path(s)",
len(self.available_skills),
len(self._available),
len(self._search_paths),
)
@ -33,6 +34,22 @@ class SkillManager:
def _config(self) -> VibeConfig:
return self._config_getter()
@property
def available_skills(self) -> dict[str, SkillInfo]:
if self._config.enabled_skills:
return {
name: info
for name, info in self._available.items()
if name_matches(name, self._config.enabled_skills)
}
if self._config.disabled_skills:
return {
name: info
for name, info in self._available.items()
if not name_matches(name, self._config.disabled_skills)
}
return dict(self._available)
@staticmethod
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = []
@ -41,9 +58,7 @@ class SkillManager:
if path.is_dir():
paths.append(path)
if (
skills_dir := resolve_local_skills_dir(config.effective_workdir)
) is not None:
if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None:
paths.append(skills_dir)
if GLOBAL_SKILLS_DIR.path.is_dir():

View file

@ -39,6 +39,11 @@ class SkillMetadata(BaseModel):
validation_alias="allowed-tools",
description="Space-delimited list of pre-approved tools (experimental).",
)
user_invocable: bool = Field(
default=True,
validation_alias="user-invocable",
description="Controls whether the skill appears in the slash command menu.",
)
@field_validator("allowed_tools", mode="before")
@classmethod
@ -64,6 +69,7 @@ class SkillInfo(BaseModel):
compatibility: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
allowed_tools: list[str] = Field(default_factory=list)
user_invocable: bool = True
skill_path: Path
model_config = {"arbitrary_types_allowed": True}
@ -81,5 +87,6 @@ class SkillInfo(BaseModel):
compatibility=meta.compatibility,
metadata=meta.metadata,
allowed_tools=meta.allowed_tools,
user_invocable=meta.user_invocable,
skill_path=skill_path.resolve(),
)

View file

@ -10,25 +10,17 @@ import sys
import time
from typing import TYPE_CHECKING
from vibe.core.llm.format import get_active_tool_classes
from vibe.core.paths.config_paths import INSTRUCTIONS_FILE
from vibe.core.prompts import UtilityPrompt
from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager
from vibe.core.utils import is_dangerous_directory, is_windows
if TYPE_CHECKING:
from vibe.core.agents import AgentManager
from vibe.core.config import ProjectContextConfig, VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.manager import ToolManager
def _load_user_instructions() -> str:
try:
return INSTRUCTIONS_FILE.path.read_text("utf-8", errors="ignore")
except (FileNotFoundError, OSError):
return ""
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
if not trusted_folders_manager.is_trusted(workdir):
return ""
@ -336,12 +328,12 @@ def _get_platform_name() -> str:
def _get_default_shell() -> str:
"""Get the default shell used by asyncio.create_subprocess_shell.
On Unix, this is always 'sh'.
On Unix, uses $SHELL env var and default to sh.
On Windows, this is COMSPEC or cmd.exe.
"""
if is_windows():
return os.environ.get("COMSPEC", "cmd.exe")
return "sh"
return os.environ.get("SHELL", "sh")
def _get_os_system_prompt() -> str:
@ -379,10 +371,7 @@ def _add_commit_signature() -> str:
)
def _get_available_skills_section(skill_manager: SkillManager | None) -> str:
if skill_manager is None:
return ""
def _get_available_skills_section(skill_manager: SkillManager) -> str:
skills = skill_manager.available_skills
if not skills:
return ""
@ -410,10 +399,24 @@ def _get_available_skills_section(skill_manager: SkillManager | None) -> str:
return "\n".join(lines)
def _get_available_subagents_section(agent_manager: AgentManager) -> str:
agents = agent_manager.get_subagents()
if not agents:
return ""
lines = ["# Available Subagents", ""]
lines.append("The following subagents can be spawned via the Task tool:")
for agent in agents:
lines.append(f"- **{agent.name}**: {agent.description}")
return "\n".join(lines)
def get_universal_system_prompt(
tool_manager: ToolManager,
config: VibeConfig,
skill_manager: SkillManager | None = None,
skill_manager: SkillManager,
agent_manager: AgentManager,
) -> str:
sections = [config.system_prompt]
@ -426,21 +429,20 @@ def get_universal_system_prompt(
if config.include_prompt_detail:
sections.append(_get_os_system_prompt())
tool_prompts = []
active_tools = get_active_tool_classes(tool_manager, config)
for tool_class in active_tools:
for tool_class in tool_manager.available_tools.values():
if prompt := tool_class.get_tool_prompt():
tool_prompts.append(prompt)
if tool_prompts:
sections.append("\n---\n".join(tool_prompts))
user_instructions = config.instructions.strip() or _load_user_instructions()
if user_instructions.strip():
sections.append(user_instructions)
skills_section = _get_available_skills_section(skill_manager)
if skills_section:
sections.append(skills_section)
subagents_section = _get_available_subagents_section(agent_manager)
if subagents_section:
sections.append(subagents_section)
if config.include_project_context:
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
@ -450,13 +452,13 @@ def get_universal_system_prompt(
)
else:
context = ProjectContextProvider(
config=config.project_context, root_path=config.effective_workdir
config=config.project_context, root_path=Path.cwd()
).get_full_context()
sections.append(context)
project_doc = _load_project_doc(
config.effective_workdir, config.project_context.max_doc_bytes
Path.cwd(), config.project_context.max_doc_bytes
)
if project_doc.strip():
sections.append(project_doc)

View file

@ -1,19 +1,47 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from enum import StrEnum, auto
import functools
import inspect
from pathlib import Path
import re
import sys
from typing import Any, ClassVar, cast, get_args, get_type_hints
import types
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Union,
cast,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.agents.manager import AgentManager
from vibe.core.types import ApprovalCallback, UserInputCallback
ARGS_COUNT = 4
@dataclass
class InvokeContext:
"""Context passed to tools during invocation."""
tool_call_id: str
approval_callback: ApprovalCallback | None = field(default=None)
agent_manager: AgentManager | None = field(default=None)
user_input_callback: UserInputCallback | None = field(default=None)
class ToolError(Exception):
"""Raised when the tool encounters an unrecoverable problem."""
@ -56,7 +84,6 @@ class BaseToolConfig(BaseModel):
Attributes:
permission: The permission level required to use the tool.
workdir: The working directory for the tool. If None, the current working directory is used.
allowlist: Patterns that automatically allow tool execution.
denylist: Patterns that automatically deny tool execution.
"""
@ -64,25 +91,9 @@ class BaseToolConfig(BaseModel):
model_config = ConfigDict(extra="allow")
permission: ToolPermission = ToolPermission.ASK
workdir: Path | None = Field(default=None, exclude=True)
allowlist: list[str] = Field(default_factory=list)
denylist: list[str] = Field(default_factory=list)
@field_validator("workdir", mode="before")
@classmethod
def _expand_workdir(cls, v: Any) -> Path | None:
if v is None or (isinstance(v, str) and not v.strip()):
return None
if isinstance(v, str):
return Path(v).expanduser().resolve()
if isinstance(v, Path):
return v.expanduser().resolve()
return None
@property
def effective_workdir(self) -> Path:
return self.workdir if self.workdir is not None else Path.cwd()
class BaseToolState(BaseModel):
model_config = ConfigDict(
@ -109,9 +120,12 @@ class BaseTool[
self.state = state
@abstractmethod
async def run(self, args: ToolArgs) -> ToolResult:
"""Invoke the tool with the given arguments. This method must be async."""
...
async def run(
self, args: ToolArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | ToolResult, None]:
"""Invoke the tool with the given arguments."""
raise NotImplementedError # pragma: no cover
yield # type: ignore[misc]
@classmethod
@functools.cache
@ -134,10 +148,10 @@ class BaseTool[
return None
async def invoke(self, **raw: Any) -> ToolResult:
"""Validate arguments and run the tool.
Pattern checking is now handled by Agent._should_execute_tool.
"""
async def invoke(
self, ctx: InvokeContext | None = None, **raw: Any
) -> AsyncGenerator[ToolStreamEvent | ToolResult, None]:
"""Validate arguments and run the tool."""
try:
args_model, _ = self._get_tool_args_results()
args = args_model.model_validate(raw)
@ -146,7 +160,8 @@ class BaseTool[
f"Validation error in tool {self.get_name()}: {err}"
) from err
return await self.run(args)
async for item in self.run(args, ctx):
yield item
@classmethod
def from_config(
@ -212,28 +227,66 @@ class BaseTool[
type_hints = get_type_hints(
run_fn,
globalns=vars(sys.modules[cls.__module__]),
localns={cls.__name__: cls},
localns={
cls.__name__: cls,
"InvokeContext": InvokeContext,
"AsyncGenerator": AsyncGenerator,
"ToolStreamEvent": ToolStreamEvent,
},
)
try:
args_model = type_hints["args"]
result_model = type_hints["return"]
return_annotation = type_hints["return"]
except KeyError as e:
raise TypeError(
f"{cls.__name__}.run must be annotated as "
"`async def run(self, args: ToolArgs) -> ToolResult`"
f"{cls.__name__}.run must be annotated with args and return type"
) from e
if not (
issubclass(args_model, BaseModel) and issubclass(result_model, BaseModel)
):
result_model = cls._extract_result_type(return_annotation)
if not issubclass(args_model, BaseModel):
raise TypeError(
f"{cls.__name__}.run annotations must be Pydantic models; "
f"got {args_model!r}, {result_model!r}"
f"{cls.__name__}.run args annotation must be a Pydantic model; "
f"got {args_model!r}"
)
if not issubclass(result_model, BaseModel):
raise TypeError(
f"{cls.__name__}.run must yield a Pydantic model as result; "
f"got {result_model!r}"
)
return cast(type[ToolArgs], args_model), cast(type[ToolResult], result_model)
@classmethod
def _extract_result_type(cls, return_annotation: Any) -> type:
"""Extract the ToolResult type from AsyncGenerator[ToolStreamEvent | ToolResult, None]."""
origin = get_origin(return_annotation)
if origin is not AsyncGenerator:
if isinstance(return_annotation, type):
return return_annotation
raise TypeError(f"Could not extract result type from {return_annotation!r}")
gen_args = get_args(return_annotation)
if not gen_args:
raise TypeError(f"Could not extract result type from {return_annotation!r}")
yield_type = gen_args[0]
yield_origin = get_origin(yield_type)
# Handle Union types (X | Y or Union[X, Y])
if yield_origin is Union or isinstance(yield_type, types.UnionType):
for arg in get_args(yield_type):
if arg is not ToolStreamEvent and isinstance(arg, type):
return arg
# Handle single type
if isinstance(yield_type, type):
return yield_type
raise TypeError(f"Could not extract result type from {return_annotation!r}")
@classmethod
def get_parameters(cls) -> dict[str, Any]:
"""Return a cleaned-up JSON-schema dict describing the arguments model

View file

@ -0,0 +1,131 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import ClassVar, cast
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent
class Choice(BaseModel):
label: str = Field(description="Short label for the choice (1-5 words)")
description: str = Field(
default="", description="Optional explanation of this choice"
)
class Question(BaseModel):
question: str = Field(description="The question text")
header: str = Field(
default="",
description="Short header for the question (1-2 words, e.g. 'Auth', 'Database')",
max_length=12,
)
options: list[Choice] = Field(
description="Available options (2-4, not including 'Other'). An 'Other' option for free text is automatically added.",
min_length=2,
max_length=4,
)
multi_select: bool = Field(
default=False, description="If true, user can select multiple options"
)
class AskUserQuestionArgs(BaseModel):
questions: list[Question] = Field(
description="Questions to ask (1-4). Displayed as tabs if multiple.",
min_length=1,
max_length=4,
)
class Answer(BaseModel):
question: str = Field(description="The original question")
answer: str = Field(description="The user's answer")
is_other: bool = Field(
default=False, description="True if user typed a custom answer via 'Other'"
)
class AskUserQuestionResult(BaseModel):
answers: list[Answer] = Field(description="List of answers")
cancelled: bool = Field(
default=False, description="True if user cancelled without answering"
)
class AskUserQuestionConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ALWAYS
class AskUserQuestion(
BaseTool[
AskUserQuestionArgs, AskUserQuestionResult, AskUserQuestionConfig, BaseToolState
],
ToolUIData[AskUserQuestionArgs, AskUserQuestionResult],
):
description: ClassVar[str] = (
"Ask the user one or more questions and wait for their responses. "
"Each question has 2-4 choices plus an automatic 'Other' option for free text. "
"Use this to gather preferences, clarify requirements, or get decisions."
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, AskUserQuestionArgs):
return ToolCallDisplay(summary="Asking user")
args = event.args
count = len(args.questions)
if count == 1:
return ToolCallDisplay(summary=f"Asking: {args.questions[0].question}")
return ToolCallDisplay(summary=f"Asking {count} questions")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if event.error:
return ToolResultDisplay(success=False, message=event.error)
if not isinstance(event.result, AskUserQuestionResult):
return ToolResultDisplay(success=True, message="Questions answered")
result = event.result
if result.cancelled:
return ToolResultDisplay(success=False, message="User cancelled")
if len(result.answers) == 1:
answer = result.answers[0]
prefix = "(Other) " if answer.is_other else ""
return ToolResultDisplay(success=True, message=f"{prefix}{answer.answer}")
return ToolResultDisplay(
success=True, message=f"{len(result.answers)} answers received"
)
@classmethod
def get_status_text(cls) -> str:
return "Waiting for user input"
async def run(
self, args: AskUserQuestionArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[AskUserQuestionResult, None]:
if ctx is None or ctx.user_input_callback is None:
raise ToolError(
"User input not available. This tool requires an interactive UI."
)
result = await ctx.user_input_callback(args)
yield cast(AskUserQuestionResult, result)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from functools import lru_cache
import os
import signal
@ -15,9 +16,12 @@ from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
from vibe.core.utils import is_windows
@ -61,6 +65,12 @@ def _get_subprocess_encoding() -> str:
return "utf-8"
def _get_shell_executable() -> str | None:
if is_windows():
return None
return os.environ.get("SHELL")
def _get_base_env() -> dict[str, str]:
base_env = {
**os.environ,
@ -167,7 +177,7 @@ class BashToolConfig(BaseToolConfig):
default=16_000, description="Maximum bytes to capture from stdout and stderr."
)
default_timeout: int = Field(
default=30, description="Default timeout for commands in seconds."
default=300, description="Default timeout for commands in seconds."
)
allowlist: list[str] = Field(
default_factory=_get_default_allowlist,
@ -191,14 +201,38 @@ class BashArgs(BaseModel):
class BashResult(BaseModel):
command: str
stdout: str
stderr: str
returncode: int
class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
class Bash(
BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState],
ToolUIData[BashArgs, BashResult],
):
description: ClassVar[str] = "Run a one-off bash command and capture its output."
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
if not isinstance(event.args, BashArgs):
return ToolCallDisplay(summary="bash")
return ToolCallDisplay(summary=f"bash: {event.args.command}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, BashResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
return ToolResultDisplay(success=True, message=f"Ran {event.result.command}")
@classmethod
def get_status_text(cls) -> str:
return "Running command"
def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
if is_windows():
return None
@ -258,9 +292,13 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
error_msg += f"\nStdout: {stdout}"
raise ToolError(error_msg.strip())
return BashResult(stdout=stdout, stderr=stderr, returncode=returncode)
return BashResult(
command=command, stdout=stdout, stderr=stderr, returncode=returncode
)
async def run(self, args: BashArgs) -> BashResult:
async def run(
self, args: BashArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes
@ -276,8 +314,8 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
cwd=self.config.effective_workdir,
env=_get_base_env(),
executable=_get_shell_executable(),
**kwargs,
)
@ -303,7 +341,7 @@ class Bash(BaseTool[BashArgs, BashResult, BashToolConfig, BaseToolState]):
returncode = proc.returncode or 0
return self._build_result(
yield self._build_result(
command=args.command,
stdout=stdout,
stderr=stderr,

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from enum import StrEnum, auto
from pathlib import Path
import shutil
@ -12,10 +13,12 @@ from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -114,7 +117,9 @@ class Grep(
"Please install ripgrep: https://github.com/BurntSushi/ripgrep#installation"
)
async def run(self, args: GrepArgs) -> GrepResult:
async def run(
self, args: GrepArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | GrepResult, None]:
backend = self._detect_backend()
self._validate_args(args)
self.state.search_history.append(args.pattern)
@ -123,7 +128,7 @@ class Grep(
cmd = self._build_command(args, exclude_patterns, backend)
stdout = await self._execute_search(cmd)
return self._parse_output(
yield self._parse_output(
stdout, args.max_matches or self.config.default_max_matches
)
@ -133,7 +138,7 @@ class Grep(
path_obj = Path(args.path).expanduser()
if not path_obj.is_absolute():
path_obj = self.config.effective_workdir / path_obj
path_obj = Path.cwd() / path_obj
if not path_obj.exists():
raise ToolError(f"Path does not exist: {args.path}")
@ -141,7 +146,7 @@ class Grep(
def _collect_exclude_patterns(self) -> list[str]:
patterns = list(self.config.exclude_patterns)
codeignore_path = self.config.effective_workdir / self.config.codeignore_file
codeignore_path = Path.cwd() / self.config.codeignore_file
if codeignore_path.is_file():
patterns.extend(self._load_codeignore_patterns(codeignore_path))
@ -217,10 +222,7 @@ class Grep(
async def _execute_search(self, cmd: list[str]) -> str:
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.config.effective_workdir),
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
@ -276,7 +278,7 @@ class Grep(
if not isinstance(event.args, GrepArgs):
return ToolCallDisplay(summary="grep")
summary = f"grep: '{event.args.pattern}'"
summary = f"Grepping '{event.args.pattern}'"
if event.args.path != ".":
summary += f" in {event.args.path}"
if event.args.max_matches:

View file

@ -0,0 +1,84 @@
Use `ask_user_question` to gather information from the user when you need clarification, want to validate assumptions, or need help making a decision. **Don't hesitate to use this tool** - it's better to ask than to guess wrong.
## When to Use
- **Clarifying requirements**: Ambiguous instructions, unclear scope
- **Technical decisions**: Architecture choices, library selection, tradeoffs
- **Preference gathering**: UI style, naming conventions, approach options
- **Validation**: Confirming understanding before starting significant work
- **Multiple valid paths**: When several approaches could work and you want user input
## Question Structure
Each question has these fields:
- `question`: The full question text (be specific and clear)
- `header`: A short label displayed as a chip (max 12 characters, e.g., "Auth", "Database", "Approach")
- `options`: 2-4 choices (an "Other" option is automatically added for free text)
- `multi_select`: Set to `true` if user can pick multiple options (default: `false`)
### Options Structure
Each option has:
- `label`: Short display text (1-5 words)
- `description`: Brief explanation of what this choice means or its implications
## Examples
**Single question with recommended option:**
```json
{
"questions": [{
"question": "Which authentication method should we use?",
"header": "Auth",
"options": [
{"label": "JWT tokens (Recommended)", "description": "Stateless, scalable, works well with APIs"},
{"label": "Session cookies", "description": "Traditional approach, requires session storage"},
{"label": "OAuth 2.0", "description": "Third-party auth, more complex setup"}
],
"multi_select": false
}]
}
```
**Multiple questions (displayed as tabs):**
```json
{
"questions": [
{
"question": "Which database should we use?",
"header": "Database",
"options": [
{"label": "PostgreSQL", "description": "Relational, ACID compliant"},
{"label": "MongoDB", "description": "Document store, flexible schema"}
],
"multi_select": false
},
{
"question": "Which features should be included in v1?",
"header": "Features",
"options": [
{"label": "User auth", "description": "Login, signup, password reset"},
{"label": "Search", "description": "Full-text search across content"},
{"label": "Export", "description": "CSV and PDF export"}
],
"multi_select": true
}
]
}
```
## Key Constraints
- **Header max length**: 12 characters (keeps UI clean)
- **Options count**: 2-4 per question (plus automatic "Other")
- **Questions count**: 1-4 per call
- **Label length**: Keep to 1-5 words for readability
## Tips
1. **Put recommended option first** and add "(Recommended)" to its label
2. **Use descriptive headers** that categorize the question type
3. **Keep descriptions concise** but informative about tradeoffs
4. **Use multi_select** when choices aren't mutually exclusive (e.g., features to include)
5. **Ask early** - it's better to clarify before starting than to redo work

View file

@ -3,6 +3,11 @@ Use the `bash` tool to run one-off shell commands.
**Key characteristics:**
- **Stateless**: Each command runs independently in a fresh environment
**Timeout:**
- The `timeout` argument controls how long the command can run before being killed
- When `timeout` is not specified (or set to `None`), the config default is used
- If a command is timing out, do not hesitate to increase the timeout using the `timeout` argument
**IMPORTANT: Use dedicated tools if available instead of these bash commands:**
**File Operations - DO NOT USE:**

View file

@ -0,0 +1,24 @@
Use `task` to delegate work to a subagent for independent execution.
## When to Use This Tool
- **Context management**: Delegate tasks that would consume too much main conversation context
- **Specialized work**: Use the appropriate subagent for the type of task (exploration, research, etc.)
- **Parallel execution**: Launch multiple subagents for independent tasks
- **Autonomous work**: Tasks that don't require back-and-forth with the user
## Best Practices
1. **Write clear, detailed task descriptions** - The subagent works autonomously, so provide enough context for it to succeed independently
2. **Choose the right subagent** - Match the subagent to the task type (see available subagents in system prompt)
3. **Prefer direct tools for simple operations** - If you know exactly which file to read or pattern to search, use those tools directly instead of spawning a subagent
4. **Trust the subagent's judgment** - Let it explore and find information without micromanaging the approach
## Limitations
- Subagents cannot write or modify files
- Subagents cannot ask the user questions
- Results are returned as text when the subagent completes

View file

@ -1,19 +1,22 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
import aiofiles
import anyio
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -70,14 +73,16 @@ class ReadFile(
)
@final
async def run(self, args: ReadFileArgs) -> ReadFileResult:
async def run(
self, args: ReadFileArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | ReadFileResult, None]:
file_path = self._prepare_and_validate_path(args)
read_result = await self._read_file(args, file_path)
self._update_state_history(file_path)
return ReadFileResult(
yield ReadFileResult(
path=str(file_path),
content="".join(read_result.lines),
lines_read=len(read_result.lines),
@ -89,7 +94,7 @@ class ReadFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = self.config.effective_workdir / file_path
file_path = Path.cwd() / file_path
file_str = str(file_path)
for pattern in self.config.denylist:
@ -107,7 +112,7 @@ class ReadFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = self.config.effective_workdir / file_path
file_path = Path.cwd() / file_path
self._validate_path(file_path)
return file_path
@ -118,7 +123,9 @@ class ReadFile(
bytes_read = 0
was_truncated = False
async with aiofiles.open(file_path, encoding="utf-8", errors="ignore") as f:
async with await anyio.Path(file_path).open(
encoding="utf-8", errors="ignore"
) as f:
line_index = 0
async for line in f:
if line_index < args.offset:
@ -159,7 +166,7 @@ class ReadFile(
resolved_path = file_path.resolve()
except ValueError:
raise ToolError(
f"Security error: Cannot read path '{file_path}' outside of the project directory '{self.config.effective_workdir}'."
f"Security error: Cannot read path '{file_path}' outside of the project directory '{Path.cwd()}'."
)
except FileNotFoundError:
raise ToolError(f"File not found at: {file_path}")
@ -179,7 +186,7 @@ class ReadFile(
if not isinstance(event.args, ReadFileArgs):
return ToolCallDisplay(summary="read_file")
summary = f"read_file: {event.args.path}"
summary = f"Reading {event.args.path}"
if event.args.offset > 0 or event.args.limit is not None:
parts = []
if event.args.offset > 0:

View file

@ -1,17 +1,24 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import difflib
from pathlib import Path
import re
import shutil
from typing import ClassVar, NamedTuple, final
import aiofiles
import anyio
from pydantic import BaseModel, Field
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, ToolError
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
@ -106,7 +113,9 @@ class SearchReplace(
return "Editing files"
@final
async def run(self, args: SearchReplaceArgs) -> SearchReplaceResult:
async def run(
self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
file_path, search_replace_blocks = self._prepare_and_validate_args(args)
original_content = await self._read_file(file_path)
@ -146,7 +155,7 @@ class SearchReplace(
await self._write_file(file_path, modified_content)
return SearchReplaceResult(
yield SearchReplaceResult(
file=str(file_path),
blocks_applied=block_result.applied,
lines_changed=lines_changed,
@ -173,7 +182,7 @@ class SearchReplace(
if not content:
raise ToolError("Empty content provided")
project_root = self.config.effective_workdir
project_root = Path.cwd()
file_path = Path(file_path_str).expanduser()
if not file_path.is_absolute():
file_path = project_root / file_path
@ -201,7 +210,7 @@ class SearchReplace(
async def _read_file(self, file_path: Path) -> str:
try:
async with aiofiles.open(file_path, encoding="utf-8") as f:
async with await anyio.Path(file_path).open(encoding="utf-8") as f:
return await f.read()
except UnicodeDecodeError as e:
raise ToolError(f"Unicode decode error reading {file_path}: {e}") from e
@ -215,7 +224,9 @@ class SearchReplace(
async def _write_file(self, file_path: Path, content: str) -> None:
try:
async with aiofiles.open(file_path, mode="w", encoding="utf-8") as f:
async with await anyio.Path(file_path).open(
mode="w", encoding="utf-8"
) as f:
await f.write(content)
except PermissionError:
raise ToolError(f"Permission denied writing to file: {file_path}")

View file

@ -0,0 +1,154 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from typing import ClassVar
from pydantic import BaseModel, Field
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import AgentType
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import (
ToolCallDisplay,
ToolResultDisplay,
ToolUIData,
ToolUIDataAdapter,
)
from vibe.core.types import (
AssistantEvent,
Role,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
)
class TaskArgs(BaseModel):
task: str = Field(description="The task to delegate to the subagent")
agent: str = Field(
default="explore",
description="Name of the agent profile to use (must be a subagent)",
)
class TaskResult(BaseModel):
response: str = Field(description="The accumulated response from the subagent")
turns_used: int = Field(description="Number of turns the subagent used")
completed: bool = Field(description="Whether the task completed normally")
class TaskToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
class Task(
BaseTool[TaskArgs, TaskResult, TaskToolConfig, BaseToolState],
ToolUIData[TaskArgs, TaskResult],
):
description: ClassVar[str] = (
"Delegate a task to a subagent for independent execution. "
"Useful for exploration, research, or parallel work that doesn't "
"require user interaction. The subagent runs in-memory without "
"saving interaction logs."
)
@classmethod
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
args = event.args
if isinstance(args, TaskArgs):
return ToolCallDisplay(summary=f"Running {args.agent} agent: {args.task}")
return ToolCallDisplay(summary="Running subagent")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
result = event.result
if isinstance(result, TaskResult):
turn_word = "turn" if result.turns_used == 1 else "turns"
if not result.completed:
return ToolResultDisplay(
success=False,
message=f"Agent interrupted after {result.turns_used} {turn_word}",
)
return ToolResultDisplay(
success=True,
message=f"Agent completed in {result.turns_used} {turn_word}",
)
return ToolResultDisplay(success=True, message="Agent completed")
@classmethod
def get_status_text(cls) -> str:
return "Running subagent"
async def run(
self, args: TaskArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | TaskResult, None]:
if not ctx or not ctx.agent_manager:
raise ToolError("Task tool requires agent_manager in context")
agent_manager = ctx.agent_manager
try:
agent_profile = agent_manager.get_agent(args.agent)
except ValueError as e:
raise ToolError(f"Unknown agent: {args.agent}") from e
if agent_profile.agent_type != AgentType.SUBAGENT:
raise ToolError(
f"Agent '{args.agent}' is a {agent_profile.agent_type.value} agent. "
f"Only subagents can be used with the task tool. "
f"This is a security constraint to prevent recursive spawning."
)
base_config = VibeConfig.load(
session_logging=SessionLoggingConfig(enabled=False)
)
subagent_loop = AgentLoop(config=base_config, agent_name=args.agent)
if ctx and ctx.approval_callback:
subagent_loop.set_approval_callback(ctx.approval_callback)
accumulated_response: list[str] = []
completed = True
try:
async for event in subagent_loop.act(args.task):
if isinstance(event, AssistantEvent) and event.content:
accumulated_response.append(event.content)
if event.stopped_by_middleware:
completed = False
elif isinstance(event, ToolResultEvent):
if event.skipped:
completed = False
elif event.result and event.tool_class:
adapter = ToolUIDataAdapter(event.tool_class)
display = adapter.get_result_display(event)
message = f"{event.tool_name}: {display.message}"
yield ToolStreamEvent(
tool_name=self.get_name(),
message=message,
tool_call_id=ctx.tool_call_id,
)
turns_used = sum(
msg.role == Role.assistant for msg in subagent_loop.messages
)
except Exception as e:
completed = False
accumulated_response.append(f"\n[Subagent error: {e}]")
turns_used = sum(
msg.role == Role.assistant for msg in subagent_loop.messages
)
yield TaskResult(
response="".join(accumulated_response),
turns_used=turns_used,
completed=completed,
)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from enum import StrEnum, auto
from typing import ClassVar
@ -9,11 +10,12 @@ from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
class TodoStatus(StrEnum):
@ -95,12 +97,14 @@ class Todo(
def get_status_text(cls) -> str:
return "Managing todos"
async def run(self, args: TodoArgs) -> TodoResult:
async def run(
self, args: TodoArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | TodoResult, None]:
match args.action:
case "read":
return self._read_todos()
yield self._read_todos()
case "write":
return self._write_todos(args.todos or [])
yield self._write_todos(args.todos or [])
case _:
raise ToolError(
f"Invalid action '{args.action}'. Use 'read' or 'write'."

View file

@ -1,20 +1,22 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import ClassVar, final
import aiofiles
import anyio
from pydantic import BaseModel, Field
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
class WriteFileArgs(BaseModel):
@ -81,7 +83,7 @@ class WriteFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = self.config.effective_workdir / file_path
file_path = Path.cwd() / file_path
file_str = str(file_path)
for pattern in self.config.denylist:
@ -95,7 +97,9 @@ class WriteFile(
return None
@final
async def run(self, args: WriteFileArgs) -> WriteFileResult:
async def run(
self, args: WriteFileArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
await self._write_file(args, file_path)
@ -105,7 +109,7 @@ class WriteFile(
if len(self.state.recently_written_files) > BUFFER_SIZE:
self.state.recently_written_files.pop(0)
return WriteFileResult(
yield WriteFileResult(
path=str(file_path),
bytes_written=content_bytes,
file_existed=file_existed,
@ -124,11 +128,11 @@ class WriteFile(
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = self.config.effective_workdir / file_path
file_path = Path.cwd() / file_path
file_path = file_path.resolve()
try:
file_path.relative_to(self.config.effective_workdir.resolve())
file_path.relative_to(Path.cwd().resolve())
except ValueError:
raise ToolError(f"Cannot write outside project directory: {file_path}")
@ -148,7 +152,9 @@ class WriteFile(
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
try:
async with aiofiles.open(file_path, mode="w", encoding="utf-8") as f:
async with await anyio.Path(file_path).open(
mode="w", encoding="utf-8"
) as f:
await f.write(args.content)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
import hashlib
import importlib.util
import inspect
from logging import getLogger
@ -19,7 +20,7 @@ from vibe.core.tools.mcp import (
list_tools_http,
list_tools_stdio,
)
from vibe.core.utils import run_sync
from vibe.core.utils import name_matches, run_sync
logger = getLogger("vibe")
@ -27,6 +28,40 @@ if TYPE_CHECKING:
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
def _try_canonical_module_name(path: Path) -> str | None:
"""Extract canonical module name for vibe package files.
Prevents Pydantic class identity mismatches when the same module
is imported via dynamic discovery and regular imports.
"""
try:
parts = path.resolve().parts
except (OSError, ValueError):
return None
try:
vibe_idx = parts.index("vibe")
except ValueError:
return None
if vibe_idx + 1 >= len(parts):
return None
module_parts = [p.removesuffix(".py") for p in parts[vibe_idx:]]
return ".".join(module_parts)
def _compute_module_name(path: Path) -> str:
"""Return canonical module name for vibe files, hash-based synthetic name otherwise."""
if canonical := _try_canonical_module_name(path):
return canonical
resolved = path.resolve()
path_hash = hashlib.md5(str(resolved).encode()).hexdigest()[:8]
stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
return f"vibe_tools_discovered_{stem}_{path_hash}"
class NoSuchToolError(Exception):
"""Exception raised when a tool is not found."""
@ -56,15 +91,12 @@ class ToolManager:
def _compute_search_paths(config: VibeConfig) -> list[Path]:
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
for path in config.tool_paths:
if path.is_dir():
paths.append(path)
paths.extend(config.tool_paths)
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
if (tools_dir := resolve_local_tools_dir(Path.cwd())) is not None:
paths.append(tools_dir)
if GLOBAL_TOOLS_DIR.path.is_dir():
paths.append(GLOBAL_TOOLS_DIR.path)
paths.append(GLOBAL_TOOLS_DIR.path)
unique: list[Path] = []
seen: set[Path] = set()
@ -77,38 +109,54 @@ class ToolManager:
@staticmethod
def _iter_tool_classes(search_paths: list[Path]) -> Iterator[type[BaseTool]]:
"""Iterate over all search_paths to find tool classes.
Note: if a search path is not a directory, it is treated as a single tool file.
"""
for base in search_paths:
if not base.is_dir():
continue
if not base.is_dir() and base.name.endswith(".py"):
if tools := ToolManager._load_tools_from_file(base):
for tool in tools:
yield tool
for path in base.rglob("*.py"):
if not path.is_file():
continue
name = path.name
if name.startswith("_"):
continue
if tools := ToolManager._load_tools_from_file(path):
for tool in tools:
yield tool
stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
module_name = f"vibe_tools_discovered_{stem}"
@staticmethod
def _load_tools_from_file(file_path: Path) -> list[type[BaseTool]] | None:
if not file_path.is_file():
return
name = file_path.name
if name.startswith("_"):
return
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
continue
module_name = _compute_module_name(file_path)
for obj in vars(module).values():
if not inspect.isclass(obj):
continue
if not issubclass(obj, BaseTool) or obj is BaseTool:
continue
if inspect.isabstract(obj):
continue
yield obj
if module_name in sys.modules:
module = sys.modules[module_name]
else:
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
return
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
return
tools = []
for tool_obj in vars(module).values():
if not inspect.isclass(tool_obj):
continue
if not issubclass(tool_obj, BaseTool) or tool_obj is BaseTool:
continue
if inspect.isabstract(tool_obj):
continue
tools.append(tool_obj)
return tools
@staticmethod
def discover_tool_defaults(
@ -130,7 +178,20 @@ class ToolManager:
continue
return defaults
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
if self._config.enabled_tools:
return {
name: cls
for name, cls in self._available.items()
if name_matches(name, self._config.enabled_tools)
}
if self._config.disabled_tools:
return {
name: cls
for name, cls in self._available.items()
if not name_matches(name, self._config.disabled_tools)
}
return dict(self._available)
def _integrate_mcp(self) -> None:
@ -169,7 +230,9 @@ class ToolManager:
headers = srv.http_headers()
try:
tools: list[RemoteTool] = await list_tools_http(url, headers=headers)
tools: list[RemoteTool] = await list_tools_http(
url, headers=headers, startup_timeout_sec=srv.startup_timeout_sec
)
except Exception as exc:
logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
return 0
@ -183,6 +246,8 @@ class ToolManager:
alias=srv.name,
server_hint=srv.prompt,
headers=headers,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
)
self._available[proxy_cls.get_name()] = proxy_cls
added += 1
@ -202,7 +267,9 @@ class ToolManager:
return 0
try:
tools: list[RemoteTool] = await list_tools_stdio(cmd)
tools: list[RemoteTool] = await list_tools_stdio(
cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
)
except Exception as exc:
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
return 0
@ -211,7 +278,13 @@ class ToolManager:
for remote in tools:
try:
proxy_cls = create_mcp_stdio_proxy_tool_class(
command=cmd, remote=remote, alias=srv.name, server_hint=srv.prompt
command=cmd,
remote=remote,
alias=srv.name,
server_hint=srv.prompt,
env=srv.env or None,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
)
self._available[proxy_cls.get_name()] = proxy_cls
added += 1
@ -240,9 +313,6 @@ class ToolManager:
else:
merged_dict = {**default_config.model_dump(), **user_overrides.model_dump()}
if self._config.workdir is not None:
merged_dict["workdir"] = self._config.workdir
return config_class.model_validate(merged_dict)
def get(self, tool_name: str) -> BaseTool:
@ -266,3 +336,6 @@ class ToolManager:
def reset_all(self) -> None:
self._instances.clear()
def invalidate_tool(self, tool_name: str) -> None:
self._instances.pop(tool_name, None)

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from datetime import timedelta
import hashlib
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
@ -9,8 +11,15 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from pydantic import BaseModel, ConfigDict, Field, field_validator
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, ToolError
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
)
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
from vibe.core.types import ToolStreamEvent
if TYPE_CHECKING:
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -57,8 +66,12 @@ class RemoteTool(BaseModel):
try:
v = dump()
except Exception:
return {"type": "object", "properties": {}}
return v if isinstance(v, dict) else {"type": "object", "properties": {}}
raise ValueError(
"inputSchema must be a dict or have a valid model_dump method"
)
if not isinstance(v, dict):
raise ValueError("inputSchema must be a dict")
return v
class _MCPContentBlock(BaseModel):
@ -100,10 +113,14 @@ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult
async def list_tools_http(
url: str, headers: dict[str, str] | None = None
url: str,
*,
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
@ -115,11 +132,21 @@ async def call_tool_http(
arguments: dict[str, Any],
*,
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
) -> MCPToolResult:
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
async with ClientSession(
read, write, read_timeout_seconds=init_timeout
) as session:
await session.initialize()
result = await session.call_tool(tool_name, arguments)
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result(url, tool_name, result)
@ -130,6 +157,8 @@ def create_mcp_http_proxy_tool_class(
alias: str | None = None,
server_hint: str | None = None,
headers: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
from urllib.parse import urlparse
@ -153,6 +182,8 @@ def create_mcp_http_proxy_tool_class(
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_headers: ClassVar[dict[str, str]] = dict(headers or {})
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
@classmethod
def get_name(cls) -> str:
@ -162,11 +193,18 @@ def create_mcp_http_proxy_tool_class(
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
async def run(self, args: _OpenArgs) -> MCPToolResult:
async def run(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
payload = args.model_dump(exclude_none=True)
return await call_tool_http(
self._mcp_url, self._remote_name, payload, headers=self._headers
yield await call_tool_http(
self._mcp_url,
self._remote_name,
payload,
headers=self._headers,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
)
except Exception as exc:
raise ToolError(f"MCP call failed: {exc}") from exc
@ -194,23 +232,43 @@ def create_mcp_http_proxy_tool_class(
return MCPHttpProxyTool
async def list_tools_stdio(command: list[str]) -> list[RemoteTool]:
params = StdioServerParameters(command=command[0], args=command[1:])
async def list_tools_stdio(
command: list[str],
*,
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_stdio(
command: list[str], tool_name: str, arguments: dict[str, Any]
command: list[str],
tool_name: str,
arguments: dict[str, Any],
*,
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
) -> MCPToolResult:
params = StdioServerParameters(command=command[0], args=command[1:])
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
async with ClientSession(
read, write, read_timeout_seconds=init_timeout
) as session:
await session.initialize()
result = await session.call_tool(tool_name, arguments)
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
@ -220,6 +278,9 @@ def create_mcp_stdio_proxy_tool_class(
remote: RemoteTool,
alias: str | None = None,
server_hint: str | None = None,
env: dict[str, str] | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
def _alias_from_command(cmd: list[str]) -> str:
prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
@ -245,6 +306,9 @@ def create_mcp_stdio_proxy_tool_class(
_stdio_command: ClassVar[list[str]] = command
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_env: ClassVar[dict[str, str] | None] = env
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
@classmethod
def get_name(cls) -> str:
@ -254,13 +318,20 @@ def create_mcp_stdio_proxy_tool_class(
def get_parameters(cls) -> dict[str, Any]:
return dict(cls._input_schema)
async def run(self, args: _OpenArgs) -> MCPToolResult:
async def run(
self, args: _OpenArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
try:
payload = args.model_dump(exclude_none=True)
result = await call_tool_stdio(
self._stdio_command, self._remote_name, payload
self._stdio_command,
self._remote_name,
payload,
env=self._env,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
)
return result
yield result
except Exception as exc:
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc

View file

@ -5,7 +5,13 @@ from collections import OrderedDict
from collections.abc import Awaitable, Callable
import copy
from enum import StrEnum, auto
from typing import Annotated, Any, Literal
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
from uuid import uuid4
if TYPE_CHECKING:
from vibe.core.tools.base import BaseTool
else:
BaseTool = Any
from pydantic import (
BaseModel,
@ -16,8 +22,6 @@ from pydantic import (
model_validator,
)
from vibe.core.tools.base import BaseTool
class AgentStats(BaseModel):
steps: int = 0
@ -29,6 +33,7 @@ class AgentStats(BaseModel):
tool_calls_succeeded: int = 0
context_tokens: int = 0
listeners: ClassVar[dict[str, Callable[[AgentStats], None]]] = {}
last_turn_prompt_tokens: int = 0
last_turn_completion_tokens: int = 0
@ -38,6 +43,21 @@ class AgentStats(BaseModel):
input_price_per_million: float = 0.0
output_price_per_million: float = 0.0
def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
if name in self.listeners:
self.listeners[name](self)
def trigger_listeners(self) -> None:
for listener in self.listeners.values():
listener(self)
@classmethod
def add_listener(
cls, attr_name: str, listener: Callable[[AgentStats], None]
) -> None:
cls.listeners[attr_name] = listener
@computed_field
@property
def session_total_llm_tokens(self) -> int:
@ -104,7 +124,6 @@ class SessionMetadata(BaseModel):
git_commit: str | None
git_branch: str | None
environment: dict[str, str | None]
auto_approve: bool = False
username: str
@ -172,6 +191,7 @@ class LLMMessage(BaseModel):
tool_calls: list[ToolCall] | None = None
name: str | None = None
tool_call_id: str | None = None
message_id: str | None = None
@model_validator(mode="before")
@classmethod
@ -179,14 +199,19 @@ class LLMMessage(BaseModel):
if isinstance(v, dict):
v.setdefault("content", "")
v.setdefault("role", "assistant")
if "message_id" not in v and v.get("role") != "tool":
v["message_id"] = str(uuid4())
return v
role = str(getattr(v, "role", "assistant"))
return {
"role": str(getattr(v, "role", "assistant")),
"role": role,
"content": getattr(v, "content", ""),
"reasoning_content": getattr(v, "reasoning_content", None),
"tool_calls": getattr(v, "tool_calls", None),
"name": getattr(v, "name", None),
"tool_call_id": getattr(v, "tool_call_id", None),
"message_id": getattr(v, "message_id", None)
or (str(uuid4()) if role != "tool" else None),
}
def __add__(self, other: LLMMessage) -> LLMMessage:
@ -238,6 +263,7 @@ class LLMMessage(BaseModel):
tool_calls=list(tool_calls_map.values()) or None,
name=self.name,
tool_call_id=self.tool_call_id,
message_id=self.message_id,
)
@ -267,25 +293,31 @@ class LLMChunk(BaseModel):
class BaseEvent(BaseModel, ABC):
"""Abstract base class for all agent events."""
model_config = ConfigDict(arbitrary_types_allowed=True)
class UserMessageEvent(BaseEvent):
content: str
message_id: str
class AssistantEvent(BaseEvent):
content: str
stopped_by_middleware: bool = False
message_id: str | None = None
def __add__(self, other: AssistantEvent) -> AssistantEvent:
return AssistantEvent(
content=self.content + other.content,
stopped_by_middleware=self.stopped_by_middleware
or other.stopped_by_middleware,
message_id=self.message_id or other.message_id,
)
class ReasoningEvent(BaseEvent):
content: str
message_id: str | None = None
class ToolCallEvent(BaseEvent):
@ -306,15 +338,31 @@ class ToolResultEvent(BaseEvent):
tool_call_id: str
class ToolStreamEvent(BaseEvent):
tool_name: str
message: str
tool_call_id: str
class CompactStartEvent(BaseEvent):
current_context_tokens: int
threshold: int
# 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)
tool_call_id: str
class CompactEndEvent(BaseEvent):
old_context_tokens: int
new_context_tokens: int
summary_length: int
# 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)
tool_call_id: str
class OutputFormat(StrEnum):
@ -332,3 +380,5 @@ type SyncApprovalCallback = Callable[
]
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]

View file

@ -4,6 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
import concurrent.futures
from enum import Enum, auto
from fnmatch import fnmatch
import functools
import logging
from pathlib import Path
@ -273,3 +274,45 @@ def run_sync[T](coro: Coroutine[Any, Any, T]) -> T:
def is_windows() -> bool:
return sys.platform == "win32"
@functools.lru_cache(maxsize=256)
def _compile_icase(expr: str) -> re.Pattern[str] | None:
try:
return re.compile(expr, re.IGNORECASE)
except re.error:
return None
def name_matches(name: str, patterns: list[str]) -> bool:
"""Check if a name matches any of the provided patterns.
Supports two forms (case-insensitive):
- Glob wildcards using fnmatch (e.g., 'serena_*')
- Regex when prefixed with 're:' (e.g., 're:serena.*')
"""
n = name.lower()
for raw in patterns:
if not (p := (raw or "").strip()):
continue
if p.startswith("re:"):
rx = _compile_icase(p.removeprefix("re:"))
if rx is not None and rx.fullmatch(name) is not None:
return True
elif fnmatch(n, p.lower()):
return True
return False
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
if old_tokens is None or new_tokens is None:
return "Compaction complete"
reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
return (
f"Compaction complete: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)

View file

@ -50,4 +50,6 @@ def run_onboarding(app: App | None = None) -> None:
f"You may need to set it manually in {GLOBAL_ENV_FILE.path}[/]\n"
)
case "completed":
pass
rprint(
'\nSetup complete 🎉. Run "vibe" to start using the Mistral Vibe CLI.\n'
)

View file

@ -18,7 +18,7 @@ from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/vibe", "Mistral AI Studio")
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
}
CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"

14
vibe/whats_new.md Normal file
View file

@ -0,0 +1,14 @@
# What's New in 2.0.0
- **Subagents**: The agent can now delegate tasks to specialized sub-agents for more complex workflows.
- **Interactive Questions**: The agent can now ask you clarifying questions as it works.
- **Slash Commands**: You can now define your own custom slash commands through skills.
- **Auto-Update**: Vibe will now keep itself up to date automatically. Disable with `enable_auto_update = false` in `config.toml`.
- **MCP Servers**: Configurations now support environment variables and custom timeouts.
- **Agents System**: Modes have been replaced by a more flexible agents system.
### ⚠️ Breaking Changes
- Custom modes must be migrated to the new agent format.
- `workdir` setting in `config.toml` is no longer supported.
- `instructions.md` files are no longer supported.
- Heuristic regex matching is removed. To use a regex in `enabled_tools`, `disabled_tools`, `enabled_skills`, or `disabled_skills`, prefix it with `re:` (e.g., `re:serena.*`).