v2.3.0 (#429)
Co-authored-by: Carlo <carloantonio.patti@mistral.ai> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> 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: Thomas Kenbeek <thomas.kenbeek@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
a560a47ce8
commit
5d2e01a6d7
139 changed files with 7152 additions and 1457 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.2.1"
|
||||
__version__ = "2.3.0"
|
||||
|
|
|
|||
|
|
@ -37,14 +37,12 @@ from acp.schema import (
|
|||
Implementation,
|
||||
ListSessionsResponse,
|
||||
McpServerStdio,
|
||||
ModelInfo,
|
||||
PromptCapabilities,
|
||||
ResumeSessionResponse,
|
||||
SessionCapabilities,
|
||||
SessionInfo,
|
||||
SessionListCapabilities,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
SetSessionConfigOptionResponse,
|
||||
SseMcpServer,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
|
|
@ -56,6 +54,7 @@ from acp.schema import (
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from vibe import VIBE_ROOT, __version__
|
||||
from vibe.acp.acp_logger import acp_message_observer
|
||||
from vibe.acp.tools.base import BaseAcpTool
|
||||
from vibe.acp.tools.session_update import (
|
||||
tool_call_session_update,
|
||||
|
|
@ -71,12 +70,13 @@ from vibe.acp.utils import (
|
|||
create_tool_call_replay,
|
||||
create_tool_result_replay,
|
||||
create_user_message_replay,
|
||||
get_all_acp_session_modes,
|
||||
get_proxy_help_text,
|
||||
is_valid_acp_agent,
|
||||
is_valid_acp_mode,
|
||||
make_mode_response,
|
||||
make_model_response,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
|
|
@ -98,6 +98,7 @@ from vibe.core.types import (
|
|||
AsyncApprovalCallback,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
EntrypointMetadata,
|
||||
LLMMessage,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
|
|
@ -121,7 +122,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def __init__(self) -> None:
|
||||
self.sessions: dict[str, AcpSessionLoop] = {}
|
||||
self.client_capabilities = None
|
||||
self.client_capabilities: ClientCapabilities | None = None
|
||||
self.client_info: Implementation | None = None
|
||||
|
||||
@override
|
||||
async def initialize(
|
||||
|
|
@ -132,6 +134,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
**kwargs: Any,
|
||||
) -> InitializeResponse:
|
||||
self.client_capabilities = client_capabilities
|
||||
self.client_info = client_info
|
||||
|
||||
# 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
|
||||
|
|
@ -200,6 +203,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
) -> AuthenticateResponse | None:
|
||||
raise NotImplementedError("Not implemented yet")
|
||||
|
||||
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
|
||||
return EntrypointMetadata(
|
||||
agent_entrypoint="acp",
|
||||
agent_version=__version__,
|
||||
client_name=self.client_info.name if self.client_info else "",
|
||||
client_version=self.client_info.version if self.client_info else "",
|
||||
)
|
||||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
|
|
@ -223,21 +234,6 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
return session
|
||||
|
||||
def _build_session_model_state(self, agent_loop: AgentLoop) -> SessionModelState:
|
||||
return SessionModelState(
|
||||
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
|
||||
],
|
||||
)
|
||||
|
||||
def _build_session_mode_state(self, session: AcpSessionLoop) -> SessionModeState:
|
||||
return SessionModeState(
|
||||
current_mode_id=session.agent_loop.agent_profile.name,
|
||||
available_modes=get_all_acp_session_modes(session.agent_loop.agent_manager),
|
||||
)
|
||||
|
||||
@override
|
||||
async def new_session(
|
||||
self,
|
||||
|
|
@ -251,19 +247,32 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
config = self._load_config()
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
# 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 = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
agent_loop.emit_new_session_telemetry("acp")
|
||||
agent_loop.emit_new_session_telemetry()
|
||||
|
||||
modes_state, modes_config = make_mode_response(
|
||||
list(agent_loop.agent_manager.available_agents.values()),
|
||||
session.agent_loop.agent_profile.name,
|
||||
)
|
||||
models_state, models_config = make_model_response(
|
||||
agent_loop.config.models, agent_loop.config.active_model
|
||||
)
|
||||
|
||||
return NewSessionResponse(
|
||||
session_id=session.id,
|
||||
models=self._build_session_model_state(agent_loop),
|
||||
modes=self._build_session_mode_state(session),
|
||||
models=models_state,
|
||||
modes=modes_state,
|
||||
config_options=[modes_config, models_config],
|
||||
)
|
||||
|
||||
def _get_acp_tool_overrides(self) -> list[Path]:
|
||||
|
|
@ -447,8 +456,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
}) from e
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
config=config,
|
||||
agent_name=BuiltinAgentName.DEFAULT,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=self._build_entrypoint_metadata(),
|
||||
)
|
||||
agent_loop.agent_manager.register_agent(CHAT_AGENT)
|
||||
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
|
|
@ -460,19 +473,24 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
await self._replay_conversation_history(session_id, non_system_messages)
|
||||
|
||||
return LoadSessionResponse(
|
||||
models=self._build_session_model_state(agent_loop),
|
||||
modes=self._build_session_mode_state(session),
|
||||
modes_state, modes_config = make_mode_response(
|
||||
list(agent_loop.agent_manager.available_agents.values()),
|
||||
session.agent_loop.agent_profile.name,
|
||||
)
|
||||
models_state, models_config = make_model_response(
|
||||
agent_loop.config.models, agent_loop.config.active_model
|
||||
)
|
||||
|
||||
@override
|
||||
async def set_session_mode(
|
||||
self, mode_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModeResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
return LoadSessionResponse(
|
||||
models=models_state,
|
||||
modes=modes_state,
|
||||
config_options=[modes_config, models_config],
|
||||
)
|
||||
|
||||
if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
|
||||
return None
|
||||
async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool:
|
||||
profiles = list(session.agent_loop.agent_manager.available_agents.values())
|
||||
if not is_valid_acp_mode(profiles, mode_id):
|
||||
return False
|
||||
|
||||
await session.agent_loop.switch_agent(mode_id)
|
||||
|
||||
|
|
@ -483,17 +501,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
self._create_approval_callback(session.id)
|
||||
)
|
||||
|
||||
return SetSessionModeResponse()
|
||||
|
||||
@override
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModelResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
return True
|
||||
|
||||
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
|
||||
model_aliases = [model.alias for model in session.agent_loop.config.models]
|
||||
if model_id not in model_aliases:
|
||||
return None
|
||||
return False
|
||||
|
||||
VibeConfig.save_updates({"active_model": model_id})
|
||||
|
||||
|
|
@ -504,8 +517,59 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
|
||||
return True
|
||||
|
||||
@override
|
||||
async def set_session_mode(
|
||||
self, mode_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModeResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
if not await self._apply_mode_change(session, mode_id):
|
||||
return None
|
||||
|
||||
return SetSessionModeResponse()
|
||||
|
||||
@override
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
) -> SetSessionModelResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
if not await self._apply_model_change(session, model_id):
|
||||
return None
|
||||
|
||||
return SetSessionModelResponse()
|
||||
|
||||
@override
|
||||
async def set_config_option(
|
||||
self, config_id: str, session_id: str, value: str, **kwargs: Any
|
||||
) -> SetSessionConfigOptionResponse | None:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
match config_id:
|
||||
case "mode":
|
||||
success = await self._apply_mode_change(session, value)
|
||||
case "model":
|
||||
success = await self._apply_model_change(session, value)
|
||||
case _:
|
||||
success = False
|
||||
|
||||
if not success:
|
||||
return None
|
||||
|
||||
profiles = list(session.agent_loop.agent_manager.available_agents.values())
|
||||
_, modes_config = make_mode_response(
|
||||
profiles, session.agent_loop.agent_profile.name
|
||||
)
|
||||
_, models_config = make_model_response(
|
||||
session.agent_loop.config.models, session.agent_loop.config.active_model
|
||||
)
|
||||
|
||||
return SetSessionConfigOptionResponse(
|
||||
config_options=[modes_config, models_config]
|
||||
)
|
||||
|
||||
@override
|
||||
async def list_sessions(
|
||||
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
|
||||
|
|
@ -736,7 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def run_acp_server() -> None:
|
||||
try:
|
||||
asyncio.run(run_agent(agent=VibeAcpAgentLoop(), use_unstable_protocol=True))
|
||||
asyncio.run(
|
||||
run_agent(
|
||||
agent=VibeAcpAgentLoop(),
|
||||
use_unstable_protocol=True,
|
||||
observers=[acp_message_observer],
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
# This is expected when the server is terminated
|
||||
pass
|
||||
|
|
|
|||
97
vibe/acp/acp_logger.py
Normal file
97
vibe/acp/acp_logger.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from acp.connection import StreamEvent
|
||||
|
||||
ACP_LOG_DIR = Path.home() / ".vibe" / "logs" / "acp"
|
||||
ACP_LOG_FILE = ACP_LOG_DIR / "messages.jsonl"
|
||||
MAX_LOG_SIZE_BYTES = 1_000_000
|
||||
BACKUP_COUNT = 3
|
||||
|
||||
ACP_LOGGING_ENABLED_KEY = "VIBE_ACP_LOGGING_ENABLED"
|
||||
|
||||
_session_cache: TTLCache[int | str, str] = TTLCache(maxsize=1000, ttl=3600)
|
||||
_current_session: str | None = None
|
||||
_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def is_acp_logging_enabled() -> bool:
|
||||
return os.getenv(ACP_LOGGING_ENABLED_KEY, "").lower() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
class JsonLineFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return json.dumps(record.msg, separators=(",", ":"))
|
||||
|
||||
|
||||
def _get_logger() -> logging.Logger:
|
||||
global _logger
|
||||
if _logger is not None:
|
||||
return _logger
|
||||
|
||||
ACP_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("acp_messages")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
ACP_LOG_FILE,
|
||||
maxBytes=MAX_LOG_SIZE_BYTES,
|
||||
backupCount=BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(JsonLineFormatter())
|
||||
logger.addHandler(handler)
|
||||
|
||||
_logger = logger
|
||||
return _logger
|
||||
|
||||
|
||||
def _extract_session_id(message: dict) -> str | None:
|
||||
json_str = json.dumps(message)
|
||||
match = re.search(r'"(?:session_id|sessionId)":\s*"([^"]+)"', json_str)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def acp_message_observer(event: StreamEvent) -> None:
|
||||
if not is_acp_logging_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
global _current_session
|
||||
|
||||
message = event.message
|
||||
msg_id = message.get("id", "")
|
||||
|
||||
if msg_id in _session_cache:
|
||||
session_id = _session_cache[msg_id]
|
||||
else:
|
||||
session_id = _extract_session_id(message) or _current_session
|
||||
|
||||
if session_id is not None:
|
||||
_current_session = session_id
|
||||
if msg_id:
|
||||
_session_cache[msg_id] = session_id
|
||||
|
||||
log_entry: dict = {
|
||||
"ts": datetime.now(UTC).isoformat(),
|
||||
"dir": "in" if event.direction.value == "incoming" else "out",
|
||||
"msg": message,
|
||||
**({"session": session_id} if session_id else {}),
|
||||
}
|
||||
|
||||
_get_logger().info(log_entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -7,8 +7,8 @@ import sys
|
|||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ from acp.helpers import SessionUpdate, ToolCallContentVariant
|
|||
from acp.schema import ToolCallProgress
|
||||
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseTool, ToolError
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils import logger
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.logger import logger
|
||||
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, ToolStreamEvent
|
||||
from vibe.core.utils import logger
|
||||
|
||||
|
||||
class AcpBashState(BaseToolState, AcpToolState):
|
||||
|
|
@ -110,7 +110,16 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
raise self._build_timeout_error(command, timeout)
|
||||
|
||||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart:
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart | None:
|
||||
if event.args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="bash",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="execute",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(event.args, BashArgs):
|
||||
raise ValueError(f"Unexpected tool args: {event.args}")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@ from pathlib import Path
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile as CoreReadFileTool,
|
||||
ReadFileArgs,
|
||||
ReadFileResult,
|
||||
ReadFileState,
|
||||
_ReadResult,
|
||||
)
|
||||
|
||||
ReadFileResult = ReadFileResult
|
||||
|
||||
|
||||
class AcpReadFileState(ReadFileState, AcpToolState):
|
||||
class AcpReadFileState(BaseToolState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,16 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplace as CoreSearchReplaceTool,
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceResult,
|
||||
SearchReplaceState,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class AcpSearchReplaceState(SearchReplaceState, AcpToolState):
|
||||
class AcpSearchReplaceState(BaseToolState, AcpToolState):
|
||||
file_backup_content: str | None = None
|
||||
|
||||
|
||||
|
|
@ -73,6 +72,15 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
args = event.args
|
||||
if args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="search_replace",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(args, SearchReplaceArgs):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,16 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.builtins.write_file import (
|
||||
WriteFile as CoreWriteFileTool,
|
||||
WriteFileArgs,
|
||||
WriteFileResult,
|
||||
WriteFileState,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class AcpWriteFileState(WriteFileState, AcpToolState):
|
||||
class AcpWriteFileState(BaseToolState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -51,6 +50,15 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
|
|||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
|
||||
args = event.args
|
||||
if args is None:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="write_file",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind="edit",
|
||||
content=None,
|
||||
raw_input=None,
|
||||
)
|
||||
if not isinstance(args, WriteFileArgs):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
|
|||
content=content,
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=TOOL_KIND.get(event.tool_name, "other"),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
raw_input=event.args.model_dump_json() if event.args else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ from acp.schema import (
|
|||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ContentToolCallContent,
|
||||
ModelInfo,
|
||||
PermissionOption,
|
||||
SessionConfigOption,
|
||||
SessionConfigOptionSelect,
|
||||
SessionConfigSelectOption,
|
||||
SessionMode,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
|
|
@ -21,7 +27,7 @@ from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
|||
from vibe.core.utils import compact_reduction_display
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.config import ModelConfig
|
||||
|
||||
|
||||
class ToolOption(StrEnum):
|
||||
|
|
@ -50,22 +56,80 @@ TOOL_OPTIONS = [
|
|||
]
|
||||
|
||||
|
||||
def agent_profile_to_acp(profile: AgentProfile) -> SessionMode:
|
||||
return SessionMode(
|
||||
id=profile.name, name=profile.display_name, description=profile.description
|
||||
def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
|
||||
return any(
|
||||
p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles
|
||||
)
|
||||
|
||||
|
||||
def is_valid_acp_agent(agent_manager: AgentManager, agent_name: str) -> bool:
|
||||
return agent_name in agent_manager.available_agents
|
||||
def make_mode_response(
|
||||
profiles: list[AgentProfile], current_mode_id: str
|
||||
) -> tuple[SessionModeState, SessionConfigOption]:
|
||||
session_modes: list[SessionMode] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
for profile in profiles:
|
||||
if profile.agent_type != AgentType.AGENT:
|
||||
continue
|
||||
session_modes.append(
|
||||
SessionMode(
|
||||
id=profile.name,
|
||||
name=profile.display_name,
|
||||
description=profile.description,
|
||||
)
|
||||
)
|
||||
config_options.append(
|
||||
SessionConfigSelectOption(
|
||||
value=profile.name,
|
||||
name=profile.display_name,
|
||||
description=profile.description,
|
||||
)
|
||||
)
|
||||
|
||||
state = SessionModeState(
|
||||
current_mode_id=current_mode_id, available_modes=session_modes
|
||||
)
|
||||
config = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="mode",
|
||||
name="Session Mode",
|
||||
current_value=current_mode_id,
|
||||
category="mode",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
)
|
||||
return state, config
|
||||
|
||||
|
||||
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 make_model_response(
|
||||
models: list[ModelConfig], current_model_id: str
|
||||
) -> tuple[SessionModelState, SessionConfigOption]:
|
||||
model_infos: list[ModelInfo] = []
|
||||
config_options: list[SessionConfigSelectOption] = []
|
||||
|
||||
for model in models:
|
||||
model_infos.append(ModelInfo(model_id=model.alias, name=model.alias))
|
||||
config_options.append(
|
||||
SessionConfigSelectOption(
|
||||
value=model.alias, name=model.alias, description=model.name
|
||||
)
|
||||
)
|
||||
|
||||
state = SessionModelState(
|
||||
current_model_id=current_model_id, available_models=model_infos
|
||||
)
|
||||
config_option = SessionConfigOption(
|
||||
root=SessionConfigOptionSelect(
|
||||
id="model",
|
||||
name="Model",
|
||||
current_value=current_model_id,
|
||||
category="model",
|
||||
type="select",
|
||||
options=config_options,
|
||||
)
|
||||
)
|
||||
return state, config_option
|
||||
|
||||
|
||||
def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
|
|
@ -14,11 +16,12 @@ from vibe.core.config import (
|
|||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
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.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
|
||||
|
|
@ -74,7 +77,7 @@ def bootstrap_config_files() -> None:
|
|||
|
||||
def load_session(
|
||||
args: argparse.Namespace, config: VibeConfig
|
||||
) -> list[LLMMessage] | None:
|
||||
) -> tuple[list[LLMMessage], Path] | None:
|
||||
if not args.continue_session and not args.resume:
|
||||
return None
|
||||
|
||||
|
|
@ -107,18 +110,26 @@ def load_session(
|
|||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_to_load)
|
||||
return loaded_messages
|
||||
return loaded_messages, session_to_load
|
||||
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]
|
||||
def _resume_previous_session(
|
||||
agent_loop: AgentLoop, loaded_messages: list[LLMMessage], session_path: Path
|
||||
) -> 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))
|
||||
|
||||
_, metadata = SessionLoader.load_session(session_path)
|
||||
session_id = metadata.get("session_id", agent_loop.session_id)
|
||||
agent_loop.session_id = session_id
|
||||
agent_loop.session_logger.resume_existing_session(session_id, session_path)
|
||||
|
||||
logger.info(
|
||||
"Resumed session %s with %d messages", session_id, len(non_system_messages)
|
||||
)
|
||||
|
||||
|
||||
def run_cli(args: argparse.Namespace) -> None:
|
||||
|
|
@ -136,7 +147,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
||||
loaded_messages = load_session(args, config)
|
||||
loaded_session = load_session(args, config)
|
||||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
|
|
@ -157,7 +168,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_messages,
|
||||
previous_messages=loaded_session[0] if loaded_session else None,
|
||||
agent_name=initial_agent_name,
|
||||
)
|
||||
if final_response:
|
||||
|
|
@ -171,11 +182,19 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
sys.exit(1)
|
||||
else:
|
||||
agent_loop = AgentLoop(
|
||||
config, agent_name=initial_agent_name, enable_streaming=True
|
||||
config,
|
||||
agent_name=initial_agent_name,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=EntrypointMetadata(
|
||||
agent_entrypoint="cli",
|
||||
agent_version=__version__,
|
||||
client_name="vibe_cli",
|
||||
client_version=__version__,
|
||||
),
|
||||
)
|
||||
|
||||
if loaded_messages:
|
||||
_load_messages_from_previous_session(agent_loop, loaded_messages)
|
||||
if loaded_session:
|
||||
_resume_previous_session(agent_loop, *loaded_session)
|
||||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ class CommandRegistry:
|
|||
description="Configure proxy and SSL certificate settings",
|
||||
handler="_show_proxy_setup",
|
||||
),
|
||||
"resume": Command(
|
||||
aliases=frozenset(["/resume", "/continue"]),
|
||||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class Terminal(Enum):
|
|||
VSCODE = "vscode"
|
||||
VSCODE_INSIDERS = "vscode_insiders"
|
||||
CURSOR = "cursor"
|
||||
JETBRAINS = "jetbrains"
|
||||
ITERM2 = "iterm2"
|
||||
WEZTERM = "wezterm"
|
||||
GHOSTTY = "ghostty"
|
||||
|
|
@ -50,6 +51,16 @@ def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDE
|
|||
return Terminal.VSCODE
|
||||
|
||||
|
||||
def _detect_terminal_from_env() -> Terminal | None:
|
||||
if os.environ.get("WEZTERM_PANE"):
|
||||
return Terminal.WEZTERM
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
||||
return Terminal.GHOSTTY
|
||||
if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
|
||||
return Terminal.JETBRAINS
|
||||
return None
|
||||
|
||||
|
||||
def detect_terminal() -> Terminal:
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
|
|
@ -66,12 +77,7 @@ def detect_terminal() -> Terminal:
|
|||
if term_program in term_map:
|
||||
return term_map[term_program]
|
||||
|
||||
if os.environ.get("WEZTERM_PANE"):
|
||||
return Terminal.WEZTERM
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
||||
return Terminal.GHOSTTY
|
||||
|
||||
return Terminal.UNKNOWN
|
||||
return _detect_terminal_from_env() or Terminal.UNKNOWN
|
||||
|
||||
|
||||
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
|
||||
|
|
@ -303,6 +309,13 @@ def setup_terminal() -> SetupResult:
|
|||
match terminal:
|
||||
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
|
||||
return _setup_vscode_like_terminal(terminal)
|
||||
case Terminal.JETBRAINS:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.JETBRAINS,
|
||||
message="Jetbrains terminal is not supported.\n"
|
||||
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
|
||||
)
|
||||
case Terminal.ITERM2:
|
||||
return _setup_iterm2()
|
||||
case Terminal.WEZTERM:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
|||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
NotificationContext,
|
||||
NotificationPort,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
|
|
@ -55,6 +60,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
|
|
@ -84,6 +90,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
|
|||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.teleport.types import (
|
||||
|
|
@ -115,7 +122,6 @@ from vibe.core.utils import (
|
|||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
logger,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -132,11 +138,16 @@ class BottomApp(StrEnum):
|
|||
Input = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
SessionPicker = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
"""Optimized scroll container that skips cascading style recalculations."""
|
||||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_offset.y >= (self.max_scroll_y - 3)
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -145,34 +156,40 @@ PRUNE_LOW_MARK = 1000
|
|||
PRUNE_HIGH_MARK = 1500
|
||||
|
||||
|
||||
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
|
||||
"""Remove older children to keep virtual height within bounds.
|
||||
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
|
||||
async def prune_oldest_children(
|
||||
messages_area: Widget, low_mark: int, high_mark: int
|
||||
) -> bool:
|
||||
"""Remove the oldest children so the virtual height stays within bounds.
|
||||
|
||||
Walks children back-to-front to find how much to keep (up to *low_mark*
|
||||
of visible height), then removes everything before that point.
|
||||
"""
|
||||
height = messages_area.virtual_size.height
|
||||
if height <= high_mark:
|
||||
total_height = messages_area.virtual_size.height
|
||||
if total_height <= high_mark:
|
||||
return False
|
||||
prune_children: list[Widget] = []
|
||||
bottom_margin = 0
|
||||
prune_height = 0
|
||||
for child in messages_area.children:
|
||||
|
||||
children = messages_area.children
|
||||
if not children:
|
||||
return False
|
||||
|
||||
accumulated = 0
|
||||
cut = len(children)
|
||||
|
||||
for child in reversed(children):
|
||||
if not child.display:
|
||||
prune_children.append(child)
|
||||
cut -= 1
|
||||
continue
|
||||
top, _, bottom, _ = child.styles.margin
|
||||
child_height = child.outer_size.height
|
||||
prune_height = (
|
||||
(prune_height - bottom_margin + max(bottom_margin, top))
|
||||
+ bottom
|
||||
+ child_height
|
||||
)
|
||||
bottom_margin = bottom
|
||||
if height - prune_height <= low_mark:
|
||||
accumulated += child.outer_size.height
|
||||
cut -= 1
|
||||
if accumulated >= low_mark:
|
||||
break
|
||||
prune_children.append(child)
|
||||
if prune_children:
|
||||
await messages_area.remove_children(prune_children)
|
||||
return bool(prune_children)
|
||||
|
||||
to_remove = list(children[:cut])
|
||||
if not to_remove:
|
||||
return False
|
||||
|
||||
await messages_area.remove_children(to_remove)
|
||||
return True
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
|
|
@ -203,10 +220,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
plan_offer_gateway: WhoAmIGateway | None = None,
|
||||
terminal_notifier: NotificationPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.agent_loop = agent_loop
|
||||
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
|
||||
self,
|
||||
get_enabled=lambda: self.config.enable_notifications,
|
||||
default_title="Vibe",
|
||||
)
|
||||
self._agent_running = False
|
||||
self._interrupt_requested = False
|
||||
self._agent_task: asyncio.Task | None = None
|
||||
|
|
@ -246,6 +269,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -279,6 +303,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
self._terminal_notifier.restore()
|
||||
|
||||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
|
|
@ -311,7 +336,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._resume_history_from_messages()
|
||||
await self._check_and_show_whats_new()
|
||||
self._schedule_update_notification()
|
||||
self.agent_loop.emit_new_session_telemetry("cli")
|
||||
self.agent_loop.emit_new_session_telemetry()
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
|
|
@ -575,12 +600,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_loop_turn(message)
|
||||
)
|
||||
|
||||
def _reset_ui_state(self) -> None:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
|
||||
async def _resume_history_from_messages(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
if not should_resume_history(list(messages_area.children)):
|
||||
return
|
||||
|
||||
self._windowing.reset()
|
||||
history_messages = non_system_history_messages(self.agent_loop.messages)
|
||||
if (
|
||||
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
|
||||
|
|
@ -592,7 +621,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
plan.tool_call_map,
|
||||
start_index=plan.tail_start_index,
|
||||
)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_after_refresh(chat.anchor)
|
||||
self._tool_call_map = plan.tool_call_map
|
||||
self._windowing.set_backfill(plan.backfill_messages)
|
||||
await self._load_more.set_visible(
|
||||
|
|
@ -643,6 +673,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return (ApprovalResponse.YES, None)
|
||||
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
|
@ -654,6 +685,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
question_args = cast(AskUserQuestionArgs, args)
|
||||
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
|
|
@ -714,6 +746,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
await self._refresh_windowing_from_history()
|
||||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
|
@ -864,11 +897,108 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _show_session_picker(self) -> None:
|
||||
session_config = self.config.session_logging
|
||||
|
||||
if not session_config.enabled:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"Session logging is disabled in configuration.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
cwd = str(Path.cwd())
|
||||
raw_sessions = SessionLoader.list_sessions(session_config, cwd=cwd)
|
||||
|
||||
if not raw_sessions:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No sessions found for this directory.")
|
||||
)
|
||||
return
|
||||
|
||||
sessions = sorted(
|
||||
raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
|
||||
)
|
||||
|
||||
latest_messages = {
|
||||
s["session_id"]: SessionLoader.get_first_user_message(
|
||||
s["session_id"], session_config
|
||||
)
|
||||
for s in sessions
|
||||
}
|
||||
|
||||
picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
|
||||
await self._switch_from_input(picker)
|
||||
|
||||
async def on_session_picker_app_session_selected(
|
||||
self, event: SessionPickerApp.SessionSelected
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
session_config = self.config.session_logging
|
||||
session_path = SessionLoader.find_session_by_id(
|
||||
event.session_id, session_config
|
||||
)
|
||||
|
||||
if not session_path:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Session `{event.session_id[:8]}` not found.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_path)
|
||||
|
||||
current_system_messages = [
|
||||
msg for msg in self.agent_loop.messages if msg.role == Role.system
|
||||
]
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
]
|
||||
|
||||
self.agent_loop.session_id = event.session_id
|
||||
self.agent_loop.session_logger.resume_existing_session(
|
||||
event.session_id, session_path
|
||||
)
|
||||
|
||||
self.agent_loop.messages.reset(
|
||||
current_system_messages + non_system_messages
|
||||
)
|
||||
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await messages_area.remove_children()
|
||||
|
||||
await self._resume_history_from_messages()
|
||||
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(f"Resumed session `{event.session_id[:8]}`")
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Failed to load session: {e}", collapsed=self._tools_collapsed
|
||||
)
|
||||
)
|
||||
|
||||
async def on_session_picker_app_cancelled(
|
||||
self, event: SessionPickerApp.Cancelled
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
base_config = VibeConfig.load()
|
||||
|
||||
|
|
@ -886,9 +1016,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _clear_history(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
self._reset_ui_state()
|
||||
await self.agent_loop.clear_history()
|
||||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
|
|
@ -1019,6 +1147,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_scroll = scroll and chat.is_at_bottom
|
||||
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.display = False
|
||||
|
|
@ -1028,8 +1158,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await bottom_container.mount(widget)
|
||||
|
||||
self.call_after_refresh(widget.focus)
|
||||
if scroll:
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
if should_scroll:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
|
||||
async def _switch_to_config_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -1069,7 +1199,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self.call_after_refresh(self._chat_input_container.focus_input)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
|
||||
def _focus_current_bottom_app(self) -> None:
|
||||
try:
|
||||
|
|
@ -1084,6 +1216,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ApprovalApp).focus()
|
||||
case BottomApp.Question:
|
||||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case app:
|
||||
assert_never(app)
|
||||
except Exception:
|
||||
|
|
@ -1115,6 +1249,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_session_picker_app_escape(self) -> None:
|
||||
try:
|
||||
session_picker = self.query_one(SessionPickerApp)
|
||||
session_picker.post_message(SessionPickerApp.Cancelled())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_input_app_escape(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
|
|
@ -1151,6 +1293,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
|
||||
if (
|
||||
self._current_bottom_app == BottomApp.Input
|
||||
and self._last_escape_time is not None
|
||||
|
|
@ -1163,7 +1309,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_running_escape()
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
|
||||
|
|
@ -1235,9 +1383,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.agent_profile
|
||||
)
|
||||
self._update_profile_widgets(new_profile)
|
||||
await self.agent_loop.switch_agent(new_profile.name)
|
||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.switching_mode = True
|
||||
|
||||
def schedule_switch() -> None:
|
||||
self._switch_agent_generation += 1
|
||||
my_gen = self._switch_agent_generation
|
||||
|
||||
def switch_agent_sync() -> None:
|
||||
try:
|
||||
asyncio.run(self.agent_loop.switch_agent(new_profile.name))
|
||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
||||
finally:
|
||||
if (
|
||||
self._chat_input_container
|
||||
and self._switch_agent_generation == my_gen
|
||||
):
|
||||
self.call_from_thread(
|
||||
setattr, self._chat_input_container, "switching_mode", False
|
||||
)
|
||||
|
||||
self.run_worker(
|
||||
switch_agent_sync, group="switch_agent", exclusive=True, thread=True
|
||||
)
|
||||
|
||||
self.call_after_refresh(schedule_switch)
|
||||
|
||||
def action_clear_quit(self) -> None:
|
||||
input_widgets = self.query(ChatInputContainer)
|
||||
|
|
@ -1296,9 +1467,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
whats_new_message.add_class("after-history")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_anchor = chat.is_at_bottom
|
||||
await chat.mount(whats_new_message, after=messages_area)
|
||||
self._whats_new_message = whats_new_message
|
||||
chat.anchor()
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _plan_offer_cta(self) -> str | None:
|
||||
|
|
@ -1324,29 +1497,37 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
return
|
||||
|
||||
def _scroll_chat_to_end(self) -> None:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_end(animate=False)
|
||||
|
||||
async def _mount_and_scroll(self, widget: Widget) -> None:
|
||||
async def _mount_and_scroll(
|
||||
self, widget: Widget, after: Widget | None = None
|
||||
) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
|
||||
await messages_area.mount(widget)
|
||||
is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage))
|
||||
should_anchor = is_user_initiated or chat.is_at_bottom
|
||||
|
||||
if after is not None and after.parent is messages_area:
|
||||
await messages_area.mount(widget, after=after)
|
||||
else:
|
||||
await messages_area.mount(widget)
|
||||
if isinstance(widget, StreamingMessageBase):
|
||||
await widget.write_initial_content()
|
||||
|
||||
self.call_after_refresh(self._try_prune)
|
||||
chat.anchor()
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
|
||||
async def _try_prune(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
|
||||
pruned = await prune_oldest_children(
|
||||
messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK
|
||||
)
|
||||
if self._load_more.widget and not self._load_more.widget.parent:
|
||||
self._load_more.widget = None
|
||||
if pruned:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_later(chat.anchor)
|
||||
if chat.is_at_bottom:
|
||||
self.call_later(chat.anchor)
|
||||
|
||||
async def _refresh_windowing_from_history(self) -> None:
|
||||
if self._load_more.widget is None:
|
||||
|
|
@ -1425,10 +1606,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
self._terminal_notifier.on_blur()
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(False)
|
||||
|
||||
def on_app_focus(self, event: AppFocus) -> None:
|
||||
self._terminal_notifier.on_focus()
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(True)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ TextArea > .text-area--cursor {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
#prompt {
|
||||
#prompt,
|
||||
#prompt-spinner {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
color: $mistral_orange;
|
||||
|
|
@ -719,17 +720,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.proxy-label {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.proxy-description {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.proxy-label-line {
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -739,7 +729,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
margin-top: 1;
|
||||
padding: 0 0 0 1;
|
||||
}
|
||||
|
||||
|
|
@ -1020,3 +1009,36 @@ ContextProgress {
|
|||
.whats-new-message.after-history {
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#sessionpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#sessionpicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#sessionpicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#sessionpicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sessionpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class EventHandler:
|
|||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.current_tool_call: ToolCallMessage | None = None
|
||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
self.current_streaming_reasoning: ReasoningMessage | None = None
|
||||
|
|
@ -90,30 +90,40 @@ class EventHandler:
|
|||
async def _handle_tool_call(
|
||||
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
|
||||
) -> ToolCallMessage | None:
|
||||
tool_call = ToolCallMessage(event)
|
||||
tool_call_id = event.tool_call_id
|
||||
existing_tool_call = self.tool_calls.get(tool_call_id) if tool_call_id else None
|
||||
if existing_tool_call:
|
||||
existing_tool_call.update_event(event)
|
||||
tool_call = existing_tool_call
|
||||
else:
|
||||
tool_call = ToolCallMessage(event)
|
||||
if tool_call_id:
|
||||
self.tool_calls[tool_call_id] = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
|
||||
if loading_widget and event.tool_class:
|
||||
adapter = ToolUIDataAdapter(event.tool_class)
|
||||
status_text = adapter.get_status_text()
|
||||
loading_widget.set_status(status_text)
|
||||
|
||||
self.current_tool_call = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
loading_widget.set_status(adapter.get_status_text())
|
||||
|
||||
return tool_call
|
||||
|
||||
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
tool_result = ToolResultMessage(
|
||||
event, self.current_tool_call, collapsed=tools_collapsed
|
||||
)
|
||||
await self.mount_callback(tool_result)
|
||||
|
||||
self.current_tool_call = None
|
||||
call_widget = (
|
||||
self.tool_calls.get(event.tool_call_id) if event.tool_call_id else None
|
||||
)
|
||||
|
||||
tool_result = ToolResultMessage(event, call_widget, collapsed=tools_collapsed)
|
||||
await self.mount_callback(tool_result, after=call_widget)
|
||||
|
||||
if event.tool_call_id and event.tool_call_id in self.tool_calls:
|
||||
del self.tool_calls[event.tool_call_id]
|
||||
|
||||
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.set_stream_message(event.message)
|
||||
tool_call = self.tool_calls.get(event.tool_call_id)
|
||||
if tool_call:
|
||||
tool_call.set_stream_message(event.message)
|
||||
|
||||
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
|
||||
if self.current_streaming_reasoning is not None:
|
||||
|
|
@ -131,6 +141,8 @@ class EventHandler:
|
|||
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
|
||||
if self.current_streaming_message is not None:
|
||||
await self.current_streaming_message.stop_stream()
|
||||
if self.current_streaming_message.is_stripped_content_empty():
|
||||
await self.current_streaming_message.remove()
|
||||
self.current_streaming_message = None
|
||||
|
||||
if self.current_streaming_reasoning is None:
|
||||
|
|
@ -166,9 +178,9 @@ class EventHandler:
|
|||
self.current_streaming_message = None
|
||||
|
||||
def stop_current_tool_call(self, success: bool = True) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_spinning(success=success)
|
||||
self.current_tool_call = None
|
||||
for tool_call in self.tool_calls.values():
|
||||
tool_call.stop_spinning(success=success)
|
||||
self.tool_calls.clear()
|
||||
|
||||
def stop_current_compact(self) -> None:
|
||||
if self.current_compact:
|
||||
|
|
|
|||
11
vibe/cli/textual_ui/notifications/__init__.py
Normal file
11
vibe/cli/textual_ui/notifications/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.notifications.adapters.textual_notification_adapter import (
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.notifications.ports.notification_port import (
|
||||
NotificationContext,
|
||||
NotificationPort,
|
||||
)
|
||||
|
||||
__all__ = ["NotificationContext", "NotificationPort", "TextualNotificationAdapter"]
|
||||
0
vibe/cli/textual_ui/notifications/adapters/__init__.py
Normal file
0
vibe/cli/textual_ui/notifications/adapters/__init__.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import time
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from vibe.cli.textual_ui.notifications.ports.notification_port import (
|
||||
NotificationContext,
|
||||
)
|
||||
|
||||
NOTIFICATION_TITLE_SUFFIXES: dict[NotificationContext, str] = {
|
||||
NotificationContext.ACTION_REQUIRED: "Action Required",
|
||||
NotificationContext.COMPLETE: "Task Complete",
|
||||
}
|
||||
|
||||
NOTIFICATION_THROTTLE_SECONDS: float = 1.0
|
||||
|
||||
|
||||
class TextualNotificationAdapter:
|
||||
def __init__(
|
||||
self, app: App, *, get_enabled: Callable[[], bool], default_title: str = "App"
|
||||
) -> None:
|
||||
self._app = app
|
||||
self._get_enabled = get_enabled
|
||||
self._default_title = default_title
|
||||
self._has_focus: bool = True
|
||||
self._last_notification_time: float = 0.0
|
||||
|
||||
def notify(self, context: NotificationContext) -> None:
|
||||
if not self._get_enabled() or self._has_focus:
|
||||
return
|
||||
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_notification_time < NOTIFICATION_THROTTLE_SECONDS:
|
||||
return
|
||||
|
||||
self._last_notification_time = current_time
|
||||
self._app.bell()
|
||||
self._set_title(self._get_notification_title(context))
|
||||
|
||||
def on_focus(self) -> None:
|
||||
self._has_focus = True
|
||||
self.restore()
|
||||
|
||||
def on_blur(self) -> None:
|
||||
self._has_focus = False
|
||||
|
||||
def restore(self) -> None:
|
||||
self._set_title(self._default_title)
|
||||
|
||||
def _get_notification_title(self, context: NotificationContext) -> str:
|
||||
suffix = NOTIFICATION_TITLE_SUFFIXES.get(context)
|
||||
if suffix is None:
|
||||
return self._default_title
|
||||
return f"{self._default_title} - {suffix}"
|
||||
|
||||
def _set_title(self, title: str) -> None:
|
||||
if not self._app.is_headless and self._app._driver is not None:
|
||||
self._app._driver.write(f"\x1b]0;{title}\x07")
|
||||
0
vibe/cli/textual_ui/notifications/ports/__init__.py
Normal file
0
vibe/cli/textual_ui/notifications/ports/__init__.py
Normal file
16
vibe/cli/textual_ui/notifications/ports/notification_port.py
Normal file
16
vibe/cli/textual_ui/notifications/ports/notification_port.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class NotificationContext(StrEnum):
|
||||
ACTION_REQUIRED = auto()
|
||||
COMPLETE = auto()
|
||||
|
||||
|
||||
class NotificationPort(Protocol):
|
||||
def notify(self, context: NotificationContext) -> None: ...
|
||||
def on_focus(self) -> None: ...
|
||||
def on_blur(self) -> None: ...
|
||||
def restore(self) -> None: ...
|
||||
|
|
@ -2,16 +2,31 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
class _PromptSpinner(SpinnerMixin, Static):
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._indicator_widget: Static | None = None
|
||||
self.init_spinner()
|
||||
super().__init__(self._spinner.current_frame(), id="prompt-spinner")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._indicator_widget = self
|
||||
self.start_spinner_timer()
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
|
|
@ -30,6 +45,7 @@ class ChatInputBody(Widget):
|
|||
self.input_widget: ChatTextArea | None = None
|
||||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._switching_mode = False
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -159,6 +175,9 @@ class ChatInputBody(Widget):
|
|||
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
||||
event.stop()
|
||||
|
||||
if self._switching_mode:
|
||||
return
|
||||
|
||||
if not self.input_widget:
|
||||
return
|
||||
|
||||
|
|
@ -175,6 +194,25 @@ class ChatInputBody(Widget):
|
|||
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
@property
|
||||
def switching_mode(self) -> bool:
|
||||
return self._switching_mode
|
||||
|
||||
@switching_mode.setter
|
||||
def switching_mode(self, value: bool) -> None:
|
||||
self._switching_mode = value
|
||||
if value:
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = False
|
||||
if not self.query(_PromptSpinner):
|
||||
self.query_one(Horizontal).mount(_PromptSpinner(), before=0)
|
||||
else:
|
||||
for spinner in self.query(_PromptSpinner):
|
||||
spinner.remove()
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
if not self.input_widget:
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ class ChatInputContainer(Vertical):
|
|||
event.stop()
|
||||
self.post_message(self.Submitted(event.value))
|
||||
|
||||
@property
|
||||
def switching_mode(self) -> bool:
|
||||
return self._body.switching_mode if self._body else False
|
||||
|
||||
@switching_mode.setter
|
||||
def switching_mode(self, value: bool) -> None:
|
||||
if self._body:
|
||||
self._body.switching_mode = value
|
||||
|
||||
def set_safety(self, safety: AgentSafety) -> None:
|
||||
self._safety = safety
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
self.init_spinner()
|
||||
self.status = status or self._get_default_status()
|
||||
self.current_color_index = 0
|
||||
self._color_direction = 1
|
||||
self.transition_progress = 0
|
||||
self._status_widget: Static | None = None
|
||||
self.hint_widget: Static | None = None
|
||||
|
|
@ -141,11 +142,12 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
return
|
||||
self._update_animation()
|
||||
|
||||
def _next_color_index(self) -> int:
|
||||
return self.current_color_index + self._color_direction
|
||||
|
||||
def _get_color_for_position(self, position: int) -> str:
|
||||
current_color = self.TARGET_COLORS[self.current_color_index]
|
||||
next_color = self.TARGET_COLORS[
|
||||
(self.current_color_index + 1) % len(self.TARGET_COLORS)
|
||||
]
|
||||
next_color = self.TARGET_COLORS[self._next_color_index()]
|
||||
if position < self.transition_progress:
|
||||
return next_color
|
||||
return current_color
|
||||
|
|
@ -173,9 +175,9 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
|
||||
self.transition_progress += 1
|
||||
if self.transition_progress > total_elements:
|
||||
self.current_color_index = (self.current_color_index + 1) % len(
|
||||
self.TARGET_COLORS
|
||||
)
|
||||
self.current_color_index = self._next_color_index()
|
||||
if not 0 < self.current_color_index < len(self.TARGET_COLORS) - 1:
|
||||
self._color_direction *= -1
|
||||
self.transition_progress = 0
|
||||
|
||||
if self.hint_widget and self.start_time is not None:
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ class StreamingMessageBase(Static):
|
|||
def _should_write_content(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_stripped_content_empty(self) -> bool:
|
||||
return self._content.strip() == ""
|
||||
|
||||
|
||||
class AssistantMessage(StreamingMessageBase):
|
||||
def __init__(self, content: str) -> None:
|
||||
|
|
|
|||
|
|
@ -43,26 +43,20 @@ class ProxySetupApp(Container):
|
|||
|
||||
with Vertical(id="proxysetup-content"):
|
||||
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for key, description in SUPPORTED_PROXY_VARS.items():
|
||||
yield Static(
|
||||
f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
|
||||
classes="proxy-label-line",
|
||||
)
|
||||
yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
|
||||
|
||||
initial_value = self.initial_values.get(key) or ""
|
||||
input_widget = Input(
|
||||
value=initial_value,
|
||||
placeholder="NOT SET",
|
||||
placeholder=description,
|
||||
id=f"proxy-input-{key}",
|
||||
classes="proxy-input",
|
||||
)
|
||||
self.inputs[key] = input_widget
|
||||
yield input_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
|
||||
)
|
||||
|
|
@ -92,7 +86,7 @@ class ProxySetupApp(Container):
|
|||
prev_idx = (idx - 1) % len(inputs)
|
||||
inputs[prev_idx].focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
||||
self._save_and_close()
|
||||
|
||||
def on_blur(self, _event: events.Blur) -> None:
|
||||
|
|
|
|||
113
vibe/cli/textual_ui/widgets/session_picker.py
Normal file
113
vibe/cli/textual_ui/widgets/session_picker.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.session.session_loader import SessionInfo
|
||||
|
||||
_SECONDS_PER_MINUTE = 60
|
||||
_SECONDS_PER_HOUR = 3600
|
||||
_SECONDS_PER_DAY = 86400
|
||||
_SECONDS_PER_WEEK = 604800
|
||||
|
||||
|
||||
def _format_relative_time(iso_time: str | None) -> str:
|
||||
if not iso_time:
|
||||
return "unknown"
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
|
||||
now = datetime.now(UTC)
|
||||
delta = now - dt
|
||||
seconds = int(delta.total_seconds())
|
||||
|
||||
if seconds < _SECONDS_PER_MINUTE:
|
||||
return "just now"
|
||||
for threshold, divisor, unit in [
|
||||
(_SECONDS_PER_HOUR, _SECONDS_PER_MINUTE, "m"),
|
||||
(_SECONDS_PER_DAY, _SECONDS_PER_HOUR, "h"),
|
||||
(_SECONDS_PER_WEEK, _SECONDS_PER_DAY, "d"),
|
||||
(float("inf"), _SECONDS_PER_WEEK, "w"),
|
||||
]:
|
||||
if seconds < threshold:
|
||||
return f"{seconds // divisor}{unit} ago"
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _build_option_text(session: SessionInfo, message: str) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
time_str = _format_relative_time(session.get("end_time"))
|
||||
session_id = session["session_id"][:8]
|
||||
text.append(f"{time_str:10}", style="dim")
|
||||
text.append(" ")
|
||||
text.append(f"{session_id} ", style="dim")
|
||||
text.append(message)
|
||||
return text
|
||||
|
||||
|
||||
class SessionPickerApp(Container):
|
||||
"""Session picker for /resume command."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Cancel", show=False)
|
||||
]
|
||||
|
||||
class SessionSelected(Message):
|
||||
def __init__(self, session_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
super().__init__()
|
||||
|
||||
class Cancelled(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sessions: list[SessionInfo],
|
||||
latest_messages: dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(id="sessionpicker-app", **kwargs)
|
||||
self._sessions = sessions
|
||||
self._latest_messages = latest_messages
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
options = [
|
||||
Option(
|
||||
_build_option_text(
|
||||
session,
|
||||
self._latest_messages.get(session["session_id"], "(empty session)"),
|
||||
),
|
||||
id=session["session_id"],
|
||||
)
|
||||
for session in self._sessions
|
||||
]
|
||||
with Vertical(id="sessionpicker-content"):
|
||||
yield OptionList(*options, id="sessionpicker-options")
|
||||
yield NoMarkupStatic(
|
||||
"Up/Down Navigate Enter Select Esc Cancel",
|
||||
classes="sessionpicker-help",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if event.option.id:
|
||||
self.post_message(self.SessionSelected(event.option.id))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.post_message(self.Cancelled())
|
||||
|
|
@ -42,8 +42,8 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
|
|||
for i, (search_text, replace_text) in enumerate(matches):
|
||||
if i > 0:
|
||||
all_diff_lines.append("") # Separator between blocks
|
||||
search_lines = search_text.strip().split("\n")
|
||||
replace_lines = replace_text.strip().split("\n")
|
||||
search_lines = search_text.strip("\n").split("\n")
|
||||
replace_lines = replace_text.strip("\n").split("\n")
|
||||
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
||||
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
|
||||
|
||||
|
|
@ -74,8 +74,10 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
MAX_MSG_SIZE = 150
|
||||
for field_name in type(self.args).model_fields:
|
||||
value = getattr(self.args, field_name)
|
||||
model_cls = type(self.args)
|
||||
field_names = model_cls.model_fields or self.args.model_extra or {}
|
||||
for field_name in field_names:
|
||||
value = getattr(self.args, field_name, None)
|
||||
if value is None or value in ("", []):
|
||||
continue
|
||||
value_str = str(value)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class ToolCallMessage(StatusMessage):
|
|||
raise ValueError("Either event or tool_name must be provided")
|
||||
|
||||
self._event = event
|
||||
self._tool_name = tool_name or (event.tool_name if event else "unknown")
|
||||
self._tool_name = tool_name or (event.tool_name if event else None) or "unknown"
|
||||
self._is_history = event is None
|
||||
self._stream_widget: NoMarkupStatic | None = None
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ class ToolCallMessage(StatusMessage):
|
|||
yield self._stream_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
super().on_mount()
|
||||
siblings = list(self.parent.children) if self.parent else []
|
||||
idx = siblings.index(self) if self in siblings else -1
|
||||
if idx > 0 and isinstance(
|
||||
|
|
@ -51,13 +52,23 @@ class ToolCallMessage(StatusMessage):
|
|||
):
|
||||
self.add_class("no-gap")
|
||||
|
||||
@property
|
||||
def tool_call_id(self) -> str | None:
|
||||
return self._event.tool_call_id if self._event else None
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._event and self._event.tool_class:
|
||||
if self._event:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_call_display(self._event)
|
||||
return display.summary
|
||||
return self._tool_name
|
||||
|
||||
def update_event(self, event: ToolCallEvent) -> None:
|
||||
self._event = event
|
||||
self._tool_name = event.tool_name
|
||||
if self._text_widget:
|
||||
self._text_widget.update(self.get_content())
|
||||
|
||||
def set_stream_message(self, message: str) -> None:
|
||||
"""Update the stream message displayed below the tool call indicator."""
|
||||
if self._stream_widget:
|
||||
|
|
@ -151,7 +162,13 @@ class ToolResultMessage(Static):
|
|||
await self._content_container.remove_children()
|
||||
|
||||
if self._event is None:
|
||||
self.display = False
|
||||
if self._content:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(self._content, classes="tool-result-detail")
|
||||
)
|
||||
self.display = not self.collapsed
|
||||
else:
|
||||
self.display = False
|
||||
return
|
||||
|
||||
if self._event.error:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from textual.widget import Widget
|
||||
|
|
@ -13,11 +14,11 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
|||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
def non_system_history_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
|
||||
return [msg for msg in messages if msg.role != Role.system]
|
||||
|
||||
|
||||
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
||||
def build_tool_call_map(messages: Sequence[LLMMessage]) -> dict[str, str]:
|
||||
tool_call_map: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.role != Role.assistant or not msg.tool_calls:
|
||||
|
|
@ -29,7 +30,7 @@ def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
|||
|
||||
|
||||
def build_history_widgets(
|
||||
batch: list[LLMMessage],
|
||||
batch: Sequence[LLMMessage],
|
||||
tool_call_map: dict[str, str],
|
||||
*,
|
||||
start_index: int,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
|
|
@ -13,6 +13,7 @@ from uuid import uuid4
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.cli.terminal_setup import detect_terminal
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import Backend, ProviderConfig, VibeConfig
|
||||
|
|
@ -26,14 +27,18 @@ from vibe.core.llm.format import (
|
|||
)
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.middleware import (
|
||||
CHAT_AGENT_EXIT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
PLAN_AGENT_REMINDER,
|
||||
AutoCompactMiddleware,
|
||||
ContextWarningMiddleware,
|
||||
ConversationContext,
|
||||
MiddlewareAction,
|
||||
MiddlewarePipeline,
|
||||
MiddlewareResult,
|
||||
PlanAgentMiddleware,
|
||||
PriceLimitMiddleware,
|
||||
ReadOnlyAgentMiddleware,
|
||||
ResetReason,
|
||||
TurnLimitMiddleware,
|
||||
)
|
||||
|
|
@ -52,6 +57,8 @@ from vibe.core.tools.base import (
|
|||
ToolPermissionError,
|
||||
)
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.tools.mcp import MCPRegistry
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.trusted_folders import has_agents_md_file
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
|
|
@ -62,13 +69,16 @@ from vibe.core.types import (
|
|||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
EntrypointMetadata,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
MessageList,
|
||||
RateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
SyncApprovalCallback,
|
||||
ToolCall,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
|
|
@ -139,6 +149,7 @@ class AgentLoop:
|
|||
max_price: float | None = None,
|
||||
backend: BackendLike | None = None,
|
||||
enable_streaming: bool = False,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
) -> None:
|
||||
self._base_config = config
|
||||
self._max_turns = max_turns
|
||||
|
|
@ -147,15 +158,20 @@ class AgentLoop:
|
|||
self.agent_manager = AgentManager(
|
||||
lambda: self._base_config, initial_agent=agent_name
|
||||
)
|
||||
self.tool_manager = ToolManager(lambda: self.config)
|
||||
self._mcp_registry = MCPRegistry()
|
||||
self.tool_manager = ToolManager(
|
||||
lambda: self.config, mcp_registry=self._mcp_registry
|
||||
)
|
||||
self.skill_manager = SkillManager(lambda: self.config)
|
||||
self.format_handler = APIToolFormatHandler()
|
||||
|
||||
self.backend_factory = lambda: backend or self._select_backend()
|
||||
self.backend = self.backend_factory()
|
||||
self._sampling_handler = MCPSamplingHandler(
|
||||
backend_getter=lambda: self.backend, config_getter=lambda: self.config
|
||||
)
|
||||
|
||||
self.message_observer = message_observer
|
||||
self._last_observed_message_index: int = 0
|
||||
self.enable_streaming = enable_streaming
|
||||
self.middleware_pipeline = MiddlewarePipeline()
|
||||
self._setup_middleware()
|
||||
|
|
@ -163,11 +179,8 @@ class AgentLoop:
|
|||
system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
)
|
||||
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
|
||||
|
||||
if self.message_observer:
|
||||
self.message_observer(self.messages[0])
|
||||
self._last_observed_message_index = 1
|
||||
system_message = LLMMessage(role=Role.system, content=system_prompt)
|
||||
self.messages = MessageList(initial=[system_message], observer=message_observer)
|
||||
|
||||
self.stats = AgentStats()
|
||||
try:
|
||||
|
|
@ -180,6 +193,7 @@ class AgentLoop:
|
|||
self.approval_callback: ApprovalCallback | None = None
|
||||
self.user_input_callback: UserInputCallback | None = None
|
||||
|
||||
self.entrypoint_metadata = entrypoint_metadata
|
||||
self.session_id = str(uuid4())
|
||||
self._current_user_message_id: str | None = None
|
||||
|
||||
|
|
@ -221,19 +235,28 @@ class AgentLoop:
|
|||
self.config.tools[tool_name].permission = permission
|
||||
self.tool_manager.invalidate_tool(tool_name)
|
||||
|
||||
def emit_new_session_telemetry(
|
||||
self, entrypoint: Literal["cli", "acp", "programmatic"]
|
||||
) -> None:
|
||||
def emit_new_session_telemetry(self) -> None:
|
||||
entrypoint = (
|
||||
self.entrypoint_metadata.agent_entrypoint
|
||||
if self.entrypoint_metadata
|
||||
else "unknown"
|
||||
)
|
||||
has_agents_md = has_agents_md_file(Path.cwd())
|
||||
nb_skills = len(self.skill_manager.available_skills)
|
||||
nb_mcp_servers = len(self.config.mcp_servers)
|
||||
nb_models = len(self.config.models)
|
||||
|
||||
terminal_emulator = None
|
||||
if entrypoint == "cli":
|
||||
terminal_emulator = detect_terminal().value
|
||||
|
||||
self.telemetry_client.send_new_session(
|
||||
has_agents_md=has_agents_md,
|
||||
nb_skills=nb_skills,
|
||||
nb_mcp_servers=nb_mcp_servers,
|
||||
nb_models=nb_models,
|
||||
entrypoint=entrypoint,
|
||||
terminal_emulator=terminal_emulator,
|
||||
)
|
||||
|
||||
def _select_backend(self) -> BackendLike:
|
||||
|
|
@ -242,9 +265,6 @@ class AgentLoop:
|
|||
timeout = self.config.api_timeout
|
||||
return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout)
|
||||
|
||||
def add_message(self, message: LLMMessage) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
async def _save_messages(self) -> None:
|
||||
await self.session_logger.save_interaction(
|
||||
self.messages,
|
||||
|
|
@ -254,19 +274,6 @@ class AgentLoop:
|
|||
self.agent_profile,
|
||||
)
|
||||
|
||||
async def _flush_new_messages(self) -> None:
|
||||
await self._save_messages()
|
||||
|
||||
if not self.message_observer:
|
||||
return
|
||||
|
||||
if self._last_observed_message_index >= len(self.messages):
|
||||
return
|
||||
|
||||
for msg in self.messages[self._last_observed_message_index :]:
|
||||
self.message_observer(msg)
|
||||
self._last_observed_message_index = len(self.messages)
|
||||
|
||||
async def act(self, msg: str) -> AsyncGenerator[BaseEvent]:
|
||||
self._clean_message_history()
|
||||
async for event in self._conversation_loop(msg):
|
||||
|
|
@ -345,7 +352,22 @@ class AgentLoop:
|
|||
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
|
||||
)
|
||||
|
||||
self.middleware_pipeline.add(PlanAgentMiddleware(lambda: self.agent_profile))
|
||||
self.middleware_pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: self.agent_profile,
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
self.middleware_pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: self.agent_profile,
|
||||
BuiltinAgentName.CHAT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
CHAT_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_middleware_result(
|
||||
self, result: MiddlewareResult
|
||||
|
|
@ -440,7 +462,7 @@ class AgentLoop:
|
|||
if is_user_cancellation_event(event):
|
||||
user_cancelled = True
|
||||
yield event
|
||||
await self._flush_new_messages()
|
||||
await self._save_messages()
|
||||
|
||||
last_message = self.messages[-1]
|
||||
should_break_loop = last_message.role != Role.tool
|
||||
|
|
@ -449,7 +471,7 @@ class AgentLoop:
|
|||
return
|
||||
|
||||
finally:
|
||||
await self._flush_new_messages()
|
||||
await self._save_messages()
|
||||
|
||||
async def _perform_llm_turn(self) -> AsyncGenerator[BaseEvent, None]:
|
||||
if self.enable_streaming:
|
||||
|
|
@ -471,15 +493,42 @@ class AgentLoop:
|
|||
async for event in self._handle_tool_calls(resolved):
|
||||
yield event
|
||||
|
||||
def _build_tool_call_events(
|
||||
self, tool_calls: list[ToolCall] | None, emitted_ids: set[str]
|
||||
) -> Generator[ToolCallEvent, None, None]:
|
||||
for tc in tool_calls or []:
|
||||
if tc.id is None or not tc.function.name:
|
||||
continue
|
||||
if tc.id in emitted_ids:
|
||||
continue
|
||||
|
||||
tool_class = self.tool_manager.available_tools.get(tc.function.name)
|
||||
if tool_class is None:
|
||||
continue
|
||||
|
||||
yield ToolCallEvent(
|
||||
tool_call_id=tc.id,
|
||||
tool_call_index=tc.index,
|
||||
tool_name=tc.function.name,
|
||||
tool_class=tool_class,
|
||||
)
|
||||
|
||||
async def _stream_assistant_events(
|
||||
self,
|
||||
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
|
||||
) -> AsyncGenerator[AssistantEvent | ReasoningEvent | ToolCallEvent]:
|
||||
message_id: str | None = None
|
||||
emitted_tool_call_ids = set[str]()
|
||||
|
||||
async for chunk in self._chat_streaming():
|
||||
if message_id is None:
|
||||
message_id = chunk.message.message_id
|
||||
|
||||
for event in self._build_tool_call_events(
|
||||
chunk.message.tool_calls, emitted_tool_call_ids
|
||||
):
|
||||
emitted_tool_call_ids.add(event.tool_call_id)
|
||||
yield event
|
||||
|
||||
if chunk.message.reasoning_content:
|
||||
yield ReasoningEvent(
|
||||
content=chunk.message.reasoning_content, message_id=message_id
|
||||
|
|
@ -560,9 +609,12 @@ class AgentLoop:
|
|||
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,
|
||||
session_dir=self.session_logger.session_dir,
|
||||
entrypoint_metadata=self.entrypoint_metadata,
|
||||
approval_callback=self.approval_callback,
|
||||
user_input_callback=self.user_input_callback,
|
||||
sampling_callback=self._sampling_handler,
|
||||
),
|
||||
**tool_call.args_dict,
|
||||
):
|
||||
|
|
@ -671,6 +723,9 @@ class AgentLoop:
|
|||
tool_choice=tool_choice,
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
)
|
||||
end_time = time.perf_counter()
|
||||
|
||||
|
|
@ -714,6 +769,9 @@ class AgentLoop:
|
|||
tool_choice=tool_choice,
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
):
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
chunk.message
|
||||
|
|
@ -759,37 +817,26 @@ class AgentLoop:
|
|||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
|
||||
allowlist_denylist_result = tool.check_allowlist_denylist(args)
|
||||
if allowlist_denylist_result == ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
elif allowlist_denylist_result == ToolPermission.NEVER:
|
||||
denylist_patterns = tool.config.denylist
|
||||
denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns)
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]",
|
||||
)
|
||||
|
||||
tool_name = tool.get_name()
|
||||
perm = self.tool_manager.get_tool_config(tool_name).permission
|
||||
effective = (
|
||||
tool.resolve_permission(args)
|
||||
or self.tool_manager.get_tool_config(tool_name).permission
|
||||
)
|
||||
|
||||
if perm is ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
if perm is ToolPermission.NEVER:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
|
||||
return await self._ask_approval(tool_name, args, tool_call_id)
|
||||
match effective:
|
||||
case ToolPermission.ALWAYS:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
case ToolPermission.NEVER:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.NEVER,
|
||||
feedback=f"Tool '{tool_name}' is permanently disabled",
|
||||
)
|
||||
case _:
|
||||
return await self._ask_approval(tool_name, args, tool_call_id)
|
||||
|
||||
async def _ask_approval(
|
||||
self, tool_name: str, args: BaseModel, tool_call_id: str
|
||||
|
|
@ -852,9 +899,11 @@ class AgentLoop:
|
|||
empty_response = LLMMessage(
|
||||
role=Role.tool,
|
||||
tool_call_id=tool_call_data.id or "",
|
||||
name=(tool_call_data.function.name or "")
|
||||
if tool_call_data.function
|
||||
else "",
|
||||
name=(
|
||||
(tool_call_data.function.name or "")
|
||||
if tool_call_data.function
|
||||
else ""
|
||||
),
|
||||
content=str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.TOOL_NO_RESPONSE
|
||||
|
|
@ -898,7 +947,7 @@ class AgentLoop:
|
|||
self.tool_manager,
|
||||
self.agent_profile,
|
||||
)
|
||||
self.messages = self.messages[:1]
|
||||
self.messages.reset(self.messages[:1])
|
||||
|
||||
self.stats = AgentStats.create_fresh(self.stats)
|
||||
self.stats.trigger_listeners()
|
||||
|
|
@ -927,10 +976,14 @@ class AgentLoop:
|
|||
)
|
||||
|
||||
summary_request = UtilityPrompt.COMPACT.read()
|
||||
self.messages.append(LLMMessage(role=Role.user, content=summary_request))
|
||||
self.stats.steps += 1
|
||||
|
||||
summary_result = await self._chat()
|
||||
with self.messages.silent():
|
||||
self.messages.append(
|
||||
LLMMessage(role=Role.user, content=summary_request)
|
||||
)
|
||||
summary_result = await self._chat()
|
||||
|
||||
if summary_result.usage is None:
|
||||
raise AgentLoopLLMResponseError(
|
||||
"Usage data missing in compaction summary response"
|
||||
|
|
@ -939,8 +992,7 @@ class AgentLoop:
|
|||
|
||||
system_message = self.messages[0]
|
||||
summary_message = LLMMessage(role=Role.user, content=summary_content)
|
||||
self.messages = [system_message, summary_message]
|
||||
self._last_observed_message_index = 1
|
||||
self.messages.reset([system_message, summary_message])
|
||||
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
|
@ -950,6 +1002,9 @@ class AgentLoop:
|
|||
messages=self.messages,
|
||||
tools=self.format_handler.get_available_tools(self.tool_manager),
|
||||
extra_headers={"user-agent": get_user_agent(provider.backend)},
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
)
|
||||
|
||||
self.stats.context_tokens = actual_context_tokens
|
||||
|
|
@ -1015,17 +1070,19 @@ class AgentLoop:
|
|||
if max_price is not None:
|
||||
self._max_price = max_price
|
||||
|
||||
self.tool_manager = ToolManager(lambda: self.config)
|
||||
self.tool_manager = ToolManager(
|
||||
lambda: self.config, mcp_registry=self._mcp_registry
|
||||
)
|
||||
self.skill_manager = SkillManager(lambda: self.config)
|
||||
|
||||
new_system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
)
|
||||
|
||||
self.messages = [
|
||||
self.messages.reset([
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
|
@ -11,15 +10,14 @@ from vibe.core.agents.models import (
|
|||
AgentType,
|
||||
BuiltinAgentName,
|
||||
)
|
||||
from vibe.core.paths.config_paths import resolve_local_agents_dir
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import discover_local_agents_dirs
|
||||
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__(
|
||||
|
|
@ -77,6 +75,10 @@ class AgentManager:
|
|||
self.active_profile = self.get_agent(name)
|
||||
self._cached_config = None
|
||||
|
||||
def register_agent(self, profile: AgentProfile) -> None:
|
||||
self._available[profile.name] = profile
|
||||
self._cached_config = None
|
||||
|
||||
def invalidate_config(self) -> None:
|
||||
self._cached_config = None
|
||||
|
||||
|
|
@ -86,8 +88,7 @@ class AgentManager:
|
|||
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)
|
||||
paths.extend(discover_local_agents_dirs(Path.cwd()))
|
||||
if GLOBAL_AGENTS_DIR.path.is_dir():
|
||||
paths.append(GLOBAL_AGENTS_DIR.path)
|
||||
unique: list[Path] = []
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class AgentType(StrEnum):
|
|||
|
||||
class BuiltinAgentName(StrEnum):
|
||||
DEFAULT = "default"
|
||||
CHAT = "chat"
|
||||
PLAN = "plan"
|
||||
ACCEPT_EDITS = "accept-edits"
|
||||
AUTO_APPROVE = "auto-approve"
|
||||
|
|
@ -69,6 +70,7 @@ class AgentProfile:
|
|||
)
|
||||
|
||||
|
||||
CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
|
||||
PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
|
||||
|
||||
DEFAULT = AgentProfile(
|
||||
|
|
@ -84,6 +86,13 @@ PLAN = AgentProfile(
|
|||
AgentSafety.SAFE,
|
||||
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
|
||||
)
|
||||
CHAT = AgentProfile(
|
||||
BuiltinAgentName.CHAT,
|
||||
"Chat",
|
||||
"Read-only conversational mode for questions and discussions",
|
||||
AgentSafety.SAFE,
|
||||
overrides={"auto_approve": True, "enabled_tools": CHAT_AGENT_TOOLS},
|
||||
)
|
||||
ACCEPT_EDITS = AgentProfile(
|
||||
BuiltinAgentName.ACCEPT_EDITS,
|
||||
"Accept Edits",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,32 @@ class IgnoreRules:
|
|||
self._patterns: list[CompiledPattern] | None = None
|
||||
self._root: Path | None = None
|
||||
|
||||
def _compile_default_patterns(self) -> list[CompiledPattern]:
|
||||
patterns: list[CompiledPattern] = []
|
||||
for raw, is_exclude in self._defaults:
|
||||
anchor_root = raw.startswith("/")
|
||||
if anchor_root:
|
||||
raw = raw[1:]
|
||||
stripped = raw.rstrip("/")
|
||||
patterns.append(
|
||||
CompiledPattern(
|
||||
raw=raw,
|
||||
stripped=stripped,
|
||||
is_exclude=is_exclude,
|
||||
dir_only=raw.endswith("/"),
|
||||
name_only="/" not in stripped,
|
||||
anchor_root=anchor_root,
|
||||
)
|
||||
)
|
||||
return patterns
|
||||
|
||||
def get_walk_skip_dir_names(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
p.stripped
|
||||
for p in self._compile_default_patterns()
|
||||
if p.dir_only and p.name_only and not p.anchor_root
|
||||
)
|
||||
|
||||
def ensure_for_root(self, root: Path) -> None:
|
||||
resolved_root = root.resolve()
|
||||
if self._patterns is None or self._root != resolved_root:
|
||||
|
|
@ -81,24 +107,7 @@ class IgnoreRules:
|
|||
self._root = None
|
||||
|
||||
def _build_patterns(self, root: Path) -> list[CompiledPattern]:
|
||||
patterns: list[CompiledPattern] = []
|
||||
for raw, is_exclude in self._defaults:
|
||||
anchor_root = raw.startswith("/")
|
||||
if anchor_root:
|
||||
raw = raw[1:]
|
||||
|
||||
stripped = raw.rstrip("/")
|
||||
patterns.append(
|
||||
CompiledPattern(
|
||||
raw=raw,
|
||||
stripped=stripped,
|
||||
is_exclude=is_exclude,
|
||||
dir_only=raw.endswith("/"),
|
||||
name_only="/" not in stripped,
|
||||
anchor_root=anchor_root,
|
||||
)
|
||||
)
|
||||
|
||||
patterns = self._compile_default_patterns()
|
||||
gitignore_path = root / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
|
|
@ -154,3 +163,6 @@ class IgnoreRules:
|
|||
return False
|
||||
|
||||
return not pattern.dir_only or is_dir
|
||||
|
||||
|
||||
WALK_SKIP_DIR_NAMES: frozenset[str] = IgnoreRules().get_walk_skip_dir_names()
|
||||
|
|
|
|||
|
|
@ -165,6 +165,10 @@ class _MCPBase(BaseModel):
|
|||
tool_timeout_sec: float = Field(
|
||||
default=60.0, gt=0, description="Timeout in seconds for tool execution."
|
||||
)
|
||||
sampling_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Allow this MCP server to request LLM completions via sampling/createMessage.",
|
||||
)
|
||||
|
||||
@field_validator("name", mode="after")
|
||||
@classmethod
|
||||
|
|
@ -324,6 +328,7 @@ class VibeConfig(BaseSettings):
|
|||
include_prompt_detail: bool = True
|
||||
enable_update_checks: bool = True
|
||||
enable_auto_update: bool = True
|
||||
enable_notifications: bool = True
|
||||
api_timeout: float = 720.0
|
||||
|
||||
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
|
@ -22,7 +23,7 @@ class AnthropicMapper:
|
|||
"""Shared mapper for converting messages to/from Anthropic API format."""
|
||||
|
||||
def prepare_messages(
|
||||
self, messages: list[LLMMessage]
|
||||
self, messages: Sequence[LLMMessage]
|
||||
) -> tuple[str | None, list[dict[str, Any]]]:
|
||||
system_prompt: str | None = None
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
|
@ -457,7 +458,7 @@ class AnthropicAdapter(APIAdapter):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
|
||||
|
||||
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
|
||||
|
|
@ -22,7 +23,7 @@ class APIAdapter(Protocol):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
import json
|
||||
import os
|
||||
import types
|
||||
|
|
@ -82,7 +82,7 @@ class OpenAIAdapter(APIAdapter):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
@ -211,12 +211,13 @@ class GenericBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.2,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> LLMChunk:
|
||||
api_key = (
|
||||
os.getenv(self._provider.api_key_env_var)
|
||||
|
|
@ -279,12 +280,13 @@ class GenericBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.2,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
api_key = (
|
||||
os.getenv(self._provider.api_key_env_var)
|
||||
|
|
@ -395,11 +397,12 @@ class GenericBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.0,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
probe_messages = list(messages)
|
||||
if not probe_messages or probe_messages[-1].role != Role.user:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -226,12 +226,13 @@ class MistralBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> LLMChunk:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
|
|
@ -247,6 +248,7 @@ class MistralBackend:
|
|||
if tool_choice
|
||||
else None,
|
||||
http_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
|
|
@ -300,12 +302,13 @@ class MistralBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
|
|
@ -321,6 +324,7 @@ class MistralBackend:
|
|||
if tool_choice
|
||||
else None,
|
||||
http_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
):
|
||||
parsed = (
|
||||
self._mapper.parse_content(chunk.data.choices[0].delta.content)
|
||||
|
|
@ -376,11 +380,12 @@ class MistralBackend:
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.0,
|
||||
tools: list[AvailableTool] | None = None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
result = await self.complete(
|
||||
model=model,
|
||||
|
|
@ -390,6 +395,7 @@ class MistralBackend:
|
|||
max_tokens=1,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers=extra_headers,
|
||||
metadata=metadata,
|
||||
)
|
||||
if result.usage is None:
|
||||
raise ValueError("Missing usage in non streaming completion")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, ClassVar
|
||||
|
|
@ -67,7 +68,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
|
|||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from typing import Any
|
||||
|
|
@ -114,7 +114,7 @@ class BackendErrorBuilder:
|
|||
response: httpx.Response,
|
||||
headers: Mapping[str, str] | None,
|
||||
model: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
has_tools: bool,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
|
|
@ -146,7 +146,7 @@ class BackendErrorBuilder:
|
|||
endpoint: str,
|
||||
error: httpx.RequestError,
|
||||
model: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
has_tools: bool,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
|
|
@ -179,7 +179,7 @@ class BackendErrorBuilder:
|
|||
@staticmethod
|
||||
def _payload_summary(
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
has_tools: bool,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
def merge_consecutive_user_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
|
||||
"""Merge consecutive user messages into a single message.
|
||||
|
||||
This handles cases where middleware injects messages resulting in
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
|
|
@ -29,12 +29,13 @@ class BackendLike(Protocol):
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> LLMChunk:
|
||||
"""Complete a chat conversation using the specified model and provider.
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ class BackendLike(Protocol):
|
|||
max_tokens: Maximum tokens to generate
|
||||
tool_choice: How to choose tools (auto, none, or specific tool)
|
||||
extra_headers: Additional HTTP headers to include
|
||||
metadata: Optional metadata to attach to the request
|
||||
|
||||
Returns:
|
||||
LLMChunk containing the response message and usage information
|
||||
|
|
@ -62,12 +64,13 @@ class BackendLike(Protocol):
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
"""Equivalent of the complete method, but yields LLMEvent objects
|
||||
instead of a single LLMEvent.
|
||||
|
|
@ -80,6 +83,7 @@ class BackendLike(Protocol):
|
|||
max_tokens: Maximum tokens to generate
|
||||
tool_choice: How to choose tools (auto, none, or specific tool)
|
||||
extra_headers: Additional HTTP headers to include
|
||||
metadata: Optional metadata to attach to the request
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[LLMEvent, None] yielding LLMEvent objects
|
||||
|
|
@ -93,11 +97,12 @@ class BackendLike(Protocol):
|
|||
self,
|
||||
*,
|
||||
model: ModelConfig,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
temperature: float = 0.0,
|
||||
tools: list[AvailableTool] | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
||||
extra_headers: dict[str, str] | None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> int:
|
||||
"""Count the number of tokens in the prompt without generating a real response.
|
||||
|
||||
|
|
@ -113,6 +118,7 @@ class BackendLike(Protocol):
|
|||
tools: Optional list of available tools
|
||||
tool_choice: How to choose tools
|
||||
extra_headers: Additional HTTP headers to include
|
||||
metadata: Optional metadata to attach to the request
|
||||
|
||||
Returns:
|
||||
The number of prompt tokens
|
||||
|
|
|
|||
58
vibe/core/logger.py
Normal file
58
vibe/core/logger.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
|
||||
from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
|
||||
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
|
||||
class StructuredLogFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
|
||||
ppid = os.getppid()
|
||||
pid = os.getpid()
|
||||
level = record.levelname
|
||||
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
|
||||
|
||||
line = f"{timestamp} {ppid} {pid} {level} {message}"
|
||||
|
||||
if record.exc_info:
|
||||
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
|
||||
line = f"{line} {exc_text}"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def apply_logging_config(target_logger: logging.Logger) -> None:
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
|
||||
|
||||
if os.environ.get("DEBUG_MODE") == "true":
|
||||
log_level_str = "DEBUG"
|
||||
else:
|
||||
log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if log_level_str not in valid_levels:
|
||||
log_level_str = "WARNING"
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
|
||||
)
|
||||
handler.setFormatter(StructuredLogFormatter())
|
||||
log_level = getattr(logging, log_level_str, logging.WARNING)
|
||||
handler.setLevel(log_level)
|
||||
|
||||
# Make sure the logger is not gating logs
|
||||
target_logger.setLevel(logging.DEBUG)
|
||||
|
||||
target_logger.addHandler(handler)
|
||||
|
||||
|
||||
apply_logging_config(logger)
|
||||
|
|
@ -6,12 +6,11 @@ from enum import StrEnum, auto
|
|||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.utils import VIBE_WARNING_TAG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.types import AgentStats, LLMMessage
|
||||
from vibe.core.types import AgentStats, MessageList
|
||||
|
||||
|
||||
class MiddlewareAction(StrEnum):
|
||||
|
|
@ -28,7 +27,7 @@ class ResetReason(StrEnum):
|
|||
|
||||
@dataclass
|
||||
class ConversationContext:
|
||||
messages: list[LLMMessage]
|
||||
messages: MessageList
|
||||
stats: AgentStats
|
||||
config: VibeConfig
|
||||
|
||||
|
|
@ -136,44 +135,53 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
|
|||
|
||||
PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
|
||||
1. Answer the user's questions directly and comprehensively
|
||||
2. Explain code, concepts, or architecture as requested
|
||||
3. Use read-only tools (grep, read_file) to look up relevant code when needed
|
||||
4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
class PlanAgentMiddleware:
|
||||
CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
|
||||
class ReadOnlyAgentMiddleware:
|
||||
def __init__(
|
||||
self,
|
||||
profile_getter: Callable[[], AgentProfile],
|
||||
reminder: str = PLAN_AGENT_REMINDER,
|
||||
exit_message: str = PLAN_AGENT_EXIT,
|
||||
agent_name: str,
|
||||
reminder: str,
|
||||
exit_message: str,
|
||||
) -> None:
|
||||
self._profile_getter = profile_getter
|
||||
self._agent_name = agent_name
|
||||
self.reminder = reminder
|
||||
self.exit_message = exit_message
|
||||
self._was_plan_agent = False
|
||||
self._was_active = False
|
||||
|
||||
def _is_plan_agent(self) -> bool:
|
||||
return self._profile_getter().name == BuiltinAgentName.PLAN
|
||||
def _is_active(self) -> bool:
|
||||
return self._profile_getter().name == self._agent_name
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
is_plan = self._is_plan_agent()
|
||||
was_plan = self._was_plan_agent
|
||||
is_active = self._is_active()
|
||||
was_active = self._was_active
|
||||
|
||||
if was_plan and not is_plan:
|
||||
self._was_plan_agent = False
|
||||
if was_active and not is_active:
|
||||
self._was_active = False
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
|
||||
)
|
||||
|
||||
if is_plan and not was_plan:
|
||||
self._was_plan_agent = True
|
||||
if is_active and not was_active:
|
||||
self._was_active = True
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
|
||||
)
|
||||
|
||||
self._was_plan_agent = is_plan
|
||||
|
||||
self._was_active = is_active
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
self._was_plan_agent = False
|
||||
self._was_active = False
|
||||
|
||||
|
||||
class MiddlewarePipeline:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||
from typing import Literal
|
||||
|
||||
from vibe.core.paths.global_paths import VIBE_HOME, GlobalPath
|
||||
from vibe.core.paths.local_config_walk import walk_local_config_dirs_all
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
_config_paths_locked: bool = True
|
||||
|
|
@ -31,30 +32,24 @@ def _resolve_config_path(basename: str, type: Literal["file", "dir"]) -> Path:
|
|||
return VIBE_HOME.path / basename
|
||||
|
||||
|
||||
def resolve_local_tools_dir(dir: Path) -> Path | None:
|
||||
if not trusted_folders_manager.is_trusted(dir):
|
||||
return None
|
||||
if (candidate := dir / ".vibe" / "tools").is_dir():
|
||||
return candidate
|
||||
return None
|
||||
def _discover_local_config_dirs_all(
|
||||
root: Path,
|
||||
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
|
||||
if not trusted_folders_manager.is_trusted(root):
|
||||
return ((), (), ())
|
||||
return walk_local_config_dirs_all(root)
|
||||
|
||||
|
||||
def resolve_local_skills_dirs(dir: Path) -> list[Path]:
|
||||
if not trusted_folders_manager.is_trusted(dir):
|
||||
return []
|
||||
return [
|
||||
candidate
|
||||
for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
|
||||
if candidate.is_dir()
|
||||
]
|
||||
def discover_local_tools_dirs(root: Path) -> list[Path]:
|
||||
return list(_discover_local_config_dirs_all(root)[0])
|
||||
|
||||
|
||||
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 discover_local_skills_dirs(root: Path) -> list[Path]:
|
||||
return list(_discover_local_config_dirs_all(root)[1])
|
||||
|
||||
|
||||
def discover_local_agents_dirs(root: Path) -> list[Path]:
|
||||
return list(_discover_local_config_dirs_all(root)[2])
|
||||
|
||||
|
||||
def unlock_config_paths() -> None:
|
||||
|
|
|
|||
39
vibe/core/paths/local_config_walk.py
Normal file
39
vibe/core/paths/local_config_walk.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
|
||||
|
||||
_VIBE_DIR = ".vibe"
|
||||
_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
|
||||
_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
|
||||
_AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
|
||||
_AGENTS_DIR = ".agents"
|
||||
_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
|
||||
|
||||
|
||||
@cache
|
||||
def walk_local_config_dirs_all(
|
||||
root: Path,
|
||||
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
|
||||
tools_dirs: list[Path] = []
|
||||
skills_dirs: list[Path] = []
|
||||
agents_dirs: list[Path] = []
|
||||
resolved_root = root.resolve()
|
||||
for dirpath, dirnames, _ in os.walk(resolved_root, topdown=True):
|
||||
dir_set = frozenset(dirnames)
|
||||
path = Path(dirpath)
|
||||
if _VIBE_DIR in dir_set:
|
||||
if (candidate := path / _TOOLS_SUBDIR).is_dir():
|
||||
tools_dirs.append(candidate)
|
||||
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
|
||||
skills_dirs.append(candidate)
|
||||
if (candidate := path / _AGENTS_SUBDIR).is_dir():
|
||||
agents_dirs.append(candidate)
|
||||
if _AGENTS_DIR in dir_set:
|
||||
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
|
||||
skills_dirs.append(candidate)
|
||||
dirnames[:] = sorted(d for d in dirnames if d not in WALK_SKIP_DIR_NAMES)
|
||||
return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))
|
||||
|
|
@ -2,12 +2,23 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.output_formatters import create_formatter
|
||||
from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException, logger
|
||||
from vibe.core.types import (
|
||||
AssistantEvent,
|
||||
ClientMetadata,
|
||||
EntrypointMetadata,
|
||||
LLMMessage,
|
||||
OutputFormat,
|
||||
Role,
|
||||
)
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
|
||||
_DEFAULT_CLIENT_METADATA = ClientMetadata(name="vibe_programmatic", version=__version__)
|
||||
|
||||
|
||||
def run_programmatic(
|
||||
|
|
@ -18,6 +29,7 @@ def run_programmatic(
|
|||
output_format: OutputFormat = OutputFormat.TEXT,
|
||||
previous_messages: list[LLMMessage] | None = None,
|
||||
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
|
||||
client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA,
|
||||
) -> str | None:
|
||||
formatter = create_formatter(output_format)
|
||||
|
||||
|
|
@ -28,6 +40,12 @@ def run_programmatic(
|
|||
max_turns=max_turns,
|
||||
max_price=max_price,
|
||||
enable_streaming=False,
|
||||
entrypoint_metadata=EntrypointMetadata(
|
||||
agent_entrypoint="programmatic",
|
||||
agent_version=__version__,
|
||||
client_name=client_metadata.name,
|
||||
client_version=client_metadata.version,
|
||||
),
|
||||
)
|
||||
logger.info("USER: %s", prompt)
|
||||
|
||||
|
|
@ -42,7 +60,7 @@ def run_programmatic(
|
|||
"Loaded %d messages from previous session", len(non_system_messages)
|
||||
)
|
||||
|
||||
agent_loop.emit_new_session_telemetry("programmatic")
|
||||
agent_loop.emit_new_session_telemetry()
|
||||
|
||||
async for event in agent_loop.act(prompt):
|
||||
formatter.on_event(event)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI, powered by the Devstral model family. You interact with a local codebase through tools.
|
||||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. You have no internet access.
|
||||
CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself.
|
||||
|
||||
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
|
||||
|
||||
1. Find the matching file in your skill directories.
|
||||
2. Read it with `read_file`.
|
||||
3. Follow its instructions step by step. You are the executor.
|
||||
|
||||
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
|
||||
|
||||
Phase 1 — Orient
|
||||
Before ANY action:
|
||||
|
|
@ -10,6 +19,7 @@ If unclear, default to investigate. It is better to explain what you would do th
|
|||
|
||||
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
|
||||
Identify constraints: language, framework, test setup, and any user restrictions on scope.
|
||||
When given multiple file paths or a complex task: Do not start reading files immediately. First, summarize your understanding of the task and propose a short plan. Wait for the user to confirm before exploring any files. This prevents wasted effort on the wrong path.
|
||||
|
||||
Phase 2 — Plan (Change tasks only)
|
||||
State your plan before writing code:
|
||||
|
|
@ -24,6 +34,10 @@ After each unit, verify: run tests, or read back the file to confirm the edit la
|
|||
Never claim completion without verification — a passing test, correct read-back, or successful build.
|
||||
|
||||
Hard Rules
|
||||
|
||||
Never Commit
|
||||
Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
|
||||
|
||||
Respect User Constraints
|
||||
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
|
||||
|
||||
|
|
@ -107,5 +121,6 @@ Cite as file_path:line_number.
|
|||
Professional Conduct
|
||||
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
|
||||
When uncertain, investigate before confirming.
|
||||
No emojis unless requested. No over-the-top validation.
|
||||
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
|
||||
No over-the-top validation.
|
||||
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ import json
|
|||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
|
||||
from vibe.core.types import LLMMessage
|
||||
from vibe.core.types import LLMMessage, SessionMetadata
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
|
||||
|
||||
METADATA_FILENAME = "meta.json"
|
||||
MESSAGES_FILENAME = "messages.jsonl"
|
||||
|
||||
|
||||
class SessionInfo(TypedDict):
|
||||
session_id: str
|
||||
cwd: str
|
||||
|
|
@ -171,6 +174,22 @@ class SessionLoader:
|
|||
|
||||
return sessions
|
||||
|
||||
@staticmethod
|
||||
def load_metadata(session_dir: Path) -> SessionMetadata:
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
if not metadata_path.exists():
|
||||
raise ValueError(f"Session metadata not found at {session_dir}")
|
||||
|
||||
try:
|
||||
metadata_content = metadata_path.read_text()
|
||||
return SessionMetadata.model_validate_json(metadata_content)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Failed to load session metadata at {session_dir}: {e}"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
|
||||
# Load session messages from MESSAGES_FILENAME
|
||||
|
|
@ -220,3 +239,37 @@ class SessionLoader:
|
|||
metadata = {}
|
||||
|
||||
return messages, metadata
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(text: str) -> str:
|
||||
text = text.strip().replace("\n", " ")
|
||||
return text or "(empty message)"
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_from_content(content: str | None) -> str | None:
|
||||
if not content:
|
||||
return None
|
||||
return SessionLoader._clean_text(content)
|
||||
|
||||
@staticmethod
|
||||
def get_first_user_message(session_id: str, config: SessionLoggingConfig) -> str:
|
||||
"""Get the first user message from a session for preview."""
|
||||
session_path = SessionLoader.find_session_by_id(session_id, config)
|
||||
if not session_path:
|
||||
return "(session not found)"
|
||||
|
||||
try:
|
||||
messages, _ = SessionLoader.load_session(session_path)
|
||||
|
||||
for msg in messages:
|
||||
if msg.role != "user":
|
||||
continue
|
||||
text = SessionLoader._extract_text_from_content(msg.content)
|
||||
if text:
|
||||
return text
|
||||
|
||||
return "(no user messages)"
|
||||
except ValueError:
|
||||
return "(corrupted session)"
|
||||
except OSError:
|
||||
return "(error reading session)"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from anyio import NamedTemporaryFile, Path as AsyncPath
|
||||
|
||||
from vibe.core.session.session_loader import (
|
||||
MESSAGES_FILENAME,
|
||||
METADATA_FILENAME,
|
||||
SessionLoader,
|
||||
)
|
||||
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
|
||||
from vibe.core.utils import is_windows, utc_now
|
||||
|
||||
|
|
@ -19,14 +26,15 @@ if TYPE_CHECKING:
|
|||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
|
||||
METADATA_FILENAME = "meta.json"
|
||||
MESSAGES_FILENAME = "messages.jsonl"
|
||||
TMP_CLEANUP_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
class SessionLogger:
|
||||
def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
|
||||
self.session_config = session_config
|
||||
self.enabled = session_config.enabled
|
||||
self._last_tmp_cleanup_at: datetime | None = None
|
||||
self._tmp_cleanup_lock = Lock()
|
||||
|
||||
if not self.enabled:
|
||||
self.save_dir: Path | None = None
|
||||
|
|
@ -127,7 +135,7 @@ class SessionLogger:
|
|||
environment={"working_directory": str(Path.cwd())},
|
||||
)
|
||||
|
||||
def _get_title(self, messages: list[LLMMessage]) -> str:
|
||||
def _get_title(self, messages: Sequence[LLMMessage]) -> str:
|
||||
first_user_message = None
|
||||
for message in messages:
|
||||
if message.role == Role.user:
|
||||
|
|
@ -196,7 +204,7 @@ class SessionLogger:
|
|||
|
||||
async def save_interaction(
|
||||
self,
|
||||
messages: list[LLMMessage],
|
||||
messages: Sequence[LLMMessage],
|
||||
stats: AgentStats,
|
||||
base_config: VibeConfig,
|
||||
tool_manager: ToolManager,
|
||||
|
|
@ -208,6 +216,9 @@ class SessionLogger:
|
|||
if self.session_metadata is None:
|
||||
return
|
||||
|
||||
if not any(msg.role != Role.system for msg in messages):
|
||||
return
|
||||
|
||||
# If the session directory does not exist, create it
|
||||
try:
|
||||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -284,7 +295,7 @@ class SessionLogger:
|
|||
f"Failed to save session to {self.session_dir}: {e}"
|
||||
) from e
|
||||
finally:
|
||||
self.cleanup_tmp_files()
|
||||
self.maybe_cleanup_tmp_files()
|
||||
|
||||
def reset_session(self, session_id: str) -> None:
|
||||
"""Clear existing session info and setup a new session"""
|
||||
|
|
@ -296,6 +307,17 @@ class SessionLogger:
|
|||
self.session_dir = self.save_folder
|
||||
self.session_metadata = self._initialize_session_metadata()
|
||||
|
||||
def resume_existing_session(self, session_id: str, session_dir: Path) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
self.session_id = session_id
|
||||
self.session_dir = session_dir
|
||||
self.session_metadata = SessionLoader.load_metadata(session_dir)
|
||||
|
||||
if self.session_metadata.start_time:
|
||||
self.session_start_time = self.session_metadata.start_time
|
||||
|
||||
def cleanup_tmp_files(self) -> None:
|
||||
"""Delete temporary files created more than 5 minutes ago"""
|
||||
if not self.enabled or not self.save_dir:
|
||||
|
|
@ -316,3 +338,22 @@ class SessionLogger:
|
|||
file_path.unlink()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def maybe_cleanup_tmp_files(self) -> None:
|
||||
if not self.enabled or not self.save_dir:
|
||||
return
|
||||
|
||||
if not self._tmp_cleanup_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
now = utc_now()
|
||||
if (
|
||||
self._last_tmp_cleanup_at is not None
|
||||
and now - self._last_tmp_cleanup_at < TMP_CLEANUP_INTERVAL
|
||||
):
|
||||
return
|
||||
|
||||
self.cleanup_tmp_files()
|
||||
self._last_tmp_cleanup_at = now
|
||||
finally:
|
||||
self._tmp_cleanup_lock.release()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
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.paths.config_paths import resolve_local_skills_dirs
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import discover_local_skills_dirs
|
||||
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
|
||||
|
|
@ -14,8 +14,6 @@ from vibe.core.utils import name_matches
|
|||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
logger = getLogger("vibe")
|
||||
|
||||
|
||||
class SkillManager:
|
||||
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
|
||||
|
|
@ -58,7 +56,7 @@ class SkillManager:
|
|||
if path.is_dir():
|
||||
paths.append(path)
|
||||
|
||||
paths.extend(resolve_local_skills_dirs(Path.cwd()))
|
||||
paths.extend(discover_local_skills_dirs(Path.cwd()))
|
||||
|
||||
if GLOBAL_SKILLS_DIR.path.is_dir():
|
||||
paths.append(GLOBAL_SKILLS_DIR.path)
|
||||
|
|
|
|||
|
|
@ -141,25 +141,25 @@ class TelemetryClient:
|
|||
"nb_files_created": nb_files_created,
|
||||
"nb_files_modified": nb_files_modified,
|
||||
}
|
||||
self.send_telemetry_event("vibe/tool_call_finished", payload)
|
||||
self.send_telemetry_event("vibe.tool_call_finished", payload)
|
||||
|
||||
def send_user_copied_text(self, text: str) -> None:
|
||||
payload = {"text_length": len(text)}
|
||||
self.send_telemetry_event("vibe/user_copied_text", payload)
|
||||
self.send_telemetry_event("vibe.user_copied_text", payload)
|
||||
|
||||
def send_user_cancelled_action(self, action: str) -> None:
|
||||
payload = {"action": action}
|
||||
self.send_telemetry_event("vibe/user_cancelled_action", payload)
|
||||
self.send_telemetry_event("vibe.user_cancelled_action", payload)
|
||||
|
||||
def send_auto_compact_triggered(self) -> None:
|
||||
payload = {}
|
||||
self.send_telemetry_event("vibe/auto_compact_triggered", payload)
|
||||
self.send_telemetry_event("vibe.auto_compact_triggered", payload)
|
||||
|
||||
def send_slash_command_used(
|
||||
self, command: str, command_type: Literal["builtin", "skill"]
|
||||
) -> None:
|
||||
payload = {"command": command.lstrip("/"), "command_type": command_type}
|
||||
self.send_telemetry_event("vibe/slash_command_used", payload)
|
||||
self.send_telemetry_event("vibe.slash_command_used", payload)
|
||||
|
||||
def send_new_session(
|
||||
self,
|
||||
|
|
@ -167,7 +167,8 @@ class TelemetryClient:
|
|||
nb_skills: int,
|
||||
nb_mcp_servers: int,
|
||||
nb_models: int,
|
||||
entrypoint: Literal["cli", "acp", "programmatic"],
|
||||
entrypoint: Literal["cli", "acp", "programmatic", "unknown"],
|
||||
terminal_emulator: str | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"has_agents_md": has_agents_md,
|
||||
|
|
@ -176,10 +177,11 @@ class TelemetryClient:
|
|||
"nb_models": nb_models,
|
||||
"entrypoint": entrypoint,
|
||||
"version": __version__,
|
||||
"terminal_emulator": terminal_emulator,
|
||||
}
|
||||
self.send_telemetry_event("vibe/new_session", payload)
|
||||
self.send_telemetry_event("vibe.new_session", payload)
|
||||
|
||||
def send_onboarding_api_key_added(self) -> None:
|
||||
self.send_telemetry_event(
|
||||
"vibe/onboarding_api_key_added", {"version": __version__}
|
||||
"vibe.onboarding_api_key_added", {"version": __version__}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ from vibe.core.types import ToolStreamEvent
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.types import ApprovalCallback, UserInputCallback
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.types import ApprovalCallback, EntrypointMetadata, UserInputCallback
|
||||
|
||||
ARGS_COUNT = 4
|
||||
|
||||
|
|
@ -40,6 +41,9 @@ class InvokeContext:
|
|||
approval_callback: ApprovalCallback | None = field(default=None)
|
||||
agent_manager: AgentManager | None = field(default=None)
|
||||
user_input_callback: UserInputCallback | None = field(default=None)
|
||||
sampling_callback: MCPSamplingHandler | None = field(default=None)
|
||||
session_dir: Path | None = field(default=None)
|
||||
entrypoint_metadata: EntrypointMetadata | None = field(default=None)
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
|
|
@ -316,6 +320,10 @@ class BaseTool[
|
|||
snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
||||
return snake_case
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def create_config_with_permission(
|
||||
cls, permission: ToolPermission
|
||||
|
|
@ -323,14 +331,13 @@ class BaseTool[
|
|||
config_class = cls._get_tool_config_class()
|
||||
return config_class(permission=permission)
|
||||
|
||||
def check_allowlist_denylist(self, args: ToolArgs) -> ToolPermission | None:
|
||||
"""Check if args match allowlist/denylist patterns.
|
||||
def resolve_permission(self, args: ToolArgs) -> ToolPermission | None:
|
||||
"""Per-invocation permission override, checked before config-level permission.
|
||||
|
||||
Returns:
|
||||
ToolPermission.ALWAYS if allowlisted
|
||||
ToolPermission.NEVER if denylisted
|
||||
None if no match (proceed with normal permission check)
|
||||
ALWAYS if auto-approved, NEVER if blocked, ASK to force approval,
|
||||
or None to fall through to config permission.
|
||||
|
||||
Base implementation returns None. Override in subclasses for specific logic.
|
||||
Override in subclasses for domain-specific rules (e.g. workdir checks).
|
||||
"""
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from vibe.core.tools.base import (
|
|||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
class Choice(BaseModel):
|
||||
|
|
@ -84,16 +84,10 @@ class AskUserQuestion(
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if not isinstance(event.args, AskUserQuestionArgs):
|
||||
return ToolCallDisplay(summary="Asking user")
|
||||
|
||||
args = event.args
|
||||
def format_call_display(cls, args: AskUserQuestionArgs) -> ToolCallDisplay:
|
||||
count = len(args.questions)
|
||||
|
||||
if count == 1:
|
||||
return ToolCallDisplay(summary=f"Asking: {args.questions[0].question}")
|
||||
|
||||
return ToolCallDisplay(summary=f"Asking {count} questions")
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from vibe.core.tools.base import (
|
|||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils import is_windows
|
||||
|
||||
|
||||
|
|
@ -208,11 +208,8 @@ class Bash(
|
|||
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}")
|
||||
def format_call_display(cls, args: BashArgs) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary=f"bash: {args.command}")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
|
|
@ -227,7 +224,7 @@ class Bash(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Running command"
|
||||
|
||||
def check_allowlist_denylist(self, args: BashArgs) -> ToolPermission | None:
|
||||
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
|
||||
if is_windows():
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ 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
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
class GrepBackend(StrEnum):
|
||||
|
|
@ -75,10 +75,6 @@ class GrepToolConfig(BaseToolConfig):
|
|||
)
|
||||
|
||||
|
||||
class GrepState(BaseToolState):
|
||||
search_history: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GrepArgs(BaseModel):
|
||||
pattern: str
|
||||
path: str = "."
|
||||
|
|
@ -99,7 +95,7 @@ class GrepResult(BaseModel):
|
|||
|
||||
|
||||
class Grep(
|
||||
BaseTool[GrepArgs, GrepResult, GrepToolConfig, GrepState],
|
||||
BaseTool[GrepArgs, GrepResult, GrepToolConfig, BaseToolState],
|
||||
ToolUIData[GrepArgs, GrepResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
|
|
@ -122,7 +118,6 @@ class Grep(
|
|||
) -> AsyncGenerator[ToolStreamEvent | GrepResult, None]:
|
||||
backend = self._detect_backend()
|
||||
self._validate_args(args)
|
||||
self.state.search_history.append(args.pattern)
|
||||
|
||||
exclude_patterns = self._collect_exclude_patterns()
|
||||
cmd = self._build_command(args, exclude_patterns, backend)
|
||||
|
|
@ -274,18 +269,14 @@ class Grep(
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if not isinstance(event.args, GrepArgs):
|
||||
return ToolCallDisplay(summary="grep")
|
||||
|
||||
summary = f"Grepping '{event.args.pattern}'"
|
||||
if event.args.path != ".":
|
||||
summary += f" in {event.args.path}"
|
||||
if event.args.max_matches:
|
||||
summary += f" (max {event.args.max_matches} matches)"
|
||||
if not event.args.use_default_ignore:
|
||||
def format_call_display(cls, args: GrepArgs) -> ToolCallDisplay:
|
||||
summary = f"Grepping '{args.pattern}'"
|
||||
if args.path != ".":
|
||||
summary += f" in {args.path}"
|
||||
if args.max_matches:
|
||||
summary += f" (max {args.max_matches} matches)"
|
||||
if not args.use_default_ignore:
|
||||
summary += " [no-ignore]"
|
||||
|
||||
return ToolCallDisplay(summary=summary)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ Use `read_file` to read the content of a file. It's designed to handle large fil
|
|||
- By default, it reads from the beginning of the file.
|
||||
- Use `offset` (line number) and `limit` (number of lines) to read specific parts or chunks of a file. This is efficient for exploring large files.
|
||||
- The result includes `was_truncated: true` if the file content was cut short due to size limits.
|
||||
- This is more efficient than using `bash` with `cat` or `wc`.
|
||||
|
||||
**Strategy for large files:**
|
||||
|
||||
1. Call `read_file` with a `limit` (e.g., 1000 lines) to get the start of the file.
|
||||
2. If `was_truncated` is true, you know the file is large.
|
||||
3. To read the next chunk, call `read_file` again with an `offset`. For example, `offset=1000, limit=1000`.
|
||||
2. If `was_truncated` is true, the file is large. STOP and assess: do you already have enough information to answer the user's question? If yes, respond immediately — do not keep reading.
|
||||
3. If you need more, prefer targeted reads (e.g., jump to a specific offset, read the last 100 lines, search for a relevant section) over reading sequentially chunk by chunk.
|
||||
4. Do not call `read_file` more than 3 times on the same file without responding to the user first.
|
||||
|
||||
This is more efficient than using `bash` with `cat` or `wc`.
|
||||
**Do not read or explore:**
|
||||
- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
|
||||
- Binary files of any kind
|
||||
- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.
|
||||
|
|
|
|||
6
vibe/core/tools/builtins/prompts/webfetch.md
Normal file
6
vibe/core/tools/builtins/prompts/webfetch.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Fetches content from a specified URL and converts HTML to markdown for readability.
|
||||
Use this tool when you need to retrieve and analyze web content.
|
||||
|
||||
- Prefer a more specialized tool over `web_fetch` when one is available.
|
||||
- URLs must be valid.
|
||||
- Read-only: does not modify any files.
|
||||
25
vibe/core/tools/builtins/prompts/websearch.md
Normal file
25
vibe/core/tools/builtins/prompts/websearch.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
Use `web_search` to find current information from the web.
|
||||
Returns answers with cited sources. Always reference sources when presenting information to the user.
|
||||
|
||||
**Query Best Practices:**
|
||||
- Avoid relative time terms ("latest", "today", "this week") - resolve to actual dates when possible
|
||||
- Be specific and use concrete terms rather than vague queries
|
||||
|
||||
**When to use:**
|
||||
- User asks about recent events or explicitly asks to search the web
|
||||
- Documentation, APIs, or libraries may have been updated since training cutoff
|
||||
- Verifying facts that could be outdated (versions, deprecations, breaking changes)
|
||||
- Looking up specific error messages or issues that may have known solutions
|
||||
- User mentions a library, framework, or version you're not familiar with
|
||||
|
||||
**When NOT to use:**
|
||||
- General programming concepts and patterns (use training knowledge)
|
||||
- Searching the local codebase (use `grep` or file search instead)
|
||||
- Static reference information unlikely to change (math, algorithms, language syntax)
|
||||
- Information you're already confident about and is unlikely to have changed
|
||||
|
||||
**Using results:**
|
||||
- Stay critical - web content may be outdated, wrong, or misleading
|
||||
- Cross-reference multiple sources when possible
|
||||
- Prefer official documentation over third-party sources
|
||||
- Always cite your sources so the user can verify
|
||||
|
|
@ -16,10 +16,11 @@ from vibe.core.tools.base import (
|
|||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
class _ReadResult(NamedTuple):
|
||||
|
|
@ -54,17 +55,10 @@ class ReadFileToolConfig(BaseToolConfig):
|
|||
max_read_bytes: int = Field(
|
||||
default=64_000, description="Maximum total bytes to read from a file in one go."
|
||||
)
|
||||
max_state_history: int = Field(
|
||||
default=10, description="Number of recently read files to remember in state."
|
||||
)
|
||||
|
||||
|
||||
class ReadFileState(BaseToolState):
|
||||
recently_read_files: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReadFile(
|
||||
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
|
||||
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
|
||||
ToolUIData[ReadFileArgs, ReadFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
|
|
@ -80,8 +74,6 @@ class ReadFile(
|
|||
|
||||
read_result = await self._read_file(args, file_path)
|
||||
|
||||
self._update_state_history(file_path)
|
||||
|
||||
yield ReadFileResult(
|
||||
path=str(file_path),
|
||||
content="".join(read_result.lines),
|
||||
|
|
@ -89,23 +81,13 @@ class ReadFile(
|
|||
was_truncated=read_result.was_truncated,
|
||||
)
|
||||
|
||||
def check_allowlist_denylist(self, args: ReadFileArgs) -> ToolPermission | None:
|
||||
import fnmatch
|
||||
|
||||
file_path = Path(args.path).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
file_str = str(file_path)
|
||||
|
||||
for pattern in self.config.denylist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.NEVER
|
||||
|
||||
for pattern in self.config.allowlist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.ALWAYS
|
||||
|
||||
return None
|
||||
def resolve_permission(self, args: ReadFileArgs) -> ToolPermission | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.path,
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
)
|
||||
|
||||
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
|
||||
self._validate_inputs(args)
|
||||
|
|
@ -176,25 +158,16 @@ class ReadFile(
|
|||
if resolved_path.is_dir():
|
||||
raise ToolError(f"Path is a directory, not a file: {file_path}")
|
||||
|
||||
def _update_state_history(self, file_path: Path) -> None:
|
||||
self.state.recently_read_files.append(str(file_path.resolve()))
|
||||
if len(self.state.recently_read_files) > self.config.max_state_history:
|
||||
self.state.recently_read_files.pop(0)
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if not isinstance(event.args, ReadFileArgs):
|
||||
return ToolCallDisplay(summary="read_file")
|
||||
|
||||
summary = f"Reading {event.args.path}"
|
||||
if event.args.offset > 0 or event.args.limit is not None:
|
||||
def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay:
|
||||
summary = f"Reading {args.path}"
|
||||
if args.offset > 0 or args.limit is not None:
|
||||
parts = []
|
||||
if event.args.offset > 0:
|
||||
parts.append(f"from line {event.args.offset}")
|
||||
if event.args.limit is not None:
|
||||
parts.append(f"limit {event.args.limit} lines")
|
||||
if args.offset > 0:
|
||||
parts.append(f"from line {args.offset}")
|
||||
if args.limit is not None:
|
||||
parts.append(f"limit {args.limit} lines")
|
||||
summary += f" ({', '.join(parts)})"
|
||||
|
||||
return ToolCallDisplay(summary=summary)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ from vibe.core.tools.base import (
|
|||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import 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
|
||||
|
|
@ -68,13 +70,9 @@ class SearchReplaceConfig(BaseToolConfig):
|
|||
fuzzy_threshold: float = 0.9
|
||||
|
||||
|
||||
class SearchReplaceState(BaseToolState):
|
||||
pass
|
||||
|
||||
|
||||
class SearchReplace(
|
||||
BaseTool[
|
||||
SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, SearchReplaceState
|
||||
SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, BaseToolState
|
||||
],
|
||||
ToolUIData[SearchReplaceArgs, SearchReplaceResult],
|
||||
):
|
||||
|
|
@ -85,13 +83,8 @@ class SearchReplace(
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if not isinstance(event.args, SearchReplaceArgs):
|
||||
return ToolCallDisplay(summary="Invalid arguments")
|
||||
|
||||
args = event.args
|
||||
def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay:
|
||||
blocks = cls._parse_search_replace_blocks(args.content)
|
||||
|
||||
return ToolCallDisplay(
|
||||
summary=f"Patching {args.file_path} ({len(blocks)} blocks)",
|
||||
content=args.content,
|
||||
|
|
@ -112,6 +105,14 @@ class SearchReplace(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Editing files"
|
||||
|
||||
def resolve_permission(self, args: SearchReplaceArgs) -> ToolPermission | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.file_path,
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import fnmatch
|
||||
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.agents.models import AgentType, BuiltinAgentName
|
||||
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
|
|
@ -47,6 +48,7 @@ class TaskResult(BaseModel):
|
|||
|
||||
class TaskToolConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
allowlist: list[str] = Field(default=[BuiltinAgentName.EXPLORE])
|
||||
|
||||
|
||||
class Task(
|
||||
|
|
@ -56,8 +58,8 @@ class Task(
|
|||
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."
|
||||
"require user interaction. The subagent runs in-memory and "
|
||||
"saves interaction logs."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -87,6 +89,19 @@ class Task(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Running subagent"
|
||||
|
||||
def resolve_permission(self, args: TaskArgs) -> ToolPermission | None:
|
||||
agent_name = args.agent
|
||||
|
||||
for pattern in self.config.denylist:
|
||||
if fnmatch.fnmatch(agent_name, pattern):
|
||||
return ToolPermission.NEVER
|
||||
|
||||
for pattern in self.config.allowlist:
|
||||
if fnmatch.fnmatch(agent_name, pattern):
|
||||
return ToolPermission.ALWAYS
|
||||
|
||||
return None
|
||||
|
||||
async def run(
|
||||
self, args: TaskArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | TaskResult, None]:
|
||||
|
|
@ -107,10 +122,17 @@ class Task(
|
|||
f"This is a security constraint to prevent recursive spawning."
|
||||
)
|
||||
|
||||
base_config = VibeConfig.load(
|
||||
session_logging=SessionLoggingConfig(enabled=False)
|
||||
session_logging = SessionLoggingConfig(
|
||||
save_dir=str(ctx.session_dir / "agents") if ctx.session_dir else "",
|
||||
session_prefix=args.agent,
|
||||
enabled=ctx.session_dir is not None,
|
||||
)
|
||||
base_config = VibeConfig.load(session_logging=session_logging)
|
||||
subagent_loop = AgentLoop(
|
||||
config=base_config,
|
||||
agent_name=args.agent,
|
||||
entrypoint_metadata=ctx.entrypoint_metadata,
|
||||
)
|
||||
subagent_loop = AgentLoop(config=base_config, agent_name=args.agent)
|
||||
|
||||
if ctx and ctx.approval_callback:
|
||||
subagent_loop.set_approval_callback(ctx.approval_callback)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from vibe.core.tools.base import (
|
|||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
|
||||
|
||||
class TodoStatus(StrEnum):
|
||||
|
|
@ -69,12 +69,7 @@ class Todo(
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if not isinstance(event.args, TodoArgs):
|
||||
return ToolCallDisplay(summary="Invalid arguments")
|
||||
|
||||
args = event.args
|
||||
|
||||
def format_call_display(cls, args: TodoArgs) -> ToolCallDisplay:
|
||||
match args.action:
|
||||
case "read":
|
||||
return ToolCallDisplay(summary="Reading todos")
|
||||
|
|
|
|||
200
vibe/core/tools/builtins/webfetch.py
Normal file
200
vibe/core/tools/builtins/webfetch.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, ClassVar, final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from markdownify import MarkdownConverter
|
||||
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
|
||||
|
||||
|
||||
_HONEST_USER_AGENT = "vibe-cli"
|
||||
_HTTP_FORBIDDEN = 403
|
||||
|
||||
|
||||
class _Converter(MarkdownConverter):
|
||||
convert_script = convert_style = convert_noscript = convert_iframe = (
|
||||
convert_object
|
||||
) = convert_embed = lambda *_, **__: ""
|
||||
|
||||
|
||||
class WebFetchArgs(BaseModel):
|
||||
url: str = Field(description="URL to fetch (http/https)")
|
||||
timeout: int | None = Field(
|
||||
default=None, description="Timeout in seconds (max 120)"
|
||||
)
|
||||
|
||||
|
||||
class WebFetchResult(BaseModel):
|
||||
url: str
|
||||
content: str
|
||||
content_type: str
|
||||
|
||||
|
||||
class WebFetchConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
|
||||
default_timeout: int = Field(default=30, description="Default timeout in seconds.")
|
||||
max_timeout: int = Field(default=120, description="Maximum allowed timeout.")
|
||||
max_content_bytes: int = Field(
|
||||
default=512_000, description="Maximum content size to fetch."
|
||||
)
|
||||
user_agent: str = Field(
|
||||
default=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
description="User agent string for requests.",
|
||||
)
|
||||
|
||||
|
||||
class WebFetch(
|
||||
BaseTool[WebFetchArgs, WebFetchResult, WebFetchConfig, BaseToolState],
|
||||
ToolUIData[WebFetchArgs, WebFetchResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Fetch content from a URL. Converts HTML to markdown for readability."
|
||||
)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: WebFetchArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | WebFetchResult, None]:
|
||||
self._validate_args(args)
|
||||
|
||||
raw = args.url.lstrip("/") if args.url.startswith("//") else args.url
|
||||
url = raw if raw.startswith(("http://", "https://")) else "https://" + raw
|
||||
timeout = self._resolve_timeout(args.timeout)
|
||||
|
||||
content, content_type = await self._fetch_url(url, timeout)
|
||||
|
||||
if "text/html" in content_type:
|
||||
content = _html_to_markdown(content)
|
||||
|
||||
yield WebFetchResult(url=url, content=content, content_type=content_type)
|
||||
|
||||
def _validate_args(self, args: WebFetchArgs) -> None:
|
||||
if not args.url.strip():
|
||||
raise ToolError("URL cannot be empty")
|
||||
|
||||
parsed = urlparse(args.url)
|
||||
if parsed.scheme and parsed.scheme not in {"http", "https"}:
|
||||
raise ToolError(
|
||||
f"Invalid URL scheme: {parsed.scheme}. Must be http or https."
|
||||
)
|
||||
|
||||
if args.timeout is not None:
|
||||
if args.timeout <= 0:
|
||||
raise ToolError("Timeout must be a positive number")
|
||||
if args.timeout > self.config.max_timeout:
|
||||
raise ToolError(
|
||||
f"Timeout cannot exceed {self.config.max_timeout} seconds"
|
||||
)
|
||||
|
||||
def _resolve_timeout(self, timeout: int | None) -> int:
|
||||
if timeout is None:
|
||||
return self.config.default_timeout
|
||||
return min(timeout, self.config.max_timeout)
|
||||
|
||||
async def _fetch_url(self, url: str, timeout: int) -> tuple[str, str]:
|
||||
headers = {
|
||||
"User-Agent": self.config.user_agent,
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._do_fetch(url, timeout, headers)
|
||||
except httpx.TimeoutException:
|
||||
raise ToolError(f"Request timed out after {timeout} seconds")
|
||||
except httpx.RequestError as e:
|
||||
raise ToolError(f"Failed to fetch URL: {e}")
|
||||
|
||||
if response.is_error:
|
||||
raise ToolError(
|
||||
f"HTTP error {response.status_code}: {response.reason_phrase}"
|
||||
)
|
||||
|
||||
content_type = response.headers.get("Content-Type", "text/plain")
|
||||
|
||||
content_bytes = response.content[: self.config.max_content_bytes]
|
||||
content = content_bytes.decode("utf-8", errors="ignore")
|
||||
|
||||
if len(response.content) > self.config.max_content_bytes:
|
||||
content += "[Content truncated due to size limit]"
|
||||
|
||||
return content, content_type
|
||||
|
||||
async def _do_fetch(
|
||||
self, url: str, timeout: int, headers: dict[str, str]
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, timeout=httpx.Timeout(timeout)
|
||||
) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
# In case we are hitting bot detection retry once honestly
|
||||
if (
|
||||
response.status_code == _HTTP_FORBIDDEN
|
||||
and response.headers.get("cf-mitigated") == "challenge"
|
||||
):
|
||||
headers["User-Agent"] = _HONEST_USER_AGENT
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if event.args is None:
|
||||
return ToolCallDisplay(summary="webfetch")
|
||||
if not isinstance(event.args, WebFetchArgs):
|
||||
return ToolCallDisplay(summary="webfetch")
|
||||
|
||||
parsed = urlparse(event.args.url)
|
||||
domain = parsed.netloc or event.args.url[:50]
|
||||
summary = f"Fetching: {domain}"
|
||||
|
||||
if event.args.timeout:
|
||||
summary += f" (timeout {event.args.timeout}s)"
|
||||
|
||||
return ToolCallDisplay(summary=summary)
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if not isinstance(event.result, WebFetchResult):
|
||||
return ToolResultDisplay(
|
||||
success=False, message=event.error or event.skip_reason or "No result"
|
||||
)
|
||||
|
||||
content_len = len(event.result.content)
|
||||
message = (
|
||||
f"Fetched {content_len:,} chars ({event.result.content_type.split(';')[0]})"
|
||||
)
|
||||
|
||||
return ToolResultDisplay(success=True, message=message)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Fetching URL"
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
return _Converter(heading_style="ATX", bullets="-").convert(html)
|
||||
131
vibe/core/tools/builtins/websearch.py
Normal file
131
vibe/core/tools/builtins/websearch.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import os
|
||||
from typing import TYPE_CHECKING, ClassVar, final
|
||||
|
||||
import mistralai
|
||||
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
|
||||
|
||||
|
||||
class WebSearchSource(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
|
||||
|
||||
class WebSearchArgs(BaseModel):
|
||||
query: str = Field(min_length=1)
|
||||
|
||||
|
||||
class WebSearchResult(BaseModel):
|
||||
answer: str
|
||||
sources: list[WebSearchSource] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebSearchConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ASK
|
||||
timeout: int = Field(default=120, description="HTTP timeout in seconds.")
|
||||
model: str = Field(
|
||||
default="mistral-vibe-cli-with-tools",
|
||||
description="Mistral model to use for web search.",
|
||||
)
|
||||
|
||||
|
||||
class WebSearch(
|
||||
BaseTool[WebSearchArgs, WebSearchResult, WebSearchConfig, BaseToolState],
|
||||
ToolUIData[WebSearchArgs, WebSearchResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Search the web for current information using Mistral's web search."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return bool(os.getenv("MISTRAL_API_KEY"))
|
||||
|
||||
@final
|
||||
async def run(
|
||||
self, args: WebSearchArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | WebSearchResult, None]:
|
||||
api_key = os.getenv("MISTRAL_API_KEY")
|
||||
if not api_key:
|
||||
raise ToolError("MISTRAL_API_KEY environment variable not set.")
|
||||
|
||||
client = mistralai.Mistral(
|
||||
api_key=api_key, timeout_ms=self.config.timeout * 1000
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
response = await client.beta.conversations.start_async(
|
||||
model=self.config.model,
|
||||
instructions="Always use the web_search tool to answer queries. Never answer from memory alone.",
|
||||
tools=[{"type": "web_search"}],
|
||||
inputs=args.query,
|
||||
store=False,
|
||||
)
|
||||
|
||||
yield self._parse_response(response)
|
||||
|
||||
except mistralai.SDKError as exc:
|
||||
raise ToolError(f"Mistral API error: {exc}") from exc
|
||||
|
||||
def _parse_response(
|
||||
self, response: mistralai.ConversationResponse
|
||||
) -> WebSearchResult:
|
||||
text_parts: list[str] = []
|
||||
sources: dict[str, WebSearchSource] = {}
|
||||
|
||||
for entry in response.outputs:
|
||||
if not isinstance(entry, mistralai.MessageOutputEntry):
|
||||
continue
|
||||
for chunk in entry.content:
|
||||
if isinstance(chunk, mistralai.TextChunk):
|
||||
text_parts.append(chunk.text)
|
||||
elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
|
||||
if chunk.url not in sources:
|
||||
sources[chunk.url] = WebSearchSource(
|
||||
title=chunk.title, url=chunk.url
|
||||
)
|
||||
|
||||
answer = "".join(text_parts).strip()
|
||||
if not answer:
|
||||
raise ToolError("No text in agent response.")
|
||||
|
||||
return WebSearchResult(answer=answer, sources=list(sources.values()))
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if event.args is None:
|
||||
return ToolCallDisplay(summary="websearch")
|
||||
if not isinstance(event.args, WebSearchArgs):
|
||||
return ToolCallDisplay(summary="websearch")
|
||||
return ToolCallDisplay(summary=f"Searching the web: '{event.args.query}'")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if not isinstance(event.result, WebSearchResult):
|
||||
return ToolResultDisplay(
|
||||
success=False, message=event.error or event.skip_reason or "No result"
|
||||
)
|
||||
return ToolResultDisplay(
|
||||
success=True, message=f"{len(event.result.sources)} sources found"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Searching the web"
|
||||
|
|
@ -16,7 +16,8 @@ from vibe.core.tools.base import (
|
|||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
|
||||
|
||||
class WriteFileArgs(BaseModel):
|
||||
|
|
@ -40,12 +41,8 @@ class WriteFileConfig(BaseToolConfig):
|
|||
create_parent_dirs: bool = True
|
||||
|
||||
|
||||
class WriteFileState(BaseToolState):
|
||||
recently_written_files: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WriteFile(
|
||||
BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, WriteFileState],
|
||||
BaseTool[WriteFileArgs, WriteFileResult, WriteFileConfig, BaseToolState],
|
||||
ToolUIData[WriteFileArgs, WriteFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
|
|
@ -53,12 +50,7 @@ class WriteFile(
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if not isinstance(event.args, WriteFileArgs):
|
||||
return ToolCallDisplay(summary="Invalid arguments")
|
||||
|
||||
args = event.args
|
||||
|
||||
def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(
|
||||
summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}",
|
||||
content=args.content,
|
||||
|
|
@ -78,23 +70,13 @@ class WriteFile(
|
|||
def get_status_text(cls) -> str:
|
||||
return "Writing file"
|
||||
|
||||
def check_allowlist_denylist(self, args: WriteFileArgs) -> ToolPermission | None:
|
||||
import fnmatch
|
||||
|
||||
file_path = Path(args.path).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
file_str = str(file_path)
|
||||
|
||||
for pattern in self.config.denylist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.NEVER
|
||||
|
||||
for pattern in self.config.allowlist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.ALWAYS
|
||||
|
||||
return None
|
||||
def resolve_permission(self, args: WriteFileArgs) -> ToolPermission | None:
|
||||
return resolve_file_tool_permission(
|
||||
args.path,
|
||||
allowlist=self.config.allowlist,
|
||||
denylist=self.config.denylist,
|
||||
config_permission=self.config.permission,
|
||||
)
|
||||
|
||||
@final
|
||||
async def run(
|
||||
|
|
@ -104,11 +86,6 @@ class WriteFile(
|
|||
|
||||
await self._write_file(args, file_path)
|
||||
|
||||
BUFFER_SIZE = 10
|
||||
self.state.recently_written_files.append(str(file_path))
|
||||
if len(self.state.recently_written_files) > BUFFER_SIZE:
|
||||
self.state.recently_written_files.pop(0)
|
||||
|
||||
yield WriteFileResult(
|
||||
path=str(file_path),
|
||||
bytes_written=content_bytes,
|
||||
|
|
@ -131,11 +108,6 @@ class WriteFile(
|
|||
file_path = Path.cwd() / file_path
|
||||
file_path = file_path.resolve()
|
||||
|
||||
try:
|
||||
file_path.relative_to(Path.cwd().resolve())
|
||||
except ValueError:
|
||||
raise ToolError(f"Cannot write outside project directory: {file_path}")
|
||||
|
||||
file_existed = file_path.exists()
|
||||
|
||||
if file_existed and not args.overwrite:
|
||||
|
|
|
|||
|
|
@ -4,28 +4,20 @@ from collections.abc import Callable, Iterator
|
|||
import hashlib
|
||||
import importlib.util
|
||||
import inspect
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vibe.core.paths.config_paths import resolve_local_tools_dir
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import discover_local_tools_dirs
|
||||
from vibe.core.paths.global_paths import DEFAULT_TOOL_DIR, GLOBAL_TOOLS_DIR
|
||||
from vibe.core.tools.base import BaseTool, BaseToolConfig
|
||||
from vibe.core.tools.mcp import (
|
||||
RemoteTool,
|
||||
create_mcp_http_proxy_tool_class,
|
||||
create_mcp_stdio_proxy_tool_class,
|
||||
list_tools_http,
|
||||
list_tools_stdio,
|
||||
)
|
||||
from vibe.core.utils import name_matches, run_sync
|
||||
|
||||
logger = getLogger("vibe")
|
||||
from vibe.core.tools.mcp import MCPRegistry
|
||||
from vibe.core.utils import name_matches
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
def _try_canonical_module_name(path: Path) -> str | None:
|
||||
|
|
@ -73,8 +65,13 @@ class ToolManager:
|
|||
should have its own ToolManager instance.
|
||||
"""
|
||||
|
||||
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config_getter: Callable[[], VibeConfig],
|
||||
mcp_registry: MCPRegistry | None = None,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._mcp_registry = mcp_registry or MCPRegistry()
|
||||
self._instances: dict[str, BaseTool] = {}
|
||||
self._search_paths: list[Path] = self._compute_search_paths(self._config)
|
||||
|
||||
|
|
@ -92,10 +89,7 @@ class ToolManager:
|
|||
paths: list[Path] = [DEFAULT_TOOL_DIR.path]
|
||||
|
||||
paths.extend(config.tool_paths)
|
||||
|
||||
if (tools_dir := resolve_local_tools_dir(Path.cwd())) is not None:
|
||||
paths.append(tools_dir)
|
||||
|
||||
paths.extend(discover_local_tools_dirs(Path.cwd()))
|
||||
paths.append(GLOBAL_TOOLS_DIR.path)
|
||||
|
||||
unique: list[Path] = []
|
||||
|
|
@ -180,122 +174,38 @@ class ToolManager:
|
|||
|
||||
@property
|
||||
def available_tools(self) -> dict[str, type[BaseTool]]:
|
||||
runtime_available = {
|
||||
name: cls for name, cls in self._available.items() if cls.is_available()
|
||||
}
|
||||
|
||||
if self._config.enabled_tools:
|
||||
return {
|
||||
name: cls
|
||||
for name, cls in self._available.items()
|
||||
for name, cls in runtime_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()
|
||||
for name, cls in runtime_available.items()
|
||||
if not name_matches(name, self._config.disabled_tools)
|
||||
}
|
||||
return dict(self._available)
|
||||
return runtime_available
|
||||
|
||||
def _integrate_mcp(self) -> None:
|
||||
if not self._config.mcp_servers:
|
||||
return
|
||||
run_sync(self._integrate_mcp_async())
|
||||
|
||||
async def _integrate_mcp_async(self) -> None:
|
||||
try:
|
||||
http_count = 0
|
||||
stdio_count = 0
|
||||
|
||||
for srv in self._config.mcp_servers:
|
||||
match srv.transport:
|
||||
case "http" | "streamable-http":
|
||||
http_count += await self._register_http_server(srv)
|
||||
case "stdio":
|
||||
stdio_count += await self._register_stdio_server(srv)
|
||||
case _:
|
||||
logger.warning("Unsupported MCP transport: %r", srv.transport)
|
||||
|
||||
logger.info(
|
||||
"MCP integration registered %d tools (http=%d, stdio=%d)",
|
||||
http_count + stdio_count,
|
||||
http_count,
|
||||
stdio_count,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to integrate MCP tools: %s", exc)
|
||||
|
||||
async def _register_http_server(self, srv: MCPHttp | MCPStreamableHttp) -> int:
|
||||
url = (srv.url or "").strip()
|
||||
if not url:
|
||||
logger.warning("MCP server '%s' missing url for http transport", srv.name)
|
||||
return 0
|
||||
|
||||
headers = srv.http_headers()
|
||||
try:
|
||||
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
|
||||
|
||||
added = 0
|
||||
for remote in tools:
|
||||
try:
|
||||
proxy_cls = create_mcp_http_proxy_tool_class(
|
||||
url=url,
|
||||
remote=remote,
|
||||
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
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to register MCP HTTP tool '%s' from %s: %r",
|
||||
getattr(remote, "name", "<unknown>"),
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
return added
|
||||
|
||||
async def _register_stdio_server(self, srv: MCPStdio) -> int:
|
||||
cmd = srv.argv()
|
||||
if not cmd:
|
||||
logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
|
||||
return 0
|
||||
|
||||
try:
|
||||
tools: list[RemoteTool] = await list_tools_stdio(
|
||||
cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
|
||||
)
|
||||
mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
|
||||
return 0
|
||||
logger.warning("MCP integration failed: %s", exc)
|
||||
return
|
||||
|
||||
added = 0
|
||||
for remote in tools:
|
||||
try:
|
||||
proxy_cls = create_mcp_stdio_proxy_tool_class(
|
||||
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
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to register MCP stdio tool '%s' from %r: %r",
|
||||
getattr(remote, "name", "<unknown>"),
|
||||
cmd,
|
||||
exc,
|
||||
)
|
||||
return added
|
||||
self._available.update(mcp_tools)
|
||||
logger.info(
|
||||
"MCP integration registered %d tools (via registry)", len(mcp_tools)
|
||||
)
|
||||
|
||||
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
|
||||
tool_class = self._available.get(tool_name)
|
||||
|
|
|
|||
31
vibe/core/tools/mcp/__init__.py
Normal file
31
vibe/core/tools/mcp/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.tools.mcp.registry import MCPRegistry
|
||||
from vibe.core.tools.mcp.tools import (
|
||||
MCPToolResult,
|
||||
RemoteTool,
|
||||
_mcp_stderr_capture,
|
||||
_parse_call_result,
|
||||
_stderr_logger_thread,
|
||||
call_tool_http,
|
||||
call_tool_stdio,
|
||||
create_mcp_http_proxy_tool_class,
|
||||
create_mcp_stdio_proxy_tool_class,
|
||||
list_tools_http,
|
||||
list_tools_stdio,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MCPRegistry",
|
||||
"MCPToolResult",
|
||||
"RemoteTool",
|
||||
"_mcp_stderr_capture",
|
||||
"_parse_call_result",
|
||||
"_stderr_logger_thread",
|
||||
"call_tool_http",
|
||||
"call_tool_stdio",
|
||||
"create_mcp_http_proxy_tool_class",
|
||||
"create_mcp_stdio_proxy_tool_class",
|
||||
"list_tools_http",
|
||||
"list_tools_stdio",
|
||||
]
|
||||
167
vibe/core/tools/mcp/registry.py
Normal file
167
vibe/core/tools/mcp/registry.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseTool
|
||||
from vibe.core.tools.mcp.tools import (
|
||||
create_mcp_http_proxy_tool_class,
|
||||
create_mcp_stdio_proxy_tool_class,
|
||||
list_tools_http,
|
||||
list_tools_stdio,
|
||||
)
|
||||
from vibe.core.utils import run_sync
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import MCPHttp, MCPServer, MCPStdio, MCPStreamableHttp
|
||||
|
||||
|
||||
class MCPRegistry:
|
||||
"""Shared cache for MCP server tool discovery.
|
||||
|
||||
Survives agent switches so that shift-tab does not re-discover
|
||||
servers whose config has not changed. The cache is keyed by a
|
||||
stable fingerprint derived from each server's full config.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, dict[str, type[BaseTool]]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _server_key(srv: MCPServer) -> str:
|
||||
raw = srv.model_dump_json(exclude_none=False)
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]:
|
||||
"""Return proxy tool classes for *servers*, using cache when possible."""
|
||||
result: dict[str, type[BaseTool]] = {}
|
||||
to_discover: list[tuple[str, MCPServer]] = []
|
||||
|
||||
for srv in servers:
|
||||
key = self._server_key(srv)
|
||||
if key in self._cache:
|
||||
result.update(self._cache[key])
|
||||
else:
|
||||
to_discover.append((key, srv))
|
||||
|
||||
if to_discover:
|
||||
discovered = run_sync(self._discover_all(to_discover))
|
||||
result.update(discovered)
|
||||
|
||||
return result
|
||||
|
||||
async def _discover_all(
|
||||
self, servers: list[tuple[str, MCPServer]]
|
||||
) -> dict[str, type[BaseTool]]:
|
||||
results = await asyncio.gather(
|
||||
*(self._discover_server(srv) for _, srv in servers), return_exceptions=True
|
||||
)
|
||||
out: dict[str, type[BaseTool]] = {}
|
||||
for (key, srv), result in zip(servers, results, strict=True):
|
||||
if isinstance(result, BaseException):
|
||||
logger.warning(
|
||||
"MCP discovery failed for server %r: %s", srv.name, result
|
||||
)
|
||||
continue
|
||||
if result is None:
|
||||
continue
|
||||
self._cache[key] = result
|
||||
out.update(result)
|
||||
return out
|
||||
|
||||
async def _discover_server(
|
||||
self, srv: MCPServer
|
||||
) -> dict[str, type[BaseTool]] | None:
|
||||
match srv.transport:
|
||||
case "http" | "streamable-http":
|
||||
return await self._discover_http(
|
||||
cast("MCPHttp | MCPStreamableHttp", srv)
|
||||
)
|
||||
case "stdio":
|
||||
return await self._discover_stdio(cast("MCPStdio", srv))
|
||||
case _:
|
||||
logger.warning("Unsupported MCP transport: %r", srv.transport)
|
||||
return {}
|
||||
|
||||
async def _discover_http(
|
||||
self, srv: MCPHttp | MCPStreamableHttp
|
||||
) -> dict[str, type[BaseTool]] | None:
|
||||
url = (srv.url or "").strip()
|
||||
if not url:
|
||||
logger.warning("MCP server '%s' missing url for http transport", srv.name)
|
||||
return {}
|
||||
|
||||
headers = srv.http_headers()
|
||||
try:
|
||||
remotes = 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 None
|
||||
|
||||
tools: dict[str, type[BaseTool]] = {}
|
||||
for remote in remotes:
|
||||
try:
|
||||
proxy_cls = create_mcp_http_proxy_tool_class(
|
||||
url=url,
|
||||
remote=remote,
|
||||
alias=srv.name,
|
||||
server_hint=srv.prompt,
|
||||
headers=headers,
|
||||
startup_timeout_sec=srv.startup_timeout_sec,
|
||||
tool_timeout_sec=srv.tool_timeout_sec,
|
||||
sampling_enabled=srv.sampling_enabled,
|
||||
)
|
||||
tools[proxy_cls.get_name()] = proxy_cls
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to register MCP HTTP tool '%s' from %s: %r",
|
||||
getattr(remote, "name", "<unknown>"),
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
return tools
|
||||
|
||||
async def _discover_stdio(self, srv: MCPStdio) -> dict[str, type[BaseTool]] | None:
|
||||
cmd = srv.argv()
|
||||
if not cmd:
|
||||
logger.warning("MCP stdio server '%s' has invalid/empty command", srv.name)
|
||||
return {}
|
||||
|
||||
try:
|
||||
remotes = 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 None
|
||||
|
||||
tools: dict[str, type[BaseTool]] = {}
|
||||
for remote in remotes:
|
||||
try:
|
||||
proxy_cls = create_mcp_stdio_proxy_tool_class(
|
||||
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,
|
||||
sampling_enabled=srv.sampling_enabled,
|
||||
)
|
||||
tools[proxy_cls.get_name()] = proxy_cls
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to register MCP stdio tool '%s' from %r: %r",
|
||||
getattr(remote, "name", "<unknown>"),
|
||||
cmd,
|
||||
exc,
|
||||
)
|
||||
return tools
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop all cached entries, forcing re-discovery on next use."""
|
||||
self._cache.clear()
|
||||
|
|
@ -4,17 +4,17 @@ from collections.abc import AsyncGenerator
|
|||
import contextlib
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
from logging import getLogger
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from mcp import ClientSession
|
||||
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.logger import logger
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
|
|
@ -22,13 +22,12 @@ from vibe.core.tools.base import (
|
|||
InvokeContext,
|
||||
ToolError,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
logger = getLogger("vibe")
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
||||
|
||||
def _stderr_logger_thread(read_fd: int) -> None:
|
||||
|
|
@ -169,6 +168,7 @@ async def call_tool_http(
|
|||
headers: dict[str, str] | None = None,
|
||||
startup_timeout_sec: float | None = None,
|
||||
tool_timeout_sec: float | None = None,
|
||||
sampling_callback: MCPSamplingHandler | None = None,
|
||||
) -> MCPToolResult:
|
||||
init_timeout = (
|
||||
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
|
||||
|
|
@ -176,7 +176,10 @@ async def call_tool_http(
|
|||
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, read_timeout_seconds=init_timeout
|
||||
read,
|
||||
write,
|
||||
read_timeout_seconds=init_timeout,
|
||||
sampling_callback=sampling_callback,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(
|
||||
|
|
@ -194,6 +197,7 @@ def create_mcp_http_proxy_tool_class(
|
|||
headers: dict[str, str] | None = None,
|
||||
startup_timeout_sec: float | None = None,
|
||||
tool_timeout_sec: float | None = None,
|
||||
sampling_enabled: bool = True,
|
||||
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -206,7 +210,8 @@ def create_mcp_http_proxy_tool_class(
|
|||
published_name = f"{(alias or _alias_from_url(url))}_{remote.name}"
|
||||
|
||||
class MCPHttpProxyTool(
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
|
||||
ToolUIData[_OpenArgs, MCPToolResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
(f"[{alias}] " if alias else "")
|
||||
|
|
@ -219,6 +224,7 @@ def create_mcp_http_proxy_tool_class(
|
|||
_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
|
||||
_sampling_enabled: ClassVar[bool] = sampling_enabled
|
||||
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
|
|
@ -232,6 +238,9 @@ def create_mcp_http_proxy_tool_class(
|
|||
self, args: _OpenArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
|
||||
try:
|
||||
sampling_callback = (
|
||||
ctx.sampling_callback if ctx and self._sampling_enabled else None
|
||||
)
|
||||
payload = args.model_dump(exclude_none=True)
|
||||
yield await call_tool_http(
|
||||
self._mcp_url,
|
||||
|
|
@ -240,14 +249,11 @@ def create_mcp_http_proxy_tool_class(
|
|||
headers=self._headers,
|
||||
startup_timeout_sec=self._startup_timeout_sec,
|
||||
tool_timeout_sec=self._tool_timeout_sec,
|
||||
sampling_callback=sampling_callback,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ToolError(f"MCP call failed: {exc}") from exc
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary=f"{published_name}")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if not isinstance(event.result, MCPToolResult):
|
||||
|
|
@ -293,6 +299,7 @@ async def call_tool_stdio(
|
|||
env: dict[str, str] | None = None,
|
||||
startup_timeout_sec: float | None = None,
|
||||
tool_timeout_sec: float | None = None,
|
||||
sampling_callback: MCPSamplingHandler | None = None,
|
||||
) -> MCPToolResult:
|
||||
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
|
||||
init_timeout = (
|
||||
|
|
@ -302,7 +309,12 @@ async def call_tool_stdio(
|
|||
async with (
|
||||
_mcp_stderr_capture() as errlog,
|
||||
stdio_client(params, errlog=errlog) as (read, write),
|
||||
ClientSession(read, write, read_timeout_seconds=init_timeout) as session,
|
||||
ClientSession(
|
||||
read,
|
||||
write,
|
||||
read_timeout_seconds=init_timeout,
|
||||
sampling_callback=sampling_callback,
|
||||
) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
result = await session.call_tool(
|
||||
|
|
@ -320,6 +332,7 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
env: dict[str, str] | None = None,
|
||||
startup_timeout_sec: float | None = None,
|
||||
tool_timeout_sec: float | None = None,
|
||||
sampling_enabled: bool = True,
|
||||
) -> type[BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]]:
|
||||
def _alias_from_command(cmd: list[str]) -> str:
|
||||
prog = Path(cmd[0]).name.replace(".", "_") if cmd else "mcp"
|
||||
|
|
@ -332,7 +345,8 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
published_name = f"{computed_alias}_{remote.name}"
|
||||
|
||||
class MCPStdioProxyTool(
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState]
|
||||
BaseTool[_OpenArgs, MCPToolResult, BaseToolConfig, BaseToolState],
|
||||
ToolUIData[_OpenArgs, MCPToolResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
(f"[{computed_alias}] " if computed_alias else "")
|
||||
|
|
@ -348,6 +362,7 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
_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
|
||||
_sampling_enabled: ClassVar[bool] = sampling_enabled
|
||||
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
|
|
@ -361,6 +376,9 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
self, args: _OpenArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
|
||||
try:
|
||||
sampling_callback = (
|
||||
ctx.sampling_callback if ctx and self._sampling_enabled else None
|
||||
)
|
||||
payload = args.model_dump(exclude_none=True)
|
||||
result = await call_tool_stdio(
|
||||
self._stdio_command,
|
||||
|
|
@ -369,15 +387,12 @@ def create_mcp_stdio_proxy_tool_class(
|
|||
env=self._env,
|
||||
startup_timeout_sec=self._startup_timeout_sec,
|
||||
tool_timeout_sec=self._tool_timeout_sec,
|
||||
sampling_callback=sampling_callback,
|
||||
)
|
||||
yield result
|
||||
except Exception as exc:
|
||||
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary=f"{published_name}")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if not isinstance(event.result, MCPToolResult):
|
||||
115
vibe/core/tools/mcp_sampling.py
Normal file
115
vibe/core/tools/mcp_sampling.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ErrorData,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
logger = getLogger("vibe")
|
||||
|
||||
|
||||
class MCPSamplingHandler:
|
||||
def __init__(
|
||||
self,
|
||||
backend_getter: Callable[[], BackendLike],
|
||||
config_getter: Callable[[], Any],
|
||||
) -> None:
|
||||
self._backend_getter = backend_getter
|
||||
self._config_getter = config_getter
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
context: RequestContext[ClientSession, Any],
|
||||
params: CreateMessageRequestParams,
|
||||
) -> CreateMessageResult | ErrorData:
|
||||
try:
|
||||
config = self._config_getter()
|
||||
model = config.get_active_model()
|
||||
|
||||
messages = _map_sampling_messages(params.messages)
|
||||
|
||||
if params.systemPrompt:
|
||||
messages.insert(
|
||||
0, LLMMessage(role=Role.system, content=params.systemPrompt)
|
||||
)
|
||||
|
||||
result = await self._backend_getter().complete(
|
||||
model=model,
|
||||
messages=messages,
|
||||
temperature=params.temperature
|
||||
if params.temperature is not None
|
||||
else model.temperature,
|
||||
tools=None,
|
||||
max_tokens=params.maxTokens,
|
||||
tool_choice=None,
|
||||
extra_headers=None,
|
||||
)
|
||||
|
||||
content_text = result.message.content or ""
|
||||
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
content=TextContent(type="text", text=content_text),
|
||||
model=model.name,
|
||||
stopReason="endTurn",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("MCP sampling request failed: %s", exc)
|
||||
return ErrorData(code=-1, message=f"Sampling failed: {exc}")
|
||||
|
||||
|
||||
def _map_sampling_messages(messages: list[Any]) -> list[LLMMessage]:
|
||||
result: list[LLMMessage] = []
|
||||
for msg in messages:
|
||||
match msg.role:
|
||||
case "user":
|
||||
role = Role.user
|
||||
case "assistant":
|
||||
role = Role.assistant
|
||||
case _:
|
||||
logger.error(
|
||||
"MCP sampling: unexpected message role, treating as assistant",
|
||||
extra={"role": getattr(msg, "role", None)},
|
||||
)
|
||||
role = Role.assistant
|
||||
content = _extract_text_content(msg.content)
|
||||
result.append(LLMMessage(role=role, content=content))
|
||||
return result
|
||||
|
||||
|
||||
def _extract_text_content(content: Any) -> str:
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if (
|
||||
hasattr(block, "type")
|
||||
and block.type == "text"
|
||||
and hasattr(block, "text")
|
||||
):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
block_type = getattr(block, "type", "unknown")
|
||||
logger.debug(
|
||||
"MCP sampling: skipping unsupported content block type=%s",
|
||||
block_type,
|
||||
)
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
if hasattr(content, "type") and content.type == "text" and hasattr(content, "text"):
|
||||
return content.text
|
||||
|
||||
block_type = getattr(content, "type", "unknown")
|
||||
logger.debug("MCP sampling: unsupported single content block type=%s", block_type)
|
||||
return ""
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -19,15 +21,46 @@ class ToolResultDisplay(BaseModel):
|
|||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolUIData[TArgs: BaseModel, TResult: BaseModel](Protocol):
|
||||
class ToolUIData[TArgs: BaseModel, TResult: BaseModel](ABC):
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay: ...
|
||||
def _display_name(cls) -> str:
|
||||
get_name = cast(Callable[[], str] | None, getattr(cls, "get_name", None))
|
||||
return get_name() if get_name is not None else cls.__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def get_no_args_display(cls) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary=cls._display_name())
|
||||
|
||||
@classmethod
|
||||
def get_invalid_args_display(cls) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary="Invalid Arguments")
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: TArgs) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary=cls._display_name())
|
||||
|
||||
@classmethod
|
||||
def get_call_display(cls, event: ToolCallEvent) -> ToolCallDisplay:
|
||||
if event.args is None:
|
||||
return cls.get_no_args_display()
|
||||
|
||||
introspect = cast(
|
||||
Callable[[], tuple[type, ...]] | None,
|
||||
getattr(cls, "_get_tool_args_results", None),
|
||||
)
|
||||
if introspect is not None:
|
||||
expected_type = introspect()[0]
|
||||
if not isinstance(event.args, expected_type):
|
||||
return cls.get_invalid_args_display()
|
||||
|
||||
return cls.format_call_display(cast(TArgs, event.args))
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: ...
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_status_text(cls) -> str: ...
|
||||
|
||||
|
||||
|
|
@ -42,7 +75,11 @@ class ToolUIDataAdapter:
|
|||
if self.ui_data_class:
|
||||
return self.ui_data_class.get_call_display(event)
|
||||
|
||||
args_dict = event.args.model_dump() if hasattr(event.args, "model_dump") else {}
|
||||
args_dict = (
|
||||
event.args.model_dump()
|
||||
if event.args and hasattr(event.args, "model_dump")
|
||||
else {}
|
||||
)
|
||||
args_str = ", ".join(f"{k}={v!r}" for k, v in list(args_dict.items())[:3])
|
||||
return ToolCallDisplay(summary=f"{event.tool_name}({args_str})")
|
||||
|
||||
|
|
|
|||
69
vibe/core/tools/utils.py
Normal file
69
vibe/core/tools/utils.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
|
||||
|
||||
def resolve_path_permission(
|
||||
path_str: str, *, allowlist: list[str], denylist: list[str]
|
||||
) -> ToolPermission | None:
|
||||
"""Resolve permission for a file path against glob patterns.
|
||||
|
||||
Returns NEVER on denylist match, ALWAYS on allowlist match, None otherwise.
|
||||
"""
|
||||
file_path = Path(path_str).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
file_str = str(file_path.resolve())
|
||||
|
||||
for pattern in denylist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.NEVER
|
||||
|
||||
for pattern in allowlist:
|
||||
if fnmatch.fnmatch(file_str, pattern):
|
||||
return ToolPermission.ALWAYS
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_path_within_workdir(path_str: str) -> bool:
|
||||
"""Return True if the resolved path is inside cwd."""
|
||||
file_path = Path(path_str).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = Path.cwd() / file_path
|
||||
try:
|
||||
file_path.resolve().relative_to(Path.cwd().resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_file_tool_permission(
|
||||
path_str: str,
|
||||
*,
|
||||
allowlist: list[str],
|
||||
denylist: list[str],
|
||||
config_permission: ToolPermission,
|
||||
) -> ToolPermission | None:
|
||||
"""Resolve permission for a file-based tool invocation.
|
||||
|
||||
Checks allowlist/denylist first, then escalates to ASK for paths outside
|
||||
the working directory (unless the tool is configured as NEVER).
|
||||
Returns None to fall back to the tool's default config permission.
|
||||
"""
|
||||
if (
|
||||
result := resolve_path_permission(
|
||||
path_str, allowlist=allowlist, denylist=denylist
|
||||
)
|
||||
) is not None:
|
||||
return result
|
||||
|
||||
if not is_path_within_workdir(path_str):
|
||||
if config_permission == ToolPermission.NEVER:
|
||||
return ToolPermission.NEVER
|
||||
return ToolPermission.ASK
|
||||
|
||||
return None
|
||||
|
|
@ -6,6 +6,7 @@ import tomllib
|
|||
import tomli_w
|
||||
|
||||
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
|
||||
from vibe.core.paths.local_config_walk import walk_local_config_dirs_all
|
||||
|
||||
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||
|
||||
|
|
@ -15,11 +16,10 @@ def has_agents_md_file(path: Path) -> bool:
|
|||
|
||||
|
||||
def has_trustable_content(path: Path) -> bool:
|
||||
return (
|
||||
(path / ".vibe").exists()
|
||||
or (path / ".agents").exists()
|
||||
or has_agents_md_file(path)
|
||||
)
|
||||
if (path / ".vibe").exists():
|
||||
return True
|
||||
tools_dirs, skills_dirs, agents_dirs = walk_local_config_dirs_all(path)
|
||||
return bool(tools_dirs or skills_dirs or agents_dirs) or has_agents_md_file(path)
|
||||
|
||||
|
||||
class TrustedFoldersManager:
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ from __future__ import annotations
|
|||
|
||||
from abc import ABC
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
import copy
|
||||
from enum import StrEnum, auto
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, overload
|
||||
from uuid import uuid4
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -136,6 +137,18 @@ class SessionMetadata(BaseModel):
|
|||
username: str
|
||||
|
||||
|
||||
class ClientMetadata(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
|
||||
|
||||
class EntrypointMetadata(BaseModel):
|
||||
agent_entrypoint: Literal["cli", "acp", "programmatic"]
|
||||
agent_version: str
|
||||
client_name: str
|
||||
client_version: str
|
||||
|
||||
|
||||
StrToolChoice = Literal["auto", "none", "any", "required"]
|
||||
|
||||
|
||||
|
|
@ -159,7 +172,7 @@ class ToolCall(BaseModel):
|
|||
id: str | None = None
|
||||
index: int | None = None
|
||||
function: FunctionCall = Field(default_factory=FunctionCall)
|
||||
type: str = "function"
|
||||
type: Literal["function"] = "function"
|
||||
|
||||
|
||||
def _content_before(v: Any) -> str:
|
||||
|
|
@ -339,10 +352,11 @@ class ReasoningEvent(BaseEvent):
|
|||
|
||||
|
||||
class ToolCallEvent(BaseEvent):
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
tool_class: type[BaseTool]
|
||||
args: BaseModel
|
||||
tool_call_id: str
|
||||
tool_call_index: int | None = None
|
||||
args: BaseModel | None = None
|
||||
|
||||
|
||||
class ToolResultEvent(BaseEvent):
|
||||
|
|
@ -402,6 +416,68 @@ type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
|||
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
|
||||
|
||||
|
||||
class MessageList(Sequence[LLMMessage]):
|
||||
def __init__(
|
||||
self,
|
||||
initial: list[LLMMessage] | None = None,
|
||||
observer: Callable[[LLMMessage], None] | None = None,
|
||||
) -> None:
|
||||
self._data: list[LLMMessage] = list(initial) if initial else []
|
||||
self._observer = observer
|
||||
self._silent = False
|
||||
if self._observer:
|
||||
for msg in self._data:
|
||||
self._observer(msg)
|
||||
|
||||
def _notify(self, msg: LLMMessage) -> None:
|
||||
if not self._silent and self._observer is not None:
|
||||
self._observer(msg)
|
||||
|
||||
def append(self, msg: LLMMessage) -> None:
|
||||
self._data.append(msg)
|
||||
self._notify(msg)
|
||||
|
||||
def insert(self, i: int, msg: LLMMessage) -> None:
|
||||
self._data.insert(i, msg)
|
||||
|
||||
def extend(self, msgs: list[LLMMessage]) -> None:
|
||||
for msg in msgs:
|
||||
self.append(msg)
|
||||
|
||||
def reset(self, new: list[LLMMessage]) -> None:
|
||||
"""Replace contents silently (never notifies)."""
|
||||
self._data = list(new)
|
||||
|
||||
@contextmanager
|
||||
def silent(self) -> Iterator[None]:
|
||||
"""Context manager that suppresses notifications."""
|
||||
prev = self._silent
|
||||
self._silent = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._silent = prev
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, index: int) -> LLMMessage: ...
|
||||
@overload
|
||||
def __getitem__(self, index: slice) -> list[LLMMessage]: ...
|
||||
def __getitem__(self, index: int | slice) -> LLMMessage | list[LLMMessage]:
|
||||
return self._data[index]
|
||||
|
||||
def __iter__(self) -> Iterator[LLMMessage]:
|
||||
return iter(self._data)
|
||||
|
||||
def __contains__(self, item: object) -> bool:
|
||||
return item in self._data
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._data)
|
||||
|
||||
|
||||
class RateLimitError(Exception):
|
||||
def __init__(self, provider: str, model: str) -> None:
|
||||
self.provider = provider
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ from datetime import UTC, datetime
|
|||
from enum import Enum, auto
|
||||
from fnmatch import fnmatch
|
||||
import functools
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -19,7 +16,6 @@ import httpx
|
|||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import Backend
|
||||
from vibe.core.paths.global_paths import LOG_DIR, LOG_FILE
|
||||
from vibe.core.types import BaseEvent, ToolResultEvent
|
||||
|
||||
CANCELLATION_TAG = "user_cancellation"
|
||||
|
|
@ -139,57 +135,6 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
|||
return False, ""
|
||||
|
||||
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
|
||||
class StructuredLogFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
|
||||
ppid = os.getppid()
|
||||
pid = os.getpid()
|
||||
level = record.levelname
|
||||
message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n")
|
||||
|
||||
line = f"{timestamp} {ppid} {pid} {level} {message}"
|
||||
|
||||
if record.exc_info:
|
||||
exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
|
||||
line = f"{line} {exc_text}"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def apply_logging_config(target_logger: logging.Logger) -> None:
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024))
|
||||
|
||||
if os.environ.get("DEBUG_MODE") == "true":
|
||||
log_level_str = "DEBUG"
|
||||
else:
|
||||
log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper()
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if log_level_str not in valid_levels:
|
||||
log_level_str = "WARNING"
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8"
|
||||
)
|
||||
handler.setFormatter(StructuredLogFormatter())
|
||||
log_level = getattr(logging, log_level_str, logging.WARNING)
|
||||
handler.setLevel(log_level)
|
||||
|
||||
# Make sure the logger is not gating logs
|
||||
target_logger.setLevel(logging.DEBUG)
|
||||
|
||||
target_logger.addHandler(handler)
|
||||
|
||||
|
||||
apply_logging_config(logger)
|
||||
|
||||
|
||||
def get_user_agent(backend: Backend | None) -> str:
|
||||
user_agent = f"Mistral-Vibe/{__version__}"
|
||||
if backend == Backend.MISTRAL:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
# What's new
|
||||
|
||||
- **Performance**: general improvements (streaming, scrolling).
|
||||
- **Fix**: autocopying now behaves correctly in cases where it previously failed.
|
||||
- **Interactive resume**: Added a /resume command to choose which session to resume
|
||||
- **Web Search & Web Fetch**: New tools to search the web and fetch content from URLs directly from your session.
|
||||
- **MCP Sampling**: MCP servers can now request LLM completions through the sampling protocol.
|
||||
- **Notification Indicator**: Terminal bell and window title update when Vibe needs your attention or completes a task.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue