v2.11.1 (#721)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Mert Unsal <mertunsal1905@gmail.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: maximevoisin-pm <maxime.voisin@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
adb1ca74ce
commit
cf3f4ca58f
143 changed files with 3457 additions and 1351 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.11.0"
|
||||
__version__ = "2.11.1"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncGenerator, Callable, Mapping
|
||||
from contextlib import aclosing
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
|
|
@ -125,6 +125,7 @@ from vibe.core.config import (
|
|||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
from vibe.core.feedback import record_feedback_asked, should_show_feedback
|
||||
from vibe.core.hooks.config import load_hooks_from_fs
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.proxy_setup import (
|
||||
ProxySetupError,
|
||||
parse_proxy_command,
|
||||
|
|
@ -165,14 +166,18 @@ from vibe.core.utils import (
|
|||
get_user_cancellation_message,
|
||||
)
|
||||
from vibe.setup.auth import (
|
||||
AuthState,
|
||||
AuthStateKind,
|
||||
BrowserSignInAttempt,
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInService,
|
||||
HttpBrowserSignInGateway,
|
||||
assess_auth_state,
|
||||
)
|
||||
from vibe.setup.auth.api_key_persistence import (
|
||||
persist_api_key,
|
||||
remove_api_key,
|
||||
resolve_api_key_provider,
|
||||
)
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
|
@ -205,6 +210,22 @@ class TelemetrySendNotification(BaseModel):
|
|||
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
|
||||
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
authenticated: bool
|
||||
auth_state: AuthStateKind = Field(alias="authState")
|
||||
sign_out_available: bool = Field(alias="signOutAvailable")
|
||||
|
||||
|
||||
def _auth_status_response_from_auth_state(auth_state: AuthState) -> AuthStatusResponse:
|
||||
return AuthStatusResponse(
|
||||
authenticated=auth_state.can_use_active_provider,
|
||||
authState=auth_state.kind,
|
||||
signOutAvailable=auth_state.sign_out_available,
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_at_mention_inserted(
|
||||
client: TelemetryClient, properties: dict[str, Any]
|
||||
) -> None:
|
||||
|
|
@ -250,6 +271,7 @@ RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS = {
|
|||
|
||||
OnboardingContextLoader = Callable[[], OnboardingContext]
|
||||
ApiKeyPersister = Callable[[ProviderConfig, str], str]
|
||||
ApiKeyRemover = Callable[[ProviderConfig], None]
|
||||
|
||||
|
||||
class BrowserSignInServiceAdapter(Protocol):
|
||||
|
|
@ -274,10 +296,17 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
onboarding_context_loader: OnboardingContextLoader | None = None,
|
||||
browser_sign_in_service_factory: BrowserSignInServiceFactory | None = None,
|
||||
api_key_persister: ApiKeyPersister = persist_api_key,
|
||||
api_key_remover: ApiKeyRemover = remove_api_key,
|
||||
environ_before_dotenv_load: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self.sessions: dict[str, AcpSessionLoop] = {}
|
||||
self.client_capabilities: ClientCapabilities | None = None
|
||||
self.client_info: Implementation | None = None
|
||||
self._environ_before_dotenv_load = dict(
|
||||
environ_before_dotenv_load
|
||||
if environ_before_dotenv_load is not None
|
||||
else os.environ
|
||||
)
|
||||
self._pending_browser_sign_in_attempts: dict[
|
||||
str, PendingBrowserSignInAttempt
|
||||
] = {}
|
||||
|
|
@ -288,11 +317,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
browser_sign_in_service_factory or self._build_browser_sign_in_service
|
||||
)
|
||||
self._persist_api_key = api_key_persister
|
||||
self._remove_api_key = api_key_remover
|
||||
|
||||
def _build_browser_auth_method(
|
||||
self, context: OnboardingContext, method_id: str
|
||||
) -> AuthMethodAgent | None:
|
||||
if not context.browser_sign_in_enabled:
|
||||
if not context.supports_browser_sign_in:
|
||||
return None
|
||||
|
||||
return AuthMethodAgent(
|
||||
|
|
@ -340,7 +370,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def _load_enabled_browser_sign_in_context(self) -> OnboardingContext:
|
||||
context = self._load_onboarding_context()
|
||||
if not context.browser_sign_in_enabled:
|
||||
if not context.supports_browser_sign_in:
|
||||
raise InvalidRequestError(
|
||||
"Browser sign-in is not available for the configured provider."
|
||||
)
|
||||
|
|
@ -1474,8 +1504,46 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
return {}
|
||||
|
||||
def _assess_current_auth_state(self) -> tuple[ProviderConfig, AuthState]:
|
||||
load_dotenv_values(env_path=GLOBAL_ENV_FILE.path)
|
||||
provider = self._load_onboarding_context().provider
|
||||
auth_state = assess_auth_state(
|
||||
provider,
|
||||
process_env_had_value_before_dotenv_load=bool(
|
||||
provider.api_key_env_var
|
||||
and self._environ_before_dotenv_load.get(provider.api_key_env_var)
|
||||
),
|
||||
)
|
||||
return provider, auth_state
|
||||
|
||||
def _handle_auth_status(self) -> dict[str, Any]:
|
||||
_, auth_state = self._assess_current_auth_state()
|
||||
return _auth_status_response_from_auth_state(auth_state).model_dump(
|
||||
mode="json", by_alias=True
|
||||
)
|
||||
|
||||
def _handle_auth_sign_out(self) -> dict[str, Any]:
|
||||
provider, auth_state = self._assess_current_auth_state()
|
||||
if not auth_state.sign_out_available:
|
||||
raise InvalidRequestError(
|
||||
f"Sign out is not available for auth state: {auth_state.kind.value}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._remove_api_key(provider)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InternalError(f"Failed to sign out: {exc}") from exc
|
||||
|
||||
return {}
|
||||
|
||||
@override
|
||||
async def ext_method(self, method: str, params: dict) -> dict:
|
||||
if method == "auth/status":
|
||||
return self._handle_auth_status()
|
||||
|
||||
if method == "auth/signOut":
|
||||
return self._handle_auth_sign_out()
|
||||
|
||||
if method == "session/set_title":
|
||||
return await self._handle_session_set_title(params)
|
||||
|
||||
|
|
@ -1744,8 +1812,10 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
def run_acp_server() -> None:
|
||||
agent = VibeAcpAgentLoop()
|
||||
def run_acp_server(
|
||||
*, environ_before_dotenv_load: Mapping[str, str] | None = None
|
||||
) -> None:
|
||||
agent = VibeAcpAgentLoop(environ_before_dotenv_load=environ_before_dotenv_load)
|
||||
install_sigterm_flush = TelemetryClient(config_getter=VibeConfig.load).is_active()
|
||||
received_sigterm = False
|
||||
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ def main() -> None:
|
|||
from vibe.core.tracing import setup_tracing
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
environ_before_dotenv_load = os.environ.copy()
|
||||
load_dotenv_values()
|
||||
bootstrap_config_files()
|
||||
args = parse_arguments()
|
||||
|
|
@ -104,7 +105,7 @@ def main() -> None:
|
|||
except Exception:
|
||||
pass # tracing disabled
|
||||
|
||||
run_acp_server()
|
||||
run_acp_server(environ_before_dotenv_load=environ_before_dotenv_load)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -11,13 +11,7 @@ import tomli_w
|
|||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.hooks.config import load_hooks_from_fs
|
||||
from vibe.core.logger import logger
|
||||
|
|
@ -44,8 +38,6 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
|
|||
|
||||
|
||||
def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str:
|
||||
if args.prompt is not None and args.agent is None:
|
||||
return BuiltinAgentName.AUTO_APPROVE
|
||||
return args.agent or config.default_agent
|
||||
|
||||
|
||||
|
|
@ -77,9 +69,6 @@ def load_config_or_exit(*, interactive: bool) -> VibeConfig:
|
|||
sys.exit(1)
|
||||
run_onboarding(entrypoint_metadata=_build_cli_entrypoint_metadata())
|
||||
return VibeConfig.load()
|
||||
except MissingPromptFileError as e:
|
||||
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
rprint(f"[yellow]{e}[/]")
|
||||
sys.exit(1)
|
||||
|
|
@ -238,6 +227,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
prompt=programmatic_prompt or "",
|
||||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
max_session_tokens=args.max_tokens,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_session[0] if loaded_session else None,
|
||||
agent_name=initial_agent_name,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ class CommandRegistry:
|
|||
),
|
||||
"teleport": Command(
|
||||
aliases=frozenset(["/teleport"]),
|
||||
description="Teleport session to Vibe Code",
|
||||
description="Teleport session to Vibe Code Web",
|
||||
handler="_teleport_command",
|
||||
is_available=CommandAvailabilityContext.is_teleport_available,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ def parse_arguments() -> argparse.Namespace:
|
|||
nargs="?",
|
||||
const="",
|
||||
metavar="TEXT",
|
||||
help="Run in programmatic mode: send prompt, auto-approve all tools, "
|
||||
"output response, and exit.",
|
||||
help="Run in programmatic mode: send prompt, output response, and exit. "
|
||||
"Tool approval follows the selected --agent (or 'default_agent' config); "
|
||||
"pass --agent auto-approve for the previous YOLO behavior.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-turns",
|
||||
|
|
@ -61,6 +62,14 @@ def parse_arguments() -> argparse.Namespace:
|
|||
help="Maximum cost in dollars (only applies in programmatic mode with -p). "
|
||||
"Session will be interrupted if cost exceeds this limit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
metavar="N",
|
||||
help="Maximum total prompt + completion tokens across the session "
|
||||
"(only applies in programmatic mode with -p). "
|
||||
"Session will be interrupted if usage exceeds this limit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enabled-tools",
|
||||
action="append",
|
||||
|
|
@ -84,10 +93,10 @@ def parse_arguments() -> argparse.Namespace:
|
|||
metavar="NAME",
|
||||
default=None,
|
||||
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
|
||||
"or custom from ~/.vibe/agents/NAME.toml). In interactive mode, "
|
||||
"defaults to the 'default_agent' config setting. In programmatic "
|
||||
"mode (-p/--prompt), defaults to auto-approve and 'default_agent' "
|
||||
"is ignored.",
|
||||
"or custom from ~/.vibe/agents/NAME.toml). Defaults to the "
|
||||
"'default_agent' config setting in both interactive and programmatic "
|
||||
"(-p/--prompt) mode. Pass --agent auto-approve for non-interactive "
|
||||
"automation that needs tools to run without approval.",
|
||||
)
|
||||
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
|
||||
parser.add_argument(
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
|||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType
|
||||
from vibe.cli.terminal_detect import Terminal, detect_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
NotificationContext,
|
||||
|
|
@ -73,13 +74,17 @@ from vibe.cli.textual_ui.widgets.loading import (
|
|||
)
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, MCPSourceKind
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX,
|
||||
AssistantMessage,
|
||||
BashOutputMessage,
|
||||
ErrorMessage,
|
||||
InterruptMessage,
|
||||
SlashCommandMessage,
|
||||
StreamingMessageBase,
|
||||
TeleportUserMessage,
|
||||
UserCommandMessage,
|
||||
UserMessage,
|
||||
VscodeExtensionPromoMessage,
|
||||
WarningMessage,
|
||||
WhatsNewMessage,
|
||||
)
|
||||
|
|
@ -121,6 +126,12 @@ from vibe.cli.update_notifier import (
|
|||
from vibe.cli.update_notifier.update import do_update
|
||||
from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
|
||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.cli.vscode_extension_promo import (
|
||||
FileSystemVscodeExtensionPromoRepository,
|
||||
VscodeExtensionPromo,
|
||||
VscodeExtensionPromoState,
|
||||
should_show_promo,
|
||||
)
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.audio_player.audio_player import AudioPlayer
|
||||
|
|
@ -186,6 +197,12 @@ from vibe.core.utils import (
|
|||
is_dangerous_directory,
|
||||
)
|
||||
|
||||
_VSCODE_FAMILY_TERMINALS = {Terminal.VSCODE, Terminal.VSCODE_INSIDERS, Terminal.CURSOR}
|
||||
|
||||
|
||||
def _is_vscode_family_terminal() -> bool:
|
||||
return detect_terminal() in _VSCODE_FAMILY_TERMINALS
|
||||
|
||||
|
||||
def _compute_connector_counts(
|
||||
config: VibeConfig, connector_registry: ConnectorRegistry | None
|
||||
|
|
@ -362,6 +379,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
terminal_notifier: NotificationPort | None = None,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
narrator_manager: NarratorManagerPort | None = None,
|
||||
vscode_extension_promo: VscodeExtensionPromo | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -404,6 +422,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._update_cache_repository = update_cache_repository
|
||||
self._current_version = current_version
|
||||
self._plan_offer_gateway = plan_offer_gateway
|
||||
self._vscode_extension_promo = vscode_extension_promo
|
||||
self._show_vscode_extension_promo = (
|
||||
vscode_extension_promo is not None
|
||||
and _is_vscode_family_terminal()
|
||||
and should_show_promo(vscode_extension_promo.initial_state)
|
||||
)
|
||||
self._configure_startup_options(startup)
|
||||
self._last_escape_time: float | None = None
|
||||
self._quit_manager = QuitManager(self)
|
||||
|
|
@ -643,6 +667,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._agent_running:
|
||||
await self._interrupt_agent_loop()
|
||||
|
||||
await self._dispatch_submitted_input(value)
|
||||
|
||||
async def _dispatch_submitted_input(self, value: str) -> None:
|
||||
if value.startswith("!"):
|
||||
self._bash_task = asyncio.create_task(self._handle_bash_command(value[1:]))
|
||||
return
|
||||
|
|
@ -956,7 +983,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
await self._mount_and_scroll(SlashCommandMessage(user_input[1:]))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler(cmd_args=cmd_args)
|
||||
|
|
@ -1510,7 +1537,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
|
||||
if not value:
|
||||
if show_message:
|
||||
await self._mount_and_scroll(UserMessage("/teleport"))
|
||||
await self._mount_and_scroll(SlashCommandMessage("teleport"))
|
||||
if not has_history:
|
||||
send_teleport_early_failure_telemetry(
|
||||
self.agent_loop.telemetry_client,
|
||||
|
|
@ -1526,7 +1553,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
return
|
||||
elif show_message:
|
||||
await self._mount_and_scroll(UserMessage(value))
|
||||
await self._mount_and_scroll(TeleportUserMessage(value))
|
||||
self.run_worker(self._teleport(value), exclusive=False)
|
||||
|
||||
async def _teleport(self, prompt: str | None = None) -> None:
|
||||
|
|
@ -2106,7 +2133,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await messages_area.remove_children()
|
||||
|
||||
await messages_area.mount(UserMessage("/clear"))
|
||||
await messages_area.mount(SlashCommandMessage("clear"))
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Conversation history cleared!")
|
||||
)
|
||||
|
|
@ -2986,23 +3013,45 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
|
||||
|
||||
async def _record_vscode_extension_promo_shown(self) -> None:
|
||||
if self._vscode_extension_promo is None:
|
||||
return
|
||||
previous_count = (
|
||||
self._vscode_extension_promo.initial_state.shown_count
|
||||
if self._vscode_extension_promo.initial_state is not None
|
||||
else 0
|
||||
)
|
||||
try:
|
||||
await self._vscode_extension_promo.repository.set(
|
||||
VscodeExtensionPromoState(shown_count=previous_count + 1)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist VSCode extension promo shown count", exc_info=True
|
||||
)
|
||||
|
||||
async def _check_and_show_whats_new(self) -> None:
|
||||
if self._update_cache_repository is None:
|
||||
await self._maybe_show_vscode_extension_promo()
|
||||
return
|
||||
|
||||
if not await should_show_whats_new(
|
||||
self._current_version, self._update_cache_repository
|
||||
):
|
||||
await self._maybe_show_vscode_extension_promo()
|
||||
return
|
||||
|
||||
content = load_whats_new_content()
|
||||
if content is not None:
|
||||
whats_new_message = WhatsNewMessage(content)
|
||||
body = content
|
||||
plan_offer = plan_offer_cta(
|
||||
self._plan_info, console_base_url=self.config.console_base_url
|
||||
)
|
||||
if plan_offer is not None:
|
||||
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
|
||||
body = f"{body}\n\n{plan_offer}"
|
||||
if self._show_vscode_extension_promo:
|
||||
body = f"{body}{VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX}"
|
||||
whats_new_message = WhatsNewMessage(body)
|
||||
if self._history_widget_indices:
|
||||
whats_new_message.add_class("after-history")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
|
|
@ -3012,8 +3061,28 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._whats_new_message = whats_new_message
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
if self._show_vscode_extension_promo:
|
||||
self.run_worker(
|
||||
self._record_vscode_extension_promo_shown(), exclusive=False
|
||||
)
|
||||
else:
|
||||
await self._maybe_show_vscode_extension_promo()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _maybe_show_vscode_extension_promo(self) -> None:
|
||||
if not self._show_vscode_extension_promo:
|
||||
return
|
||||
promo_message = VscodeExtensionPromoMessage()
|
||||
if self._history_widget_indices:
|
||||
promo_message.add_class("after-history")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_anchor = chat.is_at_bottom
|
||||
await chat.mount(promo_message, after=messages_area)
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)
|
||||
|
||||
async def _resolve_plan(self) -> None:
|
||||
if self._plan_offer_gateway is None:
|
||||
self._plan_info = None
|
||||
|
|
@ -3192,6 +3261,11 @@ def run_textual_ui(
|
|||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
|
||||
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
|
||||
vscode_extension_promo = VscodeExtensionPromo(
|
||||
repository=vscode_extension_promo_repository,
|
||||
initial_state=asyncio.run(vscode_extension_promo_repository.get()),
|
||||
)
|
||||
|
||||
with stderr_guard():
|
||||
app = VibeApp(
|
||||
|
|
@ -3200,6 +3274,7 @@ def run_textual_ui(
|
|||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
plan_offer_gateway=plan_offer_gateway,
|
||||
vscode_extension_promo=vscode_extension_promo,
|
||||
)
|
||||
session_id = app.run()
|
||||
|
||||
|
|
|
|||
|
|
@ -154,33 +154,39 @@ OptionList, OptionList:focus {
|
|||
height: auto;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: solid $foreground-muted;
|
||||
border: none;
|
||||
border-top: solid $foreground-muted;
|
||||
border-bottom: solid $foreground-muted;
|
||||
border-title-align: right;
|
||||
border-title-color: $text-muted;
|
||||
padding: 0 1;
|
||||
|
||||
&.border-warning {
|
||||
border: solid $warning;
|
||||
border-top: solid $warning;
|
||||
border-bottom: solid $warning;
|
||||
border-title-color: $warning;
|
||||
}
|
||||
|
||||
&.border-safe {
|
||||
border: solid $success;
|
||||
border-top: solid $success;
|
||||
border-bottom: solid $success;
|
||||
border-title-color: $success;
|
||||
}
|
||||
|
||||
&.border-error {
|
||||
border: solid $error;
|
||||
border-top: solid $error;
|
||||
border-bottom: solid $error;
|
||||
border-title-color: $error;
|
||||
}
|
||||
|
||||
&.border-recording {
|
||||
border: solid $mistral_orange;
|
||||
border-top: solid $mistral_orange;
|
||||
border-bottom: solid $mistral_orange;
|
||||
border-title-color: $mistral_orange;
|
||||
}
|
||||
|
||||
&.border-remote {
|
||||
border: solid $mistral_orange;
|
||||
border-top: solid $mistral_orange;
|
||||
border-bottom: solid $mistral_orange;
|
||||
border-title-color: $mistral_orange;
|
||||
}
|
||||
}
|
||||
|
|
@ -275,6 +281,12 @@ Markdown {
|
|||
}
|
||||
}
|
||||
|
||||
.user-message-wrapper {
|
||||
margin-top: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.user-message-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
@ -282,7 +294,8 @@ Markdown {
|
|||
|
||||
.user-message-prompt {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
color: $mistral_orange;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
|
|
@ -290,8 +303,22 @@ Markdown {
|
|||
width: 1fr;
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
padding-left: 1;
|
||||
border-left: heavy $mistral_orange;
|
||||
}
|
||||
|
||||
.slash-command-message {
|
||||
.user-message-content {
|
||||
color: $mistral_orange;
|
||||
}
|
||||
}
|
||||
|
||||
.user-message-separator {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
|
|
@ -1135,7 +1162,7 @@ NarratorStatus {
|
|||
|
||||
#banner-container {
|
||||
align: left middle;
|
||||
padding: 0 1 0 0;
|
||||
padding: 1 1 0 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
|
@ -1231,6 +1258,33 @@ NarratorStatus {
|
|||
margin-top: 1;
|
||||
}
|
||||
|
||||
.vscode-extension-promo-message {
|
||||
border-left: heavy $mistral_orange;
|
||||
margin-bottom: 1;
|
||||
|
||||
Markdown {
|
||||
padding: 0 0 0 1;
|
||||
margin: 0;
|
||||
|
||||
& > MarkdownBlock:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& > MarkdownBlock:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
link-style: none;
|
||||
link-background-hover: transparent;
|
||||
link-style-hover: underline;
|
||||
link-color-hover: $mistral_orange;
|
||||
}
|
||||
|
||||
.vscode-extension-promo-message.after-history {
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#sessionpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -263,9 +263,8 @@ class ChatInputContainer(Vertical):
|
|||
return SAFETY_BORDER_CLASSES.get(self._safety, "")
|
||||
|
||||
def _get_border_title(self) -> str:
|
||||
if self._custom_border_label is not None:
|
||||
return self._custom_border_label
|
||||
return self._agent_name
|
||||
label = self._custom_border_label or self._agent_name
|
||||
return f" {label} " if label else ""
|
||||
|
||||
def _apply_input_box_chrome(self) -> None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
|
||||
from vibe.core.hooks.models import HookMessageSeverity
|
||||
from vibe.core.logger import logger
|
||||
|
|
@ -47,7 +47,20 @@ class ExpandingBorder(NonSelectableStatic):
|
|||
self.refresh()
|
||||
|
||||
|
||||
# Mimic a border bottom with this component in order to have dimmed colors in ANSI themes
|
||||
# Move back to border when Textual supports dimmed borders or foreground-muted in ANSI themes
|
||||
class ExpandingSeparator(NonSelectableStatic):
|
||||
def render(self) -> str:
|
||||
return "─" * max(self.size.width, 1)
|
||||
|
||||
def on_resize(self) -> None:
|
||||
self.refresh()
|
||||
|
||||
|
||||
class UserMessage(Static):
|
||||
PROMPT_CHAR: ClassVar[str] = ">"
|
||||
SHOW_SEPARATOR: ClassVar[bool] = True
|
||||
|
||||
def __init__(
|
||||
self, content: str, pending: bool = False, message_index: int | None = None
|
||||
) -> None:
|
||||
|
|
@ -61,10 +74,16 @@ class UserMessage(Static):
|
|||
return self._content
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="user-message-container"):
|
||||
yield NoMarkupStatic(self._content, classes="user-message-content")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
with Vertical(classes="user-message-wrapper"):
|
||||
with Horizontal(classes="user-message-container"):
|
||||
yield NonSelectableStatic(
|
||||
f"{self.PROMPT_CHAR} ", classes="user-message-prompt"
|
||||
)
|
||||
yield NoMarkupStatic(self._content, classes="user-message-content")
|
||||
if self.SHOW_SEPARATOR:
|
||||
yield ExpandingSeparator(classes="user-message-separator")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
if pending == self._pending:
|
||||
|
|
@ -79,6 +98,19 @@ class UserMessage(Static):
|
|||
self.remove_class("pending")
|
||||
|
||||
|
||||
class SlashCommandMessage(UserMessage):
|
||||
PROMPT_CHAR = "/"
|
||||
SHOW_SEPARATOR = False
|
||||
|
||||
def __init__(self, content: str) -> None:
|
||||
super().__init__(content)
|
||||
self.add_class("slash-command-message")
|
||||
|
||||
|
||||
class TeleportUserMessage(UserMessage):
|
||||
PROMPT_CHAR = "&"
|
||||
|
||||
|
||||
class StreamingMessageBase(Static):
|
||||
def __init__(self, content: str) -> None:
|
||||
super().__init__()
|
||||
|
|
@ -247,6 +279,15 @@ class UserCommandMessage(Static):
|
|||
yield Markdown(self._content)
|
||||
|
||||
|
||||
VSCODE_EXTENSION_URI = "vscode:extension/mistralai.vibe-code"
|
||||
VSCODE_EXTENSION_LINK_LABEL = "VS Code extension"
|
||||
VSCODE_EXTENSION_PROMO_STANDALONE = f"We now have a [{VSCODE_EXTENSION_LINK_LABEL}]({VSCODE_EXTENSION_URI}) with a rich UI. Check it out!"
|
||||
VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX = (
|
||||
f"\n\n_Btw, we also have a new [{VSCODE_EXTENSION_LINK_LABEL}]"
|
||||
f"({VSCODE_EXTENSION_URI}). Check it out!_"
|
||||
)
|
||||
|
||||
|
||||
class WhatsNewMessage(Static):
|
||||
def __init__(self, content: str) -> None:
|
||||
super().__init__()
|
||||
|
|
@ -257,6 +298,16 @@ class WhatsNewMessage(Static):
|
|||
yield Markdown(self._content)
|
||||
|
||||
|
||||
class VscodeExtensionPromoMessage(Static):
|
||||
def __init__(self, content: str = VSCODE_EXTENSION_PROMO_STANDALONE) -> None:
|
||||
super().__init__()
|
||||
self.add_class("vscode-extension-promo-message")
|
||||
self._content = content
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Markdown(self._content)
|
||||
|
||||
|
||||
class InterruptMessage(Static):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
|
|||
if self._error:
|
||||
return f"Teleport failed: {self._error}"
|
||||
if self._final_url:
|
||||
return f"Teleported to Vibe Code: {self._final_url}"
|
||||
return f"Teleported to Vibe Code Web: {self._final_url}"
|
||||
return self._status
|
||||
|
||||
def set_status(self, status: str) -> None:
|
||||
|
|
|
|||
43
vibe/cli/vscode_extension_promo/__init__.py
Normal file
43
vibe/cli/vscode_extension_promo/__init__.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from vibe.cli.vscode_extension_promo._port import (
|
||||
VscodeExtensionPromoRepository,
|
||||
VscodeExtensionPromoState,
|
||||
)
|
||||
from vibe.cli.vscode_extension_promo.adapters.filesystem_repository import (
|
||||
FileSystemVscodeExtensionPromoRepository,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAX_SHOWN_COUNT",
|
||||
"PROMO_START",
|
||||
"FileSystemVscodeExtensionPromoRepository",
|
||||
"VscodeExtensionPromo",
|
||||
"VscodeExtensionPromoRepository",
|
||||
"VscodeExtensionPromoState",
|
||||
"should_show_promo",
|
||||
]
|
||||
|
||||
|
||||
MAX_SHOWN_COUNT = 10
|
||||
PROMO_START = datetime(2026, 5, 28, 16, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def should_show_promo(
|
||||
state: VscodeExtensionPromoState | None, now: datetime | None = None
|
||||
) -> bool:
|
||||
current = now or datetime.now(UTC)
|
||||
if current < PROMO_START:
|
||||
return False
|
||||
if state is None:
|
||||
return True
|
||||
return state.shown_count < MAX_SHOWN_COUNT
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VscodeExtensionPromo:
|
||||
repository: VscodeExtensionPromoRepository
|
||||
initial_state: VscodeExtensionPromoState | None
|
||||
14
vibe/cli/vscode_extension_promo/_port.py
Normal file
14
vibe/cli/vscode_extension_promo/_port.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VscodeExtensionPromoState:
|
||||
shown_count: int
|
||||
|
||||
|
||||
class VscodeExtensionPromoRepository(Protocol):
|
||||
async def get(self) -> VscodeExtensionPromoState | None: ...
|
||||
async def set(self, state: VscodeExtensionPromoState) -> None: ...
|
||||
0
vibe/cli/vscode_extension_promo/adapters/__init__.py
Normal file
0
vibe/cli/vscode_extension_promo/adapters/__init__.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.cli.cache import read_cache, write_cache
|
||||
from vibe.cli.vscode_extension_promo._port import (
|
||||
VscodeExtensionPromoRepository,
|
||||
VscodeExtensionPromoState,
|
||||
)
|
||||
from vibe.core.paths import VIBE_HOME
|
||||
|
||||
_CACHE_SECTION = "vscode_extension_promo"
|
||||
|
||||
|
||||
class FileSystemVscodeExtensionPromoRepository(VscodeExtensionPromoRepository):
|
||||
def __init__(self, base_path: Path | str | None = None) -> None:
|
||||
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
|
||||
self._cache_file = self._base_path / "cache.toml"
|
||||
|
||||
async def get(self) -> VscodeExtensionPromoState | None:
|
||||
data = await asyncio.to_thread(self._read_section)
|
||||
if data is None:
|
||||
return None
|
||||
shown_count = data.get("shown_count")
|
||||
if not isinstance(shown_count, int):
|
||||
return None
|
||||
return VscodeExtensionPromoState(shown_count=shown_count)
|
||||
|
||||
async def set(self, state: VscodeExtensionPromoState) -> None:
|
||||
await asyncio.to_thread(
|
||||
write_cache,
|
||||
self._cache_file,
|
||||
_CACHE_SECTION,
|
||||
{"shown_count": state.shown_count},
|
||||
)
|
||||
|
||||
def _read_section(self) -> dict | None:
|
||||
cache = read_cache(self._cache_file)
|
||||
section = cache.get(_CACHE_SECTION)
|
||||
if isinstance(section, dict):
|
||||
return section
|
||||
return None
|
||||
|
|
@ -22,6 +22,7 @@ from pydantic import BaseModel
|
|||
from vibe.cli.terminal_detect import detect_terminal
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.compaction import collect_prior_user_messages
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.experiments import ExperimentManager
|
||||
from vibe.core.experiments.client import RemoteEvalClient
|
||||
|
|
@ -53,6 +54,7 @@ from vibe.core.middleware import (
|
|||
PriceLimitMiddleware,
|
||||
ReadOnlyAgentMiddleware,
|
||||
ResetReason,
|
||||
TokenLimitMiddleware,
|
||||
TurnLimitMiddleware,
|
||||
make_plan_agent_reminder,
|
||||
)
|
||||
|
|
@ -237,6 +239,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
message_observer: Callable[[LLMMessage], None] | None = None,
|
||||
max_turns: int | None = None,
|
||||
max_price: float | None = None,
|
||||
max_session_tokens: int | None = None,
|
||||
backend: BackendLike | None = None,
|
||||
enable_streaming: bool = False,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
|
|
@ -245,6 +248,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
headless: bool = False,
|
||||
hook_config_result: HookConfigResult | None = None,
|
||||
permission_store: PermissionStore | None = None,
|
||||
mcp_registry: MCPRegistry | None = None,
|
||||
) -> None:
|
||||
self._base_config = config
|
||||
self._headless = headless
|
||||
|
|
@ -260,7 +264,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
|
||||
self._permission_store = permission_store or PermissionStore()
|
||||
|
||||
self.mcp_registry = MCPRegistry()
|
||||
self.mcp_registry = mcp_registry or MCPRegistry()
|
||||
self.connector_registry = self._create_connector_registry()
|
||||
self.agent_manager = AgentManager(
|
||||
lambda: self._base_config,
|
||||
|
|
@ -278,6 +282,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
self.message_observer = message_observer
|
||||
self._max_turns = max_turns
|
||||
self._max_price = max_price
|
||||
self._max_session_tokens = max_session_tokens
|
||||
self._plan_session = PlanSession()
|
||||
|
||||
self.format_handler = APIToolFormatHandler()
|
||||
|
|
@ -738,6 +743,9 @@ class AgentLoop: # noqa: PLR0904
|
|||
if self._max_price is not None:
|
||||
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
|
||||
|
||||
if self._max_session_tokens is not None:
|
||||
self.middleware_pipeline.add(TokenLimitMiddleware(self._max_session_tokens))
|
||||
|
||||
self.middleware_pipeline.add(AutoCompactMiddleware())
|
||||
if self.config.context_warnings:
|
||||
self.middleware_pipeline.add(ContextWarningMiddleware(0.5))
|
||||
|
|
@ -1698,7 +1706,12 @@ class AgentLoop: # noqa: PLR0904
|
|||
self.agent_profile,
|
||||
)
|
||||
|
||||
summary_request = UtilityPrompt.COMPACT.read()
|
||||
summary_prefix = UtilityPrompt.COMPACT_SUMMARY_PREFIX.read()
|
||||
prior_user_messages = collect_prior_user_messages(
|
||||
list(self.messages), summary_prefix
|
||||
)
|
||||
|
||||
summary_request = self.config.compaction_prompt
|
||||
if extra_instructions:
|
||||
summary_request += (
|
||||
f"\n\n## Additional Instructions\n{extra_instructions}"
|
||||
|
|
@ -1717,11 +1730,14 @@ class AgentLoop: # noqa: PLR0904
|
|||
raise AgentLoopLLMResponseError(
|
||||
"Usage data missing in compaction summary response"
|
||||
)
|
||||
summary_content = summary_result.message.content or ""
|
||||
summary_content = (summary_result.message.content or "").strip()
|
||||
if not summary_content:
|
||||
summary_content = "(no summary available)"
|
||||
|
||||
system_message = self.messages[0]
|
||||
summary_message = LLMMessage(role=Role.user, content=summary_content)
|
||||
self.messages.reset([system_message, summary_message])
|
||||
wrapped_summary = f"{summary_prefix}\n{summary_content}"
|
||||
summary_message = LLMMessage(role=Role.user, content=wrapped_summary)
|
||||
self.messages.reset([system_message, *prior_user_messages, summary_message])
|
||||
|
||||
active_model = self.config.get_active_model()
|
||||
await self._reset_session()
|
||||
|
|
@ -1745,7 +1761,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
|
||||
self.middleware_pipeline.reset(reset_reason=ResetReason.COMPACT)
|
||||
|
||||
return summary_content or ""
|
||||
return summary_content
|
||||
|
||||
except Exception:
|
||||
await self.session_logger.save_interaction(
|
||||
|
|
|
|||
45
vibe/core/compaction.py
Normal file
45
vibe/core/compaction.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
from vibe.core.utils.tokens import approx_token_count, truncate_middle_to_tokens
|
||||
|
||||
COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000
|
||||
|
||||
|
||||
def collect_prior_user_messages(
|
||||
messages: list[LLMMessage],
|
||||
summary_prefix: str,
|
||||
max_tokens: int = COMPACT_USER_MESSAGE_MAX_TOKENS,
|
||||
) -> list[LLMMessage]:
|
||||
"""Pick user messages to preserve through compaction.
|
||||
|
||||
Walks newest-first within a token budget, dropping system-internal
|
||||
injections and prior compaction summaries, middle-truncating the message
|
||||
that spills over. Returns kept messages in chronological order.
|
||||
"""
|
||||
candidates = [
|
||||
m
|
||||
for m in messages
|
||||
if m.role == Role.user
|
||||
and not m.injected
|
||||
and m.content
|
||||
and not m.content.startswith(summary_prefix)
|
||||
]
|
||||
|
||||
selected: list[LLMMessage] = []
|
||||
remaining = max_tokens
|
||||
for m in reversed(candidates):
|
||||
if remaining <= 0:
|
||||
break
|
||||
content = m.content or ""
|
||||
cost = approx_token_count(content)
|
||||
if cost <= remaining:
|
||||
selected.append(LLMMessage(role=Role.user, content=content))
|
||||
remaining -= cost
|
||||
else:
|
||||
truncated = truncate_middle_to_tokens(content, remaining)
|
||||
selected.append(LLMMessage(role=Role.user, content=truncated))
|
||||
remaining = 0
|
||||
|
||||
selected.reverse()
|
||||
return selected
|
||||
|
|
@ -40,6 +40,7 @@ from vibe.core.config.layer import (
|
|||
EmptyLayerError,
|
||||
LayerImplementationError,
|
||||
RawConfig,
|
||||
TrustNotResolvedError,
|
||||
TrustResolutionError,
|
||||
UntrustedLayerError,
|
||||
)
|
||||
|
|
@ -113,6 +114,7 @@ __all__ = [
|
|||
"TranscribeClient",
|
||||
"TranscribeModelConfig",
|
||||
"TranscribeProviderConfig",
|
||||
"TrustNotResolvedError",
|
||||
"TrustResolutionError",
|
||||
"UntrustedLayerError",
|
||||
"VibeConfig",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from vibe.core.agents.models import BuiltinAgentName
|
|||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||
from vibe.core.prompts import load_system_prompt
|
||||
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
|
||||
|
|
@ -512,6 +512,7 @@ class VibeConfig(BaseSettings):
|
|||
enable_telemetry: bool = True
|
||||
experiment_overrides: dict[str, str] = Field(default_factory=dict)
|
||||
system_prompt_id: str = "cli"
|
||||
compaction_prompt_id: str = "compact"
|
||||
include_commit_signature: bool = True
|
||||
include_model_info: bool = True
|
||||
include_project_context: bool = True
|
||||
|
|
@ -537,7 +538,6 @@ class VibeConfig(BaseSettings):
|
|||
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
|
||||
|
||||
enable_experimental_hooks: bool = Field(default=False, exclude=True)
|
||||
enable_experimental_browser_sign_in: bool = Field(default=False, exclude=True)
|
||||
|
||||
providers: list[ProviderConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_PROVIDERS)
|
||||
|
|
@ -634,10 +634,9 @@ class VibeConfig(BaseSettings):
|
|||
default_agent: str = Field(
|
||||
default=BuiltinAgentName.DEFAULT,
|
||||
description=(
|
||||
"Agent profile to use when no --agent flag is passed in interactive "
|
||||
"mode. Builtin: default, plan, accept-edits, auto-approve. "
|
||||
"Ignored in programmatic mode (-p/--prompt), which falls back to "
|
||||
"auto-approve when --agent is not provided."
|
||||
"Agent profile to use when no --agent flag is passed. "
|
||||
"Builtin: default, plan, accept-edits, auto-approve. "
|
||||
"Applies in both interactive and programmatic (-p/--prompt) mode."
|
||||
),
|
||||
)
|
||||
skill_paths: list[Path] = Field(
|
||||
|
|
@ -717,6 +716,14 @@ class VibeConfig(BaseSettings):
|
|||
def system_prompt(self) -> str:
|
||||
return load_system_prompt(self.system_prompt_id)
|
||||
|
||||
@property
|
||||
def compaction_prompt(self) -> str:
|
||||
return load_prompt(
|
||||
self.compaction_prompt_id,
|
||||
setting_name="compaction_prompt_id",
|
||||
builtins={"compact": UtilityPrompt.COMPACT.path},
|
||||
)
|
||||
|
||||
def get_active_model(self) -> ModelConfig:
|
||||
for model in self.models:
|
||||
if model.alias == self.active_model:
|
||||
|
|
@ -933,6 +940,11 @@ class VibeConfig(BaseSettings):
|
|||
_ = self.system_prompt
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_compaction_prompt(self) -> VibeConfig:
|
||||
_ = self.compaction_prompt
|
||||
return self
|
||||
|
||||
def set_thinking(self, level: ThinkingLevel) -> None:
|
||||
model = self.get_active_model()
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ class EmptyLayerError(ConfigLayerError):
|
|||
super().__init__(layer_name, f"Layer '{layer_name}' has no data after load")
|
||||
|
||||
|
||||
class TrustNotResolvedError(ConfigLayerError):
|
||||
"""Raised when grant_trust/revoke_trust is called before trust has been resolved."""
|
||||
|
||||
def __init__(self, layer_name: str) -> None:
|
||||
super().__init__(
|
||||
layer_name, f"Layer '{layer_name}': trust has not been resolved yet"
|
||||
)
|
||||
|
||||
|
||||
class TrustResolutionError(ConfigLayerError):
|
||||
"""Raised when trust status is not resolvable."""
|
||||
|
||||
|
|
@ -171,6 +180,9 @@ class ConfigLayer[S: BaseModel](ABC):
|
|||
return new_state
|
||||
|
||||
async def _handle_grant_trust(self, state: _LayerState[S]) -> _LayerState[S]:
|
||||
if state.is_trusted is None:
|
||||
raise TrustNotResolvedError(self.name)
|
||||
|
||||
if state.is_trusted is True:
|
||||
return state
|
||||
|
||||
|
|
@ -179,6 +191,9 @@ class ConfigLayer[S: BaseModel](ABC):
|
|||
return _LayerState(is_trusted=True, data=state.data)
|
||||
|
||||
async def _handle_revoke_trust(self, state: _LayerState[S]) -> _LayerState[S]:
|
||||
if state.is_trusted is None:
|
||||
raise TrustNotResolvedError(self.name)
|
||||
|
||||
if state.is_trusted is False:
|
||||
return state
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config.layers.overrides import OverridesLayer
|
||||
from vibe.core.config.layers.project import ProjectConfigLayer
|
||||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
|
||||
__all__ = ["UserConfigLayer"]
|
||||
__all__ = ["OverridesLayer", "ProjectConfigLayer", "UserConfigLayer"]
|
||||
|
|
|
|||
27
vibe/core/config/layers/overrides.py
Normal file
27
vibe/core/config/layers/overrides.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
|
||||
|
||||
class OverridesLayer(ConfigLayer[RawConfig]):
|
||||
"""Highest-priority layer wrapping an arbitrary dict passed at construction.
|
||||
|
||||
Always trusted and read-only.
|
||||
Used by CLI and ACP entry points to inject runtime overrides.
|
||||
"""
|
||||
|
||||
def __init__(self, *, data: dict[str, Any], name: str = "overrides") -> None:
|
||||
super().__init__(name=name)
|
||||
self._data = data
|
||||
|
||||
async def _check_trust(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _read_config(self) -> dict[str, Any]:
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)")
|
||||
84
vibe/core/config/layers/project.py
Normal file
84
vibe/core/config/layers/project.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.paths._vibe_home import VIBE_HOME
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
|
||||
class ProjectConfigLayer(ConfigLayer[RawConfig]):
|
||||
"""Reads a project-level TOML config file.
|
||||
If no file is found in the current working directory, walks up parent directories
|
||||
until a trusted .vibe/config.toml is found.
|
||||
"""
|
||||
|
||||
def __init__(self, *, path: Path | None = None, name: str = "project-toml") -> None:
|
||||
super().__init__(name=name)
|
||||
self._root = path or Path.cwd()
|
||||
self._config_file_path: Path | None = None
|
||||
self._is_set = False
|
||||
self._find_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def config_file_path(self) -> Path | None:
|
||||
return self._config_file_path
|
||||
|
||||
async def _check_trust(self) -> bool:
|
||||
await self._find_config_file()
|
||||
|
||||
if self._config_file_path is None:
|
||||
return True
|
||||
|
||||
return bool(trusted_folders_manager.is_trusted(self._config_file_path.parent))
|
||||
|
||||
async def _read_config(self) -> dict[str, Any]:
|
||||
if self._config_file_path is None:
|
||||
return {}
|
||||
with self._config_file_path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
async def _on_trust_changed(self, old: bool | None, new: bool | None) -> None:
|
||||
if new is None or self._config_file_path is None:
|
||||
return
|
||||
|
||||
if new:
|
||||
trusted_folders_manager.add_trusted(self._config_file_path.parent)
|
||||
else:
|
||||
trusted_folders_manager.add_untrusted(self._config_file_path.parent)
|
||||
|
||||
async def grant_trust(self) -> None:
|
||||
await self._find_config_file()
|
||||
if self._config_file_path is None:
|
||||
return
|
||||
|
||||
await super().grant_trust()
|
||||
|
||||
async def revoke_trust(self) -> None:
|
||||
await self._find_config_file()
|
||||
if self._config_file_path is None:
|
||||
return
|
||||
|
||||
await super().revoke_trust()
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)")
|
||||
|
||||
async def _find_config_file(self) -> None:
|
||||
async with self._find_lock:
|
||||
if self._is_set:
|
||||
return
|
||||
|
||||
for directory in [self._root, *self._root.parents]:
|
||||
if directory == VIBE_HOME.path.parent:
|
||||
break
|
||||
|
||||
candidate = directory / ".vibe" / "config.toml"
|
||||
if candidate.is_file():
|
||||
self._config_file_path = candidate
|
||||
break
|
||||
|
||||
self._is_set = True
|
||||
|
|
@ -78,6 +78,25 @@ class PriceLimitMiddleware:
|
|||
pass
|
||||
|
||||
|
||||
class TokenLimitMiddleware:
|
||||
def __init__(self, max_tokens: int) -> None:
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
if context.stats.session_total_llm_tokens > self.max_tokens:
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.STOP,
|
||||
reason=(
|
||||
"Token limit exceeded: "
|
||||
f"{context.stats.session_total_llm_tokens:,} > {self.max_tokens:,}"
|
||||
),
|
||||
)
|
||||
return MiddlewareResult()
|
||||
|
||||
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class AutoCompactMiddleware:
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
threshold = context.config.get_active_model().auto_compact_threshold
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ def run_programmatic( # noqa: PLR0913, PLR0917
|
|||
prompt: str,
|
||||
max_turns: int | None = None,
|
||||
max_price: float | None = None,
|
||||
max_session_tokens: int | None = None,
|
||||
output_format: OutputFormat = OutputFormat.TEXT,
|
||||
previous_messages: list[LLMMessage] | None = None,
|
||||
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
|
||||
agent_name: str = BuiltinAgentName.DEFAULT,
|
||||
client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA,
|
||||
teleport: bool = False,
|
||||
headless: bool = False,
|
||||
|
|
@ -45,6 +46,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917
|
|||
message_observer=formatter.on_message_added,
|
||||
max_turns=max_turns,
|
||||
max_price=max_price,
|
||||
max_session_tokens=max_session_tokens,
|
||||
enable_streaming=False,
|
||||
headless=headless,
|
||||
entrypoint_metadata=build_entrypoint_metadata(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from enum import StrEnum, auto
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -30,48 +31,82 @@ class SystemPrompt(Prompt):
|
|||
class UtilityPrompt(Prompt):
|
||||
AGENTS_DOC = auto()
|
||||
COMPACT = auto()
|
||||
COMPACT_SUMMARY_PREFIX = auto()
|
||||
DANGEROUS_DIRECTORY = auto()
|
||||
PROJECT_CONTEXT = auto()
|
||||
TURN_SUMMARY = auto()
|
||||
|
||||
|
||||
class MissingPromptFileError(RuntimeError):
|
||||
def __init__(self, system_prompt_id: str, *prompt_dirs: str) -> None:
|
||||
dirs_str = " or ".join(prompt_dirs) if prompt_dirs else "<no prompt dirs>"
|
||||
class MissingPromptFileError(ValueError):
|
||||
def __init__(
|
||||
self,
|
||||
setting_name: str,
|
||||
prompt_id: str,
|
||||
builtin_ids: Iterable[str],
|
||||
custom_dirs: Iterable[Path],
|
||||
custom_ids: Iterable[str],
|
||||
) -> None:
|
||||
builtin_hint = ", ".join('"' + i + '"' for i in builtin_ids)
|
||||
dirs_hint = " or ".join(str(d) for d in custom_dirs) or "<no prompt dirs>"
|
||||
custom_hint = ", ".join('"' + i + '"' for i in custom_ids) or "<none>"
|
||||
super().__init__(
|
||||
f"Invalid system_prompt_id value: '{system_prompt_id}'. "
|
||||
f"Must be one of the available prompts ({', '.join(p.name.lower() for p in SystemPrompt)}), "
|
||||
f"or correspond to a .md file in {dirs_str}"
|
||||
f"Invalid {setting_name} value: '{prompt_id}'. "
|
||||
f"Must be one of the available prompts ({builtin_hint}), "
|
||||
f"or correspond to a .md file in {dirs_hint} (available: {custom_hint})"
|
||||
)
|
||||
self.system_prompt_id = system_prompt_id
|
||||
self.setting_name = setting_name
|
||||
self.prompt_id = prompt_id
|
||||
|
||||
|
||||
def _validate_prompt_id(prompt_id: str, setting_name: str) -> None:
|
||||
if (
|
||||
not prompt_id
|
||||
or prompt_id in {".", ".."}
|
||||
or "/" in prompt_id
|
||||
or "\\" in prompt_id
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid {setting_name} value: '{prompt_id}' must be a bare filename "
|
||||
"without path separators"
|
||||
)
|
||||
|
||||
|
||||
def load_prompt(
|
||||
prompt_id: str, *, setting_name: str, builtins: Mapping[str, Path]
|
||||
) -> str:
|
||||
_validate_prompt_id(prompt_id, setting_name)
|
||||
mgr = get_harness_files_manager()
|
||||
custom_dirs = mgr.project_prompts_dirs + mgr.user_prompts_dirs
|
||||
for d in custom_dirs:
|
||||
path = (d / prompt_id).with_suffix(".md")
|
||||
if path.is_file():
|
||||
return read_safe(path).text.strip()
|
||||
|
||||
builtin_path = builtins.get(prompt_id.lower())
|
||||
if builtin_path is not None and builtin_path.is_file():
|
||||
return read_safe(builtin_path).text.strip()
|
||||
|
||||
custom_ids = sorted({p.stem for d in custom_dirs for p in d.glob("*.md")})
|
||||
raise MissingPromptFileError(
|
||||
setting_name, prompt_id, tuple(builtins), custom_dirs, custom_ids
|
||||
)
|
||||
|
||||
|
||||
def load_system_prompt(prompt_id: str) -> str:
|
||||
mgr = get_harness_files_manager()
|
||||
prompt_dirs = mgr.project_prompts_dirs + mgr.user_prompts_dirs
|
||||
for current_prompt_dir in prompt_dirs:
|
||||
custom_sp_path = (current_prompt_dir / prompt_id).with_suffix(".md")
|
||||
if custom_sp_path.is_file():
|
||||
return read_safe(custom_sp_path).text
|
||||
|
||||
try:
|
||||
return SystemPrompt[prompt_id.upper()].read()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
builtin_path = (PROMPTS_DIR / prompt_id).with_suffix(".md")
|
||||
if builtin_path.is_file():
|
||||
return read_safe(builtin_path).text.strip()
|
||||
|
||||
raise MissingPromptFileError(
|
||||
prompt_id, *(str(d) for d in [*prompt_dirs, PROMPTS_DIR])
|
||||
)
|
||||
builtins: dict[str, Path] = {p.name.lower(): p.path for p in SystemPrompt}
|
||||
# Experiment variants may reference bundled .md files not in the enum.
|
||||
fallback = (PROMPTS_DIR / prompt_id).with_suffix(".md")
|
||||
if fallback.is_file():
|
||||
builtins.setdefault(prompt_id.lower(), fallback)
|
||||
return load_prompt(prompt_id, setting_name="system_prompt_id", builtins=builtins)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROMPTS_DIR",
|
||||
"MissingPromptFileError",
|
||||
"Prompt",
|
||||
"SystemPrompt",
|
||||
"UtilityPrompt",
|
||||
"load_prompt",
|
||||
"load_system_prompt",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,48 +1,12 @@
|
|||
Create a comprehensive summary of our entire conversation that will serve as complete context for continuing this work. Structure your summary to capture both the narrative flow and technical details necessary for seamless continuation.
|
||||
You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume this task.
|
||||
|
||||
Your summary must include these sections in order:
|
||||
Include:
|
||||
- The user's current goal and any explicit constraints or preferences
|
||||
- Key decisions made and their rationale
|
||||
- Files touched and the current state of in-progress work (paths + one-line status)
|
||||
- What remains to be done — the concrete next step
|
||||
- Any data, identifiers, or references the next LLM needs to continue
|
||||
|
||||
## 1. User's Primary Goals and Intent
|
||||
Capture ALL explicit requests and objectives stated by the user throughout the conversation, preserving their exact priorities and constraints.
|
||||
Be concise and structured. One line per modified file unless a snippet is load-bearing. Do not repeat information already captured. Do not include a "Final Answer" section — the entire response IS the handoff.
|
||||
|
||||
## 2. Conversation Timeline and Progress
|
||||
Chronologically document the key phases of our work:
|
||||
- Initial requests and how they were addressed
|
||||
- Major decisions made and their rationale
|
||||
- Problems encountered and solutions applied
|
||||
- Current state of the work
|
||||
|
||||
## 3. Technical Context and Decisions
|
||||
- Technologies, frameworks, and tools being used
|
||||
- Architectural patterns and design decisions made
|
||||
- Key technical constraints or requirements identified
|
||||
- Important code patterns or conventions established
|
||||
|
||||
## 4. Files and Code Changes
|
||||
For each file created, modified, or examined:
|
||||
- Full file path/name
|
||||
- Purpose and importance of the file
|
||||
- Specific changes made (with key code snippets where critical)
|
||||
- Current state of the file
|
||||
|
||||
## 5. Active Work and Last Actions
|
||||
CRITICAL: Detail EXACTLY what was being worked on in the most recent exchanges:
|
||||
- The specific task or problem being addressed
|
||||
- Last completed action
|
||||
- Any partial work or mid-implementation state
|
||||
- Include relevant code snippets from the most recent work
|
||||
|
||||
## 6. Unresolved Issues and Pending Tasks
|
||||
- Any errors or issues still requiring attention
|
||||
- Tasks explicitly requested but not yet started
|
||||
- Decisions waiting for user input
|
||||
|
||||
## 7. Immediate Next Step
|
||||
State the SPECIFIC next action to take based on:
|
||||
- The user's most recent request
|
||||
- The current state of implementation
|
||||
- Any ongoing work that was interrupted
|
||||
|
||||
Important: Be precise with technical details, file names, and code. The next agent reading this should be able to continue exactly where we left off without asking clarifying questions. Include enough detail that no context is lost, but remain focused on actionable information.
|
||||
|
||||
Respond with ONLY the summary text following this structure - no additional commentary or meta-discussion.
|
||||
Respond with the summary text only — no preamble, no meta-discussion, no tool calls.
|
||||
|
|
|
|||
1
vibe/core/prompts/compact_summary_prefix.md
Normal file
1
vibe/core/prompts/compact_summary_prefix.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Another language model started to solve this problem and produced a summary of its work so far. Use this summary to continue the task without redoing work already done. Summary follows:
|
||||
|
|
@ -28,7 +28,7 @@ agents, prompts, logs, and session data live here.
|
|||
vibehistory # Command history
|
||||
trusted_folders.toml # Trust database for project folders
|
||||
agents/ # Custom agent profiles (*.toml)
|
||||
prompts/ # Custom system prompts (*.md)
|
||||
prompts/ # Custom prompts (*.md)
|
||||
skills/ # User-level skills (each skill is a subdirectory with SKILL.md)
|
||||
tools/ # Custom tool definitions
|
||||
logs/
|
||||
|
|
@ -56,6 +56,9 @@ When in a trusted folder, Vibe also looks for project-local configuration:
|
|||
The configuration file uses TOML format. Settings can also be overridden via
|
||||
environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`).
|
||||
|
||||
Custom prompt IDs are resolved from project-local `.vibe/prompts/` first, then
|
||||
from `~/.vibe/prompts/`, and finally from the built-in bundled prompts.
|
||||
|
||||
### Key Settings
|
||||
|
||||
```toml
|
||||
|
|
@ -71,6 +74,7 @@ file_watcher_for_autocomplete = false
|
|||
# Behavior
|
||||
bypass_tool_permissions = false # Skip tool approval prompts
|
||||
system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename
|
||||
compaction_prompt_id = "compact" # Compaction prompt: built-in "compact" or custom .md filename
|
||||
enable_telemetry = true
|
||||
enable_update_checks = true
|
||||
enable_auto_update = true
|
||||
|
|
@ -251,18 +255,9 @@ save_dir = "" # Defaults to ~/.vibe/logs/session
|
|||
session_prefix = "session"
|
||||
```
|
||||
|
||||
### Browser Sign-In (Experimental)
|
||||
### Browser Sign-In
|
||||
|
||||
Browser sign-in lets users authenticate through the browser during onboarding.
|
||||
The feature is **experimental** and must be enabled first:
|
||||
|
||||
```toml
|
||||
# In config.toml
|
||||
enable_experimental_browser_sign_in = true
|
||||
```
|
||||
|
||||
Or via the environment variable `VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN=true`.
|
||||
|
||||
Mistral providers use default browser sign-in URLs. Custom or renamed providers
|
||||
must configure both URLs:
|
||||
|
||||
|
|
@ -382,6 +377,7 @@ vibe -v / --version # Show version
|
|||
vibe --setup # Run onboarding/setup
|
||||
vibe --max-turns N # Max assistant turns (programmatic mode)
|
||||
vibe --max-price DOLLARS # Max cost limit (programmatic mode)
|
||||
vibe --max-tokens N # Max total session tokens (programmatic mode)
|
||||
vibe --enabled-tools TOOL # Enable specific tools (repeatable)
|
||||
vibe --output text|json|streaming # Output format (programmatic mode)
|
||||
```
|
||||
|
|
@ -441,7 +437,7 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
|
|||
- `/leanstall` - Install the Lean 4 agent (leanstral)
|
||||
- `/unleanstall` - Uninstall the Lean 4 agent
|
||||
- `/data-retention` - Show data retention information
|
||||
- `/teleport` - Teleport session to Vibe Code (only available when Vibe Code is enabled)
|
||||
- `/teleport` - Teleport session to Vibe Code Web (only available when Vibe Code is enabled)
|
||||
- `/exit` - Exit the application
|
||||
|
||||
## Skills System
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
|
@ -23,11 +24,22 @@ class ExperimentalNuageMessage(BaseModel):
|
|||
parts: list[ExperimentalNuageTextPart]
|
||||
|
||||
|
||||
class ExperimentalNuageDiff(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
format: Literal["git-diff"] = "git-diff"
|
||||
encoding: Literal["base64"] = "base64"
|
||||
compression: Literal["zstd"] = "zstd"
|
||||
content: str
|
||||
|
||||
|
||||
class ExperimentalNuageRepository(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
repo_url: str = Field(serialization_alias="repoUrl")
|
||||
branch: str | None = None
|
||||
commit_sha: str | None = Field(default=None, serialization_alias="commitSha")
|
||||
diff: ExperimentalNuageDiff | None = None
|
||||
|
||||
|
||||
class ExperimentalNuageContext(BaseModel):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from vibe.core.teleport.errors import ServiceTeleportError
|
|||
from vibe.core.teleport.experimental_nuage import (
|
||||
ExperimentalNuageClient,
|
||||
ExperimentalNuageContext,
|
||||
ExperimentalNuageDiff,
|
||||
ExperimentalNuageMessage,
|
||||
ExperimentalNuageRepository,
|
||||
ExperimentalNuageRequest,
|
||||
|
|
@ -282,6 +283,13 @@ class TeleportService:
|
|||
"Experimental Nuage teleport requires a checked-out branch."
|
||||
)
|
||||
|
||||
compressed = self._compress_diff(git_info.diff)
|
||||
diff = (
|
||||
ExperimentalNuageDiff(content=compressed.decode("ascii"))
|
||||
if compressed is not None
|
||||
else None
|
||||
)
|
||||
|
||||
return ExperimentalNuageRequest(
|
||||
idempotency_key=str(uuid4()),
|
||||
message=ExperimentalNuageMessage(
|
||||
|
|
@ -290,7 +298,10 @@ class TeleportService:
|
|||
context=ExperimentalNuageContext(
|
||||
repositories=[
|
||||
ExperimentalNuageRepository(
|
||||
repo_url=git_info.remote_url, branch=git_info.branch
|
||||
repo_url=git_info.remote_url,
|
||||
branch=git_info.branch,
|
||||
commit_sha=git_info.commit,
|
||||
diff=diff,
|
||||
)
|
||||
]
|
||||
),
|
||||
|
|
|
|||
29
vibe/core/utils/tokens.py
Normal file
29
vibe/core/utils/tokens.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
_APPROX_BYTES_PER_TOKEN = 4
|
||||
_TRUNCATION_MARKER = "\n\n[... truncated ...]\n\n"
|
||||
|
||||
|
||||
def approx_token_count(text: str) -> int:
|
||||
return math.ceil(len(text) / _APPROX_BYTES_PER_TOKEN)
|
||||
|
||||
|
||||
def truncate_middle_to_tokens(text: str, max_tokens: int) -> str:
|
||||
"""Shrink ``text`` to fit in ``max_tokens`` by dropping the middle.
|
||||
|
||||
Keeps head + tail (intent and constraints usually live at the ends of user
|
||||
messages) and inserts a marker where the middle was removed.
|
||||
"""
|
||||
if max_tokens <= 0:
|
||||
return ""
|
||||
max_chars = max_tokens * _APPROX_BYTES_PER_TOKEN
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
available = max_chars - len(_TRUNCATION_MARKER)
|
||||
if available <= 0:
|
||||
return text[:max_chars]
|
||||
head = available // 2
|
||||
tail = available - head
|
||||
return text[:head] + _TRUNCATION_MARKER + text[-tail:]
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.setup.auth.auth_state import AuthState, AuthStateKind, assess_auth_state
|
||||
from vibe.setup.auth.browser_sign_in import (
|
||||
BrowserSignInAttempt,
|
||||
BrowserSignInService,
|
||||
|
|
@ -15,6 +16,8 @@ from vibe.setup.auth.browser_sign_in_gateway import (
|
|||
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
|
||||
|
||||
__all__ = [
|
||||
"AuthState",
|
||||
"AuthStateKind",
|
||||
"BrowserSignInAttempt",
|
||||
"BrowserSignInError",
|
||||
"BrowserSignInErrorCode",
|
||||
|
|
@ -24,4 +27,5 @@ __all__ = [
|
|||
"BrowserSignInService",
|
||||
"BrowserSignInStatus",
|
||||
"HttpBrowserSignInGateway",
|
||||
"assess_auth_state",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
|
||||
from dotenv import set_key
|
||||
from dotenv import set_key, unset_key
|
||||
|
||||
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
|
|
@ -16,6 +16,12 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
|||
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||
|
||||
|
||||
def _remove_api_key_from_env_file(env_key: str) -> None:
|
||||
if not GLOBAL_ENV_FILE.path.exists():
|
||||
return
|
||||
unset_key(GLOBAL_ENV_FILE.path, env_key)
|
||||
|
||||
|
||||
def _get_mistral_provider() -> ProviderConfig:
|
||||
return next(
|
||||
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
|
||||
|
|
@ -62,3 +68,11 @@ def persist_api_key(
|
|||
except Exception:
|
||||
pass
|
||||
return "completed"
|
||||
|
||||
|
||||
def remove_api_key(provider: ProviderConfig) -> None:
|
||||
env_key = provider.api_key_env_var
|
||||
if not env_key:
|
||||
raise ValueError("Cannot remove API key without an environment variable name")
|
||||
_remove_api_key_from_env_file(env_key)
|
||||
os.environ.pop(env_key, None)
|
||||
|
|
|
|||
150
vibe/setup/auth/auth_state.py
Normal file
150
vibe/setup/auth/auth_state.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
|
||||
|
||||
class AuthStateKind(StrEnum):
|
||||
SIGNED_OUT = auto()
|
||||
AUTH_NOT_REQUIRED = auto()
|
||||
VIBE_HOME_ENV_FILE = auto()
|
||||
PROCESS_ENV = auto()
|
||||
VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV = auto()
|
||||
UNSUPPORTED_PROVIDER = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AuthEnvSnapshot:
|
||||
env_key: str
|
||||
current_process_has_value: bool
|
||||
dotenv_has_value: bool
|
||||
process_env_had_value_before_dotenv_load: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthState:
|
||||
kind: AuthStateKind
|
||||
can_use_active_provider: bool
|
||||
sign_out_available: bool
|
||||
env_key: str | None
|
||||
|
||||
|
||||
def _has_value(value: str | None) -> bool:
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _dotenv_has_value(env_path: Path, env_key: str) -> bool:
|
||||
if not env_path.is_file() and not env_path.is_fifo():
|
||||
return False
|
||||
|
||||
value = dotenv_values(env_path).get(env_key)
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
return _has_value(value)
|
||||
|
||||
|
||||
def _supports_vibe_owned_sign_out(provider: ProviderConfig) -> bool:
|
||||
return provider.api_key_env_var == DEFAULT_MISTRAL_API_ENV_KEY
|
||||
|
||||
|
||||
def _auth_state(
|
||||
kind: AuthStateKind,
|
||||
*,
|
||||
can_use_active_provider: bool,
|
||||
sign_out_available: bool = False,
|
||||
env_key: str | None = None,
|
||||
) -> AuthState:
|
||||
return AuthState(
|
||||
kind=kind,
|
||||
can_use_active_provider=can_use_active_provider,
|
||||
sign_out_available=sign_out_available,
|
||||
env_key=env_key,
|
||||
)
|
||||
|
||||
|
||||
def _capture_auth_env_snapshot(
|
||||
env_key: str,
|
||||
*,
|
||||
env_path: Path | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
process_env_had_value_before_dotenv_load: bool = False,
|
||||
) -> _AuthEnvSnapshot:
|
||||
resolved_env_path = env_path if env_path is not None else GLOBAL_ENV_FILE.path
|
||||
resolved_environ = environ if environ is not None else os.environ
|
||||
return _AuthEnvSnapshot(
|
||||
env_key=env_key,
|
||||
current_process_has_value=_has_value(resolved_environ.get(env_key)),
|
||||
dotenv_has_value=_dotenv_has_value(resolved_env_path, env_key),
|
||||
process_env_had_value_before_dotenv_load=process_env_had_value_before_dotenv_load,
|
||||
)
|
||||
|
||||
|
||||
def assess_auth_state(
|
||||
provider: ProviderConfig,
|
||||
*,
|
||||
env_path: Path | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
process_env_had_value_before_dotenv_load: bool = False,
|
||||
) -> AuthState:
|
||||
env_key = provider.api_key_env_var
|
||||
if not env_key:
|
||||
return _auth_state(
|
||||
AuthStateKind.AUTH_NOT_REQUIRED, can_use_active_provider=True
|
||||
)
|
||||
|
||||
auth_snapshot = _capture_auth_env_snapshot(
|
||||
env_key,
|
||||
env_path=env_path,
|
||||
environ=environ,
|
||||
process_env_had_value_before_dotenv_load=process_env_had_value_before_dotenv_load,
|
||||
)
|
||||
if (
|
||||
not auth_snapshot.current_process_has_value
|
||||
and not auth_snapshot.dotenv_has_value
|
||||
):
|
||||
return _auth_state(
|
||||
AuthStateKind.SIGNED_OUT, can_use_active_provider=False, env_key=env_key
|
||||
)
|
||||
|
||||
if not _supports_vibe_owned_sign_out(provider):
|
||||
return _auth_state(
|
||||
AuthStateKind.UNSUPPORTED_PROVIDER,
|
||||
can_use_active_provider=True,
|
||||
env_key=env_key,
|
||||
)
|
||||
|
||||
if (
|
||||
auth_snapshot.dotenv_has_value
|
||||
and auth_snapshot.process_env_had_value_before_dotenv_load
|
||||
):
|
||||
return _auth_state(
|
||||
AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
|
||||
can_use_active_provider=True,
|
||||
env_key=env_key,
|
||||
)
|
||||
|
||||
if auth_snapshot.dotenv_has_value:
|
||||
return _auth_state(
|
||||
AuthStateKind.VIBE_HOME_ENV_FILE,
|
||||
can_use_active_provider=True,
|
||||
sign_out_available=True,
|
||||
env_key=env_key,
|
||||
)
|
||||
|
||||
if auth_snapshot.current_process_has_value:
|
||||
return _auth_state(
|
||||
AuthStateKind.PROCESS_ENV, can_use_active_provider=True, env_key=env_key
|
||||
)
|
||||
|
||||
raise AssertionError("assess_auth_state reached unreachable state")
|
||||
|
||||
|
||||
__all__ = ["AuthState", "AuthStateKind", "assess_auth_state"]
|
||||
|
|
@ -96,7 +96,7 @@ class OnboardingApp(App[str | None]):
|
|||
def _resolve_browser_sign_in_factory(
|
||||
self, browser_sign_in_service_factory: Callable[[], BrowserSignInService] | None
|
||||
) -> Callable[[], BrowserSignInService] | None:
|
||||
if not self._config.browser_sign_in_enabled:
|
||||
if not self._config.supports_browser_sign_in:
|
||||
return None
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ def _default_model_payloads() -> list[dict[str, Any]]:
|
|||
|
||||
class _OnboardingSnapshot(BaseModel):
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||
enable_experimental_browser_sign_in: bool = False
|
||||
providers: list[Any] = Field(default_factory=_default_provider_payloads)
|
||||
models: list[Any] = Field(default_factory=_default_model_payloads)
|
||||
|
||||
|
|
@ -98,19 +97,6 @@ def _load_onboarding_env_payload_for_fields(
|
|||
and (models := _find_env_value("VIBE_MODELS")) is not None
|
||||
):
|
||||
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
|
||||
# Onboarding uses this lightweight snapshot before full VibeConfig loading,
|
||||
# so env-backed config fields must be mirrored here when onboarding needs them.
|
||||
if (
|
||||
"enable_experimental_browser_sign_in" in field_names
|
||||
and (
|
||||
enable_browser_sign_in := _find_env_value(
|
||||
"VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN"
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
payload["enable_experimental_browser_sign_in"] = enable_browser_sign_in
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -194,27 +180,14 @@ def _resolve_provider(
|
|||
@dataclass(frozen=True)
|
||||
class OnboardingContext:
|
||||
provider: ProviderConfig
|
||||
enable_experimental_browser_sign_in: bool = False
|
||||
|
||||
@property
|
||||
def supports_browser_sign_in(self) -> bool:
|
||||
return self.provider.supports_browser_sign_in
|
||||
|
||||
@property
|
||||
def browser_sign_in_enabled(self) -> bool:
|
||||
return (
|
||||
self.enable_experimental_browser_sign_in
|
||||
and self.provider.supports_browser_sign_in
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: VibeConfig) -> OnboardingContext:
|
||||
return cls(
|
||||
provider=config.get_active_provider(),
|
||||
enable_experimental_browser_sign_in=(
|
||||
config.enable_experimental_browser_sign_in
|
||||
),
|
||||
)
|
||||
return cls(provider=config.get_active_provider())
|
||||
|
||||
@classmethod
|
||||
def load(cls, **overrides: Any) -> OnboardingContext:
|
||||
|
|
@ -225,10 +198,7 @@ class OnboardingContext:
|
|||
return cls(
|
||||
provider=_resolve_provider(
|
||||
active_model=snapshot.active_model, snapshot=snapshot
|
||||
),
|
||||
enable_experimental_browser_sign_in=(
|
||||
snapshot.enable_experimental_browser_sign_in
|
||||
),
|
||||
)
|
||||
)
|
||||
except (RuntimeError, ValidationError, ValueError):
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
# What's new in v2.11.0
|
||||
# What's new in v2.11.1
|
||||
|
||||
- **Shared skills**: Vibe now loads skills from `~/.agents/skills` so they can be reused across agents
|
||||
- **Connector onboarding from the CLI**: `/mcp` lists unauthenticated connectors and lets you start the OAuth flow without leaving Vibe
|
||||
- **Theme picker is back**: Pick a theme during onboarding (`vibe --setup`) or switch any time with `/theme`
|
||||
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
|
||||
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue