v2.4.0 (#470)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Antoine W <antoine.wronka@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
5d2e01a6d7
commit
dd372ce494
89 changed files with 2086 additions and 596 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.3.0"
|
||||
__version__ = "2.4.0"
|
||||
|
|
|
|||
|
|
@ -213,7 +213,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def _load_config(self) -> VibeConfig:
|
||||
try:
|
||||
config = VibeConfig.load(disabled_tools=["ask_user_question"])
|
||||
config = VibeConfig.load(
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"]
|
||||
)
|
||||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
|
|
@ -512,7 +514,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question"],
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
)
|
||||
|
||||
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
config.disabled_tools = [*config.disabled_tools, "ask_user_question"]
|
||||
programmatic_prompt = args.prompt or stdin_prompt
|
||||
if not programmatic_prompt:
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -34,13 +34,7 @@ class HttpWhoAmIGateway:
|
|||
raise WhoAmIGatewayError(f"Unexpected status {response.status_code}")
|
||||
|
||||
payload = _safe_json(response) or {}
|
||||
return WhoAmIResponse(
|
||||
is_pro_plan=_parse_bool(payload.get("is_pro_plan")),
|
||||
advertise_pro_plan=_parse_bool(payload.get("advertise_pro_plan")),
|
||||
prompt_switching_to_pro_plan=_parse_bool(
|
||||
payload.get("prompt_switching_to_pro_plan")
|
||||
),
|
||||
)
|
||||
return WhoAmIResponse.from_payload(payload)
|
||||
|
||||
|
||||
def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
|
||||
|
|
@ -49,19 +43,3 @@ def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
|
|||
except ValueError:
|
||||
return None
|
||||
return cast(Mapping[str, object], data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _parse_bool(value: object | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
match value.strip().lower():
|
||||
case "true":
|
||||
return True
|
||||
case "false":
|
||||
return False
|
||||
case _:
|
||||
raise WhoAmIGatewayError("Invalid boolean string in whoami response")
|
||||
raise WhoAmIGatewayError("Invalid boolean value in whoami response")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from os import getenv
|
||||
|
||||
|
|
@ -8,6 +7,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
WhoAmIGateway,
|
||||
WhoAmIGatewayError,
|
||||
WhoAmIGatewayUnauthorized,
|
||||
WhoAmIPlanType,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
|
||||
|
|
@ -19,51 +19,50 @@ UPGRADE_URL = CONSOLE_CLI_URL
|
|||
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
|
||||
|
||||
|
||||
class PlanOfferAction(StrEnum):
|
||||
NONE = "none"
|
||||
UPGRADE = "upgrade"
|
||||
SWITCH_TO_PRO_KEY = "switch_to_pro_key"
|
||||
class PlanInfo:
|
||||
plan_type: WhoAmIPlanType
|
||||
plan_name: str
|
||||
prompt_switching_to_pro_plan: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plan_type: WhoAmIPlanType,
|
||||
plan_name: str = "",
|
||||
prompt_switching_to_pro_plan: bool = False,
|
||||
) -> None:
|
||||
self.plan_type = plan_type
|
||||
self.plan_name = plan_name
|
||||
self.prompt_switching_to_pro_plan = prompt_switching_to_pro_plan
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, response: WhoAmIResponse) -> PlanInfo:
|
||||
return cls(
|
||||
plan_type=response.plan_type,
|
||||
plan_name=response.plan_name,
|
||||
prompt_switching_to_pro_plan=response.prompt_switching_to_pro_plan,
|
||||
)
|
||||
|
||||
def is_paid_api_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.API and not self.is_free_api_plan()
|
||||
|
||||
def is_free_api_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.API and "FREE" in self.plan_name.upper()
|
||||
|
||||
def is_chat_pro_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.CHAT
|
||||
|
||||
|
||||
ACTION_TO_URL: dict[PlanOfferAction, str] = {
|
||||
PlanOfferAction.UPGRADE: UPGRADE_URL,
|
||||
PlanOfferAction.SWITCH_TO_PRO_KEY: SWITCH_TO_PRO_KEY_URL,
|
||||
}
|
||||
|
||||
|
||||
class PlanType(StrEnum):
|
||||
FREE = "free"
|
||||
PRO = "pro"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
async def decide_plan_offer(
|
||||
api_key: str | None, gateway: WhoAmIGateway
|
||||
) -> tuple[PlanOfferAction, PlanType]:
|
||||
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
|
||||
if not api_key:
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
return PlanInfo(WhoAmIPlanType.UNKNOWN)
|
||||
try:
|
||||
response = await gateway.whoami(api_key)
|
||||
return PlanInfo.from_response(response)
|
||||
except WhoAmIGatewayUnauthorized:
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
return PlanInfo(WhoAmIPlanType.UNAUTHORIZED)
|
||||
except WhoAmIGatewayError:
|
||||
logger.warning("Failed to fetch plan status.", exc_info=True)
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
return _action_and_plan_from_response(response)
|
||||
|
||||
|
||||
def _action_and_plan_from_response(
|
||||
response: WhoAmIResponse,
|
||||
) -> tuple[PlanOfferAction, PlanType]:
|
||||
match response:
|
||||
case WhoAmIResponse(is_pro_plan=True):
|
||||
return PlanOfferAction.NONE, PlanType.PRO
|
||||
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
|
||||
return PlanOfferAction.SWITCH_TO_PRO_KEY, PlanType.PRO
|
||||
case WhoAmIResponse(advertise_pro_plan=True):
|
||||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
case _:
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
return PlanInfo(WhoAmIPlanType.UNKNOWN)
|
||||
|
||||
|
||||
def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
||||
|
|
@ -75,13 +74,22 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
|||
return getenv(api_env_key)
|
||||
|
||||
|
||||
def plan_offer_cta(action: PlanOfferAction) -> str | None:
|
||||
if action is PlanOfferAction.NONE:
|
||||
def plan_offer_cta(payload: PlanInfo | None) -> str | None:
|
||||
if not payload:
|
||||
return
|
||||
url = ACTION_TO_URL[action]
|
||||
match action:
|
||||
case PlanOfferAction.UPGRADE:
|
||||
text = f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({url})"
|
||||
case PlanOfferAction.SWITCH_TO_PRO_KEY:
|
||||
text = f"### Switch to your [Le Chat Pro API key]({url})"
|
||||
return text
|
||||
if payload.prompt_switching_to_pro_plan:
|
||||
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
|
||||
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
|
||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
|
||||
|
||||
|
||||
def plan_title(payload: PlanInfo | None) -> str | None:
|
||||
if not payload:
|
||||
return None
|
||||
if payload.is_chat_pro_plan():
|
||||
return "[Subscription] Pro"
|
||||
if payload.is_free_api_plan():
|
||||
return "[API] Experiment plan"
|
||||
if payload.is_paid_api_plan():
|
||||
return "[API] Scale plan"
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,15 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
|
||||
class WhoAmIPlanType(StrEnum):
|
||||
API = "API"
|
||||
CHAT = "CHAT"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> WhoAmIPlanType:
|
||||
try:
|
||||
return cls(value.strip().upper())
|
||||
except ValueError:
|
||||
return cls.UNKNOWN
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WhoAmIResponse:
|
||||
is_pro_plan: bool
|
||||
advertise_pro_plan: bool
|
||||
plan_type: WhoAmIPlanType
|
||||
plan_name: str
|
||||
prompt_switching_to_pro_plan: bool
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, object]) -> WhoAmIResponse:
|
||||
plan_type = payload.get("plan_type")
|
||||
plan_name = payload.get("plan_name")
|
||||
if not isinstance(plan_type, str) or not isinstance(plan_name, str):
|
||||
raise WhoAmIGatewayError(f"Invalid whoami response: {payload}")
|
||||
return cls(
|
||||
plan_type=WhoAmIPlanType.from_string(plan_type),
|
||||
plan_name=plan_name.strip(),
|
||||
prompt_switching_to_pro_plan=_parse_bool(
|
||||
payload.get("prompt_switching_to_pro_plan")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool(value: object | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
return TypeAdapter(bool).validate_python(value)
|
||||
except ValidationError as e:
|
||||
raise WhoAmIGatewayError(
|
||||
f"Invalid boolean value in whoami response: {value}"
|
||||
) from e
|
||||
|
||||
|
||||
class WhoAmIGatewayUnauthorized(Exception):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
|
|||
from vibe.cli.commands import CommandRegistry
|
||||
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
|
||||
from vibe.cli.plan_offer.decide_plan_offer import (
|
||||
PlanType,
|
||||
PlanInfo,
|
||||
decide_plan_offer,
|
||||
plan_offer_cta,
|
||||
plan_title,
|
||||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
|
|
@ -89,7 +90,7 @@ from vibe.cli.update_notifier.update import do_update
|
|||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
|
|
@ -270,6 +271,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
self._plan_info: PlanInfo | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -319,7 +321,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def update_context_progress(stats: AgentStats) -> None:
|
||||
context_progress.tokens = TokenState(
|
||||
max_tokens=self.config.auto_compact_threshold,
|
||||
max_tokens=self.config.get_active_model().auto_compact_threshold,
|
||||
current_tokens=stats.context_tokens,
|
||||
)
|
||||
|
||||
|
|
@ -332,6 +334,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
chat_input_container = self.query_one(ChatInputContainer)
|
||||
chat_input_container.focus_input()
|
||||
await self._resolve_plan()
|
||||
await self._show_dangerous_directory_warning()
|
||||
await self._resume_history_from_messages()
|
||||
await self._check_and_show_whats_new()
|
||||
|
|
@ -728,10 +731,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
if self.plan_type == PlanType.FREE:
|
||||
message = "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
else:
|
||||
message = "Rate limits exceeded. Please wait a moment before trying again."
|
||||
message = self._rate_limit_message()
|
||||
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(message, collapsed=self._tools_collapsed)
|
||||
|
|
@ -748,6 +748,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._refresh_windowing_from_history()
|
||||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
def _rate_limit_message(self) -> str:
|
||||
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
|
||||
WhoAmIPlanType.API,
|
||||
WhoAmIPlanType.UNAUTHORIZED,
|
||||
}
|
||||
if upgrade_to_pro:
|
||||
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
return "Rate limits exceeded. Please wait a moment before trying again."
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
||||
|
|
@ -1003,9 +1012,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
base_config = VibeConfig.load()
|
||||
|
||||
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
||||
await self._resolve_plan()
|
||||
|
||||
if self._banner:
|
||||
self._banner.set_state(base_config, self.agent_loop.skill_manager)
|
||||
self._banner.set_state(
|
||||
base_config,
|
||||
self.agent_loop.skill_manager,
|
||||
plan_title(self._plan_info),
|
||||
)
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -1198,6 +1212,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.disabled = False
|
||||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self._refresh_profile_widgets()
|
||||
self.call_after_refresh(self._chat_input_container.focus_input)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
|
|
@ -1371,7 +1386,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
self._banner.set_state(self.config, self.agent_loop.skill_manager)
|
||||
self._banner.set_state(
|
||||
self.config, self.agent_loop.skill_manager, plan_title(self._plan_info)
|
||||
)
|
||||
|
||||
def _update_profile_widgets(self, profile: AgentProfile) -> None:
|
||||
if self._chat_input_container:
|
||||
|
|
@ -1460,7 +1477,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
content = load_whats_new_content()
|
||||
if content is not None:
|
||||
whats_new_message = WhatsNewMessage(content)
|
||||
plan_offer = await self._plan_offer_cta()
|
||||
plan_offer = plan_offer_cta(self._plan_info)
|
||||
if plan_offer is not None:
|
||||
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
|
||||
if self._history_widget_indices:
|
||||
|
|
@ -1474,23 +1491,21 @@ class VibeApp(App): # noqa: PLR0904
|
|||
chat.anchor()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _plan_offer_cta(self) -> str | None:
|
||||
self.plan_type = PlanType.UNKNOWN
|
||||
|
||||
async def _resolve_plan(self) -> None:
|
||||
if self._plan_offer_gateway is None:
|
||||
self._plan_info = None
|
||||
return
|
||||
|
||||
try:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
||||
api_key = resolve_api_key_for_plan(provider)
|
||||
action, plan_type = await decide_plan_offer(
|
||||
api_key, self._plan_offer_gateway
|
||||
)
|
||||
if provider.backend != Backend.MISTRAL:
|
||||
self._plan_info = None
|
||||
return
|
||||
|
||||
self.plan_type = plan_type
|
||||
return plan_offer_cta(action)
|
||||
api_key = resolve_api_key_for_plan(provider)
|
||||
self._plan_info = await decide_plan_offer(api_key, self._plan_offer_gateway)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
|
||||
|
|
|
|||
|
|
@ -840,6 +840,10 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
#question-content.question-content-docked {
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
.question-tabs {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
|
|
@ -900,6 +904,28 @@ StatusMessage {
|
|||
margin-top: 1;
|
||||
}
|
||||
|
||||
.question-content-preview {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
padding: 0 0 0 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.question-content-preview-text {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
& > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ExpandingBorder {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
|
|
@ -959,6 +985,10 @@ ContextProgress {
|
|||
width: auto;
|
||||
}
|
||||
|
||||
#banner-user-plan {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#banner-meta-counts {
|
||||
width: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class BannerState:
|
|||
models_count: int = 0
|
||||
mcp_servers_count: int = 0
|
||||
skills_count: int = 0
|
||||
plan_description: str | None = None
|
||||
|
||||
|
||||
class Banner(Static):
|
||||
|
|
@ -36,6 +37,7 @@ class Banner(Static):
|
|||
models_count=len(config.models),
|
||||
mcp_servers_count=len(config.mcp_servers),
|
||||
skills_count=len(skill_manager.available_skills),
|
||||
plan_description=None,
|
||||
)
|
||||
self._animated = not config.disable_welcome_banner_animation
|
||||
|
||||
|
|
@ -49,6 +51,7 @@ class Banner(Static):
|
|||
yield NoMarkupStatic(" ", classes="banner-spacer")
|
||||
yield NoMarkupStatic(f"v{__version__} · ", classes="banner-meta")
|
||||
yield NoMarkupStatic("", id="banner-model")
|
||||
yield NoMarkupStatic("", id="banner-user-plan")
|
||||
with Horizontal(classes="banner-line"):
|
||||
yield NoMarkupStatic("", id="banner-meta-counts")
|
||||
with Horizontal(classes="banner-line"):
|
||||
|
|
@ -64,17 +67,24 @@ class Banner(Static):
|
|||
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
|
||||
self._format_meta_counts()
|
||||
)
|
||||
self.query_one("#banner-user-plan", NoMarkupStatic).update(self._format_plan())
|
||||
|
||||
def freeze_animation(self) -> None:
|
||||
if self._animated:
|
||||
self.query_one(PetitChat).freeze_animation()
|
||||
|
||||
def set_state(self, config: VibeConfig, skill_manager: SkillManager) -> None:
|
||||
def set_state(
|
||||
self,
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
plan_description: str | None = None,
|
||||
) -> None:
|
||||
self.state = BannerState(
|
||||
active_model=config.active_model,
|
||||
models_count=len(config.models),
|
||||
mcp_servers_count=len(config.mcp_servers),
|
||||
skills_count=len(skill_manager.available_skills),
|
||||
plan_description=plan_description,
|
||||
)
|
||||
|
||||
def _format_meta_counts(self) -> str:
|
||||
|
|
@ -83,3 +93,10 @@ class Banner(Static):
|
|||
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
|
||||
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
|
||||
)
|
||||
|
||||
def _format_plan(self) -> str:
|
||||
return (
|
||||
""
|
||||
if self.state.plan_description is None
|
||||
else f" · {self.state.plan_description}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -113,9 +113,10 @@ class ChatTextArea(TextArea):
|
|||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def _reset_prefix(self) -> None:
|
||||
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
|
||||
self._history_prefix = None
|
||||
self._last_used_prefix = None
|
||||
if clear_last_used:
|
||||
self._last_used_prefix = None
|
||||
|
||||
def _mark_cursor_moved_if_needed(self) -> None:
|
||||
if (
|
||||
|
|
@ -124,7 +125,7 @@ class ChatTextArea(TextArea):
|
|||
and self.cursor_location != self._cursor_pos_after_load
|
||||
):
|
||||
self._cursor_moved_since_load = True
|
||||
self._reset_prefix()
|
||||
self._reset_prefix(clear_last_used=False)
|
||||
|
||||
def _get_prefix_up_to_cursor(self) -> str:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
|
|
@ -137,8 +138,17 @@ class ChatTextArea(TextArea):
|
|||
return ""
|
||||
|
||||
def _handle_history_up(self) -> bool:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
if cursor_row == 0:
|
||||
_, cursor_col = self.cursor_location
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._cursor_pos_after_load is not None
|
||||
and not self._cursor_moved_since_load
|
||||
)
|
||||
should_intercept = (
|
||||
self.navigator.is_first_wrapped_line(self.cursor_location)
|
||||
or history_loaded_and_cursor_unmoved
|
||||
)
|
||||
|
||||
if should_intercept:
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
|
@ -151,26 +161,45 @@ class ChatTextArea(TextArea):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _handle_history_down(self) -> bool:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
total_lines = self.text.count("\n") + 1
|
||||
def _is_on_loaded_history_entry(self) -> bool:
|
||||
return self._cursor_pos_after_load is not None
|
||||
|
||||
on_first_line_unmoved = cursor_row == 0 and not self._cursor_moved_since_load
|
||||
on_last_line = cursor_row == total_lines - 1
|
||||
def _has_history_prefix(self) -> bool:
|
||||
return self._history_prefix is not None
|
||||
|
||||
should_intercept = (
|
||||
on_first_line_unmoved and self._history_prefix is not None
|
||||
) or on_last_line
|
||||
def _has_history_navigation_context(self) -> bool:
|
||||
return self._is_on_loaded_history_entry() or self._has_history_prefix()
|
||||
|
||||
if not should_intercept:
|
||||
def _should_intercept_history_down(self) -> bool:
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
|
||||
)
|
||||
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
|
||||
return True
|
||||
|
||||
if not self.navigator.is_last_wrapped_line(self.cursor_location):
|
||||
return False
|
||||
|
||||
return self._has_history_navigation_context()
|
||||
|
||||
def _handle_history_down(self) -> bool:
|
||||
_, cursor_col = self.cursor_location
|
||||
|
||||
if not self._should_intercept_history_down():
|
||||
return False
|
||||
|
||||
navigating_loaded_history = self._is_on_loaded_history_entry()
|
||||
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
self._reset_prefix()
|
||||
if not navigating_loaded_history:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
||||
if self._history_prefix is None:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
if navigating_loaded_history and self._last_used_prefix is not None:
|
||||
self._history_prefix = self._last_used_prefix
|
||||
else:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryNext(self._history_prefix))
|
||||
|
|
@ -238,6 +267,11 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
return
|
||||
|
||||
# Work around VS Code 1.110+ sending space as CSI u (\x1b[32u),
|
||||
# which Textual parses as Key("space", character=None, is_printable=False).
|
||||
if event.key == "space" and event.character is None:
|
||||
event.character = " "
|
||||
|
||||
await super()._on_key(event)
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ from typing import TYPE_CHECKING, ClassVar
|
|||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Input
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -108,7 +109,16 @@ class QuestionApp(Container):
|
|||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="question-content"):
|
||||
if self.args.content_preview:
|
||||
with VerticalScroll(classes="question-content-preview"):
|
||||
yield AnsiMarkdown(
|
||||
self.args.content_preview, classes="question-content-preview-text"
|
||||
)
|
||||
|
||||
question_content_classes = (
|
||||
"question-content-docked" if self.args.content_preview else ""
|
||||
)
|
||||
with Vertical(id="question-content", classes=question_content_classes):
|
||||
if len(self.questions) > 1:
|
||||
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
|
||||
yield self.tabs_widget
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ from vibe.core.middleware import (
|
|||
CHAT_AGENT_EXIT,
|
||||
CHAT_AGENT_REMINDER,
|
||||
PLAN_AGENT_EXIT,
|
||||
PLAN_AGENT_REMINDER,
|
||||
AutoCompactMiddleware,
|
||||
ContextWarningMiddleware,
|
||||
ConversationContext,
|
||||
|
|
@ -41,7 +40,9 @@ from vibe.core.middleware import (
|
|||
ReadOnlyAgentMiddleware,
|
||||
ResetReason,
|
||||
TurnLimitMiddleware,
|
||||
make_plan_agent_reminder,
|
||||
)
|
||||
from vibe.core.plan_session import PlanSession
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.session.session_migration import migrate_sessions_entrypoint
|
||||
|
|
@ -154,6 +155,7 @@ class AgentLoop:
|
|||
self._base_config = config
|
||||
self._max_turns = max_turns
|
||||
self._max_price = max_price
|
||||
self._plan_session = PlanSession()
|
||||
|
||||
self.agent_manager = AgentManager(
|
||||
lambda: self._base_config, initial_agent=agent_name
|
||||
|
|
@ -343,20 +345,21 @@ class AgentLoop:
|
|||
if self._max_price is not None:
|
||||
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
|
||||
|
||||
if self.config.auto_compact_threshold > 0:
|
||||
active_model = self.config.get_active_model()
|
||||
if active_model.auto_compact_threshold > 0:
|
||||
self.middleware_pipeline.add(
|
||||
AutoCompactMiddleware(self.config.auto_compact_threshold)
|
||||
AutoCompactMiddleware(active_model.auto_compact_threshold)
|
||||
)
|
||||
if self.config.context_warnings:
|
||||
self.middleware_pipeline.add(
|
||||
ContextWarningMiddleware(0.5, self.config.auto_compact_threshold)
|
||||
ContextWarningMiddleware(0.5, active_model.auto_compact_threshold)
|
||||
)
|
||||
|
||||
self.middleware_pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
lambda: self.agent_profile,
|
||||
BuiltinAgentName.PLAN,
|
||||
PLAN_AGENT_REMINDER,
|
||||
lambda: make_plan_agent_reminder(self._plan_session.plan_file_path_str),
|
||||
PLAN_AGENT_EXIT,
|
||||
)
|
||||
)
|
||||
|
|
@ -391,7 +394,7 @@ class AgentLoop:
|
|||
"old_tokens", self.stats.context_tokens
|
||||
)
|
||||
threshold = result.metadata.get(
|
||||
"threshold", self.config.auto_compact_threshold
|
||||
"threshold", self.config.get_active_model().auto_compact_threshold
|
||||
)
|
||||
tool_call_id = str(uuid4())
|
||||
|
||||
|
|
@ -615,6 +618,8 @@ class AgentLoop:
|
|||
approval_callback=self.approval_callback,
|
||||
user_input_callback=self.user_input_callback,
|
||||
sampling_callback=self._sampling_handler,
|
||||
plan_file_path=self._plan_session.plan_file_path,
|
||||
switch_agent_callback=self.switch_agent,
|
||||
),
|
||||
**tool_call.args_dict,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from vibe.core.agents.models import (
|
|||
DEFAULT,
|
||||
EXPLORE,
|
||||
PLAN,
|
||||
PLAN_AGENT_TOOLS,
|
||||
AgentProfile,
|
||||
AgentSafety,
|
||||
AgentType,
|
||||
|
|
@ -22,7 +21,6 @@ __all__ = [
|
|||
"DEFAULT",
|
||||
"EXPLORE",
|
||||
"PLAN",
|
||||
"PLAN_AGENT_TOOLS",
|
||||
"AgentManager",
|
||||
"AgentProfile",
|
||||
"AgentSafety",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from pathlib import Path
|
|||
import tomllib
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vibe.core.paths.global_paths import PLANS_DIR
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
|
@ -71,7 +73,17 @@ class AgentProfile:
|
|||
|
||||
|
||||
CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
|
||||
PLAN_AGENT_TOOLS = ["grep", "read_file", "todo", "ask_user_question", "task"]
|
||||
|
||||
|
||||
def _plan_overrides() -> dict[str, Any]:
|
||||
plans_pattern = str(PLANS_DIR.path / "*")
|
||||
return {
|
||||
"tools": {
|
||||
"write_file": {"permission": "never", "allowlist": [plans_pattern]},
|
||||
"search_replace": {"permission": "never", "allowlist": [plans_pattern]},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DEFAULT = AgentProfile(
|
||||
BuiltinAgentName.DEFAULT,
|
||||
|
|
@ -84,7 +96,7 @@ PLAN = AgentProfile(
|
|||
"Plan",
|
||||
"Read-only agent for exploration and planning",
|
||||
AgentSafety.SAFE,
|
||||
overrides={"auto_approve": True, "enabled_tools": PLAN_AGENT_TOOLS},
|
||||
overrides=_plan_overrides(),
|
||||
)
|
||||
CHAT = AgentProfile(
|
||||
BuiltinAgentName.CHAT,
|
||||
|
|
|
|||
|
|
@ -71,16 +71,6 @@ class MissingPromptFileError(RuntimeError):
|
|||
self.prompt_dir = prompt_dir
|
||||
|
||||
|
||||
class WrongBackendError(RuntimeError):
|
||||
def __init__(self, backend: Backend, is_mistral_api: bool) -> None:
|
||||
super().__init__(
|
||||
f"Wrong backend '{backend}' for {'' if is_mistral_api else 'non-'}"
|
||||
f"mistral API. Use '{Backend.MISTRAL}' for mistral API and '{Backend.GENERIC}' for others."
|
||||
)
|
||||
self.backend = backend
|
||||
self.is_mistral_api = is_mistral_api
|
||||
|
||||
|
||||
class TomlFileSettingsSource(PydanticBaseSettingsSource):
|
||||
def __init__(self, settings_cls: type[BaseSettings]) -> None:
|
||||
super().__init__(settings_cls)
|
||||
|
|
@ -108,13 +98,10 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
|
|||
|
||||
|
||||
class ProjectContextConfig(BaseSettings):
|
||||
max_chars: int = 40_000
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
default_commit_count: int = 5
|
||||
max_doc_bytes: int = 32 * 1024
|
||||
truncation_buffer: int = 1_000
|
||||
max_depth: int = 3
|
||||
max_files: int = 1000
|
||||
max_dirs_per_level: int = 20
|
||||
timeout_seconds: float = 2.0
|
||||
|
||||
|
||||
|
|
@ -258,6 +245,7 @@ class ModelConfig(BaseModel):
|
|||
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"
|
||||
auto_compact_threshold: int = 200_000
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
|
@ -317,7 +305,6 @@ class VibeConfig(BaseSettings):
|
|||
autocopy_to_clipboard: bool = True
|
||||
file_watcher_for_autocomplete: bool = False
|
||||
displayed_workdir: str = ""
|
||||
auto_compact_threshold: int = 200_000
|
||||
context_warnings: bool = False
|
||||
auto_approve: bool = False
|
||||
enable_telemetry: bool = True
|
||||
|
|
@ -497,27 +484,6 @@ class VibeConfig(BaseSettings):
|
|||
pass
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_api_backend_compatibility(self) -> VibeConfig:
|
||||
try:
|
||||
active_model = self.get_active_model()
|
||||
provider = self.get_provider_for_model(active_model)
|
||||
MISTRAL_API_BASES = [
|
||||
"https://codestral.mistral.ai",
|
||||
"https://api.mistral.ai",
|
||||
]
|
||||
is_mistral_api = any(
|
||||
provider.api_base.startswith(api_base) for api_base in MISTRAL_API_BASES
|
||||
)
|
||||
if (is_mistral_api and provider.backend != Backend.MISTRAL) or (
|
||||
not is_mistral_api and provider.backend != Backend.GENERIC
|
||||
):
|
||||
raise WrongBackendError(provider.backend, is_mistral_api)
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
return self
|
||||
|
||||
@field_validator("tool_paths", mode="before")
|
||||
@classmethod
|
||||
def _expand_tool_paths(cls, v: Any) -> list[Path]:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import httpx
|
|||
|
||||
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter
|
||||
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
|
||||
|
|
@ -159,6 +160,7 @@ ADAPTERS: dict[str, APIAdapter] = {
|
|||
"openai": OpenAIAdapter(),
|
||||
"anthropic": AnthropicAdapter(),
|
||||
"vertex-anthropic": VertexAnthropicAdapter(),
|
||||
"reasoning": ReasoningAdapter(),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
227
vibe/core/llm/backend/reasoning_adapter.py
Normal file
227
vibe/core/llm/backend/reasoning_adapter.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import json
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
from vibe.core.types import (
|
||||
AvailableTool,
|
||||
FunctionCall,
|
||||
LLMChunk,
|
||||
LLMMessage,
|
||||
LLMUsage,
|
||||
Role,
|
||||
StrToolChoice,
|
||||
ToolCall,
|
||||
)
|
||||
|
||||
|
||||
class ReasoningAdapter(APIAdapter):
|
||||
endpoint: ClassVar[str] = "/chat/completions"
|
||||
|
||||
def _convert_message(self, msg: LLMMessage) -> dict[str, Any]:
|
||||
match msg.role:
|
||||
case Role.system:
|
||||
return {"role": "system", "content": msg.content or ""}
|
||||
case Role.user:
|
||||
return {"role": "user", "content": msg.content or ""}
|
||||
case Role.assistant:
|
||||
return self._convert_assistant_message(msg)
|
||||
case Role.tool:
|
||||
result: dict[str, Any] = {
|
||||
"role": "tool",
|
||||
"content": msg.content or "",
|
||||
"tool_call_id": msg.tool_call_id,
|
||||
}
|
||||
if msg.name:
|
||||
result["name"] = msg.name
|
||||
return result
|
||||
|
||||
def _convert_assistant_message(self, msg: LLMMessage) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"role": "assistant"}
|
||||
|
||||
if msg.reasoning_content:
|
||||
content: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": msg.reasoning_content}],
|
||||
},
|
||||
{"type": "text", "text": msg.content or ""},
|
||||
]
|
||||
result["content"] = content
|
||||
else:
|
||||
result["content"] = msg.content or ""
|
||||
|
||||
if msg.tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name or "",
|
||||
"arguments": tc.function.arguments or "",
|
||||
},
|
||||
**({"index": tc.index} if tc.index is not None else {}),
|
||||
}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float,
|
||||
tools: list[AvailableTool] | None,
|
||||
max_tokens: int | None,
|
||||
tool_choice: StrToolChoice | AvailableTool | None,
|
||||
thinking: str,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
if thinking != "off":
|
||||
payload["reasoning_effort"] = thinking
|
||||
|
||||
if tools:
|
||||
payload["tools"] = [tool.model_dump(exclude_none=True) for tool in tools]
|
||||
|
||||
if tool_choice:
|
||||
payload["tool_choice"] = (
|
||||
tool_choice
|
||||
if isinstance(tool_choice, str)
|
||||
else tool_choice.model_dump()
|
||||
)
|
||||
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
return payload
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
messages: Sequence[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:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
converted_messages = [self._convert_message(msg) for msg in merged_messages]
|
||||
|
||||
payload = self._build_payload(
|
||||
model_name=model_name,
|
||||
messages=converted_messages,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
tool_choice=tool_choice,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
if enable_streaming:
|
||||
payload["stream"] = True
|
||||
payload["stream_options"] = {
|
||||
"include_usage": True,
|
||||
"stream_tool_calls": True,
|
||||
}
|
||||
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
return PreparedRequest(self.endpoint, headers, body)
|
||||
|
||||
@staticmethod
|
||||
def _parse_content_blocks(
|
||||
content: str | list[dict[str, Any]],
|
||||
) -> tuple[str | None, str | None]:
|
||||
if isinstance(content, str):
|
||||
return content or None, None
|
||||
|
||||
text_parts: list[str] = []
|
||||
thinking_parts: list[str] = []
|
||||
|
||||
for block in content:
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block_type == "thinking":
|
||||
for inner in block.get("thinking", []):
|
||||
if isinstance(inner, dict) and inner.get("type") == "text":
|
||||
thinking_parts.append(inner.get("text", ""))
|
||||
elif isinstance(inner, str):
|
||||
thinking_parts.append(inner)
|
||||
|
||||
return ("".join(text_parts) or None, "".join(thinking_parts) or None)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_calls(
|
||||
tool_calls: list[dict[str, Any]] | None,
|
||||
) -> list[ToolCall] | None:
|
||||
if not tool_calls:
|
||||
return None
|
||||
return [
|
||||
ToolCall(
|
||||
id=tc.get("id"),
|
||||
index=tc.get("index"),
|
||||
function=FunctionCall(
|
||||
name=tc.get("function", {}).get("name"),
|
||||
arguments=tc.get("function", {}).get("arguments", ""),
|
||||
),
|
||||
)
|
||||
for tc in tool_calls
|
||||
]
|
||||
|
||||
def _parse_message_dict(self, msg_dict: dict[str, Any]) -> LLMMessage:
|
||||
content = msg_dict.get("content")
|
||||
text_content: str | None = None
|
||||
reasoning_content: str | None = None
|
||||
|
||||
if content is not None:
|
||||
text_content, reasoning_content = self._parse_content_blocks(content)
|
||||
|
||||
return LLMMessage(
|
||||
role=Role.assistant,
|
||||
content=text_content,
|
||||
reasoning_content=reasoning_content,
|
||||
tool_calls=self._parse_tool_calls(msg_dict.get("tool_calls")),
|
||||
)
|
||||
|
||||
def parse_response(
|
||||
self, data: dict[str, Any], provider: ProviderConfig
|
||||
) -> LLMChunk:
|
||||
message: LLMMessage | None = None
|
||||
|
||||
if data.get("choices"):
|
||||
choice = data["choices"][0]
|
||||
if "message" in choice:
|
||||
message = self._parse_message_dict(choice["message"])
|
||||
elif "delta" in choice:
|
||||
message = self._parse_message_dict(choice["delta"])
|
||||
|
||||
if message is None:
|
||||
message = LLMMessage(role=Role.assistant, content="")
|
||||
|
||||
usage_data = data.get("usage") or {}
|
||||
usage = LLMUsage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
)
|
||||
|
||||
return LLMChunk(message=message, usage=usage)
|
||||
|
|
@ -129,9 +129,20 @@ class ContextWarningMiddleware:
|
|||
self.has_warned = False
|
||||
|
||||
|
||||
PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). Instead, you should:
|
||||
1. Answer the user's query comprehensively
|
||||
2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.</{VIBE_WARNING_TAG}>"""
|
||||
def make_plan_agent_reminder(plan_file_path: str) -> str:
|
||||
return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
||||
|
||||
## Plan File Info
|
||||
Create or edit your plan at {plan_file_path} using the write_file and search_replace tools.
|
||||
Build your plan incrementally by writing to or editing this file.
|
||||
This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan.
|
||||
|
||||
## Instructions
|
||||
1. Research the user's query using read-only tools (grep, read_file, etc.)
|
||||
2. If you are unsure about requirements or approach, use the ask_user_question tool to clarify before finalizing your plan
|
||||
3. Write your plan to the plan file above
|
||||
4. When your plan is complete, call the exit_plan_mode tool to request user approval and switch to implementation mode</{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}>"""
|
||||
|
||||
|
|
@ -149,15 +160,19 @@ class ReadOnlyAgentMiddleware:
|
|||
self,
|
||||
profile_getter: Callable[[], AgentProfile],
|
||||
agent_name: str,
|
||||
reminder: str,
|
||||
reminder: str | Callable[[], str],
|
||||
exit_message: str,
|
||||
) -> None:
|
||||
self._profile_getter = profile_getter
|
||||
self._agent_name = agent_name
|
||||
self.reminder = reminder
|
||||
self._reminder = reminder
|
||||
self.exit_message = exit_message
|
||||
self._was_active = False
|
||||
|
||||
@property
|
||||
def reminder(self) -> str:
|
||||
return self._reminder() if callable(self._reminder) else self._reminder
|
||||
|
||||
def _is_active(self) -> bool:
|
||||
return self._profile_getter().name == self._agent_name
|
||||
|
||||
|
|
|
|||
|
|
@ -36,5 +36,6 @@ 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 / "logs" / "vibe.log")
|
||||
PLANS_DIR = GlobalPath(lambda: VIBE_HOME.path / "plans")
|
||||
|
||||
DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins")
|
||||
|
|
|
|||
24
vibe/core/plan_session.py
Normal file
24
vibe/core/plan_session.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from vibe.core.paths.global_paths import PLANS_DIR
|
||||
from vibe.core.slug import create_slug
|
||||
|
||||
|
||||
class PlanSession:
|
||||
def __init__(self) -> None:
|
||||
self._plan_file_path: Path | None = None
|
||||
|
||||
@property
|
||||
def plan_file_path(self) -> Path:
|
||||
if self._plan_file_path is None:
|
||||
slug = create_slug()
|
||||
timestamp = int(time.time())
|
||||
self._plan_file_path = PLANS_DIR.path / f"{timestamp}-{slug}.md"
|
||||
return self._plan_file_path
|
||||
|
||||
@property
|
||||
def plan_file_path_str(self) -> str:
|
||||
return str(self.plan_file_path)
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
directoryStructure: Below is a snapshot of this project's file structure at the start of the conversation. This snapshot will NOT update during the conversation. It skips over .gitignore patterns.{large_repo_warning}
|
||||
|
||||
{structure}
|
||||
|
||||
Absolute path: {abs_path}
|
||||
|
||||
gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ class SessionLoader:
|
|||
raise ValueError(f"Session metadata not found at {session_dir}")
|
||||
|
||||
try:
|
||||
metadata_content = metadata_path.read_text()
|
||||
metadata_content = metadata_path.read_text("utf-8", errors="ignore")
|
||||
return SessionMetadata.model_validate_json(metadata_content)
|
||||
except ValueError:
|
||||
raise
|
||||
|
|
|
|||
113
vibe/core/slug.py
Normal file
113
vibe/core/slug.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
_ADJECTIVES = [
|
||||
"bold",
|
||||
"brave",
|
||||
"bright",
|
||||
"calm",
|
||||
"clever",
|
||||
"cool",
|
||||
"cosmic",
|
||||
"crisp",
|
||||
"curious",
|
||||
"daring",
|
||||
"eager",
|
||||
"fair",
|
||||
"fierce",
|
||||
"gentle",
|
||||
"golden",
|
||||
"grand",
|
||||
"happy",
|
||||
"keen",
|
||||
"kind",
|
||||
"lively",
|
||||
"lucky",
|
||||
"merry",
|
||||
"mighty",
|
||||
"noble",
|
||||
"proud",
|
||||
"quick",
|
||||
"quiet",
|
||||
"rapid",
|
||||
"rustic",
|
||||
"serene",
|
||||
"sharp",
|
||||
"shiny",
|
||||
"silent",
|
||||
"smooth",
|
||||
"snowy",
|
||||
"steady",
|
||||
"swift",
|
||||
"tiny",
|
||||
"vivid",
|
||||
"warm",
|
||||
"wild",
|
||||
"wise",
|
||||
"witty",
|
||||
"zesty",
|
||||
]
|
||||
|
||||
_NOUNS = [
|
||||
"arch",
|
||||
"bloom",
|
||||
"breeze",
|
||||
"brook",
|
||||
"cabin",
|
||||
"canyon",
|
||||
"cedar",
|
||||
"cliff",
|
||||
"cloud",
|
||||
"comet",
|
||||
"coral",
|
||||
"crane",
|
||||
"creek",
|
||||
"dawn",
|
||||
"dune",
|
||||
"ember",
|
||||
"falcon",
|
||||
"fern",
|
||||
"flame",
|
||||
"flint",
|
||||
"forest",
|
||||
"frost",
|
||||
"glen",
|
||||
"grove",
|
||||
"harbor",
|
||||
"hawk",
|
||||
"lake",
|
||||
"lark",
|
||||
"maple",
|
||||
"marsh",
|
||||
"meadow",
|
||||
"mesa",
|
||||
"mist",
|
||||
"moon",
|
||||
"oak",
|
||||
"orbit",
|
||||
"peak",
|
||||
"pine",
|
||||
"pixel",
|
||||
"pond",
|
||||
"reef",
|
||||
"ridge",
|
||||
"river",
|
||||
"rocket",
|
||||
"sage",
|
||||
"shore",
|
||||
"spark",
|
||||
"stone",
|
||||
"storm",
|
||||
"trail",
|
||||
"vale",
|
||||
"wave",
|
||||
"willow",
|
||||
"wolf",
|
||||
]
|
||||
|
||||
|
||||
def create_slug() -> str:
|
||||
adj1, adj2 = random.sample(_ADJECTIVES, 2)
|
||||
noun = random.choice(_NOUNS)
|
||||
return f"{adj1}-{adj2}-{noun}"
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
import fnmatch
|
||||
import html
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
|
|
@ -20,6 +17,8 @@ if TYPE_CHECKING:
|
|||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
_git_status_cache: dict[Path, str] = {}
|
||||
|
||||
|
||||
def _load_project_doc(workdir: Path, max_bytes: int) -> str:
|
||||
if not trusted_folders_manager.is_trusted(workdir):
|
||||
|
|
@ -39,162 +38,16 @@ class ProjectContextProvider:
|
|||
) -> None:
|
||||
self.root_path = Path(root_path).resolve()
|
||||
self.config = config
|
||||
self.gitignore_patterns = self._load_gitignore_patterns()
|
||||
self._file_count = 0
|
||||
self._start_time = 0.0
|
||||
|
||||
def _load_gitignore_patterns(self) -> list[str]:
|
||||
gitignore_path = self.root_path / ".gitignore"
|
||||
patterns = []
|
||||
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
patterns.extend(
|
||||
line.strip()
|
||||
for line in gitignore_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.startswith("#")
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read .gitignore: {e}", file=sys.stderr)
|
||||
|
||||
default_patterns = [
|
||||
".git",
|
||||
".git/*",
|
||||
"*.pyc",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
"node_modules/*",
|
||||
".env",
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
".vscode/settings.json",
|
||||
".idea/*",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".next",
|
||||
".nuxt",
|
||||
"coverage",
|
||||
".nyc_output",
|
||||
"*.egg-info",
|
||||
".pytest_cache",
|
||||
".tox",
|
||||
"vendor",
|
||||
"third_party",
|
||||
"deps",
|
||||
"*.min.js",
|
||||
"*.min.css",
|
||||
"*.bundle.js",
|
||||
"*.chunk.js",
|
||||
".cache",
|
||||
"tmp",
|
||||
"temp",
|
||||
"logs",
|
||||
]
|
||||
|
||||
return patterns + default_patterns
|
||||
|
||||
def _is_ignored(self, path: Path) -> bool:
|
||||
try:
|
||||
relative_path = path.relative_to(self.root_path)
|
||||
path_str = str(relative_path)
|
||||
|
||||
for pattern in self.gitignore_patterns:
|
||||
if pattern.endswith("/"):
|
||||
if path.is_dir() and fnmatch.fnmatch(f"{path_str}/", pattern):
|
||||
return True
|
||||
elif fnmatch.fnmatch(path_str, pattern):
|
||||
return True
|
||||
elif "*" in pattern or "?" in pattern:
|
||||
if fnmatch.fnmatch(path_str, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
except (ValueError, OSError):
|
||||
return True
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
return (
|
||||
self._file_count >= self.config.max_files
|
||||
or (time.time() - self._start_time) > self.config.timeout_seconds
|
||||
)
|
||||
|
||||
def _build_tree_structure_iterative(self) -> Generator[str]:
|
||||
self._start_time = time.time()
|
||||
self._file_count = 0
|
||||
|
||||
yield from self._process_directory(self.root_path, "", 0, is_root=True)
|
||||
|
||||
def _process_directory(
|
||||
self, path: Path, prefix: str, depth: int, is_root: bool = False
|
||||
) -> Generator[str]:
|
||||
if depth > self.config.max_depth or self._should_stop():
|
||||
return
|
||||
|
||||
try:
|
||||
all_items = list(path.iterdir())
|
||||
items = [item for item in all_items if not self._is_ignored(item)]
|
||||
|
||||
items.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
|
||||
show_truncation = len(items) > self.config.max_dirs_per_level
|
||||
if show_truncation:
|
||||
items = items[: self.config.max_dirs_per_level]
|
||||
|
||||
for i, item in enumerate(items):
|
||||
if self._should_stop():
|
||||
break
|
||||
|
||||
is_last = i == len(items) - 1 and not show_truncation
|
||||
connector = "└── " if is_last else "├── "
|
||||
name = f"{item.name}{'/' if item.is_dir() else ''}"
|
||||
|
||||
yield f"{prefix}{connector}{name}"
|
||||
self._file_count += 1
|
||||
|
||||
if item.is_dir() and depth < self.config.max_depth:
|
||||
child_prefix = prefix + (" " if is_last else "│ ")
|
||||
yield from self._process_directory(item, child_prefix, depth + 1)
|
||||
|
||||
if show_truncation and not self._should_stop():
|
||||
remaining = len(all_items) - len(items)
|
||||
yield f"{prefix}└── ... ({remaining} more items)"
|
||||
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
def get_directory_structure(self) -> str:
|
||||
lines = []
|
||||
header = f"Directory structure of {self.root_path.name} (depth≤{self.config.max_depth}, max {self.config.max_files} items):\n"
|
||||
|
||||
try:
|
||||
for line in self._build_tree_structure_iterative():
|
||||
lines.append(line)
|
||||
|
||||
current_text = header + "\n".join(lines)
|
||||
if (
|
||||
len(current_text)
|
||||
> self.config.max_chars - self.config.truncation_buffer
|
||||
):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
lines.append(f"Error building structure: {e}")
|
||||
|
||||
structure = header + "\n".join(lines)
|
||||
|
||||
if self._file_count >= self.config.max_files:
|
||||
structure += f"\n... (truncated at {self.config.max_files} files limit)"
|
||||
elif (time.time() - self._start_time) > self.config.timeout_seconds:
|
||||
structure += (
|
||||
f"\n... (truncated due to {self.config.timeout_seconds}s timeout)"
|
||||
)
|
||||
elif len(structure) > self.config.max_chars:
|
||||
structure += f"\n... (truncated at {self.config.max_chars} characters)"
|
||||
|
||||
return structure
|
||||
|
||||
def get_git_status(self) -> str:
|
||||
if self.root_path in _git_status_cache:
|
||||
return _git_status_cache[self.root_path]
|
||||
|
||||
result = self._fetch_git_status()
|
||||
_git_status_cache[self.root_path] = result
|
||||
return result
|
||||
|
||||
def _fetch_git_status(self) -> str:
|
||||
try:
|
||||
timeout = min(self.config.timeout_seconds, 10.0)
|
||||
num_commits = self.config.default_commit_count
|
||||
|
|
@ -294,23 +147,10 @@ class ProjectContextProvider:
|
|||
return f"Error getting git status: {e}"
|
||||
|
||||
def get_full_context(self) -> str:
|
||||
structure = self.get_directory_structure()
|
||||
git_status = self.get_git_status()
|
||||
|
||||
large_repo_warning = ""
|
||||
if len(structure) >= self.config.max_chars - self.config.truncation_buffer:
|
||||
large_repo_warning = (
|
||||
f" Large repository detected - showing summary view with depth limit {self.config.max_depth}. "
|
||||
f"Use the LS tool (passing a specific path), Bash tool, and other tools to explore nested directories in detail."
|
||||
)
|
||||
|
||||
template = UtilityPrompt.PROJECT_CONTEXT.read()
|
||||
return template.format(
|
||||
large_repo_warning=large_repo_warning,
|
||||
structure=structure,
|
||||
abs_path=self.root_path,
|
||||
git_status=git_status,
|
||||
)
|
||||
return template.format(abs_path=self.root_path, git_status=git_status)
|
||||
|
||||
|
||||
def _get_platform_name() -> str:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ from vibe.core.types import ToolStreamEvent
|
|||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||
from vibe.core.types import ApprovalCallback, EntrypointMetadata, UserInputCallback
|
||||
from vibe.core.types import (
|
||||
ApprovalCallback,
|
||||
EntrypointMetadata,
|
||||
SwitchAgentCallback,
|
||||
UserInputCallback,
|
||||
)
|
||||
|
||||
ARGS_COUNT = 4
|
||||
|
||||
|
|
@ -44,6 +49,8 @@ class InvokeContext:
|
|||
sampling_callback: MCPSamplingHandler | None = field(default=None)
|
||||
session_dir: Path | None = field(default=None)
|
||||
entrypoint_metadata: EntrypointMetadata | None = field(default=None)
|
||||
plan_file_path: Path | None = field(default=None)
|
||||
switch_agent_callback: SwitchAgentCallback | None = field(default=None)
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ class AskUserQuestionArgs(BaseModel):
|
|||
min_length=1,
|
||||
max_length=4,
|
||||
)
|
||||
content_preview: str | None = Field(
|
||||
default=None,
|
||||
description="Optional text content to display in a scrollable area above the questions.",
|
||||
)
|
||||
|
||||
|
||||
class Answer(BaseModel):
|
||||
|
|
|
|||
145
vibe/core/tools/builtins/exit_plan_mode.py
Normal file
145
vibe/core/tools/builtins/exit_plan_mode.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import ClassVar, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
BaseToolState,
|
||||
InvokeContext,
|
||||
ToolError,
|
||||
ToolPermission,
|
||||
)
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
AskUserQuestionResult,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
|
||||
|
||||
class ExitPlanModeArgs(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class ExitPlanModeResult(BaseModel):
|
||||
switched: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ExitPlanModeConfig(BaseToolConfig):
|
||||
permission: ToolPermission = ToolPermission.ALWAYS
|
||||
|
||||
|
||||
class ExitPlanMode(
|
||||
BaseTool[ExitPlanModeArgs, ExitPlanModeResult, ExitPlanModeConfig, BaseToolState],
|
||||
ToolUIData[ExitPlanModeArgs, ExitPlanModeResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Signal that your plan is complete and you are ready to start implementing. "
|
||||
"This will ask the user to confirm switching from plan mode to accept-edits mode. "
|
||||
"Only use this tool when you have finished writing your plan to the plan file "
|
||||
"and are ready for user approval to begin implementation."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def format_call_display(cls, args: ExitPlanModeArgs) -> ToolCallDisplay:
|
||||
return ToolCallDisplay(summary="Ready to exit plan mode")
|
||||
|
||||
@classmethod
|
||||
def format_result_display(cls, result: ExitPlanModeResult) -> ToolResultDisplay:
|
||||
return ToolResultDisplay(success=result.switched, message=result.message)
|
||||
|
||||
@classmethod
|
||||
def get_status_text(cls) -> str:
|
||||
return "Waiting for user confirmation"
|
||||
|
||||
async def run(
|
||||
self, args: ExitPlanModeArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ExitPlanModeResult, None]:
|
||||
if ctx is None or ctx.agent_manager is None:
|
||||
raise ToolError("ExitPlanMode requires an agent manager context.")
|
||||
|
||||
if ctx.agent_manager.active_profile.name != BuiltinAgentName.PLAN:
|
||||
raise ToolError("ExitPlanMode can only be used in plan mode.")
|
||||
|
||||
if ctx.user_input_callback is None:
|
||||
raise ToolError("ExitPlanMode requires an interactive UI.")
|
||||
|
||||
plan_content: str | None = None
|
||||
if ctx.plan_file_path and ctx.plan_file_path.is_file():
|
||||
try:
|
||||
plan_content = ctx.plan_file_path.read_text()
|
||||
except OSError as e:
|
||||
raise ToolError(
|
||||
f"Failed to read plan file at {ctx.plan_file_path}: {e}"
|
||||
) from e
|
||||
|
||||
confirmation = AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question="Plan is complete. Switch to accept-edits mode and start implementing?",
|
||||
header="Plan ready",
|
||||
options=[
|
||||
Choice(
|
||||
label="Yes, and auto approve edits",
|
||||
description="Switch to accept-edits mode with auto-approve permissions",
|
||||
),
|
||||
Choice(
|
||||
label="Yes, and request approval for edits",
|
||||
description="Switch to default agent mode (manual approval for edits)",
|
||||
),
|
||||
Choice(
|
||||
label="No",
|
||||
description="Stay in plan mode and continue planning",
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
content_preview=plan_content,
|
||||
)
|
||||
|
||||
result = await ctx.user_input_callback(confirmation)
|
||||
result = cast(AskUserQuestionResult, result)
|
||||
|
||||
if result.cancelled or not result.answers:
|
||||
yield ExitPlanModeResult(
|
||||
switched=False, message="User cancelled. Staying in plan mode."
|
||||
)
|
||||
return
|
||||
|
||||
answer = result.answers[0]
|
||||
answer_lower = answer.answer.lower()
|
||||
if answer_lower == "yes, and auto approve edits":
|
||||
if ctx.switch_agent_callback:
|
||||
await ctx.switch_agent_callback(BuiltinAgentName.ACCEPT_EDITS)
|
||||
else:
|
||||
ctx.agent_manager.switch_profile(BuiltinAgentName.ACCEPT_EDITS)
|
||||
yield ExitPlanModeResult(
|
||||
switched=True,
|
||||
message="Switched to accept-edits mode. You can now start implementing the plan.",
|
||||
)
|
||||
elif answer_lower == "yes, and request approval for edits":
|
||||
if ctx.switch_agent_callback:
|
||||
await ctx.switch_agent_callback(BuiltinAgentName.DEFAULT)
|
||||
else:
|
||||
ctx.agent_manager.switch_profile(BuiltinAgentName.DEFAULT)
|
||||
yield ExitPlanModeResult(
|
||||
switched=True,
|
||||
message="Switched to default agent mode. Edits will require your approval.",
|
||||
)
|
||||
elif answer.is_other:
|
||||
yield ExitPlanModeResult(
|
||||
switched=False,
|
||||
message=f"Staying in plan mode. User feedback: {answer.answer}",
|
||||
)
|
||||
else:
|
||||
yield ExitPlanModeResult(
|
||||
switched=False,
|
||||
message="Staying in plan mode. Continue refining the plan.",
|
||||
)
|
||||
|
|
@ -62,6 +62,7 @@ class SearchReplaceResult(BaseModel):
|
|||
lines_changed: int
|
||||
content: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
file_content_before: str
|
||||
|
||||
|
||||
class SearchReplaceConfig(BaseToolConfig):
|
||||
|
|
@ -162,6 +163,7 @@ class SearchReplace(
|
|||
lines_changed=lines_changed,
|
||||
warnings=block_result.warnings,
|
||||
content=args.content,
|
||||
file_content_before=original_content,
|
||||
)
|
||||
|
||||
@final
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class WriteFileResult(BaseModel):
|
|||
bytes_written: int
|
||||
file_existed: bool
|
||||
content: str
|
||||
file_content_before: str | None = None
|
||||
|
||||
|
||||
class WriteFileConfig(BaseToolConfig):
|
||||
|
|
@ -84,6 +85,14 @@ class WriteFile(
|
|||
) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
|
||||
file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
|
||||
|
||||
file_content_before: str | None = None
|
||||
if file_existed and args.overwrite:
|
||||
try:
|
||||
async with await anyio.Path(file_path).open(encoding="utf-8") as f:
|
||||
file_content_before = await f.read(524_288) # 512kb
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self._write_file(args, file_path)
|
||||
|
||||
yield WriteFileResult(
|
||||
|
|
@ -91,6 +100,7 @@ class WriteFile(
|
|||
bytes_written=content_bytes,
|
||||
file_existed=file_existed,
|
||||
content=args.content,
|
||||
file_content_before=file_content_before,
|
||||
)
|
||||
|
||||
def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, bool, int]:
|
||||
|
|
|
|||
|
|
@ -56,8 +56,24 @@ class ToolUIData[TArgs: BaseModel, TResult: BaseModel](ABC):
|
|||
return cls.format_call_display(cast(TArgs, event.args))
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: ...
|
||||
def format_result_display(cls, result: TResult) -> ToolResultDisplay:
|
||||
return ToolResultDisplay(success=True, message="Success")
|
||||
|
||||
@classmethod
|
||||
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
|
||||
if event.result is None:
|
||||
return ToolResultDisplay(success=True, message="Success")
|
||||
|
||||
introspect = cast(
|
||||
Callable[[], tuple[type, ...]] | None,
|
||||
getattr(cls, "_get_tool_args_results", None),
|
||||
)
|
||||
if introspect is not None:
|
||||
expected_type = introspect()[1]
|
||||
if not isinstance(event.result, expected_type):
|
||||
return ToolResultDisplay(success=True, message="Success")
|
||||
|
||||
return cls.format_result_display(cast(TResult, event.result))
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
|||
|
||||
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
|
||||
|
||||
type SwitchAgentCallback = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
class MessageList(Sequence[LLMMessage]):
|
||||
def __init__(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# What's new
|
||||
|
||||
- **Interactive resume**: Added a /resume command to choose which session to resume
|
||||
- **Web Search & Web Fetch**: New tools to search the web and fetch content from URLs directly from your session.
|
||||
- **MCP Sampling**: MCP servers can now request LLM completions through the sampling protocol.
|
||||
- **Notification Indicator**: Terminal bell and window title update when Vibe needs your attention or completes a task.
|
||||
# What's new in v2.4.0
|
||||
- **Plan mode**: Plan can be reviewed before switching to accept mode to apply edits.
|
||||
- **Pricing plan**: Your current plan appears in the CLI banner
|
||||
- **Per-model auto-compact**: Auto-compact threshold is configurable per model
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue