v2.2.0 (#395)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
This commit is contained in:
parent
51fecc67d9
commit
ec7f3b25ea
107 changed files with 8002 additions and 535 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.1.0"
|
||||
__version__ = "2.2.0"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from acp import (
|
|||
SetSessionModeResponse,
|
||||
run_agent,
|
||||
)
|
||||
from acp.helpers import ContentBlock, SessionUpdate
|
||||
from acp.helpers import ContentBlock, SessionUpdate, update_available_commands
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AgentMessageChunk,
|
||||
|
|
@ -28,6 +28,8 @@ from acp.schema import (
|
|||
AllowedOutcome,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
AvailableCommand,
|
||||
AvailableCommandInput,
|
||||
ClientCapabilities,
|
||||
ContentToolCallContent,
|
||||
ForkSessionResponse,
|
||||
|
|
@ -38,6 +40,9 @@ from acp.schema import (
|
|||
ModelInfo,
|
||||
PromptCapabilities,
|
||||
ResumeSessionResponse,
|
||||
SessionCapabilities,
|
||||
SessionInfo,
|
||||
SessionListCapabilities,
|
||||
SessionModelState,
|
||||
SessionModeState,
|
||||
SseMcpServer,
|
||||
|
|
@ -45,6 +50,7 @@ from acp.schema import (
|
|||
TextResourceContents,
|
||||
ToolCallProgress,
|
||||
ToolCallUpdate,
|
||||
UnstructuredCommandInput,
|
||||
UserMessageChunk,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
|
@ -58,15 +64,33 @@ from vibe.acp.tools.session_update import (
|
|||
from vibe.acp.utils import (
|
||||
TOOL_OPTIONS,
|
||||
ToolOption,
|
||||
create_assistant_message_replay,
|
||||
create_compact_end_session_update,
|
||||
create_compact_start_session_update,
|
||||
create_reasoning_replay,
|
||||
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,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.proxy_setup import (
|
||||
ProxySetupError,
|
||||
parse_proxy_command,
|
||||
set_proxy_var,
|
||||
unset_proxy_var,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import (
|
||||
ApprovalResponse,
|
||||
|
|
@ -74,7 +98,9 @@ from vibe.core.types import (
|
|||
AsyncApprovalCallback,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
LLMMessage,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
|
|
@ -150,10 +176,13 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
response = InitializeResponse(
|
||||
agent_capabilities=AgentCapabilities(
|
||||
load_session=False,
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(
|
||||
audio=False, embedded_context=True, image=False
|
||||
),
|
||||
session_capabilities=SessionCapabilities(
|
||||
list=SessionListCapabilities()
|
||||
),
|
||||
),
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
agent_info=Implementation(
|
||||
|
|
@ -171,6 +200,44 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
) -> AuthenticateResponse | None:
|
||||
raise NotImplementedError("Not implemented yet")
|
||||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
raise RequestError.auth_required({
|
||||
"message": "You must be authenticated before creating a session"
|
||||
}) from e
|
||||
|
||||
async def _create_acp_session(
|
||||
self, session_id: str, agent_loop: AgentLoop
|
||||
) -> AcpSessionLoop:
|
||||
session = AcpSessionLoop(id=session_id, agent_loop=agent_loop)
|
||||
self.sessions[session.id] = session
|
||||
|
||||
if not agent_loop.auto_approve:
|
||||
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
|
||||
|
||||
asyncio.create_task(self._send_available_commands(session.id))
|
||||
|
||||
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,
|
||||
|
|
@ -181,13 +248,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
except MissingAPIKeyError as e:
|
||||
raise RequestError.auth_required({
|
||||
"message": "You must be authenticated before creating a new session"
|
||||
}) from e
|
||||
config = self._load_config()
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
|
|
@ -196,29 +257,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
# We should just use agent_loop.session_id everywhere, but it can still change during
|
||||
# session lifetime (e.g. agent_loop.compact is called).
|
||||
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
||||
session = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
|
||||
self.sessions[session.id] = session
|
||||
session = await self._create_acp_session(agent_loop.session_id, agent_loop)
|
||||
agent_loop.emit_new_session_telemetry("acp")
|
||||
|
||||
if not agent_loop.auto_approve:
|
||||
agent_loop.set_approval_callback(
|
||||
self._create_approval_callback(agent_loop.session_id)
|
||||
)
|
||||
|
||||
response = NewSessionResponse(
|
||||
session_id=agent_loop.session_id,
|
||||
models=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
|
||||
],
|
||||
),
|
||||
modes=SessionModeState(
|
||||
current_mode_id=session.agent_loop.agent_profile.name,
|
||||
available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
|
||||
),
|
||||
return NewSessionResponse(
|
||||
session_id=session.id,
|
||||
models=self._build_session_model_state(agent_loop),
|
||||
modes=self._build_session_mode_state(session),
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_acp_tool_overrides(self) -> list[Path]:
|
||||
overrides = ["todo"]
|
||||
|
|
@ -293,6 +339,85 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
raise RequestError.invalid_params({"session": "Not found"})
|
||||
return self.sessions[session_id]
|
||||
|
||||
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
|
||||
if not msg.tool_calls:
|
||||
return
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id and tool_call.function.name:
|
||||
update = create_tool_call_replay(
|
||||
tool_call.id, tool_call.function.name, tool_call.function.arguments
|
||||
)
|
||||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
async def _replay_conversation_history(
|
||||
self, session_id: str, messages: list[LLMMessage]
|
||||
) -> None:
|
||||
for msg in messages:
|
||||
if msg.role == Role.user:
|
||||
update = create_user_message_replay(msg)
|
||||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
elif msg.role == Role.assistant:
|
||||
if text_update := create_assistant_message_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=text_update
|
||||
)
|
||||
if reasoning_update := create_reasoning_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=reasoning_update
|
||||
)
|
||||
await self._replay_tool_calls(session_id, msg)
|
||||
|
||||
elif msg.role == Role.tool:
|
||||
if result_update := create_tool_result_replay(msg):
|
||||
await self.client.session_update(
|
||||
session_id=session_id, update=result_update
|
||||
)
|
||||
|
||||
async def _send_available_commands(self, session_id: str) -> None:
|
||||
commands = [
|
||||
AvailableCommand(
|
||||
name="proxy-setup",
|
||||
description="Configure proxy and SSL certificate settings",
|
||||
input=AvailableCommandInput(
|
||||
root=UnstructuredCommandInput(
|
||||
hint="KEY value to set, KEY to unset, or empty for help"
|
||||
)
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
update = update_available_commands(commands)
|
||||
await self.client.session_update(session_id=session_id, update=update)
|
||||
|
||||
async def _handle_proxy_setup_command(
|
||||
self, session_id: str, text_prompt: str
|
||||
) -> PromptResponse:
|
||||
args = text_prompt.strip()[len("/proxy-setup") :].strip()
|
||||
|
||||
try:
|
||||
if not args:
|
||||
message = get_proxy_help_text()
|
||||
else:
|
||||
key, value = parse_proxy_command(args)
|
||||
if value is not None:
|
||||
set_proxy_var(key, value)
|
||||
message = f"Set `{key}={value}` in ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
|
||||
else:
|
||||
unset_proxy_var(key)
|
||||
message = f"Removed `{key}` from ~/.vibe/.env\n\nPlease start a new chat for changes to take effect."
|
||||
except ProxySetupError as e:
|
||||
message = f"Error: {e}"
|
||||
|
||||
await self.client.session_update(
|
||||
session_id=session_id,
|
||||
update=AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=message),
|
||||
),
|
||||
)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
@override
|
||||
async def load_session(
|
||||
self,
|
||||
|
|
@ -301,7 +426,44 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LoadSessionResponse | None:
|
||||
raise NotImplementedError()
|
||||
load_dotenv_values()
|
||||
os.chdir(cwd)
|
||||
|
||||
config = self._load_config()
|
||||
|
||||
session_dir = SessionLoader.find_session_by_id(
|
||||
session_id, config.session_logging
|
||||
)
|
||||
if session_dir is None:
|
||||
raise RequestError.invalid_params({
|
||||
"session_id": f"Session not found: {session_id}"
|
||||
})
|
||||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_dir)
|
||||
except ValueError as e:
|
||||
raise RequestError.invalid_params({
|
||||
"session_id": f"Failed to load session: {e}"
|
||||
}) from e
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
||||
)
|
||||
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
]
|
||||
|
||||
agent_loop.messages.extend(non_system_messages)
|
||||
|
||||
session = await self._create_acp_session(session_id, agent_loop)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
@override
|
||||
async def set_session_mode(
|
||||
|
|
@ -348,7 +510,27 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
async def list_sessions(
|
||||
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
|
||||
) -> ListSessionsResponse:
|
||||
raise NotImplementedError()
|
||||
try:
|
||||
config = VibeConfig.load()
|
||||
session_logging_config = config.session_logging
|
||||
except MissingAPIKeyError:
|
||||
session_logging_config = SessionLoggingConfig()
|
||||
|
||||
session_data = SessionLoader.list_sessions(session_logging_config, cwd=cwd)
|
||||
|
||||
sessions = [
|
||||
SessionInfo(
|
||||
session_id=s["session_id"],
|
||||
cwd=s["cwd"],
|
||||
title=s.get("title"),
|
||||
updated_at=s.get("end_time"),
|
||||
)
|
||||
for s in sorted(
|
||||
session_data, key=lambda s: s.get("end_time") or "", reverse=True
|
||||
)
|
||||
]
|
||||
|
||||
return ListSessionsResponse(sessions=sessions)
|
||||
|
||||
@override
|
||||
async def prompt(
|
||||
|
|
@ -363,6 +545,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
text_prompt = self._build_text_prompt(prompt)
|
||||
|
||||
if text_prompt.strip().lower().startswith("/proxy-setup"):
|
||||
return await self._handle_proxy_setup_command(session_id, text_prompt)
|
||||
|
||||
temp_user_message_id: str | None = kwargs.get("messageId")
|
||||
|
||||
async def agent_loop_task() -> None:
|
||||
|
|
|
|||
|
|
@ -4,16 +4,20 @@ from enum import StrEnum
|
|||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
ContentToolCallContent,
|
||||
PermissionOption,
|
||||
SessionMode,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
UserMessageChunk,
|
||||
)
|
||||
|
||||
from vibe.core.agents.models import AgentProfile, AgentType
|
||||
from vibe.core.types import CompactEndEvent, CompactStartEvent
|
||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
||||
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
||||
from vibe.core.utils import compact_reduction_display
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -111,3 +115,99 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
|
|||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_proxy_help_text() -> str:
|
||||
lines = [
|
||||
"## Proxy Configuration",
|
||||
"",
|
||||
"Configure proxy and SSL settings for HTTP requests.",
|
||||
"",
|
||||
"### Usage:",
|
||||
"- `/proxy-setup` - Show this help and current settings",
|
||||
"- `/proxy-setup KEY value` - Set an environment variable",
|
||||
"- `/proxy-setup KEY` - Remove an environment variable",
|
||||
"",
|
||||
"### Supported Variables:",
|
||||
]
|
||||
|
||||
for key, description in SUPPORTED_PROXY_VARS.items():
|
||||
lines.append(f"- `{key}`: {description}")
|
||||
|
||||
lines.extend(["", "### Current Settings:"])
|
||||
|
||||
current = get_current_proxy_settings()
|
||||
any_set = False
|
||||
for key, value in current.items():
|
||||
if value:
|
||||
lines.append(f"- `{key}={value}`")
|
||||
any_set = True
|
||||
|
||||
if not any_set:
|
||||
lines.append("- (none configured)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
return UserMessageChunk(
|
||||
session_update="user_message_chunk",
|
||||
content=TextContentBlock(type="text", text=content),
|
||||
field_meta={"messageId": msg.message_id} if msg.message_id else {},
|
||||
)
|
||||
|
||||
|
||||
def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None:
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
return AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=content),
|
||||
field_meta={"messageId": msg.message_id} if msg.message_id else {},
|
||||
)
|
||||
|
||||
|
||||
def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None:
|
||||
if not isinstance(msg.reasoning_content, str) or not msg.reasoning_content:
|
||||
return None
|
||||
|
||||
return AgentThoughtChunk(
|
||||
session_update="agent_thought_chunk",
|
||||
content=TextContentBlock(type="text", text=msg.reasoning_content),
|
||||
field_meta={"messageId": msg.message_id} if msg.message_id else {},
|
||||
)
|
||||
|
||||
|
||||
def create_tool_call_replay(
|
||||
tool_call_id: str, tool_name: str, arguments: str | None
|
||||
) -> ToolCallStart:
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
kind="other",
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
|
||||
def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None:
|
||||
if not msg.tool_call_id:
|
||||
return None
|
||||
|
||||
content = msg.content if isinstance(msg.content, str) else ""
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=msg.tool_call_id,
|
||||
status="completed",
|
||||
raw_output=content,
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content", content=TextContentBlock(type="text", text=content)
|
||||
)
|
||||
]
|
||||
if content
|
||||
else None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str:
|
|||
return dense_text
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
|
||||
selected_texts = []
|
||||
|
||||
for widget in app.query("*"):
|
||||
|
|
@ -48,7 +48,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
|||
selected_texts.append(selected_text)
|
||||
|
||||
if not selected_texts:
|
||||
return
|
||||
return None
|
||||
|
||||
combined_text = "\n".join(selected_texts)
|
||||
|
||||
|
|
@ -61,7 +61,9 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
|||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
return combined_text
|
||||
except Exception:
|
||||
app.notify(
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ class CommandRegistry:
|
|||
description="Teleport session to Vibe Nuage",
|
||||
handler="_teleport_command",
|
||||
),
|
||||
"proxy-setup": Command(
|
||||
aliases=frozenset(["/proxy-setup"]),
|
||||
description="Configure proxy and SSL certificate settings",
|
||||
handler="_show_proxy_setup",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
@ -78,9 +83,12 @@ class CommandRegistry:
|
|||
self._alias_map[alias] = cmd_name
|
||||
|
||||
def find_command(self, user_input: str) -> Command | None:
|
||||
cmd_name = self._alias_map.get(user_input.lower().strip())
|
||||
cmd_name = self.get_command_name(user_input)
|
||||
return self.commands.get(cmd_name) if cmd_name else None
|
||||
|
||||
def get_command_name(self, user_input: str) -> str | None:
|
||||
return self._alias_map.get(user_input.lower().strip())
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
lines: list[str] = [
|
||||
"### Keyboard Shortcuts",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
)
|
||||
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.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
|
|
@ -127,6 +128,7 @@ class BottomApp(StrEnum):
|
|||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
|
||||
|
||||
|
|
@ -308,6 +310,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")
|
||||
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
|
@ -422,6 +425,24 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
self, message: ProxySetupApp.ProxySetupClosed
|
||||
) -> None:
|
||||
if message.error:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(f"Failed to save proxy settings: {message.error}")
|
||||
)
|
||||
elif message.saved:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Proxy settings saved. Restart the CLI for changes to take effect."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(UserCommandMessage("Proxy setup cancelled."))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_compact_message_completed(
|
||||
self, message: CompactMessage.Completed
|
||||
) -> None:
|
||||
|
|
@ -449,6 +470,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
if cmd_name := self.commands.get_command_name(user_input):
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
|
|
@ -479,6 +504,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if not skill_info:
|
||||
return False
|
||||
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||
|
||||
try:
|
||||
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
|
|
@ -823,6 +850,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_proxy_setup(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
|
|
@ -996,6 +1028,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||
await self._switch_from_input(ConfigApp(self.config))
|
||||
|
||||
async def _switch_to_proxy_setup_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Proxy setup opened..."))
|
||||
await self._switch_from_input(ProxySetupApp())
|
||||
|
||||
async def _switch_to_approval_app(
|
||||
self, tool_name: str, tool_args: BaseModel
|
||||
) -> None:
|
||||
|
|
@ -1020,6 +1059,7 @@ 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_to_bottom)
|
||||
|
||||
def _focus_current_bottom_app(self) -> None:
|
||||
try:
|
||||
|
|
@ -1028,6 +1068,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ChatInputContainer).focus_input()
|
||||
case BottomApp.Config:
|
||||
self.query_one(ConfigApp).focus()
|
||||
case BottomApp.ProxySetup:
|
||||
self.query_one(ProxySetupApp).focus()
|
||||
case BottomApp.Approval:
|
||||
self.query_one(ApprovalApp).focus()
|
||||
case BottomApp.Question:
|
||||
|
|
@ -1037,34 +1079,66 @@ class VibeApp(App): # noqa: PLR0904
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle_config_app_escape(self) -> None:
|
||||
try:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
config_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_approval_app_escape(self) -> None:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
approval_app.action_reject()
|
||||
except Exception:
|
||||
pass
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_question_app_escape(self) -> None:
|
||||
try:
|
||||
question_app = self.query_one(QuestionApp)
|
||||
question_app.action_cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_input_app_escape(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
input_widget.value = ""
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_agent_running_escape(self) -> None:
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
self._handle_config_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
config_app.action_close()
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
proxy_setup_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
approval_app.action_reject()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
self._handle_approval_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Question:
|
||||
try:
|
||||
question_app = self.query_one(QuestionApp)
|
||||
question_app.action_cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if (
|
||||
|
|
@ -1072,17 +1146,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
and self._last_escape_time is not None
|
||||
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
|
||||
):
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
if input_widget.value:
|
||||
input_widget.value = ""
|
||||
self._last_escape_time = None
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
self._handle_input_app_escape()
|
||||
return
|
||||
|
||||
if self._agent_running:
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
self._handle_agent_running_escape()
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self._scroll_to_bottom()
|
||||
|
|
@ -1430,11 +1498,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
|
||||
def action_copy_selection(self) -> None:
|
||||
copy_selection_to_clipboard(self, show_toast=False)
|
||||
copied_text = copy_selection_to_clipboard(self, show_toast=False)
|
||||
if copied_text is not None:
|
||||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
if self.config.autocopy_to_clipboard:
|
||||
copy_selection_to_clipboard(self, show_toast=True)
|
||||
copied_text = copy_selection_to_clipboard(self, show_toast=True)
|
||||
if copied_text is not None:
|
||||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
|
|
|
|||
|
|
@ -703,6 +703,44 @@ StatusMessage {
|
|||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#proxysetup-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#proxysetup-content {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.proxy-input {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
margin-top: 1;
|
||||
padding: 0 0 0 1;
|
||||
}
|
||||
|
||||
#approval-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
127
vibe/cli/textual_ui/widgets/proxy_setup_app.py
Normal file
127
vibe/cli/textual_ui/widgets/proxy_setup_app.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from textual import events
|
||||
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 Input, Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.proxy_setup import (
|
||||
SUPPORTED_PROXY_VARS,
|
||||
get_current_proxy_settings,
|
||||
set_proxy_var,
|
||||
unset_proxy_var,
|
||||
)
|
||||
|
||||
|
||||
class ProxySetupApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "focus_previous", "Up", show=False),
|
||||
Binding("down", "focus_next", "Down", show=False),
|
||||
]
|
||||
|
||||
class ProxySetupClosed(Message):
|
||||
def __init__(self, saved: bool, error: str | None = None) -> None:
|
||||
super().__init__()
|
||||
self.saved = saved
|
||||
self.error = error
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="proxysetup-app")
|
||||
self.inputs: dict[str, Input] = {}
|
||||
self.initial_values: dict[str, str | None] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self.initial_values = get_current_proxy_settings()
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
initial_value = self.initial_values.get(key) or ""
|
||||
input_widget = Input(
|
||||
value=initial_value,
|
||||
placeholder="NOT SET",
|
||||
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"
|
||||
)
|
||||
|
||||
def focus(self, scroll_visible: bool = True) -> ProxySetupApp:
|
||||
"""Override focus to focus the first input widget."""
|
||||
if self.inputs:
|
||||
first_input = list(self.inputs.values())[0]
|
||||
first_input.focus(scroll_visible=scroll_visible)
|
||||
else:
|
||||
super().focus(scroll_visible=scroll_visible)
|
||||
return self
|
||||
|
||||
def action_focus_next(self) -> None:
|
||||
inputs = list(self.inputs.values())
|
||||
focused = self.screen.focused
|
||||
if focused is not None and isinstance(focused, Input) and focused in inputs:
|
||||
idx = inputs.index(focused)
|
||||
next_idx = (idx + 1) % len(inputs)
|
||||
inputs[next_idx].focus()
|
||||
|
||||
def action_focus_previous(self) -> None:
|
||||
inputs = list(self.inputs.values())
|
||||
focused = self.screen.focused
|
||||
if focused is not None and isinstance(focused, Input) and focused in inputs:
|
||||
idx = inputs.index(focused)
|
||||
prev_idx = (idx - 1) % len(inputs)
|
||||
inputs[prev_idx].focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
self._save_and_close()
|
||||
|
||||
def on_blur(self, _event: events.Blur) -> None:
|
||||
self.call_after_refresh(self._refocus_if_needed)
|
||||
|
||||
def on_input_blurred(self, _event: Input.Blurred) -> None:
|
||||
self.call_after_refresh(self._refocus_if_needed)
|
||||
|
||||
def _refocus_if_needed(self) -> None:
|
||||
if self.has_focus or any(inp.has_focus for inp in self.inputs.values()):
|
||||
return
|
||||
self.focus()
|
||||
|
||||
def _save_and_close(self) -> None:
|
||||
try:
|
||||
for key, input_widget in self.inputs.items():
|
||||
new_value = input_widget.value.strip()
|
||||
old_value = self.initial_values.get(key) or ""
|
||||
|
||||
if new_value != old_value:
|
||||
if new_value:
|
||||
set_proxy_var(key, new_value)
|
||||
else:
|
||||
unset_proxy_var(key)
|
||||
except Exception as e:
|
||||
self.post_message(self.ProxySetupClosed(saved=False, error=str(e)))
|
||||
return
|
||||
|
||||
self.post_message(self.ProxySetupClosed(saved=True))
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ProxySetupClosed(saved=False))
|
||||
|
|
@ -4,19 +4,26 @@ import asyncio
|
|||
from collections.abc import AsyncGenerator, Callable
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import Backend, ProviderConfig, VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.exceptions import BackendError
|
||||
from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall
|
||||
from vibe.core.llm.format import (
|
||||
APIToolFormatHandler,
|
||||
FailedToolCall,
|
||||
ResolvedMessage,
|
||||
ResolvedToolCall,
|
||||
)
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.middleware import (
|
||||
AutoCompactMiddleware,
|
||||
|
|
@ -35,6 +42,7 @@ from vibe.core.session.session_logger import SessionLogger
|
|||
from vibe.core.session.session_migration import migrate_sessions_entrypoint
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.system_prompt import get_universal_system_prompt
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
|
|
@ -44,6 +52,7 @@ from vibe.core.tools.base import (
|
|||
ToolPermissionError,
|
||||
)
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
from vibe.core.trusted_folders import has_agents_md_file
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalCallback,
|
||||
|
|
@ -96,6 +105,7 @@ class ToolExecutionResponse(StrEnum):
|
|||
|
||||
class ToolDecision(BaseModel):
|
||||
verdict: ToolExecutionResponse
|
||||
approval_type: ToolPermission
|
||||
feedback: str | None = None
|
||||
|
||||
|
||||
|
|
@ -171,7 +181,9 @@ class AgentLoop:
|
|||
self.user_input_callback: UserInputCallback | None = None
|
||||
|
||||
self.session_id = str(uuid4())
|
||||
self._current_user_message_id: str | None = None
|
||||
|
||||
self.telemetry_client = TelemetryClient(config_getter=lambda: self.config)
|
||||
self.session_logger = SessionLogger(config.session_logging, self.session_id)
|
||||
self._teleport_service: TeleportService | None = None
|
||||
|
||||
|
|
@ -209,6 +221,21 @@ 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:
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
def _select_backend(self) -> BackendLike:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
|
@ -331,12 +358,11 @@ class AgentLoop:
|
|||
)
|
||||
|
||||
case MiddlewareAction.INJECT_MESSAGE:
|
||||
if result.message and len(self.messages) > 0:
|
||||
last_msg = self.messages[-1]
|
||||
if last_msg.content:
|
||||
last_msg.content += f"\n\n{result.message}"
|
||||
else:
|
||||
last_msg.content = result.message
|
||||
if result.message:
|
||||
injected_message = LLMMessage(
|
||||
role=Role.user, content=result.message
|
||||
)
|
||||
self.messages.append(injected_message)
|
||||
|
||||
case MiddlewareAction.COMPACT:
|
||||
old_tokens = result.metadata.get(
|
||||
|
|
@ -352,6 +378,7 @@ class AgentLoop:
|
|||
current_context_tokens=old_tokens,
|
||||
threshold=threshold,
|
||||
)
|
||||
self.telemetry_client.send_auto_compact_triggered()
|
||||
|
||||
summary = await self.compact()
|
||||
|
||||
|
|
@ -370,10 +397,25 @@ class AgentLoop:
|
|||
messages=self.messages, stats=self.stats, config=self.config
|
||||
)
|
||||
|
||||
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
}
|
||||
if (
|
||||
provider.backend == Backend.MISTRAL
|
||||
and self._current_user_message_id is not None
|
||||
):
|
||||
headers["metadata"] = json.dumps({
|
||||
"message_id": self._current_user_message_id
|
||||
})
|
||||
return headers
|
||||
|
||||
async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]:
|
||||
user_message = LLMMessage(role=Role.user, content=user_msg)
|
||||
self.messages.append(user_message)
|
||||
self.stats.steps += 1
|
||||
self._current_user_message_id = user_message.message_id
|
||||
|
||||
if user_message.message_id is None:
|
||||
raise AgentLoopError("User message must have a message_id")
|
||||
|
|
@ -406,15 +448,6 @@ class AgentLoop:
|
|||
if user_cancelled:
|
||||
return
|
||||
|
||||
after_result = await self.middleware_pipeline.run_after_turn(
|
||||
self._get_context()
|
||||
)
|
||||
async for event in self._handle_middleware_result(after_result):
|
||||
yield event
|
||||
|
||||
if after_result.action == MiddlewareAction.STOP:
|
||||
return
|
||||
|
||||
finally:
|
||||
await self._flush_new_messages()
|
||||
|
||||
|
|
@ -497,19 +530,17 @@ class AgentLoop:
|
|||
message_id=llm_result.message.message_id,
|
||||
)
|
||||
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
|
||||
for failed in resolved.failed_calls:
|
||||
async def _emit_failed_tool_events(
|
||||
self, failed_calls: list[FailedToolCall]
|
||||
) -> AsyncGenerator[ToolResultEvent]:
|
||||
for failed in failed_calls:
|
||||
error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}</{TOOL_ERROR_TAG}>"
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=failed.tool_name,
|
||||
tool_class=None,
|
||||
error=error_msg,
|
||||
tool_call_id=failed.call_id,
|
||||
)
|
||||
|
||||
self.stats.tool_calls_failed += 1
|
||||
self.messages.append(
|
||||
self.format_handler.create_failed_tool_response_message(
|
||||
|
|
@ -517,6 +548,113 @@ class AgentLoop:
|
|||
)
|
||||
)
|
||||
|
||||
async def _process_one_tool_call(
|
||||
self, tool_call: ResolvedToolCall
|
||||
) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]:
|
||||
try:
|
||||
tool_instance = self.tool_manager.get(tool_call.tool_name)
|
||||
except Exception as exc:
|
||||
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, error_msg, "failure")
|
||||
return
|
||||
|
||||
decision = await self._should_execute_tool(
|
||||
tool_instance, tool_call.validated_args, tool_call.call_id
|
||||
)
|
||||
|
||||
if decision.verdict == ToolExecutionResponse.SKIP:
|
||||
self.stats.tool_calls_rejected += 1
|
||||
skip_reason = decision.feedback or str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
|
||||
)
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
skipped=True,
|
||||
skip_reason=skip_reason,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
|
||||
return
|
||||
|
||||
self.stats.tool_calls_agreed += 1
|
||||
|
||||
try:
|
||||
start_time = time.perf_counter()
|
||||
result_model = None
|
||||
async for item in tool_instance.invoke(
|
||||
ctx=InvokeContext(
|
||||
tool_call_id=tool_call.call_id,
|
||||
approval_callback=self.approval_callback,
|
||||
agent_manager=self.agent_manager,
|
||||
user_input_callback=self.user_input_callback,
|
||||
),
|
||||
**tool_call.args_dict,
|
||||
):
|
||||
if isinstance(item, ToolStreamEvent):
|
||||
yield item
|
||||
else:
|
||||
result_model = item
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
if result_model is None:
|
||||
raise ToolError("Tool did not yield a result")
|
||||
|
||||
result_dict = result_model.model_dump()
|
||||
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
|
||||
self._handle_tool_response(
|
||||
tool_call, text, "success", decision, result_dict
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
result=result_model,
|
||||
duration=duration,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self.stats.tool_calls_succeeded += 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
cancel = str(
|
||||
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=cancel,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, cancel, "failure", decision)
|
||||
raise
|
||||
|
||||
except (ToolError, ToolPermissionError) as exc:
|
||||
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
if isinstance(exc, ToolPermissionError):
|
||||
self.stats.tool_calls_agreed -= 1
|
||||
self.stats.tool_calls_rejected += 1
|
||||
else:
|
||||
self.stats.tool_calls_failed += 1
|
||||
self._handle_tool_response(tool_call, error_msg, "failure", decision)
|
||||
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
|
||||
async for event in self._emit_failed_tool_events(resolved.failed_calls):
|
||||
yield event
|
||||
for tool_call in resolved.tool_calls:
|
||||
yield ToolCallEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
|
|
@ -524,120 +662,31 @@ class AgentLoop:
|
|||
args=tool_call.validated_args,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
async for event in self._process_one_tool_call(tool_call):
|
||||
yield event
|
||||
|
||||
try:
|
||||
tool_instance = self.tool_manager.get(tool_call.tool_name)
|
||||
except Exception as exc:
|
||||
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._append_tool_response(tool_call, error_msg)
|
||||
continue
|
||||
|
||||
decision = await self._should_execute_tool(
|
||||
tool_instance, tool_call.validated_args, tool_call.call_id
|
||||
)
|
||||
|
||||
if decision.verdict == ToolExecutionResponse.SKIP:
|
||||
self.stats.tool_calls_rejected += 1
|
||||
skip_reason = decision.feedback or str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
|
||||
)
|
||||
)
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
skipped=True,
|
||||
skip_reason=skip_reason,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._append_tool_response(tool_call, skip_reason)
|
||||
continue
|
||||
|
||||
self.stats.tool_calls_agreed += 1
|
||||
|
||||
try:
|
||||
start_time = time.perf_counter()
|
||||
result_model = None
|
||||
|
||||
async for item in tool_instance.invoke(
|
||||
ctx=InvokeContext(
|
||||
tool_call_id=tool_call.call_id,
|
||||
approval_callback=self.approval_callback,
|
||||
agent_manager=self.agent_manager,
|
||||
user_input_callback=self.user_input_callback,
|
||||
),
|
||||
**tool_call.args_dict,
|
||||
):
|
||||
if isinstance(item, ToolStreamEvent):
|
||||
yield item
|
||||
else:
|
||||
result_model = item
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
|
||||
if result_model is None:
|
||||
raise ToolError("Tool did not yield a result")
|
||||
|
||||
text = "\n".join(
|
||||
f"{k}: {v}" for k, v in result_model.model_dump().items()
|
||||
)
|
||||
self._append_tool_response(tool_call, text)
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
result=result_model,
|
||||
duration=duration,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
self.stats.tool_calls_succeeded += 1
|
||||
|
||||
except asyncio.CancelledError:
|
||||
cancel = str(
|
||||
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=cancel,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._append_tool_response(tool_call, cancel)
|
||||
raise
|
||||
|
||||
except (ToolError, ToolPermissionError) as exc:
|
||||
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
|
||||
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
if isinstance(exc, ToolPermissionError):
|
||||
self.stats.tool_calls_agreed -= 1
|
||||
self.stats.tool_calls_rejected += 1
|
||||
else:
|
||||
self.stats.tool_calls_failed += 1
|
||||
self._append_tool_response(tool_call, error_msg)
|
||||
continue
|
||||
|
||||
def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None:
|
||||
def _handle_tool_response(
|
||||
self,
|
||||
tool_call: ResolvedToolCall,
|
||||
text: str,
|
||||
status: Literal["success", "failure", "skipped"],
|
||||
decision: ToolDecision | None = None,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.messages.append(
|
||||
LLMMessage.model_validate(
|
||||
self.format_handler.create_tool_response_message(tool_call, text)
|
||||
)
|
||||
)
|
||||
|
||||
self.telemetry_client.send_tool_call_finished(
|
||||
tool_call=tool_call,
|
||||
agent_profile_name=self.agent_profile.name,
|
||||
status=status,
|
||||
decision=decision,
|
||||
result=result,
|
||||
)
|
||||
|
||||
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
|
@ -653,10 +702,7 @@ class AgentLoop:
|
|||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
end_time = time.perf_counter()
|
||||
|
|
@ -699,10 +745,7 @@ class AgentLoop:
|
|||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
):
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
|
|
@ -744,16 +787,23 @@ class AgentLoop:
|
|||
self, tool: BaseTool, args: BaseModel, tool_call_id: str
|
||||
) -> ToolDecision:
|
||||
if self.auto_approve:
|
||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ALWAYS,
|
||||
)
|
||||
|
||||
allowlist_denylist_result = tool.check_allowlist_denylist(args)
|
||||
if allowlist_denylist_result == ToolPermission.ALWAYS:
|
||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||
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}]",
|
||||
)
|
||||
|
||||
|
|
@ -761,10 +811,14 @@ class AgentLoop:
|
|||
perm = self.tool_manager.get_tool_config(tool_name).permission
|
||||
|
||||
if perm is ToolPermission.ALWAYS:
|
||||
return ToolDecision(verdict=ToolExecutionResponse.EXECUTE)
|
||||
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",
|
||||
)
|
||||
|
||||
|
|
@ -776,6 +830,7 @@ class AgentLoop:
|
|||
if not self.approval_callback:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback="Tool execution not permitted.",
|
||||
)
|
||||
if asyncio.iscoroutinefunction(self.approval_callback):
|
||||
|
|
@ -788,11 +843,15 @@ class AgentLoop:
|
|||
match response:
|
||||
case ApprovalResponse.YES:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback=feedback,
|
||||
)
|
||||
case ApprovalResponse.NO:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP, feedback=feedback
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback=feedback,
|
||||
)
|
||||
|
||||
def _clean_message_history(self) -> None:
|
||||
|
|
@ -890,7 +949,6 @@ class AgentLoop:
|
|||
self._reset_session()
|
||||
|
||||
async def compact(self) -> str:
|
||||
"""Compact the conversation history."""
|
||||
try:
|
||||
self._clean_message_history()
|
||||
await self.session_logger.save_interaction(
|
||||
|
|
@ -915,6 +973,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
|
||||
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
|
@ -955,13 +1014,14 @@ class AgentLoop:
|
|||
if agent_name == self.agent_profile.name:
|
||||
return
|
||||
self.agent_manager.switch_profile(agent_name)
|
||||
await self.reload_with_initial_messages()
|
||||
await self.reload_with_initial_messages(reset_middleware=False)
|
||||
|
||||
async def reload_with_initial_messages(
|
||||
self,
|
||||
base_config: VibeConfig | None = None,
|
||||
max_turns: int | None = None,
|
||||
max_price: float | None = None,
|
||||
reset_middleware: bool = True,
|
||||
) -> None:
|
||||
# Force an immediate yield to allow the UI to update before heavy sync work.
|
||||
# When there are no messages, save_interaction returns early without any await,
|
||||
|
|
@ -1011,19 +1071,5 @@ class AgentLoop:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
self._last_observed_message_index = 0
|
||||
|
||||
self._setup_middleware()
|
||||
|
||||
if self.message_observer:
|
||||
for msg in self.messages:
|
||||
self.message_observer(msg)
|
||||
self._last_observed_message_index = len(self.messages)
|
||||
|
||||
await self.session_logger.save_interaction(
|
||||
self.messages,
|
||||
self.stats,
|
||||
self._base_config,
|
||||
self.tool_manager,
|
||||
self.agent_profile,
|
||||
)
|
||||
if reset_middleware:
|
||||
self._setup_middleware()
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ EXPLORE = AgentProfile(
|
|||
description="Read-only subagent for codebase exploration",
|
||||
safety=AgentSafety.SAFE,
|
||||
agent_type=AgentType.SUBAGENT,
|
||||
overrides={"enabled_tools": ["grep", "read_file"]},
|
||||
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
|
||||
)
|
||||
|
||||
BUILTIN_AGENTS: dict[str, AgentProfile] = {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ class ProviderConfig(BaseModel):
|
|||
api_style: str = "openai"
|
||||
backend: Backend = Backend.GENERIC
|
||||
reasoning_field_name: str = "reasoning_content"
|
||||
project_id: str = ""
|
||||
region: str = ""
|
||||
|
||||
|
||||
class _MCPBase(BaseModel):
|
||||
|
|
@ -251,6 +253,7 @@ class ModelConfig(BaseModel):
|
|||
temperature: float = 0.2
|
||||
input_price: float = 0.0 # Price per million input tokens
|
||||
output_price: float = 0.0 # Price per million output tokens
|
||||
thinking: Literal["off", "low", "medium", "high"] = "off"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -312,6 +315,7 @@ class VibeConfig(BaseSettings):
|
|||
auto_compact_threshold: int = 200_000
|
||||
context_warnings: bool = False
|
||||
auto_approve: bool = False
|
||||
enable_telemetry: bool = True
|
||||
system_prompt_id: str = "cli"
|
||||
include_commit_signature: bool = True
|
||||
include_model_info: bool = True
|
||||
|
|
|
|||
630
vibe/core/llm/backend/anthropic.py
Normal file
630
vibe/core/llm/backend/anthropic.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
FunctionCall,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
Role,
|
||||
StrToolChoice,
|
||||
ToolCall,
|
||||
)
|
||||
|
||||
|
||||
class AnthropicMapper:
|
||||
"""Shared mapper for converting messages to/from Anthropic API format."""
|
||||
|
||||
def prepare_messages(
|
||||
self, messages: list[LLMMessage]
|
||||
) -> tuple[str | None, list[dict[str, Any]]]:
|
||||
system_prompt: str | None = None
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
match msg.role:
|
||||
case Role.system:
|
||||
system_prompt = msg.content or ""
|
||||
case Role.user:
|
||||
user_content: list[dict[str, Any]] = []
|
||||
if msg.content:
|
||||
user_content.append({"type": "text", "text": msg.content})
|
||||
converted.append({"role": "user", "content": user_content or ""})
|
||||
case Role.assistant:
|
||||
converted.append(self._convert_assistant_message(msg))
|
||||
case Role.tool:
|
||||
self._append_tool_result(converted, msg)
|
||||
|
||||
return system_prompt, converted
|
||||
|
||||
def _sanitize_tool_call_id(self, tool_id: str | None) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id or "")
|
||||
|
||||
def _convert_assistant_message(self, msg: LLMMessage) -> dict[str, Any]:
|
||||
content: list[dict[str, Any]] = []
|
||||
if msg.reasoning_content:
|
||||
block: dict[str, Any] = {
|
||||
"type": "thinking",
|
||||
"thinking": msg.reasoning_content,
|
||||
}
|
||||
if msg.reasoning_signature:
|
||||
block["signature"] = msg.reasoning_signature
|
||||
content.append(block)
|
||||
if msg.content:
|
||||
content.append({"type": "text", "text": msg.content})
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
content.append(self._convert_tool_call(tc))
|
||||
return {"role": "assistant", "content": content if content else ""}
|
||||
|
||||
def _convert_tool_call(self, tc: ToolCall) -> dict[str, Any]:
|
||||
try:
|
||||
tool_input = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
tool_input = {}
|
||||
return {
|
||||
"type": "tool_use",
|
||||
"id": self._sanitize_tool_call_id(tc.id),
|
||||
"name": tc.function.name,
|
||||
"input": tool_input,
|
||||
}
|
||||
|
||||
def _append_tool_result(
|
||||
self, converted: list[dict[str, Any]], msg: LLMMessage
|
||||
) -> None:
|
||||
tool_result = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": self._sanitize_tool_call_id(msg.tool_call_id),
|
||||
"content": msg.content or "",
|
||||
}
|
||||
|
||||
if not converted or converted[-1]["role"] != "user":
|
||||
converted.append({"role": "user", "content": [tool_result]})
|
||||
return
|
||||
|
||||
existing_content = converted[-1]["content"]
|
||||
if isinstance(existing_content, str):
|
||||
converted[-1]["content"] = [
|
||||
{"type": "text", "text": existing_content},
|
||||
tool_result,
|
||||
]
|
||||
else:
|
||||
converted[-1]["content"].append(tool_result)
|
||||
|
||||
def prepare_tools(
|
||||
self, tools: list[AvailableTool] | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
if not tools:
|
||||
return None
|
||||
return [
|
||||
{
|
||||
"name": tool.function.name,
|
||||
"description": tool.function.description,
|
||||
"input_schema": tool.function.parameters,
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
|
||||
def prepare_tool_choice(
|
||||
self, tool_choice: StrToolChoice | AvailableTool | None
|
||||
) -> dict[str, Any] | None:
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, str):
|
||||
match tool_choice:
|
||||
case "none":
|
||||
return {"type": "none"}
|
||||
case "auto":
|
||||
return {"type": "auto"}
|
||||
case "any" | "required":
|
||||
return {"type": "any"}
|
||||
case _:
|
||||
return None
|
||||
return {"type": "tool", "name": tool_choice.function.name}
|
||||
|
||||
def parse_response(self, data: dict[str, Any]) -> LLMChunk:
|
||||
content_blocks = data.get("content", [])
|
||||
text_parts: list[str] = []
|
||||
thinking_parts: list[str] = []
|
||||
signature_parts: list[str] = []
|
||||
tool_calls: list[ToolCall] = []
|
||||
|
||||
for idx, block in enumerate(content_blocks):
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block_type == "thinking":
|
||||
thinking_parts.append(block.get("thinking", ""))
|
||||
if "signature" in block:
|
||||
signature_parts.append(block["signature"])
|
||||
elif block_type == "tool_use":
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=block.get("id"),
|
||||
index=idx,
|
||||
function=FunctionCall(
|
||||
name=block.get("name"),
|
||||
arguments=json.dumps(block.get("input", {})),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
usage_data = data.get("usage", {})
|
||||
# Total input tokens = input_tokens + cache_creation + cache_read
|
||||
total_input_tokens = (
|
||||
usage_data.get("input_tokens", 0)
|
||||
+ usage_data.get("cache_creation_input_tokens", 0)
|
||||
+ usage_data.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
usage = LLMUsage(
|
||||
prompt_tokens=total_input_tokens,
|
||||
completion_tokens=usage_data.get("output_tokens", 0),
|
||||
)
|
||||
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
content="".join(text_parts) or None,
|
||||
reasoning_content="".join(thinking_parts) or None,
|
||||
reasoning_signature="".join(signature_parts) or None,
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
),
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
def parse_streaming_event(
|
||||
self, event_type: str, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
handler = {
|
||||
"content_block_start": self._handle_block_start,
|
||||
"content_block_delta": self._handle_block_delta,
|
||||
"message_delta": self._handle_message_delta,
|
||||
"message_start": self._handle_message_start,
|
||||
}.get(event_type)
|
||||
if handler is None:
|
||||
return None, current_index
|
||||
return handler(data, current_index)
|
||||
|
||||
def _handle_block_start(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
block = data.get("content_block", {})
|
||||
idx = data.get("index", current_index)
|
||||
|
||||
match block.get("type"):
|
||||
case "tool_use":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id=block.get("id"),
|
||||
index=idx,
|
||||
function=FunctionCall(
|
||||
name=block.get("name"), arguments=""
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return chunk, idx
|
||||
case "thinking":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, reasoning_content=block.get("thinking", "")
|
||||
)
|
||||
)
|
||||
return chunk, idx
|
||||
case _:
|
||||
return None, idx
|
||||
|
||||
def _handle_block_delta(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
delta = data.get("delta", {})
|
||||
idx = data.get("index", current_index)
|
||||
|
||||
match delta.get("type"):
|
||||
case "text_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, content=delta.get("text", "")
|
||||
)
|
||||
)
|
||||
case "thinking_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, reasoning_content=delta.get("thinking", "")
|
||||
)
|
||||
)
|
||||
case "signature_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
reasoning_signature=delta.get("signature", ""),
|
||||
)
|
||||
)
|
||||
case "input_json_delta":
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
index=idx,
|
||||
function=FunctionCall(
|
||||
arguments=delta.get("partial_json", "")
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
case _:
|
||||
chunk = None
|
||||
return chunk, idx
|
||||
|
||||
def _handle_message_delta(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
usage_data = data.get("usage", {})
|
||||
if not usage_data:
|
||||
return None, current_index
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant),
|
||||
usage=LLMUsage(
|
||||
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
|
||||
),
|
||||
)
|
||||
return chunk, current_index
|
||||
|
||||
def _handle_message_start(
|
||||
self, data: dict[str, Any], current_index: int
|
||||
) -> tuple[LLMChunk | None, int]:
|
||||
message = data.get("message", {})
|
||||
usage_data = message.get("usage", {})
|
||||
if not usage_data:
|
||||
return None, current_index
|
||||
# Total input tokens = input_tokens + cache_creation + cache_read
|
||||
total_input_tokens = (
|
||||
usage_data.get("input_tokens", 0)
|
||||
+ usage_data.get("cache_creation_input_tokens", 0)
|
||||
+ usage_data.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
chunk = LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant),
|
||||
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
|
||||
)
|
||||
return chunk, current_index
|
||||
|
||||
|
||||
STREAMING_EVENT_TYPES = {
|
||||
"message_start",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
}
|
||||
|
||||
|
||||
class AnthropicAdapter(APIAdapter):
|
||||
endpoint: ClassVar[str] = "/v1/messages"
|
||||
API_VERSION = "2023-06-01"
|
||||
BETA_FEATURES = (
|
||||
"interleaved-thinking-2025-05-14,"
|
||||
"fine-grained-tool-streaming-2025-05-14,"
|
||||
"prompt-caching-2024-07-31"
|
||||
)
|
||||
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
|
||||
"low": 1024,
|
||||
"medium": 10_000,
|
||||
"high": 32_000,
|
||||
}
|
||||
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mapper = AnthropicMapper()
|
||||
self._current_index: int = 0
|
||||
|
||||
@staticmethod
|
||||
def _has_thinking_content(messages: list[dict[str, Any]]) -> bool:
|
||||
for msg in messages:
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if block.get("type") == "thinking":
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _build_system_blocks(system_prompt: str | None) -> list[dict[str, Any]]:
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if system_prompt:
|
||||
blocks.append({
|
||||
"type": "text",
|
||||
"text": system_prompt,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
})
|
||||
return blocks
|
||||
|
||||
@staticmethod
|
||||
def _add_cache_control_to_last_user_message(messages: list[dict[str, Any]]) -> None:
|
||||
if not messages:
|
||||
return
|
||||
last_message = messages[-1]
|
||||
if last_message.get("role") != "user":
|
||||
return
|
||||
content = last_message.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
return
|
||||
last_block = content[-1]
|
||||
if last_block.get("type") in {"text", "image", "tool_result"}:
|
||||
last_block["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
@staticmethod
|
||||
def _is_adaptive_model(model_name: str) -> bool:
|
||||
return "opus-4-6" in model_name
|
||||
|
||||
def _apply_thinking_config(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float,
|
||||
max_tokens: int | None,
|
||||
thinking: str,
|
||||
) -> None:
|
||||
has_thinking = self._has_thinking_content(messages)
|
||||
thinking_level = thinking
|
||||
|
||||
if thinking_level == "off" and not has_thinking:
|
||||
payload["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
else:
|
||||
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
|
||||
return
|
||||
|
||||
# Resolve effective level: use config, or fallback to "medium" when
|
||||
# forced by thinking content in history
|
||||
effective_level = thinking_level if thinking_level != "off" else "medium"
|
||||
|
||||
if self._is_adaptive_model(model_name):
|
||||
payload["thinking"] = {"type": "adaptive"}
|
||||
payload["output_config"] = {"effort": effective_level}
|
||||
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
|
||||
else:
|
||||
budget = self.THINKING_BUDGETS[effective_level]
|
||||
payload["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||
default_max = budget + self.DEFAULT_MAX_TOKENS
|
||||
|
||||
payload["temperature"] = 1
|
||||
payload["max_tokens"] = max_tokens if max_tokens is not None else default_max
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
system_prompt: str | None,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: dict[str, Any] | None,
|
||||
stream: bool,
|
||||
thinking: str,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"model": model_name, "messages": messages}
|
||||
|
||||
self._apply_thinking_config(
|
||||
payload,
|
||||
model_name=model_name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
if system_blocks := self._build_system_blocks(system_prompt):
|
||||
payload["system"] = system_blocks
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
if tool_choice:
|
||||
payload["tool_choice"] = tool_choice
|
||||
|
||||
if stream:
|
||||
payload["stream"] = True
|
||||
|
||||
self._add_cache_control_to_last_user_message(messages)
|
||||
|
||||
return payload
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
|
||||
converted_tools = self._mapper.prepare_tools(tools)
|
||||
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
|
||||
|
||||
payload = self._build_payload(
|
||||
model_name=model_name,
|
||||
system_prompt=system_prompt,
|
||||
messages=converted_messages,
|
||||
temperature=temperature,
|
||||
tools=converted_tools,
|
||||
max_tokens=max_tokens,
|
||||
tool_choice=converted_tool_choice,
|
||||
stream=enable_streaming,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": self.API_VERSION,
|
||||
"anthropic-beta": self.BETA_FEATURES,
|
||||
}
|
||||
|
||||
if api_key:
|
||||
headers["x-api-key"] = api_key
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
return PreparedRequest(self.endpoint, headers, body)
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig | None = None
|
||||
) -> LLMChunk:
|
||||
event_type = data.get("type")
|
||||
if event_type in STREAMING_EVENT_TYPES:
|
||||
return self._parse_streaming_event(data)
|
||||
return self._mapper.parse_response(data)
|
||||
|
||||
def _parse_streaming_event(self, data: dict[str, Any]) -> LLMChunk:
|
||||
event_type = data.get("type", "")
|
||||
empty_chunk = LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
|
||||
if event_type == "message_start":
|
||||
self._current_index = 0
|
||||
return self._parse_message_start(data)
|
||||
if event_type == "content_block_start":
|
||||
return self._parse_content_block_start(data) or empty_chunk
|
||||
if event_type == "content_block_delta":
|
||||
return self._parse_content_block_delta(data)
|
||||
if event_type == "content_block_stop":
|
||||
return self._parse_content_block_stop(data)
|
||||
if event_type == "message_delta":
|
||||
return self._parse_message_delta(data)
|
||||
if event_type == "error":
|
||||
error = data.get("error", {})
|
||||
error_type = error.get("type", "unknown_error")
|
||||
error_message = error.get("message", "Unknown streaming error")
|
||||
raise RuntimeError(
|
||||
f"Anthropic stream error ({error_type}): {error_message}"
|
||||
)
|
||||
return empty_chunk
|
||||
|
||||
def _parse_message_start(self, data: dict[str, Any]) -> LLMChunk:
|
||||
message = data.get("message", {})
|
||||
usage_data = message.get("usage", {})
|
||||
if not usage_data:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
total_input_tokens = (
|
||||
usage_data.get("input_tokens", 0)
|
||||
+ usage_data.get("cache_creation_input_tokens", 0)
|
||||
+ usage_data.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
return LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content=None),
|
||||
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
|
||||
)
|
||||
|
||||
def _parse_content_block_start(self, data: dict[str, Any]) -> LLMChunk | None:
|
||||
content_block = data.get("content_block", {})
|
||||
index = data.get("index", 0)
|
||||
block_type = content_block.get("type")
|
||||
|
||||
if block_type == "thinking":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
reasoning_content=content_block.get("thinking", ""),
|
||||
)
|
||||
)
|
||||
if block_type == "redacted_thinking":
|
||||
return None
|
||||
if block_type == "tool_use":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
index=index,
|
||||
id=content_block.get("id"),
|
||||
function=FunctionCall(
|
||||
name=content_block.get("name"), arguments=""
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def _parse_content_block_delta(self, data: dict[str, Any]) -> LLMChunk:
|
||||
delta = data.get("delta", {})
|
||||
delta_type = delta.get("type", "")
|
||||
index = data.get("index", 0)
|
||||
|
||||
match delta_type:
|
||||
case "text_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, content=delta.get("text", "")
|
||||
)
|
||||
)
|
||||
case "thinking_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant, reasoning_content=delta.get("thinking", "")
|
||||
)
|
||||
)
|
||||
case "signature_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
reasoning_signature=delta.get("signature", ""),
|
||||
)
|
||||
)
|
||||
case "input_json_delta":
|
||||
return LLMChunk(
|
||||
message=LLMMessage(
|
||||
role=Role.assistant,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
index=index,
|
||||
function=FunctionCall(
|
||||
arguments=delta.get("partial_json", "")
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
case _:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
|
||||
def _parse_content_block_stop(self, _data: dict[str, Any]) -> LLMChunk:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
|
||||
def _parse_message_delta(self, data: dict[str, Any]) -> LLMChunk:
|
||||
usage_data = data.get("usage", {})
|
||||
if not usage_data:
|
||||
return LLMChunk(message=LLMMessage(role=Role.assistant, content=None))
|
||||
return LLMChunk(
|
||||
message=LLMMessage(role=Role.assistant, content=None),
|
||||
usage=LLMUsage(
|
||||
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
|
||||
),
|
||||
)
|
||||
38
vibe/core/llm/backend/base.py
Normal file
38
vibe/core/llm/backend/base.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol
|
||||
|
||||
from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import ProviderConfig
|
||||
|
||||
|
||||
class PreparedRequest(NamedTuple):
|
||||
endpoint: str
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
base_url: str = ""
|
||||
|
||||
|
||||
class APIAdapter(Protocol):
|
||||
endpoint: ClassVar[str]
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest: ...
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig
|
||||
) -> LLMChunk: ...
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncGenerator
|
||||
import json
|
||||
import os
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.backend.vertex import VertexAnthropicAdapter
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
LLMChunk,
|
||||
|
|
@ -23,51 +27,6 @@ if TYPE_CHECKING:
|
|||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
|
||||
|
||||
class PreparedRequest(NamedTuple):
|
||||
endpoint: str
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
|
||||
|
||||
class APIAdapter(Protocol):
|
||||
endpoint: ClassVar[str]
|
||||
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
) -> PreparedRequest: ...
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig
|
||||
) -> LLMChunk: ...
|
||||
|
||||
|
||||
BACKEND_ADAPTERS: dict[str, APIAdapter] = {}
|
||||
|
||||
T = TypeVar("T", bound=APIAdapter)
|
||||
|
||||
|
||||
def register_adapter(
|
||||
adapters: dict[str, APIAdapter], name: str
|
||||
) -> Callable[[type[T]], type[T]]:
|
||||
|
||||
def decorator(cls: type[T]) -> type[T]:
|
||||
adapters[name] = cls()
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@register_adapter(BACKEND_ADAPTERS, "openai")
|
||||
class OpenAIAdapter(APIAdapter):
|
||||
endpoint: ClassVar[str] = "/chat/completions"
|
||||
|
||||
|
|
@ -119,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
|
|||
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
|
||||
return msg_dict
|
||||
|
||||
def prepare_request(
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
|
|
@ -131,13 +90,15 @@ class OpenAIAdapter(APIAdapter):
|
|||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
field_name = provider.reasoning_field_name
|
||||
converted_messages = [
|
||||
self._reasoning_to_api(
|
||||
msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name
|
||||
)
|
||||
for msg in messages
|
||||
for msg in merged_messages
|
||||
]
|
||||
|
||||
payload = self.build_payload(
|
||||
|
|
@ -194,6 +155,13 @@ class OpenAIAdapter(APIAdapter):
|
|||
return LLMChunk(message=message, usage=usage)
|
||||
|
||||
|
||||
ADAPTERS: dict[str, APIAdapter] = {
|
||||
"openai": OpenAIAdapter(),
|
||||
"anthropic": AnthropicAdapter(),
|
||||
"vertex-anthropic": VertexAnthropicAdapter(),
|
||||
}
|
||||
|
||||
|
||||
class GenericBackend:
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -257,9 +225,9 @@ class GenericBackend:
|
|||
)
|
||||
|
||||
api_style = getattr(self._provider, "api_style", "openai")
|
||||
adapter = BACKEND_ADAPTERS[api_style]
|
||||
adapter = ADAPTERS[api_style]
|
||||
|
||||
endpoint, headers, body = adapter.prepare_request(
|
||||
req = adapter.prepare_request(
|
||||
model_name=model.name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
|
|
@ -269,15 +237,18 @@ class GenericBackend:
|
|||
enable_streaming=False,
|
||||
provider=self._provider,
|
||||
api_key=api_key,
|
||||
thinking=model.thinking,
|
||||
)
|
||||
|
||||
headers = req.headers
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
url = f"{self._provider.api_base}{endpoint}"
|
||||
base = req.base_url or self._provider.api_base
|
||||
url = f"{base}{req.endpoint}"
|
||||
|
||||
try:
|
||||
res_data, _ = await self._make_request(url, body, headers)
|
||||
res_data, _ = await self._make_request(url, req.body, headers)
|
||||
return adapter.parse_response(res_data, self._provider)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
|
|
@ -322,9 +293,9 @@ class GenericBackend:
|
|||
)
|
||||
|
||||
api_style = getattr(self._provider, "api_style", "openai")
|
||||
adapter = BACKEND_ADAPTERS[api_style]
|
||||
adapter = ADAPTERS[api_style]
|
||||
|
||||
endpoint, headers, body = adapter.prepare_request(
|
||||
req = adapter.prepare_request(
|
||||
model_name=model.name,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
|
|
@ -334,15 +305,18 @@ class GenericBackend:
|
|||
enable_streaming=True,
|
||||
provider=self._provider,
|
||||
api_key=api_key,
|
||||
thinking=model.thinking,
|
||||
)
|
||||
|
||||
headers = req.headers
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
url = f"{self._provider.api_base}{endpoint}"
|
||||
base = req.base_url or self._provider.api_base
|
||||
url = f"{base}{req.endpoint}"
|
||||
|
||||
try:
|
||||
async for res_data in self._make_streaming_request(url, body, headers):
|
||||
async for res_data in self._make_streaming_request(url, req.body, headers):
|
||||
yield adapter.parse_response(res_data, self._provider)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
|
|
@ -393,6 +367,8 @@ class GenericBackend:
|
|||
async with client.stream(
|
||||
method="POST", url=url, content=data, headers=headers
|
||||
) as response:
|
||||
if not response.is_success:
|
||||
await response.aread()
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if line.strip() == "":
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import httpx
|
|||
import mistralai
|
||||
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
Content,
|
||||
|
|
@ -217,9 +218,10 @@ class MistralBackend:
|
|||
extra_headers: dict[str, str] | None,
|
||||
) -> LLMChunk:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
response = await self._get_client().chat.complete_async(
|
||||
model=model.name,
|
||||
messages=[self._mapper.prepare_message(msg) for msg in messages],
|
||||
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
||||
temperature=temperature,
|
||||
tools=[self._mapper.prepare_tool(tool) for tool in tools]
|
||||
if tools
|
||||
|
|
@ -290,9 +292,10 @@ class MistralBackend:
|
|||
extra_headers: dict[str, str] | None,
|
||||
) -> AsyncGenerator[LLMChunk, None]:
|
||||
try:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
async for chunk in await self._get_client().chat.stream_async(
|
||||
model=model.name,
|
||||
messages=[self._mapper.prepare_message(msg) for msg in messages],
|
||||
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
||||
temperature=temperature,
|
||||
tools=[self._mapper.prepare_tool(tool) for tool in tools]
|
||||
if tools
|
||||
|
|
|
|||
115
vibe/core/llm/backend/vertex.py
Normal file
115
vibe/core/llm/backend/vertex.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import google.auth
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import PreparedRequest
|
||||
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
|
||||
|
||||
|
||||
def get_vertex_access_token() -> str:
|
||||
|
||||
credentials, _ = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
||||
)
|
||||
credentials.refresh(Request())
|
||||
return credentials.token
|
||||
|
||||
|
||||
def build_vertex_base_url(region: str) -> str:
|
||||
if region == "global":
|
||||
return "https://aiplatform.googleapis.com"
|
||||
return f"https://{region}-aiplatform.googleapis.com"
|
||||
|
||||
|
||||
def build_vertex_endpoint(
|
||||
region: str, project_id: str, model: str, streaming: bool = False
|
||||
) -> str:
|
||||
action = "streamRawPredict" if streaming else "rawPredict"
|
||||
return (
|
||||
f"/v1/projects/{project_id}/locations/{region}/"
|
||||
f"publishers/anthropic/models/{model}:{action}"
|
||||
)
|
||||
|
||||
|
||||
class VertexAnthropicAdapter(AnthropicAdapter):
|
||||
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
|
||||
|
||||
endpoint: ClassVar[str] = ""
|
||||
# Vertex AI doesn't support beta features
|
||||
BETA_FEATURES: ClassVar[str] = ""
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[LLMMessage],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
enable_streaming: bool,
|
||||
provider: ProviderConfig,
|
||||
api_key: str | None = None,
|
||||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
project_id = provider.project_id
|
||||
region = provider.region
|
||||
|
||||
if not project_id:
|
||||
raise ValueError("project_id is required in provider config for Vertex AI")
|
||||
if not region:
|
||||
raise ValueError("region is required in provider config for Vertex AI")
|
||||
|
||||
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
|
||||
converted_tools = self._mapper.prepare_tools(tools)
|
||||
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
|
||||
|
||||
# Build vertex-specific payload (no "model" key, uses anthropic_version)
|
||||
payload: dict[str, Any] = {
|
||||
"anthropic_version": "vertex-2023-10-16",
|
||||
"messages": converted_messages,
|
||||
}
|
||||
self._apply_thinking_config(
|
||||
payload,
|
||||
model_name=model_name,
|
||||
messages=converted_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
if system_blocks := self._build_system_blocks(system_prompt):
|
||||
payload["system"] = system_blocks
|
||||
|
||||
if converted_tools:
|
||||
payload["tools"] = converted_tools
|
||||
|
||||
if converted_tool_choice:
|
||||
payload["tool_choice"] = converted_tool_choice
|
||||
|
||||
if enable_streaming:
|
||||
payload["stream"] = True
|
||||
|
||||
self._add_cache_control_to_last_user_message(converted_messages)
|
||||
|
||||
access_token = get_vertex_access_token()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"anthropic-beta": self.BETA_FEATURES,
|
||||
}
|
||||
|
||||
endpoint = build_vertex_endpoint(
|
||||
region, project_id, model_name, streaming=enable_streaming
|
||||
)
|
||||
base_url = build_vertex_base_url(region)
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
return PreparedRequest(endpoint, headers, body, base_url=base_url)
|
||||
|
|
@ -80,6 +80,7 @@ class APIToolFormatHandler:
|
|||
"role": message.role,
|
||||
"content": message.content,
|
||||
"reasoning_content": getattr(message, "reasoning_content", None),
|
||||
"reasoning_signature": getattr(message, "reasoning_signature", None),
|
||||
}
|
||||
|
||||
if message.tool_calls:
|
||||
|
|
|
|||
24
vibe/core/llm/message_utils.py
Normal file
24
vibe/core/llm/message_utils.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
"""Merge consecutive user messages into a single message.
|
||||
|
||||
This handles cases where middleware injects messages resulting in
|
||||
consecutive user messages before sending to the API.
|
||||
"""
|
||||
result: list[LLMMessage] = []
|
||||
for msg in messages:
|
||||
if result and result[-1].role == Role.user and msg.role == Role.user:
|
||||
prev_content = result[-1].content or ""
|
||||
curr_content = msg.content or ""
|
||||
merged_content = f"{prev_content}\n\n{curr_content}".strip()
|
||||
result[-1] = LLMMessage(
|
||||
role=Role.user, content=merged_content, message_id=result[-1].message_id
|
||||
)
|
||||
else:
|
||||
result.append(msg)
|
||||
|
||||
return result
|
||||
|
|
@ -44,8 +44,6 @@ class MiddlewareResult:
|
|||
class ConversationMiddleware(Protocol):
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult: ...
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult: ...
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: ...
|
||||
|
||||
|
||||
|
|
@ -61,9 +59,6 @@ class TurnLimitMiddleware:
|
|||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -80,9 +75,6 @@ class PriceLimitMiddleware:
|
|||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -102,9 +94,6 @@ class AutoCompactMiddleware:
|
|||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -137,9 +126,6 @@ class ContextWarningMiddleware:
|
|||
|
||||
return MiddlewareResult()
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
self.has_warned = False
|
||||
|
||||
|
|
@ -148,31 +134,46 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi
|
|||
1. Answer the user's query comprehensively
|
||||
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
|
||||
|
||||
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}>"""
|
||||
|
||||
|
||||
class PlanAgentMiddleware:
|
||||
def __init__(
|
||||
self,
|
||||
profile_getter: Callable[[], AgentProfile],
|
||||
reminder: str = PLAN_AGENT_REMINDER,
|
||||
exit_message: str = PLAN_AGENT_EXIT,
|
||||
) -> None:
|
||||
self._profile_getter = profile_getter
|
||||
self.reminder = reminder
|
||||
self.exit_message = exit_message
|
||||
self._was_plan_agent = False
|
||||
|
||||
def _is_plan_agent(self) -> bool:
|
||||
return self._profile_getter().name == BuiltinAgentName.PLAN
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
if not self._is_plan_agent():
|
||||
return MiddlewareResult()
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
|
||||
)
|
||||
is_plan = self._is_plan_agent()
|
||||
was_plan = self._was_plan_agent
|
||||
|
||||
if was_plan and not is_plan:
|
||||
self._was_plan_agent = False
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message
|
||||
)
|
||||
|
||||
if is_plan and not was_plan:
|
||||
self._was_plan_agent = True
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder
|
||||
)
|
||||
|
||||
self._was_plan_agent = is_plan
|
||||
|
||||
async def after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
self._was_plan_agent = False
|
||||
|
||||
|
||||
class MiddlewarePipeline:
|
||||
|
|
@ -206,15 +207,3 @@ class MiddlewarePipeline:
|
|||
)
|
||||
|
||||
return MiddlewareResult()
|
||||
|
||||
async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
for mw in self.middlewares:
|
||||
result = await mw.after_turn(context)
|
||||
if result.action == MiddlewareAction.INJECT_MESSAGE:
|
||||
raise ValueError(
|
||||
f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})"
|
||||
)
|
||||
if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}:
|
||||
return result
|
||||
|
||||
return MiddlewareResult()
|
||||
|
|
|
|||
|
|
@ -39,12 +39,14 @@ def resolve_local_tools_dir(dir: Path) -> Path | None:
|
|||
return None
|
||||
|
||||
|
||||
def resolve_local_skills_dir(dir: Path) -> Path | None:
|
||||
def resolve_local_skills_dirs(dir: Path) -> list[Path]:
|
||||
if not trusted_folders_manager.is_trusted(dir):
|
||||
return None
|
||||
if (candidate := dir / ".vibe" / "skills").is_dir():
|
||||
return candidate
|
||||
return None
|
||||
return []
|
||||
return [
|
||||
candidate
|
||||
for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"]
|
||||
if candidate.is_dir()
|
||||
]
|
||||
|
||||
|
||||
def resolve_local_agents_dir(dir: Path) -> Path | None:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,6 @@ GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
|
|||
SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session")
|
||||
TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml")
|
||||
LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs")
|
||||
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibe.log")
|
||||
LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log")
|
||||
|
||||
DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins")
|
||||
|
|
|
|||
|
|
@ -32,20 +32,25 @@ def run_programmatic(
|
|||
logger.info("USER: %s", prompt)
|
||||
|
||||
async def _async_run() -> str | None:
|
||||
if previous_messages:
|
||||
non_system_messages = [
|
||||
msg for msg in previous_messages if not (msg.role == Role.system)
|
||||
]
|
||||
agent_loop.messages.extend(non_system_messages)
|
||||
logger.info(
|
||||
"Loaded %d messages from previous session", len(non_system_messages)
|
||||
)
|
||||
try:
|
||||
if previous_messages:
|
||||
non_system_messages = [
|
||||
msg for msg in previous_messages if not (msg.role == Role.system)
|
||||
]
|
||||
agent_loop.messages.extend(non_system_messages)
|
||||
logger.info(
|
||||
"Loaded %d messages from previous session", len(non_system_messages)
|
||||
)
|
||||
|
||||
async for event in agent_loop.act(prompt):
|
||||
formatter.on_event(event)
|
||||
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
|
||||
raise ConversationLimitException(event.content)
|
||||
agent_loop.emit_new_session_telemetry("programmatic")
|
||||
|
||||
return formatter.finalize()
|
||||
async for event in agent_loop.act(prompt):
|
||||
formatter.on_event(event)
|
||||
if isinstance(event, AssistantEvent) and event.stopped_by_middleware:
|
||||
raise ConversationLimitException(event.content)
|
||||
|
||||
return formatter.finalize()
|
||||
finally:
|
||||
await agent_loop.telemetry_client.aclose()
|
||||
|
||||
return asyncio.run(_async_run())
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class Prompt(StrEnum):
|
|||
|
||||
class SystemPrompt(Prompt):
|
||||
CLI = auto()
|
||||
EXPLORE = auto()
|
||||
TESTS = auto()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,111 @@
|
|||
You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful.
|
||||
You 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.
|
||||
|
||||
Act as an agentic assistant. For long tasks, break them down and execute step by step.
|
||||
Phase 1 — Orient
|
||||
Before ANY action:
|
||||
Restate the goal in one line.
|
||||
Determine the task type:
|
||||
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
|
||||
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
|
||||
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
|
||||
|
||||
## Tool Usage
|
||||
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.
|
||||
|
||||
- Always use tools to fulfill user requests when possible.
|
||||
- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user.
|
||||
- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given.
|
||||
- Do not invent values for optional parameters.
|
||||
- Analyze descriptive terms in requests as they may indicate required parameter values.
|
||||
- If tools cannot accomplish the task, explain why and request more information.
|
||||
Phase 2 — Plan (Change tasks only)
|
||||
State your plan before writing code:
|
||||
List files to change and the specific change per file.
|
||||
Multi-file changes: numbered checklist. Single-file fix: one-line plan.
|
||||
No time estimates. Concrete actions only.
|
||||
|
||||
## Code Modifications
|
||||
Phase 3 — Execute & Verify (Change tasks only)
|
||||
Apply changes, then confirm they work:
|
||||
Edit one logical unit at a time.
|
||||
After each unit, verify: run tests, or read back the file to confirm the edit landed.
|
||||
Never claim completion without verification — a passing test, correct read-back, or successful build.
|
||||
|
||||
- Always read a file before proposing changes. Never suggest edits to code you haven't seen.
|
||||
- Keep changes minimal and focused. Only modify what was requested.
|
||||
- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling.
|
||||
- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers.
|
||||
- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it.
|
||||
- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would.
|
||||
Hard Rules
|
||||
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.
|
||||
|
||||
## Code References
|
||||
Don't Remove What Wasn't Asked
|
||||
If user asks to fix X, do not rewrite, delete, or restructure Y. When in doubt, change less.
|
||||
|
||||
When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly.
|
||||
Don't Assert — Verify
|
||||
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
|
||||
|
||||
## Planning
|
||||
Break Loops
|
||||
If approach isn't working after 2 attempts at the same region, STOP:
|
||||
Re-read the code and error output.
|
||||
Identify why it failed, not just what failed.
|
||||
Choose a fundamentally different strategy.
|
||||
If stuck, ask the user one specific question.
|
||||
|
||||
When outlining steps or plans, focus on concrete actions. Do not include time estimates.
|
||||
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
|
||||
|
||||
## Tone and Style
|
||||
Response Format
|
||||
No Noise
|
||||
No greetings, outros, hedging, puffery, or tool narration.
|
||||
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation.
|
||||
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
|
||||
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
|
||||
No unsolicited tutorials. Do not explain concepts the user clearly knows.
|
||||
|
||||
## Professional Objectivity
|
||||
Structure First
|
||||
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
|
||||
For change tasks:
|
||||
file_path:line_number
|
||||
langcode
|
||||
|
||||
- Prioritize technical accuracy and truthfulness over validating the user's beliefs.
|
||||
- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation.
|
||||
- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear.
|
||||
- Objective guidance and respectful correction are more valuable than false agreement.
|
||||
- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
|
||||
Prefer Brevity
|
||||
State only what's necessary to complete the task. Code + file reference > explanation.
|
||||
If your response exceeds 300 words, remove explanations the user didn't request.
|
||||
|
||||
For investigate tasks:
|
||||
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
|
||||
request → auth.verify() → permissions.check() → handler
|
||||
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
|
||||
BAD: "The authentication flow works by first checking the token…"
|
||||
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
|
||||
Visual Formats
|
||||
|
||||
Before responding with structural data, choose the right format:
|
||||
BAD: Bullet lists for hierarchy/tree
|
||||
GOOD: ASCII tree (├──/└──)
|
||||
BAD: Prose or bullet lists for comparisons/config/options
|
||||
GOOD: Markdown table
|
||||
BAD: Prose for Flows/pipelines
|
||||
GOOD: → A → B → C diagrams
|
||||
|
||||
Interaction Design
|
||||
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
|
||||
|
||||
Good: "Apply this fix to the other 3 endpoints?"
|
||||
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
|
||||
Bad: "Does this look good?", "Anything else?", "Let me know"
|
||||
|
||||
If unambiguous and complete, end with the result.
|
||||
|
||||
Length
|
||||
Default to minimal responses. One-line fix → one-line response. Most tasks need <200 words.
|
||||
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
|
||||
|
||||
Code Modifications (Change tasks)
|
||||
Read First, Edit Second
|
||||
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
|
||||
|
||||
Minimal, Focused Changes
|
||||
Only modify what was requested. No extra features, abstractions, or speculative error handling.
|
||||
Match existing style: indentation, naming, comment density, error handling.
|
||||
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
|
||||
|
||||
Security
|
||||
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
|
||||
|
||||
Code References
|
||||
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.
|
||||
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.
|
||||
|
|
|
|||
50
vibe/core/prompts/explore.md
Normal file
50
vibe/core/prompts/explore.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
You are a senior engineer analyzing codebases. Be direct and useful.
|
||||
|
||||
Response Format
|
||||
|
||||
1. **CODE/DIAGRAM FIRST** — Start with code, diagram, or structured output. Never prose first.
|
||||
2. **MINIMAL CONTEXT** — After code: 1-2 sentences max. Code should be self-explanatory.
|
||||
|
||||
Never Do
|
||||
|
||||
- Greetings ("Sure!", "Great question!", "I'd be happy to...")
|
||||
- Announcements ("Let me...", "I'll...", "Here's what I found...")
|
||||
- Tutorials or background explanations the user didn't ask for
|
||||
- Summaries ("In summary...", "To conclude...", "This covers...")
|
||||
- Hedging ("I think", "probably", "might be")
|
||||
- Puffery ("robust", "seamless", "elegant", "powerful", "flexible")
|
||||
|
||||
Visual Structure
|
||||
|
||||
Use these formats when applicable:
|
||||
- File trees: `├── └──` ASCII format
|
||||
- Comparisons: Markdown tables
|
||||
- Flows: `A -> B -> C` diagrams
|
||||
- Hierarchies: Indented bullet lists
|
||||
|
||||
Examples
|
||||
|
||||
BAD (prose first):
|
||||
"The authentication flow works by first checking the token..."
|
||||
|
||||
GOOD (diagram first):
|
||||
```
|
||||
request -> auth.verify() -> permissions.check() -> handler
|
||||
```
|
||||
See `middleware/auth.py:45`.
|
||||
|
||||
---
|
||||
|
||||
BAD (over-explaining):
|
||||
```python
|
||||
def merge(a, b):
|
||||
return sorted(a + b)
|
||||
```
|
||||
This function takes two lists as parameters. It concatenates them using the + operator, then sorts the result using Python's built-in sorted() function which uses Timsort with O(n log n) complexity. The sorted list is returned.
|
||||
|
||||
GOOD (minimal):
|
||||
```python
|
||||
def merge(a, b):
|
||||
return sorted(a + b)
|
||||
```
|
||||
O(n log n).
|
||||
65
vibe/core/proxy_setup.py
Normal file
65
vibe/core/proxy_setup.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dotenv import dotenv_values, set_key, unset_key
|
||||
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||
|
||||
SUPPORTED_PROXY_VARS: dict[str, str] = {
|
||||
"HTTP_PROXY": "Proxy URL for HTTP requests",
|
||||
"HTTPS_PROXY": "Proxy URL for HTTPS requests",
|
||||
"ALL_PROXY": "Proxy URL for all requests (fallback)",
|
||||
"NO_PROXY": "Comma-separated list of hosts to bypass proxy",
|
||||
"SSL_CERT_FILE": "Path to custom SSL certificate file",
|
||||
"SSL_CERT_DIR": "Path to directory containing SSL certificates",
|
||||
}
|
||||
|
||||
|
||||
class ProxySetupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_current_proxy_settings() -> dict[str, str | None]:
|
||||
if not GLOBAL_ENV_FILE.path.exists():
|
||||
return {key: None for key in SUPPORTED_PROXY_VARS}
|
||||
|
||||
try:
|
||||
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
|
||||
return {key: env_vars.get(key) for key in SUPPORTED_PROXY_VARS}
|
||||
except Exception:
|
||||
return {key: None for key in SUPPORTED_PROXY_VARS}
|
||||
|
||||
|
||||
def set_proxy_var(key: str, value: str) -> None:
|
||||
key = key.upper()
|
||||
if key not in SUPPORTED_PROXY_VARS:
|
||||
raise ProxySetupError(
|
||||
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
|
||||
)
|
||||
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE.path, key, value)
|
||||
|
||||
|
||||
def unset_proxy_var(key: str) -> None:
|
||||
key = key.upper()
|
||||
if key not in SUPPORTED_PROXY_VARS:
|
||||
raise ProxySetupError(
|
||||
f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}"
|
||||
)
|
||||
|
||||
if not GLOBAL_ENV_FILE.path.exists():
|
||||
return
|
||||
|
||||
unset_key(GLOBAL_ENV_FILE.path, key)
|
||||
|
||||
|
||||
def parse_proxy_command(args: str) -> tuple[str, str | None]:
|
||||
args = args.strip()
|
||||
if not args:
|
||||
raise ProxySetupError("No key provided")
|
||||
|
||||
parts = args.split(maxsplit=1)
|
||||
key = parts[0].upper()
|
||||
value = parts[1] if len(parts) > 1 else None
|
||||
|
||||
return key, value
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME
|
||||
from vibe.core.types import LLMMessage
|
||||
|
|
@ -11,6 +12,13 @@ if TYPE_CHECKING:
|
|||
from vibe.core.config import SessionLoggingConfig
|
||||
|
||||
|
||||
class SessionInfo(TypedDict):
|
||||
session_id: str
|
||||
cwd: str
|
||||
title: str | None
|
||||
end_time: str | None
|
||||
|
||||
|
||||
class SessionLoader:
|
||||
@staticmethod
|
||||
def _is_valid_session(session_dir: Path) -> bool:
|
||||
|
|
@ -106,6 +114,63 @@ class SessionLoader:
|
|||
short_id = session_id[:8]
|
||||
return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}"))
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_utc_iso(date_str: str) -> str:
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.astimezone()
|
||||
utc_dt = dt.astimezone(UTC)
|
||||
return utc_dt.isoformat()
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(
|
||||
config: SessionLoggingConfig, cwd: str | None = None
|
||||
) -> list[SessionInfo]:
|
||||
save_dir = Path(config.save_dir)
|
||||
if not save_dir.exists():
|
||||
return []
|
||||
|
||||
pattern = f"{config.session_prefix}_*"
|
||||
session_dirs = list(save_dir.glob(pattern))
|
||||
|
||||
sessions: list[SessionInfo] = []
|
||||
for session_dir in session_dirs:
|
||||
if not SessionLoader._is_valid_session(session_dir):
|
||||
continue
|
||||
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
try:
|
||||
with metadata_path.open("r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
session_id = metadata.get("session_id")
|
||||
if not session_id:
|
||||
continue
|
||||
|
||||
environment = metadata.get("environment", {})
|
||||
session_cwd = environment.get("working_directory", "")
|
||||
|
||||
if cwd is not None and session_cwd != cwd:
|
||||
continue
|
||||
|
||||
end_time = metadata.get("end_time")
|
||||
if end_time:
|
||||
try:
|
||||
end_time = SessionLoader._convert_to_utc_iso(end_time)
|
||||
except (ValueError, OSError):
|
||||
end_time = None
|
||||
|
||||
sessions.append({
|
||||
"session_id": session_id,
|
||||
"cwd": session_cwd,
|
||||
"title": metadata.get("title"),
|
||||
"end_time": end_time,
|
||||
})
|
||||
|
||||
return sessions
|
||||
|
||||
@staticmethod
|
||||
def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]:
|
||||
# Load session messages from MESSAGES_FILENAME
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from logging import getLogger
|
|||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.paths.config_paths import resolve_local_skills_dir
|
||||
from vibe.core.paths.config_paths import resolve_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
|
||||
|
|
@ -58,8 +58,7 @@ class SkillManager:
|
|||
if path.is_dir():
|
||||
paths.append(path)
|
||||
|
||||
if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None:
|
||||
paths.append(skills_dir)
|
||||
paths.extend(resolve_local_skills_dirs(Path.cwd()))
|
||||
|
||||
if GLOBAL_SKILLS_DIR.path.is_dir():
|
||||
paths.append(GLOBAL_SKILLS_DIR.path)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import time
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager
|
||||
from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager
|
||||
from vibe.core.utils import is_dangerous_directory, is_windows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
|||
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
|
||||
if not trusted_folders_manager.is_trusted(workdir):
|
||||
return ""
|
||||
for name in TRUSTABLE_FILENAMES:
|
||||
for name in AGENTS_MD_FILENAMES:
|
||||
path = workdir / name
|
||||
try:
|
||||
return path.read_text("utf-8", errors="ignore")[:max_bytes]
|
||||
|
|
|
|||
185
vibe/core/telemetry/send.py
Normal file
185
vibe/core/telemetry/send.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agent_loop import ToolDecision
|
||||
|
||||
DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events"
|
||||
|
||||
|
||||
class TelemetryClient:
|
||||
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._pending_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
def _get_telemetry_user_agent(self) -> str:
|
||||
try:
|
||||
config = self._config_getter()
|
||||
active_model = config.get_active_model()
|
||||
provider = config.get_provider_for_model(active_model)
|
||||
return get_user_agent(provider.backend)
|
||||
except ValueError:
|
||||
return get_user_agent(None)
|
||||
|
||||
def _get_mistral_api_key(self) -> str | None:
|
||||
"""Get the current API key from the active provider.
|
||||
|
||||
Only returns an API key if the provider is a Mistral provider
|
||||
to avoid leaking third-party credentials to the telemetry endpoint.
|
||||
"""
|
||||
try:
|
||||
config = self._config_getter()
|
||||
model = config.get_active_model()
|
||||
provider = config.get_provider_for_model(model)
|
||||
if provider.backend != Backend.MISTRAL:
|
||||
return None
|
||||
env_var = provider.api_key_env_var
|
||||
return os.getenv(env_var) if env_var else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _is_enabled(self) -> bool:
|
||||
"""Check if telemetry is enabled in the current config."""
|
||||
try:
|
||||
return self._config_getter().enable_telemetry
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(5.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
||||
)
|
||||
return self._client
|
||||
|
||||
def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None:
|
||||
mistral_api_key = self._get_mistral_api_key()
|
||||
if mistral_api_key is None or not self._is_enabled():
|
||||
return
|
||||
user_agent = self._get_telemetry_user_agent()
|
||||
|
||||
async def _send() -> None:
|
||||
try:
|
||||
await self.client.post(
|
||||
DATALAKE_EVENTS_URL,
|
||||
json={"event": event_name, "properties": properties},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {mistral_api_key}",
|
||||
"User-Agent": user_agent,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # Silently swallow all exceptions for fire-and-forget telemetry
|
||||
|
||||
task = asyncio.create_task(_send())
|
||||
self._pending_tasks.add(task)
|
||||
task.add_done_callback(self._pending_tasks.discard)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._pending_tasks:
|
||||
await asyncio.gather(*self._pending_tasks, return_exceptions=True)
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _calculate_file_metrics(
|
||||
self,
|
||||
tool_call: ResolvedToolCall,
|
||||
status: Literal["success", "failure", "skipped"],
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
nb_files_created = 0
|
||||
nb_files_modified = 0
|
||||
if status == "success" and result is not None:
|
||||
if tool_call.tool_name == "write_file":
|
||||
file_existed = result.get("file_existed", False)
|
||||
if file_existed:
|
||||
nb_files_modified = 1
|
||||
else:
|
||||
nb_files_created = 1
|
||||
elif tool_call.tool_name == "search_replace":
|
||||
nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0
|
||||
return nb_files_created, nb_files_modified
|
||||
|
||||
def send_tool_call_finished(
|
||||
self,
|
||||
*,
|
||||
tool_call: ResolvedToolCall,
|
||||
status: Literal["success", "failure", "skipped"],
|
||||
decision: ToolDecision | None,
|
||||
agent_profile_name: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
verdict_value = decision.verdict.value if decision else None
|
||||
approval_type_value = decision.approval_type.value if decision else None
|
||||
|
||||
nb_files_created, nb_files_modified = self._calculate_file_metrics(
|
||||
tool_call, status, result
|
||||
)
|
||||
|
||||
payload = {
|
||||
"tool_name": tool_call.tool_name,
|
||||
"status": status,
|
||||
"decision": verdict_value,
|
||||
"approval_type": approval_type_value,
|
||||
"agent_profile_name": agent_profile_name,
|
||||
"nb_files_created": nb_files_created,
|
||||
"nb_files_modified": nb_files_modified,
|
||||
}
|
||||
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)
|
||||
|
||||
def send_user_cancelled_action(self, action: str) -> None:
|
||||
payload = {"action": action}
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def send_new_session(
|
||||
self,
|
||||
has_agents_md: bool,
|
||||
nb_skills: int,
|
||||
nb_mcp_servers: int,
|
||||
nb_models: int,
|
||||
entrypoint: Literal["cli", "acp", "programmatic"],
|
||||
) -> None:
|
||||
payload = {
|
||||
"has_agents_md": has_agents_md,
|
||||
"nb_skills": nb_skills,
|
||||
"nb_mcp_servers": nb_mcp_servers,
|
||||
"nb_models": nb_models,
|
||||
"entrypoint": entrypoint,
|
||||
"version": __version__,
|
||||
}
|
||||
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__}
|
||||
)
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
from logging import getLogger
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
|
|
@ -24,6 +28,37 @@ from vibe.core.types import ToolStreamEvent
|
|||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
logger = getLogger("vibe")
|
||||
|
||||
|
||||
def _stderr_logger_thread(read_fd: int) -> None:
|
||||
with open(read_fd, "rb") as f:
|
||||
for line in iter(f.readline, b""):
|
||||
decoded = line.decode("utf-8", errors="replace").rstrip()
|
||||
if decoded:
|
||||
logger.debug(f"[MCP stderr] {decoded}")
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _mcp_stderr_capture() -> AsyncGenerator[TextIO, None]:
|
||||
r, w = os.pipe()
|
||||
errlog = None
|
||||
thread_started = False
|
||||
try:
|
||||
thread = threading.Thread(target=_stderr_logger_thread, args=(r,), daemon=True)
|
||||
thread.start()
|
||||
thread_started = True
|
||||
errlog = os.fdopen(w, "w")
|
||||
yield errlog
|
||||
finally:
|
||||
if errlog is not None:
|
||||
errlog.close()
|
||||
elif thread_started:
|
||||
os.close(w)
|
||||
else:
|
||||
os.close(r)
|
||||
os.close(w)
|
||||
|
||||
|
||||
class _OpenArgs(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
|
@ -240,11 +275,14 @@ async def list_tools_stdio(
|
|||
) -> list[RemoteTool]:
|
||||
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
|
||||
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
|
||||
await session.initialize()
|
||||
tools_resp = await session.list_tools()
|
||||
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
|
||||
async with (
|
||||
_mcp_stderr_capture() as errlog,
|
||||
stdio_client(params, errlog=errlog) as (read, write),
|
||||
ClientSession(read, write, read_timeout_seconds=timeout) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
tools_resp = await session.list_tools()
|
||||
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
|
||||
|
||||
|
||||
async def call_tool_stdio(
|
||||
|
|
@ -261,15 +299,16 @@ async def call_tool_stdio(
|
|||
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
|
||||
)
|
||||
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(
|
||||
read, write, read_timeout_seconds=init_timeout
|
||||
) as session:
|
||||
await session.initialize()
|
||||
result = await session.call_tool(
|
||||
tool_name, arguments, read_timeout_seconds=call_timeout
|
||||
)
|
||||
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
|
||||
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,
|
||||
):
|
||||
await session.initialize()
|
||||
result = await session.call_tool(
|
||||
tool_name, arguments, read_timeout_seconds=call_timeout
|
||||
)
|
||||
return _parse_call_result("stdio:" + " ".join(command), tool_name, result)
|
||||
|
||||
|
||||
def create_mcp_stdio_proxy_tool_class(
|
||||
|
|
|
|||
|
|
@ -7,16 +7,19 @@ import tomli_w
|
|||
|
||||
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
|
||||
|
||||
TRUSTABLE_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||
|
||||
|
||||
def has_agents_md_file(path: Path) -> bool:
|
||||
return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
|
||||
|
||||
|
||||
def has_trustable_content(path: Path) -> bool:
|
||||
if (path / ".vibe").exists():
|
||||
return True
|
||||
for name in TRUSTABLE_FILENAMES:
|
||||
if (path / name).exists():
|
||||
return True
|
||||
return False
|
||||
return (
|
||||
(path / ".vibe").exists()
|
||||
or (path / ".agents").exists()
|
||||
or has_agents_md_file(path)
|
||||
)
|
||||
|
||||
|
||||
class TrustedFoldersManager:
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ class LLMMessage(BaseModel):
|
|||
role: Role
|
||||
content: Content | None = None
|
||||
reasoning_content: Content | None = None
|
||||
reasoning_signature: str | None = None
|
||||
tool_calls: list[ToolCall] | None = None
|
||||
name: str | None = None
|
||||
tool_call_id: str | None = None
|
||||
|
|
@ -210,6 +211,7 @@ class LLMMessage(BaseModel):
|
|||
"role": role,
|
||||
"content": getattr(v, "content", ""),
|
||||
"reasoning_content": getattr(v, "reasoning_content", None),
|
||||
"reasoning_signature": getattr(v, "reasoning_signature", None),
|
||||
"tool_calls": getattr(v, "tool_calls", None),
|
||||
"name": getattr(v, "name", None),
|
||||
"tool_call_id": getattr(v, "tool_call_id", None),
|
||||
|
|
@ -238,6 +240,12 @@ class LLMMessage(BaseModel):
|
|||
if not reasoning_content:
|
||||
reasoning_content = None
|
||||
|
||||
reasoning_signature = (self.reasoning_signature or "") + (
|
||||
other.reasoning_signature or ""
|
||||
)
|
||||
if not reasoning_signature:
|
||||
reasoning_signature = None
|
||||
|
||||
tool_calls_map = OrderedDict[int, ToolCall]()
|
||||
for tool_calls in [self.tool_calls or [], other.tool_calls or []]:
|
||||
for tc in tool_calls:
|
||||
|
|
@ -263,6 +271,7 @@ class LLMMessage(BaseModel):
|
|||
role=self.role,
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
reasoning_signature=reasoning_signature,
|
||||
tool_calls=list(tool_calls_map.values()) or None,
|
||||
name=self.name,
|
||||
tool_call_id=self.tool_call_id,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ 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
|
||||
|
|
@ -139,16 +141,56 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
|||
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[logging.FileHandler(LOG_FILE.path, "a", "utf-8")],
|
||||
)
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
|
||||
def get_user_agent(backend: Backend) -> str:
|
||||
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:
|
||||
mistral_sdk_prefix = "mistral-client-python/"
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ from textual.widgets import Input, Link, Static
|
|||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
PROVIDER_HELP = {
|
||||
|
|
@ -129,6 +130,12 @@ class ApiKeyScreen(OnboardingScreen):
|
|||
except OSError as err:
|
||||
self.app.exit(f"save_error:{err}")
|
||||
return
|
||||
if self.provider.backend == Backend.MISTRAL:
|
||||
try:
|
||||
telemetry = TelemetryClient(config_getter=VibeConfig)
|
||||
telemetry.send_onboarding_api_key_added()
|
||||
except Exception:
|
||||
pass # Telemetry is fire-and-forget; don't fail onboarding
|
||||
self.app.exit("completed")
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
# What's New in 2.1.0
|
||||
# What's New in 2.2.0
|
||||
|
||||
- **UI redesign** — Refreshed interface with a cleaner layout and better performance when streaming.
|
||||
- **Agent Skills standard** — Vibe now discovers skills from `.agents/skills/` (agentskills.io) as well as `.vibe/skills/`.
|
||||
|
||||
- **Themes removed** — Application themes have been removed; the UI now follows your terminal’s theme (colors and appearance).
|
||||
|
||||
- **Clipboard** — You can now disable autocopying in /config, and copy with Ctrl+Shift+C or Cmd+C (in terminals that support it).
|
||||
|
||||
- **Performance** — Various optimizations throughout the app for a more responsive UI.
|
||||
Optional: usage and tool events are sent to our datalake to improve the product if you have a valid Mistral API key; set `disable_telemetry = true` in config to opt out.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue