2.0.0
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:
parent
79f215d91c
commit
d33db9fff8
217 changed files with 16911 additions and 4305 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]):
|
|||
}
|
||||
|
||||
update = AgentPlanUpdate(
|
||||
sessionUpdate="plan",
|
||||
session_update="plan",
|
||||
entries=[
|
||||
PlanEntry(
|
||||
content=todo.content,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
67
vibe/cli/plan_offer/adapters/http_whoami_gateway.py
Normal file
67
vibe/cli/plan_offer/adapters/http_whoami_gateway.py
Normal 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")
|
||||
56
vibe/cli/plan_offer/decide_plan_offer.py
Normal file
56
vibe/cli/plan_offer/decide_plan_offer.py
Normal 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
|
||||
23
vibe/cli/plan_offer/ports/whoami_gateway.py
Normal file
23
vibe/cli/plan_offer/ports/whoami_gateway.py
Normal 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: ...
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
vibe/cli/textual_ui/external_editor.py
Normal file
32
vibe/cli/textual_ui/external_editor.py
Normal 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)
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
41
vibe/cli/textual_ui/widgets/agent_indicator.py
Normal file
41
vibe/cli/textual_ui/widgets/agent_indicator.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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__()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
479
vibe/cli/textual_ui/widgets/question_app.py
Normal file
479
vibe/cli/textual_ui/widgets/question_app.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[/]"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
53
vibe/cli/update_notifier/ports/update_gateway.py
Normal file
53
vibe/cli/update_notifier/ports/update_gateway.py
Normal 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: ...
|
||||
|
|
@ -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: ...
|
||||
|
|
@ -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
|
||||
49
vibe/cli/update_notifier/whats_new.py
Normal file
49
vibe/cli/update_notifier/whats_new.py
Normal 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,
|
||||
)
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
31
vibe/core/agents/__init__.py
Normal file
31
vibe/core/agents/__init__.py
Normal 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
165
vibe/core/agents/manager.py
Normal 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
122
vibe/core/agents/models.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
130
vibe/core/session/session_loader.py
Normal file
130
vibe/core/session/session_loader.py
Normal 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
|
||||
314
vibe/core/session/session_logger.py
Normal file
314
vibe/core/session/session_logger.py
Normal 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
|
||||
41
vibe/core/session/session_migration.py
Normal file
41
vibe/core/session/session_migration.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
131
vibe/core/tools/builtins/ask_user_question.py
Normal file
131
vibe/core/tools/builtins/ask_user_question.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
84
vibe/core/tools/builtins/prompts/ask_user_question.md
Normal file
84
vibe/core/tools/builtins/prompts/ask_user_question.md
Normal 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
|
||||
|
|
@ -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:**
|
||||
|
|
|
|||
24
vibe/core/tools/builtins/prompts/task.md
Normal file
24
vibe/core/tools/builtins/prompts/task.md
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
154
vibe/core/tools/builtins/task.py
Normal file
154
vibe/core/tools/builtins/task.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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'."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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}%)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
14
vibe/whats_new.md
Normal 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.*`).
|
||||
Loading…
Add table
Add a link
Reference in a new issue