Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Hdandria <henri.dandria@mistral.ai>
Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai>
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@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: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-06-19 11:01:24 +02:00 committed by GitHub
parent 564a14365e
commit 6bedf271ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
223 changed files with 10533 additions and 6947 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.16.1"
__version__ = "2.17.0"

View file

@ -10,7 +10,7 @@ import os
from pathlib import Path
import signal
import sys
from typing import Any, Literal, Protocol, cast, override
from typing import Any, Protocol, cast, override
from uuid import uuid4
from acp import (
@ -21,7 +21,6 @@ from acp import (
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
RequestError,
SetSessionModelResponse,
SetSessionModeResponse,
run_agent,
@ -69,6 +68,7 @@ from acp.schema import (
Usage,
UsageUpdate,
)
from keyring.errors import KeyringError
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
from vibe import VIBE_ROOT, __version__
@ -79,6 +79,7 @@ from vibe.acp.exceptions import (
ConfigurationError,
ContextTooLongError,
ConversationLimitError,
ImagesNotSupportedError as AcpImagesNotSupportedError,
InternalError,
InvalidRequestError,
NotImplementedMethodError,
@ -88,6 +89,7 @@ from vibe.acp.exceptions import (
SessionNotFoundError,
UnauthenticatedError,
)
from vibe.acp.image_blocks import extract_image_attachments
from vibe.acp.session import AcpSessionLoop
from vibe.acp.teleport import handle_teleport_command
from vibe.acp.title import acp_blocks_to_title_segments
@ -98,6 +100,10 @@ from vibe.acp.tools.session_update import (
tool_call_session_update,
tool_result_session_update,
)
from vibe.acp.user_display_content import (
USER_DISPLAY_CONTENT_META_KEY,
parse_user_display_content_metadata,
)
from vibe.acp.utils import (
THINKING_LEVELS,
ThinkingLevel,
@ -109,17 +115,23 @@ from vibe.acp.utils import (
create_compact_end_session_update,
create_compact_start_session_update,
create_reasoning_replay,
create_tool_call_replay,
create_tool_result_replay,
create_user_message_replay,
get_proxy_help_text,
is_jetbrains_client,
is_valid_acp_mode,
make_thinking_response,
tool_call_replay_update,
)
from vibe.core.agent_loop import (
AgentLoop,
CompactionFailedError,
ImagesNotSupportedError,
)
from vibe.core.agent_loop import AgentLoop, CompactionFailedError
from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
from vibe.core.auth import MCPOAuthError
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.cache_store import FileSystemVibeCodeCacheStore
from vibe.core.config import (
MissingAPIKeyError,
ProviderConfig,
@ -148,13 +160,16 @@ from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.tools.mcp import AuthStatus
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.trusted_folders import (
WorkspaceTrustDecision,
WorkspaceTrustPrompt,
WorkspaceTrustStatus,
apply_workspace_trust_decision,
available_workspace_trust_decisions,
maybe_build_workspace_trust_prompt,
trusted_folders_manager,
)
from vibe.core.types import (
AgentProfileChangedEvent,
@ -164,6 +179,7 @@ from vibe.core.types import (
CompactEndEvent,
CompactStartEvent,
ContextTooLongError as CoreContextTooLongError,
ImageAttachment,
LLMMessage,
RateLimitError as CoreRateLimitError,
ReasoningEvent,
@ -174,6 +190,7 @@ from vibe.core.types import (
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
UserDisplayContentMetadata,
)
from vibe.core.utils import (
CancellationReason,
@ -201,8 +218,23 @@ logger = logging.getLogger("vibe")
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1
WORKSPACE_TRUST_CAPABILITY = "workspace-trust"
TRUST_REQUEST_METHOD = "trust/request"
_MCP_COMMAND_MAX_SPLITS = 2
_MCP_COMMAND_ARG_INDEX = 2
WORKSPACE_TRUST_META_KEY = "workspace_trust"
TRUST_GRANT_DECISIONS = {
WorkspaceTrustDecision.TRUST_REPO,
WorkspaceTrustDecision.TRUST_CWD,
WorkspaceTrustDecision.TRUST_SESSION,
}
def _mcp_tui_login_message(alias: str) -> str:
return (
"MCP OAuth login must be performed via the Vibe TUI. "
"Run `vibe` in a terminal and execute "
f"`/mcp login {alias}` there. Tokens are stored in the OS keyring; "
"run `/reload` in this ACP session afterward if the tools are not visible."
)
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
@ -242,22 +274,38 @@ class TelemetrySendNotification(BaseModel):
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
class WorkspaceTrustRequest(BaseModel):
class WorkspaceTrustDetails(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
cwd: str
repo_root: str | None = Field(default=None, alias="repoRoot")
detected_files: list[str] = Field(alias="detectedFiles")
repo_detected_files: list[str] = Field(alias="repoDetectedFiles")
ignored_files: list[str] = Field(alias="ignoredFiles")
available_decisions: list[WorkspaceTrustDecision] = Field(
alias="availableDecisions"
)
class WorkspaceTrustResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
class WorkspaceTrustStatusRequest(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
decision: WorkspaceTrustDecision | Literal["cancelled"]
cwd: str | None = None
class WorkspaceTrustDecisionRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
cwd: str | None = None
decision: WorkspaceTrustDecision
session_id: str | None = Field(
default=None, validation_alias=AliasChoices("session_id", "sessionId")
)
class WorkspaceTrustStatusResponse(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
trust_status: WorkspaceTrustStatus = Field(alias="trust_status")
details: WorkspaceTrustDetails | None = None
class AuthStatusResponse(BaseModel):
@ -435,14 +483,6 @@ class VibeAcpAgentLoop(AcpAgent):
is True
)
def _client_supports_workspace_trust(self) -> bool:
return bool(
self.client_capabilities
and self.client_capabilities.field_meta
and self.client_capabilities.field_meta.get(WORKSPACE_TRUST_CAPABILITY)
is True
)
@override
async def initialize(
self,
@ -502,7 +542,7 @@ class VibeAcpAgentLoop(AcpAgent):
agent_capabilities=AgentCapabilities(
load_session=True,
prompt_capabilities=PromptCapabilities(
audio=False, embedded_context=True, image=False
audio=False, embedded_context=True, image=True
),
session_capabilities=SessionCapabilities(
close=SessionCloseCapabilities(),
@ -689,7 +729,7 @@ class VibeAcpAgentLoop(AcpAgent):
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
session.spawn(self._send_initial_available_commands(session))
session.spawn(self._warm_up_agent_loop(agent_loop))
session.spawn(self._warm_up_agent_loop(session))
return session
@ -699,16 +739,41 @@ class VibeAcpAgentLoop(AcpAgent):
await asyncio.sleep(INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS)
await self._send_available_commands(session)
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
async def _warm_up_agent_loop(self, session: AcpSessionLoop) -> None:
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
without waiting for the user's first prompt. Errors are swallowed here
and will resurface on the first `act()` call via `requires_init`.
"""
try:
await agent_loop.wait_until_ready()
await session.agent_loop.wait_until_ready()
await self._notify_mcp_auth_required(session)
except Exception:
pass
async def _notify_mcp_auth_required(self, session: AcpSessionLoop) -> None:
statuses = session.agent_loop.mcp_registry.status()
aliases = sorted(
alias
for alias, status in statuses.items()
if status is AuthStatus.NEEDS_AUTH
)
if not aliases:
return
message = _mcp_tui_login_message(aliases[0])
if len(aliases) > 1:
message = (
"MCP OAuth login must be performed via the Vibe TUI for these "
f"servers first: {', '.join(aliases)}.\n\n{message}"
)
await self.client.session_update(
session_id=session.id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=message),
message_id=str(uuid4()),
),
)
def _create_agent_loop(
self, config: VibeConfig, agent_name: str, hook_config_result: Any = None
) -> AgentLoop:
@ -719,6 +784,7 @@ class VibeAcpAgentLoop(AcpAgent):
entrypoint_metadata=self._build_entrypoint_metadata(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
cache_store=FileSystemVibeCodeCacheStore(),
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
return agent_loop
@ -735,55 +801,127 @@ class VibeAcpAgentLoop(AcpAgent):
)
return modes_state, modes_config, models_state, models_config
def _build_workspace_trust_request(
def _workspace_trust_details(
self, cwd: Path
) -> tuple[WorkspaceTrustStatus, WorkspaceTrustDetails | None]:
status = self._workspace_trust_status(cwd)
if status is not WorkspaceTrustStatus.UNTRUSTED:
return status, None
prompt = maybe_build_workspace_trust_prompt(
cwd, include_explicitly_untrusted=True
)
if prompt is None:
return status, None
return status, self._build_workspace_trust_details(prompt)
def _build_workspace_trust_details(
self, prompt: WorkspaceTrustPrompt
) -> WorkspaceTrustRequest:
return WorkspaceTrustRequest(
) -> WorkspaceTrustDetails:
return WorkspaceTrustDetails(
cwd=str(prompt.cwd.resolve()),
repoRoot=str(prompt.repo_root.resolve())
if prompt.offer_repo_trust and prompt.repo_root
else None,
detectedFiles=prompt.detected_files,
repoDetectedFiles=prompt.repo_detected_files,
repoRoot=str(prompt.repo_root.resolve()) if prompt.repo_root else None,
ignoredFiles=self._workspace_trust_ignored_files(prompt),
availableDecisions=available_workspace_trust_decisions(
prompt, include_session=True
),
)
async def _resolve_workspace_trust(self, cwd: Path) -> None:
if not self._client_supports_workspace_trust():
return
def _workspace_trust_ignored_files(self, prompt: WorkspaceTrustPrompt) -> list[str]:
found = set(prompt.repo_detected_files)
if prompt.repo_root is None:
found.update(prompt.detected_files)
return sorted(found)
prompt = maybe_build_workspace_trust_prompt(cwd)
try:
cwd_relative = prompt.cwd.resolve().relative_to(prompt.repo_root.resolve())
except ValueError:
found.update(prompt.detected_files)
return sorted(found)
cwd_prefix = "" if cwd_relative == Path(".") else cwd_relative.as_posix()
for file in prompt.detected_files:
found.add(file if not cwd_prefix else f"{cwd_prefix}/{file}")
return sorted(found)
def _workspace_trust_status(self, cwd: Path) -> WorkspaceTrustStatus:
return trusted_folders_manager.trust_status(cwd)
def _workspace_trust_response(self, cwd: Path) -> WorkspaceTrustStatusResponse:
status, details = self._workspace_trust_details(cwd)
return WorkspaceTrustStatusResponse(trust_status=status, details=details)
def _workspace_trust_meta(self, cwd: Path) -> dict[str, Any]:
response = self._workspace_trust_response(cwd).model_dump(
mode="json", by_alias=True
)
return {
WORKSPACE_TRUST_META_KEY: {
"status": response["trust_status"],
"details": response["details"],
}
}
def _workspace_trust_prompt_for_decision(
self, cwd: Path, decision: WorkspaceTrustDecision
) -> WorkspaceTrustPrompt:
prompt = maybe_build_workspace_trust_prompt(
cwd, include_explicitly_untrusted=True
)
if prompt is None:
return
raise InvalidRequestError("No workspace trust decision is available.")
request = self._build_workspace_trust_request(prompt)
available_decisions = available_workspace_trust_decisions(
prompt, include_session=True
)
if decision not in available_decisions:
raise InvalidRequestError(f"Unsupported trust decision: {decision}")
return prompt
async def _handle_workspace_trust_status(self, params: dict) -> dict[str, Any]:
try:
raw_response = await self.client.ext_method(
TRUST_REQUEST_METHOD, request.model_dump(mode="json", by_alias=True)
)
except RequestError as exc:
if exc.code == NotImplementedMethodError.code:
return
raise
try:
response = WorkspaceTrustResponse.model_validate(raw_response)
request = WorkspaceTrustStatusRequest.model_validate(params)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP trust decision response: {exc}"
f"Invalid ACP workspace trust status request: {exc}"
) from exc
if response.decision == "cancelled":
raise InvalidRequestError("Workspace trust prompt was cancelled.")
cwd = Path(request.cwd).expanduser().resolve() if request.cwd else Path.cwd()
return self._workspace_trust_response(cwd).model_dump(
mode="json", by_alias=True
)
async def _handle_workspace_trust_decision(self, params: dict) -> dict[str, Any]:
try:
request = WorkspaceTrustDecisionRequest.model_validate(params)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP workspace trust decision request: {exc}"
) from exc
cwd = Path(request.cwd).expanduser().resolve() if request.cwd else Path.cwd()
session = self.sessions.get(request.session_id) if request.session_id else None
if request.session_id is not None and session is None:
raise SessionNotFoundError(request.session_id)
prompt = self._workspace_trust_prompt_for_decision(cwd, request.decision)
try:
apply_workspace_trust_decision(prompt, response.decision)
apply_workspace_trust_decision(prompt, request.decision)
except ValueError as exc:
raise InvalidRequestError(str(exc)) from exc
if session is not None and request.decision in TRUST_GRANT_DECISIONS:
os.chdir(cwd)
await self._reload_session_config(session)
await session.command_registry.notify_changed()
return self._workspace_trust_response(cwd).model_dump(
mode="json", by_alias=True
)
@override
async def new_session(
self,
@ -794,7 +932,6 @@ class VibeAcpAgentLoop(AcpAgent):
) -> NewSessionResponse:
load_dotenv_values()
os.chdir(cwd)
await self._resolve_workspace_trust(Path.cwd())
config = self._load_config()
hook_config_result = load_hooks_from_fs(config)
@ -820,6 +957,7 @@ class VibeAcpAgentLoop(AcpAgent):
models=models_state,
modes=modes_state,
config_options=self._build_config_options(session),
field_meta=self._workspace_trust_meta(Path.cwd()),
)
def _get_acp_tool_overrides(self) -> list[Path]:
@ -978,19 +1116,33 @@ class VibeAcpAgentLoop(AcpAgent):
session.spawn(_send())
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> set[str]:
if not msg.tool_calls:
return
return set()
agent_loop = self._get_session(session_id).agent_loop
parsed = agent_loop.format_handler.parse_message(msg)
resolved = agent_loop.format_handler.resolve_tool_calls(
parsed, agent_loop.tool_manager
)
resolved_by_id = {call.call_id: call for call in resolved.tool_calls}
emitted_call_ids: set[str] = set()
for tool_call in msg.tool_calls:
if tool_call.id and tool_call.function.name:
update = create_tool_call_replay(
tool_call.id, tool_call.function.name, tool_call.function.arguments
)
if not (tool_call.id and tool_call.function.name):
continue
update = tool_call_replay_update(
resolved_by_id.get(tool_call.id), tool_call
)
if update:
emitted_call_ids.add(tool_call.id)
await self.client.session_update(session_id=session_id, update=update)
return emitted_call_ids
async def _replay_conversation_history(
self, session_id: str, messages: list[LLMMessage]
) -> None:
replayed_call_ids: set[str] = set()
for msg in messages:
if msg.role == Role.user:
update = create_user_message_replay(msg)
@ -1005,9 +1157,14 @@ class VibeAcpAgentLoop(AcpAgent):
await self.client.session_update(
session_id=session_id, update=text_update
)
await self._replay_tool_calls(session_id, msg)
replayed_call_ids |= await self._replay_tool_calls(session_id, msg)
elif msg.role == Role.tool:
# Skip results whose call was not replayed (e.g. hidden tools
# like todo): emitting one would orphan a tool_call_update with
# no preceding tool_call.
if msg.tool_call_id not in replayed_call_ids:
continue
if result_update := create_tool_result_replay(msg):
await self.client.session_update(
session_id=session_id, update=result_update
@ -1059,7 +1216,6 @@ class VibeAcpAgentLoop(AcpAgent):
) -> LoadSessionResponse | None:
load_dotenv_values()
os.chdir(cwd)
await self._resolve_workspace_trust(Path.cwd())
config = self._load_config()
hook_config_result = load_hooks_from_fs(config)
@ -1101,6 +1257,7 @@ class VibeAcpAgentLoop(AcpAgent):
models=models_state,
modes=modes_state,
config_options=self._build_config_options(session),
field_meta=self._workspace_trust_meta(Path.cwd()),
)
async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool:
@ -1241,6 +1398,15 @@ class VibeAcpAgentLoop(AcpAgent):
"Concurrent prompts are not supported yet, wait for agent loop to finish"
)
try:
user_display_content = parse_user_display_content_metadata(
kwargs.get(USER_DISPLAY_CONTENT_META_KEY)
)
except ValidationError as e:
raise InvalidRequestError(
f"Invalid user display content metadata: {e}"
) from e
text_prompt = self._build_text_prompt(prompt)
resolved_message_id = _resolved_user_message_id(message_id)
@ -1266,9 +1432,18 @@ class VibeAcpAgentLoop(AcpAgent):
format_session_title(acp_blocks_to_title_segments(prompt)) or None
)
images = extract_image_attachments(
prompt, session_dir=session.agent_loop.session_logger.session_dir
)
async def agent_loop_task() -> None:
async for update in self._run_agent_loop(
session, text_prompt, resolved_message_id, auto_title=auto_title
session,
text_prompt,
resolved_message_id,
auto_title=auto_title,
user_display_content=user_display_content,
images=images,
):
await self.client.session_update(session_id=session.id, update=update)
@ -1307,6 +1482,9 @@ class VibeAcpAgentLoop(AcpAgent):
except ConversationLimitException as e:
raise ConversationLimitError(str(e)) from e
except ImagesNotSupportedError as e:
raise AcpImagesNotSupportedError(str(e)) from e
except Exception as e:
raise InternalError(str(e)) from e
@ -1328,9 +1506,10 @@ class VibeAcpAgentLoop(AcpAgent):
telemetry_active=agent_loop.telemetry_client.is_active(),
is_mistral_model=agent_loop.config.is_active_model_mistral(),
user_message_count=user_message_count,
cache_store=agent_loop.cache_store,
):
return None
record_feedback_asked()
record_feedback_asked(agent_loop.cache_store)
return {"show_feedback_prompt": True}
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
@ -1383,6 +1562,10 @@ class VibeAcpAgentLoop(AcpAgent):
]
block_prompt = "\n".join(parts)
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case "image":
# Images carry no prompt text; they are extracted separately
# via extract_image_attachments and passed to act(images=...).
continue
case _:
raise InvalidRequestError(
f"We currently don't support {block.type} content blocks"
@ -1413,6 +1596,8 @@ class VibeAcpAgentLoop(AcpAgent):
client_message_id: str | None = None,
*,
auto_title: str | None = None,
user_display_content: UserDisplayContentMetadata | None = None,
images: list[ImageAttachment] | None = None,
) -> AsyncGenerator[SessionUpdate | UsageUpdate]:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
@ -1421,6 +1606,8 @@ class VibeAcpAgentLoop(AcpAgent):
rendered_prompt,
client_message_id=client_message_id,
auto_title=auto_title,
user_display_content=user_display_content,
images=images or None,
)
) as events:
async for event in events:
@ -1451,7 +1638,9 @@ class VibeAcpAgentLoop(AcpAgent):
session_id=session.id,
)
session_update = tool_call_session_update(event)
# A starting tool call is pending until it streams or
# finishes; the host drops created events without a status.
session_update = tool_call_session_update(event, status="pending")
if session_update:
yield session_update
@ -1742,14 +1931,6 @@ class VibeAcpAgentLoop(AcpAgent):
)
return provider, auth_state
def _process_env_value_before_dotenv_load(
self, provider: ProviderConfig
) -> str | None:
if not provider.api_key_env_var:
return None
return self._environ_before_dotenv_load.get(provider.api_key_env_var)
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(
@ -1765,15 +1946,7 @@ class VibeAcpAgentLoop(AcpAgent):
try:
self._remove_api_key(provider)
if (
auth_state.kind
== AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV
and auth_state.env_key
):
process_env_value = self._process_env_value_before_dotenv_load(provider)
if process_env_value:
os.environ[auth_state.env_key] = process_env_value
except (OSError, ValueError) as exc:
except (OSError, ValueError, KeyringError) as exc:
raise InternalError(f"Failed to sign out: {exc}") from exc
return {}
@ -1792,6 +1965,12 @@ class VibeAcpAgentLoop(AcpAgent):
if method == "session/delete":
return await self._handle_session_delete(params)
if method == "trust/status":
return await self._handle_workspace_trust_status(params)
if method == "trust/decision":
return await self._handle_workspace_trust_decision(params)
raise NotImplementedMethodError(method)
@override
@ -1875,6 +2054,90 @@ class VibeAcpAgentLoop(AcpAgent):
return await self._command_reply(session, "\n".join(lines), message_id)
async def _handle_mcp(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
parts = text_prompt.strip().split(None, _MCP_COMMAND_MAX_SPLITS)
subcommand = parts[1].lower() if len(parts) > 1 else "status"
arg = (
parts[_MCP_COMMAND_ARG_INDEX].strip()
if len(parts) > _MCP_COMMAND_ARG_INDEX
else ""
)
match subcommand:
case "status":
return await self._handle_mcp_status(session, arg, message_id)
case "login":
return await self._handle_mcp_login(session, arg, message_id)
case "logout":
return await self._handle_mcp_logout(session, arg, message_id)
case _:
return await self._command_reply(
session,
"Usage: `/mcp status`, `/mcp login <alias>`, or `/mcp logout <alias>`",
message_id,
)
async def _handle_mcp_status(
self, session: AcpSessionLoop, arg: str, message_id: str
) -> PromptResponse:
if arg:
return await self._command_reply(
session, "Usage: `/mcp status`", message_id
)
await session.agent_loop.wait_until_ready()
statuses = session.agent_loop.mcp_registry.status()
if not statuses:
return await self._command_reply(
session, "No MCP servers configured.", message_id
)
lines = ["### MCP auth status", ""]
for alias, status in sorted(statuses.items()):
lines.append(f"- `{alias}`: `{status.value}`")
return await self._command_reply(session, "\n".join(lines), message_id)
async def _handle_mcp_login(
self, session: AcpSessionLoop, alias: str, message_id: str
) -> PromptResponse:
if not alias:
return await self._command_reply(
session, "Usage: `/mcp login <alias>`", message_id
)
await session.agent_loop.wait_until_ready()
statuses = session.agent_loop.mcp_registry.status()
if alias not in statuses:
return await self._command_reply(
session, f"Unknown MCP server: `{alias}`", message_id
)
if statuses[alias] in {AuthStatus.STATIC, AuthStatus.STDIO}:
return await self._command_reply(
session,
f"MCP server `{alias}` is not configured for OAuth.",
message_id,
)
return await self._command_reply(
session, _mcp_tui_login_message(alias), message_id
)
async def _handle_mcp_logout(
self, session: AcpSessionLoop, alias: str, message_id: str
) -> PromptResponse:
if not alias:
return await self._command_reply(
session, "Usage: `/mcp logout <alias>`", message_id
)
await session.agent_loop.wait_until_ready()
try:
await session.agent_loop.mcp_registry.logout(alias)
await session.agent_loop.tool_manager.refresh_remote_tools_async()
await session.agent_loop.refresh_system_prompt()
except (MCPOAuthError, ValueError) as exc:
return await self._command_reply(session, str(exc), message_id)
return await self._command_reply(
session, f"MCP server `{alias}` logged out.", message_id
)
async def _handle_compact(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:

View file

@ -91,6 +91,12 @@ def _build_commands() -> dict[str, AcpCommand]:
description="Show path to current session log directory",
handler="_handle_log",
),
"mcp": AcpCommand(
name="mcp",
description="Show MCP OAuth status, login guidance, or log out an OAuth MCP server",
handler="_handle_mcp",
input_hint="status | login <alias> | logout <alias>",
),
"teleport": AcpCommand(
name="teleport",
description="Teleport session to Vibe Code Web",

View file

@ -18,6 +18,8 @@ Vibe application codes:
-31004 Context too long
-31005 Refusal
-31006 Compaction failed
-31007 Invalid image attachment
-31008 Images not supported by the active model
"""
from __future__ import annotations
@ -49,6 +51,8 @@ CONVERSATION_LIMIT = -31003
CONTEXT_TOO_LONG = -31004
REFUSAL = -31005
COMPACTION_FAILED = -31006
INVALID_IMAGE_ATTACHMENT = -31007
IMAGES_NOT_SUPPORTED = -31008
class VibeRequestError(RequestError):
@ -181,6 +185,23 @@ class ConfigurationError(VibeRequestError):
super().__init__(message=detail)
class InvalidImageAttachmentError(VibeRequestError):
code = INVALID_IMAGE_ATTACHMENT
def __init__(self, detail: str, reason: str) -> None:
super().__init__(message=detail, data={"reason": reason})
class ImagesNotSupportedError(VibeRequestError):
code = IMAGES_NOT_SUPPORTED
def __init__(self, model: str) -> None:
super().__init__(
message=f"Model `{model}` does not support images. "
f"Switch model, or ask me to enable the support for this model."
)
class ConversationLimitError(VibeRequestError):
code = CONVERSATION_LIMIT

62
vibe/acp/image_blocks.py Normal file
View file

@ -0,0 +1,62 @@
from __future__ import annotations
import base64
import binascii
from collections.abc import Sequence
from pathlib import Path
from acp.helpers import ContentBlock, ImageContentBlock
from vibe.acp.exceptions import InvalidImageAttachmentError
from vibe.core.session.image_snapshot import (
ImageSnapshotError,
extension_for_mime,
snapshot_image_bytes,
)
from vibe.core.types import MAX_IMAGE_BYTES, MAX_IMAGES_PER_MESSAGE, ImageAttachment
def extract_image_attachments(
blocks: Sequence[ContentBlock], *, session_dir: Path | None
) -> list[ImageAttachment]:
image_blocks = [block for block in blocks if isinstance(block, ImageContentBlock)]
if len(image_blocks) > MAX_IMAGES_PER_MESSAGE:
raise InvalidImageAttachmentError(
f"Too many images: {len(image_blocks)} > {MAX_IMAGES_PER_MESSAGE}",
reason="too_many",
)
return [
_block_to_attachment(block, session_dir=session_dir) for block in image_blocks
]
def _block_to_attachment(
block: ImageContentBlock, *, session_dir: Path | None
) -> ImageAttachment:
ext = extension_for_mime(block.mime_type)
if ext is None:
raise InvalidImageAttachmentError(
f"Unsupported image mime type: {block.mime_type}", reason="wrong_type"
)
try:
data = base64.b64decode(block.data, validate=True)
except (binascii.Error, ValueError) as e:
raise InvalidImageAttachmentError(
f"Invalid base64 image data: {e}", reason="invalid_base64"
) from e
if len(data) > MAX_IMAGE_BYTES:
raise InvalidImageAttachmentError(
f"Image is too large: {len(data)} > {MAX_IMAGE_BYTES}", reason="too_large"
)
alias = Path(block.uri).name if block.uri else f"pasted-image{ext}"
try:
return snapshot_image_bytes(
data, alias=alias, mime_type=block.mime_type, session_dir=session_dir
)
except ImageSnapshotError as e:
raise InvalidImageAttachmentError(str(e), reason="snapshot_failed") from e

View file

@ -22,6 +22,7 @@ from vibe.acp.tools.session_update import (
)
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read import (
DEFAULT_LINE_LIMIT,
Read as CoreReadTool,
ReadArgs,
ReadResult,
@ -81,19 +82,48 @@ class Read(
tool_call_id=event.tool_call_id,
kind=resolve_kind(event.tool_name),
raw_input=event.args.model_dump_json(),
locations=[
ToolCallLocation(
path=resolved,
field_meta={
"type": "file_range",
"offset": event.args.offset,
"limit": event.args.limit,
},
)
],
locations=[cls._call_location(resolved, event.args)],
field_meta={"tool_name": event.tool_name},
)
@staticmethod
def _call_location(resolved: str, args: ReadArgs) -> ToolCallLocation:
# Only range explicitly bounded reads; a whole-file read's default
# limit would render a misleading "L1-L<default>" chip.
if args.limit != DEFAULT_LINE_LIMIT:
return ToolCallLocation(
path=resolved,
field_meta={
"type": "file_range",
"offset": args.offset,
"limit": args.limit,
},
)
return ToolCallLocation(
path=resolved, line=args.offset, field_meta={"type": "file"}
)
@staticmethod
def _result_location(resolved: str, result: ReadResult) -> ToolCallLocation:
# Mirror the start-event logic: a whole-file read points at the file with
# no line (requested_offset is None), so it renders "Read foo.ts" rather
# than a stray "L1". A default-limit read that got truncated only read
# part of the file, so surface the real range instead of implying the
# whole file was read. num_lines is already clamped to the lines read.
bounded = result.requested_limit != DEFAULT_LINE_LIMIT or result.was_truncated
if bounded:
return ToolCallLocation(
path=resolved,
field_meta={
"type": "file_range",
"offset": result.start_line,
"limit": result.num_lines,
},
)
return ToolCallLocation(
path=resolved, line=result.requested_offset, field_meta={"type": "file"}
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if failure := failed_tool_result(event, ReadResult):
@ -102,16 +132,7 @@ class Read(
result = event.result
assert isinstance(result, ReadResult)
resolved = str(Path(result.file_path).resolve())
locations = [
ToolCallLocation(
path=resolved,
field_meta={
"type": "file_range",
"offset": result.start_line,
"limit": result.num_lines,
},
)
]
locations = [cls._result_location(resolved, result)]
return ToolCallProgress(
session_update="tool_call_update",

View file

@ -8,6 +8,7 @@ from acp.schema import (
TextContentBlock,
ToolCallProgress,
ToolCallStart,
ToolCallStatus,
ToolKind,
)
from pydantic import BaseModel
@ -99,7 +100,18 @@ def fallback_tool_call(event: ToolCallEvent, title: str) -> ToolCallStart:
)
def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
def tool_call_session_update(
event: ToolCallEvent, status: ToolCallStatus | None = None
) -> SessionUpdate | None:
update = _build_tool_call_start(event)
# Status is a lifecycle concern owned by the caller (live start -> pending,
# replay -> completed); the per-tool builders leave it unset.
if isinstance(update, ToolCallStart) and update.status is None:
update.status = status
return update
def _build_tool_call_start(event: ToolCallEvent) -> SessionUpdate | None:
if issubclass(event.tool_class, ToolCallSessionUpdateProtocol):
return event.tool_class.tool_call_session_update(event)

View file

@ -0,0 +1,14 @@
from __future__ import annotations
from vibe.core.types import UserDisplayContentMetadata
USER_DISPLAY_CONTENT_META_KEY = "user_display_content"
def parse_user_display_content_metadata(
value: object,
) -> UserDisplayContentMetadata | None:
if value is None:
return None
return UserDisplayContentMetadata.model_validate(value)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from enum import StrEnum
from typing import TYPE_CHECKING
from acp.helpers import SessionUpdate
from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
@ -22,11 +23,20 @@ from acp.schema import (
UserMessageChunk,
)
from vibe.acp.tools.session_update import resolve_kind, tool_call_session_update
from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY
from vibe.core.agents.models import AgentProfile, AgentType
from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.types import (
CompactEndEvent,
CompactStartEvent,
LLMMessage,
ToolCall,
ToolCallEvent,
)
from vibe.core.utils import compact_complete_display
if TYPE_CHECKING:
@ -280,10 +290,20 @@ def get_proxy_help_text() -> str:
def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk:
content = msg.content if isinstance(msg.content, str) else ""
field_meta = (
{
USER_DISPLAY_CONTENT_META_KEY: msg.user_display_content.model_dump(
mode="json"
)
}
if msg.user_display_content is not None
else None
)
return UserMessageChunk(
session_update="user_message_chunk",
content=TextContentBlock(type="text", text=content),
message_id=msg.message_id,
field_meta=field_meta,
)
@ -313,26 +333,54 @@ def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None:
def create_tool_call_replay(
tool_call_id: str, tool_name: str, arguments: str | None
) -> ToolCallStart:
# Fallback when a stored tool call can't be resolved (unknown tool or
# args that no longer validate). Replayed calls are historical, so they
# carry a terminal status; the host drops created events without one.
return ToolCallStart(
session_update="tool_call",
title=tool_name,
tool_call_id=tool_call_id,
kind="other",
kind=resolve_kind(tool_name),
status="completed",
raw_input=arguments,
field_meta={"tool_name": tool_name},
)
def tool_call_replay_update(
resolved: ResolvedToolCall | None, tool_call: ToolCall
) -> SessionUpdate | None:
tool_name = tool_call.function.name or ""
tool_call_id = tool_call.id or ""
if resolved is None:
return create_tool_call_replay(
tool_call_id, tool_name, tool_call.function.arguments
)
return tool_call_session_update(
ToolCallEvent(
tool_call_id=resolved.call_id,
tool_name=resolved.tool_name,
tool_class=resolved.tool_class,
args=resolved.validated_args,
),
status="completed",
)
def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None:
if not msg.tool_call_id:
return None
content = msg.content if isinstance(msg.content, str) else ""
tool_name = msg.name or ""
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=msg.tool_call_id,
status="completed",
kind=resolve_kind(tool_name),
raw_output=content,
field_meta={"tool_name": tool_name},
content=[
ContentToolCallContent(
type="content", content=TextContentBlock(type="text", text=content)

View file

@ -18,5 +18,5 @@ class CompletionView(Protocol):
def clear_completion_suggestions(self) -> None: ...
def replace_completion_range(
self, start: int, end: int, replacement: str
self, start: int, end: int, replacement: str, *, suppress_update: bool = False
) -> None: ...

View file

@ -90,6 +90,6 @@ class SlashCommandController:
return False
start, end = replacement_range
self._view.replace_completion_range(start, end, alias)
self._view.replace_completion_range(start, end, alias, suppress_update=True)
self.reset()
return True

View file

@ -1,32 +0,0 @@
from __future__ import annotations
import logging
from pathlib import Path
import tomllib
from typing import Any
import tomli_w
logger = logging.getLogger(__name__)
def read_cache(cache_path: Path) -> dict[str, Any]:
"""Read the cache.toml file, returning an empty dict on any error."""
try:
with cache_path.open("rb") as f:
return tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return {}
def write_cache(cache_path: Path, section: str, data: dict[str, Any]) -> None:
"""Write the full cache dict to cache.toml, merging with existing data."""
existing = read_cache(cache_path)
existing.setdefault(section, {})
existing[section].update(data)
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with cache_path.open("wb") as f:
tomli_w.dump(existing, f)
except OSError:
logger.debug("Failed to write cache file %s", cache_path, exc_info=True)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
from collections.abc import Callable
from pathlib import Path
import sys
@ -15,12 +16,17 @@ from vibe.cli.terminal_detect import detect_terminal
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
PyPIUpdateGateway,
UpdateCacheRepository,
UpdateError,
UpdateGateway,
get_pending_update_from_cache,
get_update_if_available,
mark_update_as_dismissed,
)
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.cache_store import FileSystemVibeCodeCacheStore
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 HookConfigResult, load_hooks_from_fs
@ -36,7 +42,12 @@ from vibe.core.trusted_folders import find_trustable_files, trusted_folders_mana
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
from vibe.setup.update_prompt import UpdatePromptResult, ask_update_prompt
from vibe.setup.update_prompt import (
UpdatePromptMode,
UpdatePromptResult,
ask_update_prompt,
load_update_prompt_theme,
)
def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
@ -256,31 +267,25 @@ def _run_programmatic_mode(
sys.exit(1)
def _maybe_run_startup_update_prompt(
config: VibeConfig, repository: UpdateCacheRepository
def _show_update_prompt(
repository: UpdateCacheRepository,
latest_version: str,
*,
theme: str | None,
dismiss_on_continue: bool,
prompt_mode: UpdatePromptMode,
) -> None:
if not config.enable_update_checks:
return
try:
latest_version = asyncio.run(
get_pending_update_from_cache(repository, __version__)
)
except OSError as exc:
logger.debug("Failed to read pending update from cache", exc_info=exc)
return
if latest_version is None:
return
result = ask_update_prompt(__version__, latest_version, theme=config.theme)
result = ask_update_prompt(
__version__, latest_version, theme=theme, prompt_mode=prompt_mode
)
match result:
case UpdatePromptResult.CONTINUE:
try:
asyncio.run(mark_update_as_dismissed(repository, latest_version))
except OSError as exc:
logger.debug("Failed to persist dismissed update", exc_info=exc)
if dismiss_on_continue:
try:
asyncio.run(mark_update_as_dismissed(repository, latest_version))
except OSError as exc:
logger.debug("Failed to persist dismissed update", exc_info=exc)
return
case UpdatePromptResult.QUIT:
sys.exit(0)
@ -301,7 +306,74 @@ def _maybe_run_startup_update_prompt(
sys.exit(1)
def run_cli(args: argparse.Namespace) -> None:
def _maybe_run_startup_update_prompt(
config: VibeConfig, repository: UpdateCacheRepository
) -> None:
if not config.enable_update_checks:
return
try:
latest_version = asyncio.run(
get_pending_update_from_cache(repository, __version__)
)
except OSError as exc:
logger.debug("Failed to read pending update from cache", exc_info=exc)
return
if latest_version is None:
return
_show_update_prompt(
repository,
latest_version,
theme=config.theme,
dismiss_on_continue=True,
prompt_mode=UpdatePromptMode.STARTUP,
)
def _run_check_upgrade(
repository: UpdateCacheRepository,
*,
update_notifier: UpdateGateway | None = None,
theme: str | None = None,
) -> None:
notifier = update_notifier or PyPIUpdateGateway(project_name="mistral-vibe")
try:
update = asyncio.run(
get_update_if_available(
update_notifier=notifier,
current_version=__version__,
update_cache_repository=repository,
force_check=True,
)
)
except UpdateError as exc:
rprint(f"[red]✗ Update check failed:[/] {exc.message}")
sys.exit(1)
except OSError as exc:
logger.debug("Failed to persist forced update check", exc_info=exc)
rprint("[red]✗ Update check failed while writing the update cache.[/]")
sys.exit(1)
if update is None:
rprint(f"[green]Vibe is already up to date ({__version__}).[/]")
return
_show_update_prompt(
repository,
update.latest_version,
theme=theme,
dismiss_on_continue=False,
prompt_mode=UpdatePromptMode.CHECK_UPGRADE,
)
def run_cli(
args: argparse.Namespace,
*,
resolve_trusted_folder: Callable[[], None] | None = None,
) -> None:
load_dotenv_values()
bootstrap_config_files()
@ -310,12 +382,21 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(0)
try:
update_cache_repository = FileSystemUpdateCacheRepository()
if getattr(args, "check_upgrade", False):
_run_check_upgrade(
update_cache_repository, theme=load_update_prompt_theme()
)
sys.exit(0)
is_interactive = args.prompt is None
config = load_config_or_exit(interactive=is_interactive)
update_cache_repository = FileSystemUpdateCacheRepository()
if is_interactive:
_maybe_run_startup_update_prompt(config, update_cache_repository)
if resolve_trusted_folder is not None:
resolve_trusted_folder()
config = load_config_or_exit(interactive=True)
initial_agent_name = get_initial_agent_name(args, config)
hook_config_result = load_hooks_from_fs(config)
@ -337,6 +418,7 @@ def run_cli(args: argparse.Namespace) -> None:
terminal_emulator=detect_terminal(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
cache_store=FileSystemVibeCodeCacheStore(),
)
except ValueError as e:
rprint(f"[red]Error:[/] {e}")

View file

@ -137,7 +137,8 @@ class CommandRegistry:
aliases=frozenset(["/mcp", "/connectors"]),
description=(
"Display available MCP servers and connectors. "
"Pass a name to list its tools"
"Pass a name to list tools; subcommands: status, "
"login <alias>, logout <alias>"
),
handler="_show_mcp",
),

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
from collections.abc import Callable
import os
from pathlib import Path
import sys
@ -50,7 +51,7 @@ def parse_arguments() -> argparse.Namespace:
metavar="TEXT",
help="Run in programmatic mode: send prompt, output response, and exit. "
"Tool approval follows the selected --agent (or 'default_agent' config); "
"pass --auto-approve to allow all tool calls.",
"pass --auto-approve or --yolo to allow all tool calls.",
)
parser.add_argument(
"--max-turns",
@ -104,11 +105,17 @@ def parse_arguments() -> argparse.Namespace:
)
agent_group.add_argument(
"--auto-approve",
"--yolo",
action="store_true",
help="Shortcut for --agent auto-approve. Approves all tool calls without "
"prompting.",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument(
"--check-upgrade",
action="store_true",
help="Check for a Vibe update now, prompt to install it, and exit",
)
parser.add_argument(
"--workdir",
type=Path,
@ -216,14 +223,19 @@ def main() -> None:
additional_dirs.append(resolved)
trusted_folders_manager.trust_for_session(resolved)
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder(cwd)
init_harness_files_manager("user", "project", additional_dirs=additional_dirs)
from vibe.cli.cli import run_cli
run_cli(args)
resolve_trusted_folder: Callable[[], None] | None = None
if args.prompt is None and not args.check_upgrade:
def _resolve_trusted_folder() -> None:
check_and_resolve_trusted_folder(cwd)
resolve_trusted_folder = _resolve_trusted_folder
run_cli(args, resolve_trusted_folder=resolve_trusted_folder)
if __name__ == "__main__":

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from enum import StrEnum
import logging
from os import getenv
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGateway,
@ -15,6 +14,7 @@ from vibe.core.config import (
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_VIBE_BASE_URL,
ProviderConfig,
resolve_api_key,
)
from vibe.core.types import Backend
@ -110,7 +110,7 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
if provider.backend == Backend.MISTRAL:
api_env_key = provider.api_key_env_var
return getenv(api_env_key)
return resolve_api_key(api_env_key)
def plan_offer_cta(

View file

@ -10,10 +10,12 @@ import gc
import os
from pathlib import Path
import signal
import sys
import time
from typing import Any, ClassVar, assert_never, cast
from uuid import uuid4
from weakref import WeakKeyDictionary
import webbrowser
from pydantic import BaseModel
from rich import print as rprint
@ -53,7 +55,6 @@ from vibe.cli.textual_ui.notifications import (
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.quit_manager import QuitManager
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
@ -151,6 +152,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.auth import MCPOAuthError
from vibe.core.autocompletion.path_prompt import (
PathPromptPayload,
PathResource,
@ -170,10 +172,7 @@ from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
from vibe.core.session.resume_sessions import (
RemoteResumeResult,
RemoteResumeSessions,
ResumeSessionInfo,
can_delete_resume_session_source,
list_local_resume_sessions,
session_latest_messages,
short_session_id,
@ -201,6 +200,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Question,
)
from vibe.core.tools.connectors import compute_connector_counts
from vibe.core.tools.mcp import AuthStatus
from vibe.core.tools.mcp_settings import persist_mcp_toggle
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
@ -209,13 +209,17 @@ from vibe.core.types import (
MAX_IMAGES_PER_MESSAGE,
AgentStats,
ApprovalResponse,
AssistantEvent,
BaseEvent,
ContextTooLongError,
ImageAttachment,
LLMMessage,
RateLimitError,
ReasoningEvent,
RefusalError,
Role,
ToolCallEvent,
ToolStreamEvent,
WaitingForInputEvent,
)
from vibe.core.utils import (
@ -227,6 +231,12 @@ from vibe.core.utils import (
_VSCODE_FAMILY_TERMINALS = {Terminal.VSCODE, Terminal.VSCODE_INSIDERS, Terminal.CURSOR}
def is_progress_event(event: object) -> bool:
return isinstance(
event, (AssistantEvent, ReasoningEvent, ToolCallEvent, ToolStreamEvent)
)
def _is_vscode_family_terminal() -> bool:
return detect_terminal() in _VSCODE_FAMILY_TERMINALS
@ -429,9 +439,6 @@ class VibeApp(App): # noqa: PLR0904
self._agent_task: asyncio.Task | None = None
self._bash_task: asyncio.Task | None = None
self._queue = QueueController(self._build_queue_ports())
self._remote_manager = RemoteSessionManager()
self._remote_resume = RemoteResumeSessions(lambda: self.config)
self._resume_merge_task: asyncio.Task[None] | None = None
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
@ -515,8 +522,6 @@ class VibeApp(App): # noqa: PLR0904
agent_running=lambda: self._agent_running,
bash_task=lambda: self._bash_task,
active_model=self._active_model_or_none,
remote_is_active=lambda: self._remote_manager.is_active,
remote_stop_stream=lambda: self._remote_manager.stop_stream(),
remove_loading_widget=self._remove_loading_widget,
set_loading_queue_count=self._set_loading_queue_count,
inject_user_context=self.agent_loop.inject_user_context,
@ -524,7 +529,6 @@ class VibeApp(App): # noqa: PLR0904
start_agent_turn=self._start_queued_agent_turn,
await_agent_turn=self._await_agent_turn,
run_bash=self._start_queued_bash,
handle_user_message=self._handle_user_message,
maybe_show_feedback_bar=self._maybe_show_feedback_bar,
send_skill_telemetry=self._send_skill_telemetry,
send_at_mention_telemetry=self._send_at_mention_telemetry,
@ -546,7 +550,7 @@ class VibeApp(App): # noqa: PLR0904
def _maybe_show_feedback_bar(self) -> None:
if self._feedback_bar_manager.should_show(self.agent_loop):
self._feedback_bar.show()
self._feedback_bar_manager.record_feedback_asked()
self._feedback_bar_manager.record_feedback_asked(self.agent_loop)
def _start_queued_agent_turn(
self,
@ -668,7 +672,6 @@ class VibeApp(App): # noqa: PLR0904
mount_callback=self._mount_and_scroll,
get_tools_collapsed=lambda: self._tools_collapsed,
on_profile_changed=self._on_profile_changed,
is_remote=self._remote_manager.is_active,
)
self._chat_input_container = self.query_one(ChatInputContainer)
@ -734,6 +737,7 @@ class VibeApp(App): # noqa: PLR0904
await self._ensure_loading_widget("Initializing", show_hint=False)
init_widget = self._loading_widget
await self.agent_loop.wait_until_ready()
await self._show_mcp_auth_required_notice()
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
@ -757,6 +761,29 @@ class VibeApp(App): # noqa: PLR0904
except Exception:
pass
async def _show_mcp_auth_required_notice(self) -> None:
statuses = self.agent_loop.mcp_registry.status()
aliases = sorted(
alias
for alias, status in statuses.items()
if status is AuthStatus.NEEDS_AUTH
)
if not aliases:
return
command = f"/mcp login {aliases[0]}"
if len(aliases) > 1:
detail = ", ".join(aliases)
message = (
"MCP servers need OAuth authentication: "
f"{detail}. Run `{command}` to start with {aliases[0]!r}."
)
else:
message = (
f"MCP server {aliases[0]!r} needs OAuth authentication. "
f"Run `{command}` to authenticate."
)
await self._mount_and_scroll(UserCommandMessage(message))
def _process_initial_prompt(self) -> None:
if self._teleport_on_start and self.commands.has_command("teleport"):
self.run_worker(
@ -926,21 +953,11 @@ class VibeApp(App): # noqa: PLR0904
await self._remove_loading_widget()
async def on_question_app_answered(self, message: QuestionApp.Answered) -> None:
if self._remote_manager.has_pending_input and self._remote_manager.is_active:
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
await self._handle_remote_answer(result)
return
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
self._pending_question.set_result(result)
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
if self._remote_manager.has_pending_input:
self._remote_manager.cancel_pending_input()
await self._switch_to_input_app()
return
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
@ -1544,10 +1561,6 @@ class VibeApp(App): # noqa: PLR0904
async def _handle_user_message(
self, message: str, *, title_source: str | None = None
) -> None:
if self._remote_manager.is_active:
await self._handle_remote_user_message(message)
return
prompt_payload = build_path_prompt_payload(message, base_dir=Path.cwd())
images = await self._prepare_images_or_abort(prompt_payload)
if images is None:
@ -1572,10 +1585,9 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(user_message)
if self._feedback_bar_manager.should_show(self.agent_loop):
self._feedback_bar.show()
self._feedback_bar_manager.record_feedback_asked()
self._feedback_bar_manager.record_feedback_asked(self.agent_loop)
if not self._agent_running:
await self._remote_manager.stop_stream()
await self._remove_loading_widget()
self._agent_task = asyncio.create_task(
self._handle_agent_loop_turn(
@ -1587,40 +1599,6 @@ class VibeApp(App): # noqa: PLR0904
)
self._queue.notify_busy_changed()
async def _handle_remote_user_message(self, message: str) -> None:
warning = self._remote_manager.validate_input()
if warning:
await self._mount_and_scroll(WarningMessage(warning))
return
try:
await self._remote_manager.send_prompt(message)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to send message: {e}", collapsed=self._tools_collapsed
)
)
return
await self._ensure_loading_widget()
async def _handle_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
self._remote_manager.set_pending_input(event)
if question_args := self._remote_manager.build_question_args(event):
await self._switch_to_question_app(question_args)
return
await self._switch_to_input_app()
async def _handle_remote_answer(self, result: AskUserQuestionResult) -> None:
if result.cancelled or not result.answers:
self._remote_manager.cancel_pending_input()
await self._switch_to_input_app()
return
await self._remote_manager.send_prompt(
result.answers[0].answer, require_source=False
)
await self._switch_to_input_app()
await self._ensure_loading_widget()
def _reset_ui_state(self) -> None:
self._windowing.reset()
self._tool_call_map = None
@ -1772,8 +1750,6 @@ class VibeApp(App): # noqa: PLR0904
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif isinstance(event, HookStartEvent):
await self._ensure_loading_widget(f"Running hook {event.hook_name}")
elif self._loading_widget is None and is_progress_event(event):
@ -1937,22 +1913,6 @@ class VibeApp(App): # noqa: PLR0904
teleport_msg = TeleportMessage()
await self._mount_and_scroll(teleport_msg)
if self._remote_manager.is_active:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="remote_session",
error_class="TeleportRemoteSessionError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await loading.remove()
await self._mount_and_scroll(
ErrorMessage(
"Teleport is not available for remote sessions.",
collapsed=self._tools_collapsed,
)
)
return
try:
gen = self.agent_loop.teleport_to_vibe_code(prompt)
async for event in gen:
@ -2083,7 +2043,92 @@ class VibeApp(App): # noqa: PLR0904
self._refresh_banner()
return "Refreshed."
async def _maybe_handle_mcp_subcommand(self, cmd_args: str) -> bool:
parts = cmd_args.strip().split(None, 1)
if not parts or parts[0] not in {"login", "logout", "status"}:
return False
subcommand = parts[0]
arg = parts[1].strip() if len(parts) > 1 else ""
match subcommand:
case "status":
if arg:
await self._mount_and_scroll(
ErrorMessage("Usage: /mcp status", collapsed=True)
)
return True
await self._show_mcp_status()
case "login":
await self._mcp_login(arg)
case "logout":
await self._mcp_logout(arg)
return True
async def _show_mcp_status(self) -> None:
await self.agent_loop.wait_until_ready()
statuses = self.agent_loop.mcp_registry.status()
if not statuses:
await self._mount_and_scroll(
UserCommandMessage("No MCP servers configured.")
)
return
lines = ["### MCP auth status", ""]
for alias, status in sorted(statuses.items()):
lines.append(f"- `{alias}`: `{status.value}`")
await self._mount_and_scroll(UserCommandMessage("\n".join(lines)))
async def _mcp_login(self, alias: str) -> None:
if not alias:
await self._mount_and_scroll(
ErrorMessage("Usage: /mcp login <alias>", collapsed=True)
)
return
await self.agent_loop.wait_until_ready()
async def on_url(url: str) -> None:
await self._mount_and_scroll(
UserCommandMessage(f"Open this URL in your browser:\n\n {url}")
)
try:
webbrowser.open(url)
except Exception as exc:
logger.debug("Failed to open MCP OAuth URL in browser: %s", exc)
try:
await self.agent_loop.mcp_registry.login(alias, on_url=on_url)
await self._refresh_mcp_browser()
except (MCPOAuthError, ValueError) as exc:
await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True))
return
await self._mount_and_scroll(
UserCommandMessage(f"MCP server `{alias}` authenticated.")
)
async def _mcp_logout(self, alias: str) -> None:
if not alias:
await self._mount_and_scroll(
ErrorMessage("Usage: /mcp logout <alias>", collapsed=True)
)
return
await self.agent_loop.wait_until_ready()
try:
await self.agent_loop.mcp_registry.logout(alias)
await self._refresh_mcp_browser()
except (MCPOAuthError, ValueError) as exc:
await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True))
return
await self._mount_and_scroll(
UserCommandMessage(f"MCP server `{alias}` logged out.")
)
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
if await self._maybe_handle_mcp_subcommand(cmd_args):
return
mcp_servers = self.config.mcp_servers
connector_registry = (
self.agent_loop.connector_registry if self._connectors_enabled else None
@ -2190,15 +2235,6 @@ class VibeApp(App): # noqa: PLR0904
return renamed_title
async def _rename_session(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._remote_manager.is_active:
await self._mount_and_scroll(
ErrorMessage(
"Renaming is only supported for local sessions.",
collapsed=self._tools_collapsed,
)
)
return
title = cmd_args.strip()
if not title:
await self._mount_and_scroll(
@ -2229,96 +2265,26 @@ class VibeApp(App): # noqa: PLR0904
cwd=str(Path.cwd()),
)
async def _merge_remote_into_picker(
self, picker: SessionPickerApp, remote_task: asyncio.Task[RemoteResumeResult]
) -> None:
remote_sessions, remote_error = await remote_task
if not picker.is_mounted:
return
if remote_error is not None:
await self._mount_and_scroll(
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
)
if remote_sessions:
picker.add_sessions(
remote_sessions, session_latest_messages(remote_sessions, self.config)
)
async def _cancel_resume_merge(self) -> None:
"""Cancel the task that fetch and merges remote sessions into the picker, if it exists."""
if self._resume_merge_task is not None and not self._resume_merge_task.done():
self._resume_merge_task.cancel()
with suppress(asyncio.CancelledError):
await self._resume_merge_task
self._resume_merge_task = None
async def _close_remote_resume(self) -> None:
"""Close the remote resume connection and cancel any ongoing merge task.
Used to gracefully close the connection when the app is exiting
"""
await self._cancel_resume_merge()
await self._remote_resume.aclose()
async def _show_session_picker(self, **kwargs: Any) -> None:
await self._cancel_resume_merge()
remote_list_timeout = max(float(self.config.api_timeout), 10.0)
remote_task = self._remote_resume.start(remote_list_timeout)
# If there are no local sessions, show the remote picker directly
if not self.config.session_logging.enabled or not (
local_sessions := list_local_resume_sessions(self.config, str(Path.cwd()))
):
await self._show_session_picker_remote_only(remote_task)
return
picker = self._build_picker(local_sessions)
await self._switch_from_input(picker)
self._resume_merge_task = asyncio.create_task(
self._merge_remote_into_picker(picker, remote_task)
)
async def _show_session_picker_remote_only(
self, remote_task: asyncio.Task[RemoteResumeResult]
) -> None:
await self._ensure_loading_widget("Loading sessions")
try:
remote_sessions, remote_error = await remote_task
except asyncio.CancelledError:
return
finally:
await self._remove_loading_widget()
if remote_error is not None:
await self._mount_and_scroll(
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
)
if not remote_sessions:
await self._mount_and_scroll(
UserCommandMessage("No sessions found for this directory.")
)
return
await self._switch_from_input(self._build_picker(remote_sessions))
await self._switch_from_input(self._build_picker(local_sessions))
async def on_session_picker_app_session_selected(
self, event: SessionPickerApp.SessionSelected
) -> None:
await self._switch_to_input_app()
session = ResumeSessionInfo(
session_id=event.session_id,
source=event.source,
cwd="",
title=None,
end_time=None,
session_id=event.session_id, cwd="", title=None, end_time=None
)
try:
if event.source == "local":
await self._resume_local_session(session)
elif event.source == "remote":
await self._resume_remote_session(session)
else:
raise ValueError(f"Unknown session source: {event.source}")
await self._resume_local_session(session)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
@ -2329,17 +2295,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_session_picker_app_session_delete_requested(
self, event: SessionPickerApp.SessionDeleteRequested
) -> None:
if not can_delete_resume_session_source(event.source):
self._clear_pending_session_delete(event.option_id)
await self._mount_and_scroll(
ErrorMessage(
"Deleting remote sessions is not supported.",
collapsed=self._tools_collapsed,
)
)
return
if event.source == "local" and event.session_id == self.agent_loop.session_id:
if event.session_id == self.agent_loop.session_id:
self._clear_pending_session_delete(event.option_id)
await self._mount_and_scroll(
ErrorMessage(
@ -2394,7 +2350,6 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
async def _resume_local_session(self, session: ResumeSessionInfo) -> None:
await self._remote_manager.detach()
session_config = self.config.session_logging
session_path = SessionLoader.find_session_by_id(
session.session_id, session_config
@ -2432,8 +2387,6 @@ class VibeApp(App): # noqa: PLR0904
await self._messages_area.remove_children()
if self.event_handler:
self.event_handler.is_remote = False
await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
await self._mount_and_scroll(
@ -2442,68 +2395,6 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _resume_remote_session(self, session: ResumeSessionInfo) -> None:
self.agent_loop.telemetry_client.send_remote_resume_requested(
session_id=session.session_id
)
await self._remote_manager.attach(
session_id=session.session_id, config=self.config
)
self._emit_session_closed_for_active_session()
self.agent_loop.session_id = session.session_id
self._refresh_profile_widgets()
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
self._reset_ui_state()
await self._load_more.hide()
await self._messages_area.remove_children()
if self.event_handler:
self.event_handler.is_remote = True
self._remote_manager.start_stream(self)
async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None:
if self.event_handler:
await self.event_handler.handle_event(event, loading_widget=loading_widget)
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
await self._handle_remote_waiting_input(event)
async def on_remote_user_message_cleared_input(self) -> None:
await self._switch_to_input_app()
async def on_remote_stream_error(self, error: str) -> None:
await self._mount_and_scroll(
ErrorMessage(error, collapsed=self._tools_collapsed)
)
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None:
if msg_type == "error":
widget = ErrorMessage(text, collapsed=self._tools_collapsed)
elif msg_type == "warning":
widget = WarningMessage(text)
else:
widget = UserCommandMessage(text)
await self._mount_and_scroll(widget)
if self._chat_input_container:
self._chat_input_container.set_custom_border("Remote session ended")
async def on_remote_finalize_streaming(self) -> None:
if self.event_handler:
await self.event_handler.finalize_streaming()
async def remove_loading(self) -> None:
await self._remove_loading_widget()
async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None:
await self._ensure_loading_widget(status)
@property
def loading_widget(self) -> LoadingWidget | None:
return self._loading_widget
async def _reload_config(self, **kwargs: Any) -> None:
try:
self._reset_ui_state()
@ -2579,11 +2470,6 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self, **kwargs: Any) -> None:
try:
self._reset_ui_state()
if self._remote_manager.is_active:
await self._remote_manager.detach()
self._refresh_profile_widgets()
if self.event_handler:
self.event_handler.is_remote = False
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
await self.agent_loop.clear_history()
@ -2688,8 +2574,6 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler.current_compact = None
def _get_session_resume_info(self) -> str | None:
if self._remote_manager.is_active:
return None
if not self.agent_loop.session_logger.enabled:
return None
if not self.agent_loop.session_logger.session_id:
@ -2703,17 +2587,14 @@ class VibeApp(App): # noqa: PLR0904
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self, **kwargs: Any) -> None:
self._emit_session_closed_for_active_session()
await self._loop_runner.stop()
self._log_reader.shutdown()
await self._voice_manager.close()
await self._narrator_manager.close()
await self._close_remote_resume()
await self.agent_loop.aclose()
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error("Failed to close telemetry client during exit", exc_info=exc)
self._emit_session_closed_for_active_session()
await self._begin_shutdown()
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._log_reader.shutdown()
finally:
self.exit(result=self._get_session_resume_info())
@ -3348,14 +3229,7 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container:
self._chat_input_container.set_safety(profile.safety)
self._chat_input_container.set_agent_name(profile.display_name.lower())
if self._remote_manager.is_active:
session_id = self._remote_manager.session_id
self._chat_input_container.set_custom_border(
f"Remote session {short_session_id(session_id, source='remote') if session_id else ''}",
ChatInputContainer.REMOTE_BORDER_CLASS,
)
else:
self._chat_input_container.set_custom_border(None)
self._chat_input_container.set_custom_border(None)
async def _cycle_agent(self) -> None:
new_profile = self.agent_loop.agent_manager.next_agent(
@ -3445,31 +3319,52 @@ class VibeApp(App): # noqa: PLR0904
def _emit_session_closed_for_active_session(self) -> None:
self.agent_loop.emit_session_closed_telemetry()
async def _begin_shutdown(self) -> None:
await self._queue.shutdown()
await self._loop_runner.stop()
def _force_quit(self) -> None:
if self._force_quit_task is not None and not self._force_quit_task.done():
return
self._force_quit_task = asyncio.create_task(self._force_quit_async())
async def _force_quit_async(self) -> None:
self._emit_session_closed_for_active_session()
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._remote_manager.cancel_stream_task()
try:
self._emit_session_closed_for_active_session()
await self._begin_shutdown()
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._log_reader.shutdown()
self._narrator_manager.cancel()
finally:
self.exit(result=self._get_session_resume_info())
self._log_reader.shutdown()
self._narrator_manager.cancel()
await self._close_remote_resume()
await self.agent_loop.aclose()
async def shutdown_cleanup(self) -> None:
with suppress(Exception):
await self._begin_shutdown()
for task in (self._agent_task, self._bash_task):
if task is None or task.done():
continue
task.cancel()
for task in (self._agent_task, self._bash_task):
if task is None or task.done():
continue
with suppress(asyncio.CancelledError, Exception):
await task
with suppress(Exception):
await self._voice_manager.close()
with suppress(Exception):
await self._narrator_manager.close()
with suppress(Exception):
await self.agent_loop.aclose()
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error(
"Failed to close telemetry client during force quit", exc_info=exc
"Failed to close telemetry client during shutdown", exc_info=exc
)
finally:
self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None:
try:
@ -3709,13 +3604,27 @@ class VibeApp(App): # noqa: PLR0904
)
async def _run_app_with_cleanup(app: VibeApp) -> str | None:
from vibe.cli.stderr_guard import stderr_guard
try:
with stderr_guard():
return await app.run_async()
finally:
sys.stderr.write("Closing\u2026\r")
sys.stderr.flush()
try:
await app.shutdown_cleanup()
finally:
sys.stderr.write("\033[2K\r")
sys.stderr.flush()
def run_textual_ui(
agent_loop: AgentLoop,
update_cache_repository: UpdateCacheRepository,
startup: StartupOptions | None = None,
) -> None:
from vibe.cli.stderr_guard import stderr_guard
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
@ -3724,16 +3633,15 @@ def run_textual_ui(
initial_state=asyncio.run(vscode_extension_promo_repository.get()),
)
with stderr_guard():
app = VibeApp(
agent_loop=agent_loop,
startup=startup,
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()
app = VibeApp(
agent_loop=agent_loop,
startup=startup,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
vscode_extension_promo=vscode_extension_promo,
)
session_id = asyncio.run(_run_app_with_cleanup(app))
print_session_resume_message(
session_id, agent_loop.stats, agent_loop.config.session_logging

View file

@ -116,17 +116,15 @@ TextArea > .text-area--cursor {
}
#completion-popup {
overlay: screen;
constrain: inside inside;
display: none;
width: auto;
width: 100%;
height: auto;
color: $foreground;
background: $background;
border: solid $foreground-muted;
overflow-y: auto;
scrollbar-size-vertical: 1;
/* max-height, max-width and padding set in completion_popup.py */
/* max-height and padding set in completion_popup.py */
}
OptionList, OptionList:focus {
@ -134,11 +132,40 @@ OptionList, OptionList:focus {
background-tint: transparent;
}
#completion-popup _CompletionItem {
#completion-popup _CompletionRow {
height: auto;
width: 1fr;
}
#completion-popup _CompletionItem {
height: auto;
}
#completion-popup .completion-command {
/* width set per-row in completion_popup.py, capped to 30% of the popup */
max-width: 30%;
margin-right: 2;
text-style: bold;
}
#completion-popup .completion-description {
width: 1fr;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
#completion-popup _CompletionRow.completion-selected .completion-command {
text-style: bold reverse;
}
#completion-popup _CompletionRow.completion-selected .completion-description {
color: $foreground;
text-style: italic;
}
#input-box {
height: auto;
width: 100%;
@ -172,12 +199,6 @@ OptionList, OptionList:focus {
border-bottom: solid $mistral_orange;
border-title-color: $mistral_orange;
}
&.border-remote {
border-top: solid $mistral_orange;
border-bottom: solid $mistral_orange;
border-title-color: $mistral_orange;
}
}
#input-body {
@ -308,7 +329,7 @@ Markdown {
.user-message-attachments {
width: 100%;
height: auto;
padding-left: 2;
padding: 0 2 0 2;
color: $text-muted;
&:ansi {
@ -350,7 +371,7 @@ Markdown {
margin-top: 1;
width: 100%;
height: auto;
padding-left: 2;
padding: 0 2 0 2;
Markdown {
width: 100%;
@ -412,7 +433,7 @@ Markdown {
layout: stream;
width: 100%;
height: auto;
padding: 0 0 0 2;
padding: 0 2 0 2;
margin: 0;
color: $text-muted;
text-style: italic;
@ -497,6 +518,7 @@ Markdown {
width: 1fr;
height: auto;
margin: 0;
padding-right: 2;
color: $warning;
}
@ -504,6 +526,7 @@ Markdown {
width: 1fr;
height: auto;
margin: 0;
padding-right: 2;
color: $error;
text-style: bold;
}
@ -512,6 +535,7 @@ Markdown {
width: 1fr;
height: auto;
margin: 0;
padding-right: 2;
color: $warning;
}
@ -519,6 +543,7 @@ Markdown {
width: 1fr;
height: auto;
margin: 0;
padding-right: 2;
Markdown {
margin: 0;
@ -605,6 +630,7 @@ Markdown {
.bash-output-body {
width: 1fr;
height: auto;
padding-right: 2;
}
.bash-output {
@ -716,7 +742,7 @@ StatusMessage {
width: 100%;
height: auto;
color: $text-muted;
padding-left: 2;
padding: 0 2 0 2;
&:ansi {
text-style: dim;
@ -762,7 +788,7 @@ StatusMessage {
.tool-result-content {
width: 1fr;
height: auto;
padding: 0;
padding-right: 2;
}
.tool-call-widget,

View file

@ -14,7 +14,6 @@ from vibe.cli.textual_ui.widgets.messages import (
HookSystemMessageLine,
PlanFileMessage,
ReasoningMessage,
UserMessage,
)
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
@ -55,12 +54,10 @@ class EventHandler:
mount_callback: Callable,
get_tools_collapsed: Callable[[], bool],
on_profile_changed: Callable[[], None] | None = None,
is_remote: bool = False,
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.on_profile_changed = on_profile_changed
self.is_remote = is_remote
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
@ -168,8 +165,6 @@ class EventHandler:
pass
case UserMessageEvent():
await self.finalize_streaming()
if self.is_remote:
await self.mount_callback(UserMessage(event.content))
case HookEvent():
await self._handle_hook_event(event, loading_widget)
case PlanReviewRequestedEvent():

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass, field
from enum import StrEnum, auto
from pathlib import Path
@ -121,8 +122,6 @@ class QueuePorts:
agent_running: Callable[[], bool]
bash_task: Callable[[], asyncio.Task | None]
active_model: Callable[[], ModelConfig | None]
remote_is_active: Callable[[], bool]
remote_stop_stream: Callable[[], Awaitable[None]]
remove_loading_widget: Callable[[], Awaitable[None]]
set_loading_queue_count: Callable[[int], None]
inject_user_context: Callable[..., Awaitable[None]]
@ -130,7 +129,6 @@ class QueuePorts:
start_agent_turn: Callable[..., asyncio.Task]
await_agent_turn: Callable[[], Awaitable[None]]
run_bash: Callable[..., asyncio.Task]
handle_user_message: Callable[[str], Awaitable[None]]
maybe_show_feedback_bar: Callable[[], None]
send_skill_telemetry: Callable[[str | None], None]
send_at_mention_telemetry: Callable[[PathPromptPayload, str], None]
@ -157,6 +155,7 @@ class QueueController:
self._widgets: list[Widget] = []
self._header: QueueHeaderMessage | None = None
self._drain_task: asyncio.Task | None = None
self._drain_enabled = True
@property
def queue(self) -> MessageQueue:
@ -275,6 +274,8 @@ class QueueController:
# -- drain engine -----------------------------------------------------
def start_drain_if_needed(self) -> None:
if not self._drain_enabled:
return
if self._drain_task is not None and not self._drain_task.done():
return
if not self._queue or self._queue.paused:
@ -290,9 +291,18 @@ class QueueController:
def draining(self) -> bool:
return self._drain_task is not None and not self._drain_task.done()
async def shutdown(self) -> None:
self._drain_enabled = False
drain_task = self._drain_task
if drain_task is None or drain_task.done():
return
drain_task.cancel()
with suppress(asyncio.CancelledError, Exception):
await drain_task
async def _drain(self) -> None:
try:
while self._queue and not self._queue.paused:
while self._drain_enabled and self._queue and not self._queue.paused:
await self._remove_header()
pending = await self._consume_until_bash_or_empty()
if not pending:
@ -390,17 +400,10 @@ class QueueController:
self._ports.send_at_mention_telemetry(item.payload, message_id)
async def _run_tail_prompt(self, item: QueuedItem, widget: UserMessage) -> None:
if self._ports.remote_is_active():
await widget.remove()
await self._ports.handle_user_message(item.content)
self._ports.send_skill_telemetry(item.skill_name)
return
widget.message_index = self._ports.next_message_index()
await widget.set_pending(False)
self._ports.maybe_show_feedback_bar()
await self._ports.remote_stop_stream()
await self._ports.remove_loading_widget()
self._ports.start_agent_turn(
item.content, prebuilt_images=item.images, prebuilt_payload=item.payload
@ -416,6 +419,9 @@ class QueueController:
try:
await bash_task
except asyncio.CancelledError:
current = asyncio.current_task()
if current is not None and current.cancelling():
raise
return False
return True

View file

@ -1,8 +0,0 @@
from __future__ import annotations
from vibe.cli.textual_ui.remote.remote_session_manager import (
RemoteSessionManager,
is_progress_event,
)
__all__ = ["RemoteSessionManager", "is_progress_event"]

View file

@ -1,211 +0,0 @@
from __future__ import annotations
import asyncio
from typing import Any, Protocol
from vibe.cli.textual_ui.widgets.loading import DEFAULT_LOADING_STATUS
from vibe.core.config import VibeConfig
from vibe.core.nuage.remote_events_source import RemoteEventsSource
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
from vibe.core.types import (
AssistantEvent,
BaseEvent,
ReasoningEvent,
ToolCallEvent,
ToolStreamEvent,
UserMessageEvent,
WaitingForInputEvent,
)
_MIN_QUESTION_OPTIONS = 2
_MAX_QUESTION_OPTIONS = 4
class RemoteSessionUI(Protocol):
async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None: ...
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None: ...
async def on_remote_user_message_cleared_input(self) -> None: ...
async def on_remote_stream_error(self, error: str) -> None: ...
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None: ...
async def on_remote_finalize_streaming(self) -> None: ...
async def remove_loading(self) -> None: ...
async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None: ...
@property
def loading_widget(self) -> Any: ...
def is_progress_event(event: object) -> bool:
return isinstance(
event, (AssistantEvent, ReasoningEvent, ToolCallEvent, ToolStreamEvent)
)
class RemoteSessionManager:
def __init__(self) -> None:
self._events_source: RemoteEventsSource | None = None
self._stream_task: asyncio.Task | None = None
self._pending_waiting_input: WaitingForInputEvent | None = None
@property
def is_active(self) -> bool:
return self._events_source is not None
@property
def is_terminated(self) -> bool:
if self._events_source is None:
return False
return self._events_source.is_terminated
@property
def is_waiting_for_input(self) -> bool:
if self._events_source is None:
return False
return self._events_source.is_waiting_for_input
@property
def has_pending_input(self) -> bool:
return self._pending_waiting_input is not None
@property
def session_id(self) -> str | None:
if self._events_source is None:
return None
return self._events_source.session_id
async def attach(self, session_id: str, config: VibeConfig) -> None:
await self.detach()
self._events_source = RemoteEventsSource(session_id=session_id, config=config)
async def detach(self) -> None:
await self._stop_stream()
if self._events_source is not None:
await self._events_source.close()
self._events_source = None
self._pending_waiting_input = None
def validate_input(self) -> str | None:
if self.is_terminated:
return (
"Remote session has ended. Use /clear to start a new session"
" or /resume to attach to another."
)
if not self.is_waiting_for_input:
return (
"Remote session is not waiting for input. Please wait for the"
" current task to complete."
)
return None
async def send_prompt(self, message: str, *, require_source: bool = True) -> None:
if self._events_source is None:
if require_source:
raise RuntimeError("No active remote session")
return
saved_pending = self._pending_waiting_input
self._pending_waiting_input = None
try:
await self._events_source.send_prompt(message)
except Exception:
self._pending_waiting_input = saved_pending
raise
def cancel_pending_input(self) -> None:
self._pending_waiting_input = None
def build_question_args(
self, event: WaitingForInputEvent
) -> AskUserQuestionArgs | None:
if (
not event.predefined_answers
or len(event.predefined_answers) < _MIN_QUESTION_OPTIONS
):
return None
question = event.label or "Choose an answer"
return AskUserQuestionArgs(
questions=[
Question(
question=question,
options=[
Choice(label=answer)
for answer in event.predefined_answers[:_MAX_QUESTION_OPTIONS]
],
)
]
)
def set_pending_input(self, event: WaitingForInputEvent) -> None:
self._pending_waiting_input = event
def start_stream(self, ui: RemoteSessionUI) -> None:
if self._events_source is None:
return
if self._stream_task and not self._stream_task.done():
return
self._stream_task = asyncio.create_task(
self._consume_stream(ui), name="remote-session-stream"
)
async def stop_stream(self) -> None:
await self._stop_stream()
def build_terminal_message(self) -> tuple[str, str]:
if self._events_source is None:
return ("info", "Remote session completed")
if self._events_source.is_failed:
return ("error", "Remote session failed")
if self._events_source.is_canceled:
return ("warning", "Remote session was canceled")
return ("info", "Remote session completed")
def cancel_stream_task(self) -> None:
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
async def _stop_stream(self) -> None:
if self._stream_task is None or self._stream_task.done():
self._stream_task = None
return
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
async def _consume_stream(self, ui: RemoteSessionUI) -> None:
events_source = self._events_source
if events_source is None:
return
await ui.ensure_loading(DEFAULT_LOADING_STATUS)
try:
async for event in events_source.attach():
if isinstance(event, WaitingForInputEvent):
await ui.remove_loading()
self._pending_waiting_input = event
await ui.on_remote_waiting_input(event)
elif (
isinstance(event, UserMessageEvent)
and self._pending_waiting_input is not None
):
self._pending_waiting_input = None
await ui.on_remote_user_message_cleared_input()
elif ui.loading_widget is None and is_progress_event(event):
await ui.ensure_loading()
await ui.on_remote_event(event, loading_widget=ui.loading_widget)
except asyncio.CancelledError:
raise
except Exception as e:
await ui.on_remote_stream_error(f"Remote stream stopped: {e}")
finally:
await ui.on_remote_finalize_streaming()
await ui.remove_loading()
self._stream_task = None
self._pending_waiting_input = None
if events_source.is_terminated:
msg_type, text = self.build_terminal_message()
await ui.on_remote_stream_ended(msg_type, text)

View file

@ -3,27 +3,30 @@ from __future__ import annotations
from typing import Any
from rich.cells import cell_len
from rich.text import Text
from textual.containers import VerticalScroll
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Static
COMPLETION_POPUP_MAX_HEIGHT = 12
COMPLETION_POPUP_MAX_WIDTH = 80
COMPLETION_POPUP_PADDING_X = 1
SELECTED_CLASS = "completion-selected"
class _CompletionItem(Static):
pass
class _CompletionRow(Horizontal):
pass
class CompletionPopup(VerticalScroll):
def __init__(self, **kwargs: Any) -> None:
super().__init__(id="completion-popup", **kwargs)
self.styles.display = "none"
self.styles.max_height = COMPLETION_POPUP_MAX_HEIGHT
self.styles.max_width = COMPLETION_POPUP_MAX_WIDTH
self.styles.padding = (0, COMPLETION_POPUP_PADDING_X)
self.can_focus = False
self._suggestions: list[tuple[str, str]] = []
def update_suggestions(
self, suggestions: list[tuple[str, str]], selected: int
@ -32,30 +35,42 @@ class CompletionPopup(VerticalScroll):
self.hide()
return
self.remove_children()
items: list[_CompletionItem] = []
for idx, (label, description) in enumerate(suggestions):
text = Text()
label_style = "bold reverse" if idx == selected else "bold"
description_style = "italic" if idx == selected else "dim"
text.append(self._display_label(label), style=label_style)
if description:
text.append(" ")
text.append(description, style=description_style)
item = _CompletionItem(text)
items.append(item)
self.mount_all(items)
if suggestions != self._suggestions:
rows = self._rebuild(suggestions)
else:
rows = list(self.query(_CompletionRow))
self._select(rows, selected)
self.styles.display = "block"
if 0 <= selected < len(items):
items[selected].scroll_visible(animate=False)
def _rebuild(self, suggestions: list[tuple[str, str]]) -> list[_CompletionRow]:
self.remove_children()
self._suggestions = suggestions
command_width = max(
cell_len(self._display_label(label)) for label, _ in suggestions
)
rows: list[_CompletionRow] = []
for label, description in suggestions:
command = _CompletionItem(
self._display_label(label), classes="completion-command"
)
command.styles.width = command_width
description_cell = _CompletionItem(
description, classes="completion-description"
)
rows.append(_CompletionRow(command, description_cell))
self.mount_all(rows)
return rows
@staticmethod
def _select(rows: list[_CompletionRow], selected: int) -> None:
for idx, row in enumerate(rows):
row.set_class(idx == selected, SELECTED_CLASS)
if 0 <= selected < len(rows):
rows[selected].scroll_visible(animate=False)
def hide(self) -> None:
self.remove_children()
self._suggestions = []
self.styles.display = "none"
@property
@ -67,10 +82,3 @@ class CompletionPopup(VerticalScroll):
if label.startswith("@"):
return label[1:]
return label
@classmethod
def rendered_text_length(cls, label: str, description: str) -> int:
text_length = cell_len(cls._display_label(label)) + cell_len(description)
if description:
text_length += 2
return text_length

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
import math
from pathlib import Path
from typing import Any
@ -16,12 +15,7 @@ from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import (
COMPLETION_POPUP_MAX_HEIGHT,
COMPLETION_POPUP_MAX_WIDTH,
COMPLETION_POPUP_PADDING_X,
CompletionPopup,
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
from vibe.core.agents import AgentSafety
@ -34,15 +28,8 @@ SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
}
COMPLETION_POPUP_MAX_LINES = COMPLETION_POPUP_MAX_HEIGHT - 2
COMPLETION_POPUP_MAX_CHARS = (
COMPLETION_POPUP_MAX_WIDTH - 2 * COMPLETION_POPUP_PADDING_X - 2
) # -2 for borders
class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box"
REMOTE_BORDER_CLASS = "border-remote"
class Submitted(Message):
def __init__(self, value: str) -> None:
@ -71,7 +58,6 @@ class ChatInputContainer(Vertical):
)
self._voice_manager = voice_manager
self._custom_border_label: str | None = None
self._custom_border_class: str | None = None
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@ -157,7 +143,6 @@ class ChatInputContainer(Vertical):
except Exception:
return
popup.update_suggestions(suggestions, selected_index)
self._position_popup(popup, suggestions)
def clear_completion_suggestions(self) -> None:
try:
@ -166,29 +151,6 @@ class ChatInputContainer(Vertical):
return
popup.hide()
def _compute_line_count(self, suggestions: list[tuple[str, str]]) -> int:
line_count_without_scrollbar = sum(
math.ceil(
CompletionPopup.rendered_text_length(label, description)
/ COMPLETION_POPUP_MAX_CHARS
)
for label, description in suggestions
)
return min(line_count_without_scrollbar, COMPLETION_POPUP_MAX_LINES)
def _position_popup(
self, popup: CompletionPopup, suggestions: list[tuple[str, str]]
) -> None:
widget = self.input_widget
if not widget:
return
cursor = widget.cursor_screen_offset
my_region = self.region
# Place popup bottom edge just above the cursor row
popup_height = self._compute_line_count(suggestions) + 2 # +2 for solid border
offset = (cursor.x - my_region.x, cursor.y - popup_height - my_region.y)
popup.styles.offset = offset
def _format_insertion(self, replacement: str, suffix: str) -> str:
"""Format the insertion text with appropriate spacing.
@ -208,7 +170,9 @@ class ChatInputContainer(Vertical):
# For other completions, add space only if suffix exists and doesn't start with whitespace
return replacement + (" " if suffix and not suffix[0].isspace() else "")
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
def replace_completion_range(
self, start: int, end: int, replacement: str, *, suppress_update: bool = False
) -> None:
widget = self.input_widget
if not widget or not self._body:
return
@ -225,6 +189,8 @@ class ChatInputContainer(Vertical):
insertion = self._format_insertion(replacement, suffix)
new_text = f"{prefix}{insertion}{suffix}"
if suppress_update:
widget.applying_completion = True
self._body.replace_input(new_text, cursor_offset=start + len(insertion))
def on_chat_input_body_submitted(self, event: ChatInputBody.Submitted) -> None:
@ -248,16 +214,11 @@ class ChatInputContainer(Vertical):
self._agent_name = name
self._apply_input_box_chrome()
def set_custom_border(
self, label: str | None, border_class: str | None = None
) -> None:
def set_custom_border(self, label: str | None) -> None:
self._custom_border_label = label
self._custom_border_class = border_class
self._apply_input_box_chrome()
def _get_border_class(self) -> str:
if self._custom_border_class is not None:
return self._custom_border_class
if self._custom_border_label is not None:
return ""
return SAFETY_BORDER_CLASSES.get(self._safety, "")
@ -272,7 +233,6 @@ class ChatInputContainer(Vertical):
except Exception:
return
input_box.remove_class(self.REMOTE_BORDER_CLASS)
for border_class in SAFETY_BORDER_CLASSES.values():
input_box.remove_class(border_class)

View file

@ -76,6 +76,7 @@ class ChatTextArea(TextArea):
self._input_mode: InputMode = self.DEFAULT_MODE
self._last_text = ""
self._navigating_history = False
self._applying_completion = False
self._original_text: str = ""
self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False
@ -144,8 +145,14 @@ class ChatTextArea(TextArea):
self._last_text = self.text
was_navigating_history = self._navigating_history
self._navigating_history = False
was_applying_completion = self._applying_completion
self._applying_completion = False
if self._completion_manager and not was_navigating_history:
if (
self._completion_manager
and not was_navigating_history
and not was_applying_completion
):
self._completion_manager.on_text_changed(
self.get_full_text(), self._get_full_cursor_offset()
)
@ -318,6 +325,14 @@ class ChatTextArea(TextArea):
await super()._on_key(event)
self._mark_cursor_moved_if_needed()
@property
def applying_completion(self) -> bool:
return self._applying_completion
@applying_completion.setter
def applying_completion(self, value: bool) -> None:
self._applying_completion = value
def set_completion_manager(self, manager: MultiCompletionManager | None) -> None:
self._completion_manager = manager
if self._completion_manager:

View file

@ -16,7 +16,7 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.utils.io import read_safe
from vibe.core.utils.text import snippet_start_line
from vibe.core.utils.text import snippet_start_lines
_HUNK_HEADER_RE = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
@ -42,11 +42,11 @@ def language_for_path(file_path: str) -> str:
return Path(file_path).suffix.lstrip(".") or "text"
def locate_snippet_in_file(file_path: str, snippet: str) -> int | None:
def locate_snippets_in_file(file_path: str, snippet: str) -> list[int]:
path = Path(file_path)
if not path.is_file():
return None
return snippet_start_line(read_safe(path).text, snippet)
return []
return snippet_start_lines(read_safe(path).text, snippet)
def _pick_theme(*, ansi: bool, dark: bool) -> type[HighlightTheme]:
@ -98,7 +98,7 @@ def render_edit_diff(
old_string: str,
new_string: str,
language: str,
start_line: int | None,
start_lines: list[int] | None,
*,
ansi: bool,
dark: bool,
@ -113,6 +113,30 @@ def render_edit_diff(
)
)[2:]
# No known locations: render the hunk once without gutter line numbers.
if not start_lines:
return _render_occurrence(diff_lines, None, language, ansi=ansi, theme=theme)
# replace_all repeats the same change at each match; render one block per
# occurrence, anchored at its own line number, with a gap in between.
widgets: list[Static] = []
for index, start_line in enumerate(start_lines):
if index > 0:
widgets.append(NoMarkupStatic("", classes="diff-gap"))
widgets.extend(
_render_occurrence(diff_lines, start_line, language, ansi=ansi, theme=theme)
)
return widgets
def _render_occurrence(
diff_lines: list[str],
start_line: int | None,
language: str,
*,
ansi: bool,
theme: type[HighlightTheme],
) -> list[Static]:
offset = (start_line - 1) if start_line else 0
old_lineno = new_lineno = 0 # overwritten by the first @@ header
widgets: list[Static] = []

View file

@ -17,7 +17,8 @@ class FeedbackBarManager:
telemetry_active=agent_loop.telemetry_client.is_active(),
is_mistral_model=agent_loop.config.is_active_model_mistral(),
user_message_count=user_message_count,
cache_store=agent_loop.cache_store,
)
def record_feedback_asked(self) -> None:
record_feedback_asked()
def record_feedback_asked(self, agent_loop: AgentLoop) -> None:
record_feedback_asked(agent_loop.cache_store)

View file

@ -8,7 +8,7 @@ from rich.markup import escape
from vibe.core.hooks.models import HookMessageSeverity
from vibe.core.logger import logger
from vibe.core.types import ImageAttachment
from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource
from vibe.core.utils.io import read_safe_async
if TYPE_CHECKING:
@ -120,16 +120,23 @@ class UserMessage(Static):
if self._pending:
self.add_class("pending")
@staticmethod
def _format_attachment_link(att: ImageAttachment) -> str:
match att.source:
case FileImageSource(path=path):
# Quote the URL in Textual [link="..."] markup: the parser stops
# at `:` inside an unquoted tag value, so a raw `file://...` URL
# would raise MarkupError. Textual auto-wires the click to
# webbrowser.open(url), opening the OS default viewer.
return f'[link="{path.as_uri()}"]{escape(att.alias)}[/link]'
case InlineImageSource():
# Inline images have no file on disk, so there's nothing to link.
return escape(att.alias)
@staticmethod
def _format_attachments_footer(images: list[ImageAttachment]) -> str:
label = "attached image" if len(images) == 1 else "attached images"
# Use Textual [link="..."] markup with the URL quoted: Textual's
# markup parser stops at `:` inside an unquoted tag value, so a raw
# `file://...` URL would raise MarkupError. Textual auto-wires the
# click to webbrowser.open(url), opening the OS default viewer.
links = ", ".join(
f'[link="{att.path.as_uri()}"]{escape(att.alias)}[/link]' for att in images
)
links = ", ".join(UserMessage._format_attachment_link(att) for att in images)
return f"{label}: {links}"
async def set_pending(self, pending: bool) -> None:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, ClassVar, Literal, cast
from typing import Any, ClassVar, Literal
from rich.text import Text
from textual.app import ComposeResult
@ -13,11 +13,7 @@ from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.session.resume_sessions import (
ResumeSessionInfo,
ResumeSessionSource,
short_session_id,
)
from vibe.core.session.resume_sessions import ResumeSessionInfo, short_session_id
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 3600
@ -57,26 +53,19 @@ def _format_relative_time(iso_time: str | None) -> str:
return "unknown"
def _build_header_text(cwd: str | None, has_remote: bool) -> Text:
def _build_header_text(cwd: str | None) -> Text:
text = Text(no_wrap=True)
text.append("local ", style="cyan")
text.append(cwd or "this folder", style="dim")
if has_remote:
text.append(" · ", style="dim")
text.append("remote ", style="cyan")
text.append("all folders", style="dim")
return text
def _build_option_text(session: ResumeSessionInfo, message: str) -> Text:
text = Text(no_wrap=True)
time_str = _format_relative_time(session.end_time)
session_id = short_session_id(session.session_id, source=session.source)
source = session.source
session_id = short_session_id(session.session_id)
text.append(f"{time_str:10}", style="dim")
text.append(" ")
text.append(f"{source:6}", style="cyan")
text.append(" ")
text.append(f"{session_id} ", style="dim")
text.append(message)
return text
@ -94,14 +83,10 @@ class SessionPickerApp(Container):
class SessionSelected(Message):
option_id: str
source: ResumeSessionSource
session_id: str
def __init__(
self, option_id: str, source: ResumeSessionSource, session_id: str
) -> None:
def __init__(self, option_id: str, session_id: str) -> None:
self.option_id = option_id
self.source = source
self.session_id = session_id
super().__init__()
@ -110,14 +95,10 @@ class SessionPickerApp(Container):
class SessionDeleteRequested(Message):
option_id: str
source: ResumeSessionSource
session_id: str
def __init__(
self, option_id: str, source: ResumeSessionSource, session_id: str
) -> None:
def __init__(self, option_id: str, session_id: str) -> None:
self.option_id = option_id
self.source = source
self.session_id = session_id
super().__init__()
@ -196,9 +177,6 @@ class SessionPickerApp(Container):
if session.session_id == self._current_session_id:
return "Can't delete current session"
if not session.can_delete:
return "Can't delete remote session"
return "Can't delete session"
def _delete_pending_option_text(self, session: ResumeSessionInfo) -> Text:
@ -282,9 +260,8 @@ class SessionPickerApp(Container):
return
def _refresh_header(self) -> None:
has_remote = any(session.source == "remote" for session in self._sessions)
header = self.query_one(".sessionpicker-header", NoMarkupStatic)
header.update(_build_header_text(self._cwd, has_remote))
header.update(_build_header_text(self._cwd))
def clear_pending_delete(self, option_id: str) -> bool:
if not self._delete_state_matches(option_id, "pending"):
@ -298,11 +275,9 @@ class SessionPickerApp(Container):
Option(self._normal_option_text(session), id=session.option_id)
for session in self._sessions
]
has_remote = any(session.source == "remote" for session in self._sessions)
with Vertical(id="sessionpicker-content"):
yield NoMarkupStatic(
_build_header_text(self._cwd, has_remote),
classes="sessionpicker-header",
_build_header_text(self._cwd), classes="sessionpicker-header"
)
yield OptionList(*options, id="sessionpicker-options")
yield NoMarkupStatic(
@ -332,13 +307,8 @@ class SessionPickerApp(Container):
if self._delete_state_matches(option_id, "confirmation"):
return
source, _, session_id = option_id.partition(":")
self.post_message(
self.SessionSelected(
option_id=option_id,
source=cast(ResumeSessionSource, source),
session_id=session_id,
)
self.SessionSelected(option_id=option_id, session_id=option_id)
)
def action_cancel(self) -> None:
@ -359,7 +329,7 @@ class SessionPickerApp(Container):
if session is None:
return
if session.session_id == self._current_session_id or not session.can_delete:
if session.session_id == self._current_session_id:
self._show_delete_state(
session, "feedback", self._delete_feedback_option_text(session)
)
@ -371,9 +341,7 @@ class SessionPickerApp(Container):
)
self.post_message(
self.SessionDeleteRequested(
option_id=session.option_id,
source=session.source,
session_id=session.session_id,
option_id=session.option_id, session_id=session.session_id
)
)
return

View file

@ -15,7 +15,7 @@ from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection, lines_la
from vibe.cli.textual_ui.widgets.diff_rendering import (
diff_border_colors,
language_for_path,
locate_snippet_in_file,
locate_snippets_in_file,
render_edit_diff,
)
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@ -221,13 +221,15 @@ class EditApprovalWidget(ToolApprovalWidget[EditArgs]):
)
yield NoMarkupStatic("")
# Approximate: queued edits ahead of this one may shift the real line.
start_line = locate_snippet_in_file(self.args.file_path, self.args.old_string)
# Approximate: queued edits ahead of this one may shift the real lines.
start_lines = locate_snippets_in_file(self.args.file_path, self.args.old_string)
if not self.args.replace_all:
start_lines = start_lines[:1]
yield from render_edit_diff(
self.args.old_string,
self.args.new_string,
language_for_path(self.args.file_path),
start_line,
start_lines,
ansi=self.app.native_ansi_color,
dark=self.app.current_theme.dark,
)
@ -252,7 +254,7 @@ class EditResultWidget(ToolResultWidget[EditResult]):
self.result.old_string,
self.result.new_string,
language_for_path(self.result.file),
self.result.ui_start_line,
self.result.ui_start_lines,
ansi=self.app.native_ansi_color,
dark=self.app.current_theme.dark,
)

View file

@ -1,9 +1,7 @@
from __future__ import annotations
import os
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.config import ModelConfig, VibeConfig, resolve_api_key
from vibe.core.llm.backend.factory import create_backend
from vibe.core.llm.types import BackendLike
NARRATOR_MODEL = ModelConfig(
@ -22,9 +20,11 @@ def create_narrator_backend(
provider = config.get_provider_for_model(NARRATOR_MODEL)
except ValueError:
return None
if provider.api_key_env_var and not os.getenv(provider.api_key_env_var):
if provider.api_key_env_var and not resolve_api_key(provider.api_key_env_var):
return None
backend = BACKEND_FACTORY[provider.backend](
provider=provider, timeout=config.api_timeout
backend = create_backend(
provider=provider,
timeout=config.api_timeout,
retry_max_elapsed_time=config.api_retry_max_elapsed_time,
)
return backend, NARRATOR_MODEL

View file

@ -4,11 +4,11 @@ import asyncio
import json
from pathlib import Path
from vibe.cli.cache import read_cache, write_cache
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.core.cache_store import FileSystemVibeCodeCacheStore, VibeCodeCacheStore
from vibe.core.paths import VIBE_HOME
_CACHE_SECTION = "update_cache"
@ -18,6 +18,9 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
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"
self._cache_store: VibeCodeCacheStore = FileSystemVibeCodeCacheStore(
self._cache_file
)
self._legacy_json = self._base_path / "update_cache.json"
self._cached: UpdateCache | None = None
self._loaded = False
@ -39,13 +42,14 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
payload["seen_whats_new_version"] = update_cache.seen_whats_new_version
if update_cache.dismissed_version is not None:
payload["dismissed_version"] = update_cache.dismissed_version
await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload)
await asyncio.to_thread(
self._cache_store.write_section, _CACHE_SECTION, payload
)
self._cached = update_cache
self._loaded = True
def _read_section(self) -> dict | None:
cache = read_cache(self._cache_file)
if section := cache.get(_CACHE_SECTION):
if section := self._cache_store.read_section(_CACHE_SECTION):
return section
try:
@ -54,10 +58,8 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
return None
if isinstance(data, dict):
write_cache(
self._cache_file,
_CACHE_SECTION,
{k: v for k, v in data.items() if v is not None},
self._cache_store.write_section(
_CACHE_SECTION, {k: v for k, v in data.items() if v is not None}
)
return data

View file

@ -122,12 +122,14 @@ async def get_update_if_available(
current_version: str,
update_cache_repository: UpdateCacheRepository,
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
*,
force_check: bool = False,
) -> UpdateAvailability | None:
current = _parse_version(current_version)
if current is None:
return None
if update_cache := await update_cache_repository.get():
if not force_check and (update_cache := await update_cache_repository.get()):
if _is_cache_fresh(update_cache, get_current_timestamp):
return _get_cached_update_if_any(update_cache, current)

View file

@ -3,11 +3,11 @@ 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.cache_store import FileSystemVibeCodeCacheStore, VibeCodeCacheStore
from vibe.core.paths import VIBE_HOME
_CACHE_SECTION = "vscode_extension_promo"
@ -17,6 +17,9 @@ 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"
self._cache_store: VibeCodeCacheStore = FileSystemVibeCodeCacheStore(
self._cache_file
)
async def get(self) -> VscodeExtensionPromoState | None:
data = await asyncio.to_thread(self._read_section)
@ -29,15 +32,13 @@ class FileSystemVscodeExtensionPromoRepository(VscodeExtensionPromoRepository):
async def set(self, state: VscodeExtensionPromoState) -> None:
await asyncio.to_thread(
write_cache,
self._cache_file,
self._cache_store.write_section,
_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):
section = self._cache_store.read_section(_CACHE_SECTION)
if section:
return section
return None

View file

@ -8,7 +8,6 @@ from enum import StrEnum, auto
from functools import wraps
from http import HTTPStatus
import inspect
import os
from pathlib import Path
import threading
from threading import Thread
@ -22,8 +21,9 @@ from pydantic import BaseModel
from vibe.core.agent_loop_hooks import AgentLoopHooksMixin
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.cache_store import InMemoryVibeCodeCacheStore, VibeCodeCacheStore
from vibe.core.compaction import collect_prior_user_messages, render_compaction_context
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig, resolve_api_key
from vibe.core.experiments import ExperimentManager
from vibe.core.experiments.client import RemoteEvalClient
from vibe.core.experiments.session import (
@ -32,7 +32,7 @@ from vibe.core.experiments.session import (
)
from vibe.core.hooks.manager import HooksManager
from vibe.core.hooks.models import HookConfigResult, HookEvent
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.backend.factory import create_backend
from vibe.core.llm.exceptions import BackendError
from vibe.core.llm.format import (
APIToolFormatHandler,
@ -91,7 +91,7 @@ from vibe.core.tools.base import (
)
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp import MCPConnectionPool, MCPRegistry
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.permissions import (
ApprovedRule,
@ -128,6 +128,7 @@ from vibe.core.types import (
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
UserDisplayContentMetadata,
UserInputCallback,
UserMessageEvent,
)
@ -287,9 +288,11 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
hook_config_result: HookConfigResult | None = None,
permission_store: PermissionStore | None = None,
mcp_registry: MCPRegistry | None = None,
cache_store: VibeCodeCacheStore | None = None,
) -> None:
self._base_config = config
self._headless = headless
self.cache_store = cache_store or InMemoryVibeCodeCacheStore()
self._defer_heavy_init = defer_heavy_init
self._deferred_init_thread: threading.Thread | None = None
@ -303,6 +306,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
self._permission_store = permission_store or PermissionStore()
self.mcp_registry = mcp_registry or MCPRegistry()
self._mcp_pool = MCPConnectionPool()
self.connector_registry = self._create_connector_registry()
self.agent_manager = AgentManager(
lambda: self._base_config,
@ -313,7 +317,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
lambda: self.config,
mcp_registry=self.mcp_registry,
connector_registry=self.connector_registry,
defer_mcp=defer_heavy_init,
defer_mcp=True,
permission_getter=self._permission_store.get_tool_permission,
)
self.skill_manager = SkillManager(lambda: self.config)
@ -346,17 +350,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
init_scratchpad(self.session_id) if not is_subagent else None
)
system_prompt = get_universal_system_prompt(
self.tool_manager,
self.config,
self.skill_manager,
self.agent_manager,
include_git_status=not defer_heavy_init,
scratchpad_dir=self.scratchpad_dir,
headless=self._headless,
)
system_message = LLMMessage(role=Role.system, content=system_prompt)
self.messages = MessageList(initial=[system_message], observer=message_observer)
self.messages = MessageList(initial=[], observer=message_observer)
self.stats = AgentStats()
self.approval_callback: ApprovalCallback | None = None
@ -414,6 +408,10 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
if defer_heavy_init:
self._start_deferred_init()
else:
self._complete_init()
if err := self._init_error:
raise err
def _start_deferred_init(self) -> threading.Thread:
"""Spawn a daemon thread that finishes deferred heavy I/O once."""
@ -444,16 +442,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
"""
try:
self.tool_manager.integrate_all(raise_on_mcp_failure=True)
system_prompt = get_universal_system_prompt(
self.tool_manager,
self.config,
self.skill_manager,
self.agent_manager,
scratchpad_dir=self.scratchpad_dir,
headless=self._headless,
experiment_manager=self.experiment_manager,
)
self.messages.update_system_prompt(system_prompt)
self.messages.update_system_prompt(self._build_system_prompt(), notify=True)
except Exception as exc:
self._init_error = exc
@ -496,6 +485,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
def refresh_config(self) -> None:
self._base_config = VibeConfig.load()
self.agent_manager.invalidate_config()
self.mcp_registry.sync_active_servers(self.config.mcp_servers)
def _drain_pending_injections(self) -> bool:
if not self._pending_injected_messages:
@ -616,6 +606,8 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
task.cancel()
with contextlib.suppress(BaseException):
await task
with contextlib.suppress(Exception):
await self._mcp_pool.aclose()
with contextlib.suppress(Exception):
await self.backend.__aexit__(None, None, None)
with contextlib.suppress(Exception):
@ -630,17 +622,15 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
return None
api_key_env = provider.api_key_env_var or "MISTRAL_API_KEY"
api_key = os.getenv(api_key_env, "")
api_key = resolve_api_key(api_key_env) or ""
if not api_key:
return None
server_url = get_server_url_from_api_base(provider.api_base)
return ConnectorRegistry(api_key=api_key, server_url=server_url)
@requires_init
async def refresh_system_prompt(self) -> None:
"""Rebuild and replace the system prompt with current tool/skill state."""
system_prompt = get_universal_system_prompt(
def _build_system_prompt(self) -> str:
return get_universal_system_prompt(
self.tool_manager,
self.config,
self.skill_manager,
@ -649,12 +639,19 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
headless=self._headless,
experiment_manager=self.experiment_manager,
)
self.messages.update_system_prompt(system_prompt)
@requires_init
async def refresh_system_prompt(self) -> None:
"""Rebuild and replace the system prompt with current tool/skill state."""
self.messages.update_system_prompt(self._build_system_prompt())
def _select_backend(self) -> BackendLike:
provider = self.config.get_active_provider()
timeout = self.config.api_timeout
return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout)
return create_backend(
provider=provider,
timeout=self.config.api_timeout,
retry_max_elapsed_time=self.config.api_retry_max_elapsed_time,
)
async def _save_messages(self) -> None:
await self.session_logger.save_interaction(
@ -702,6 +699,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
*,
auto_title: str | None = None,
images: list[ImageAttachment] | None = None,
user_display_content: UserDisplayContentMetadata | None = None,
) -> AsyncGenerator[BaseEvent, None]:
try:
active_model = self.config.get_active_model()
@ -719,6 +717,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
client_message_id=client_message_id,
auto_title=auto_title,
images=images,
user_display_content=user_display_content,
):
yield event
@ -937,12 +936,14 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
*,
auto_title: str | None = None,
images: list[ImageAttachment] | None = None,
user_display_content: UserDisplayContentMetadata | None = None,
) -> AsyncGenerator[BaseEvent]:
user_message = LLMMessage(
role=Role.user,
content=user_msg,
message_id=client_message_id,
images=images or None,
user_display_content=user_display_content,
)
self.messages.append(user_message)
self.stats.steps += 1
@ -1363,6 +1364,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
permission_store=self._permission_store,
hook_config_result=self._hook_config_result,
session_id=self.session_id,
mcp_pool=self._mcp_pool,
terminal_emulator=self.terminal_emulator,
),
**tool_call.args_dict,
@ -1785,6 +1787,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
terminal_emulator=self.terminal_emulator,
defer_heavy_init=True,
hook_config_result=self._hook_config_result,
cache_store=self.cache_store,
)
forked.session_id = generate_session_id(suffix=extract_suffix(self.session_id))
forked.parent_session_id = self.session_id
@ -1982,17 +1985,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904
)
self.skill_manager = SkillManager(lambda: self.config)
new_system_prompt = get_universal_system_prompt(
self.tool_manager,
self.config,
self.skill_manager,
self.agent_manager,
scratchpad_dir=self.scratchpad_dir,
headless=self._headless,
experiment_manager=self.experiment_manager,
)
self.messages.update_system_prompt(new_system_prompt)
self.messages.update_system_prompt(self._build_system_prompt())
if len(self.messages) == 1:
self.stats.reset_context_state()

View file

@ -10,6 +10,7 @@ import anyio.to_thread
import httpx
import keyring
import keyring.backends.fail
import keyring.errors
from mcp.client.auth import (
OAuthClientProvider,
OAuthFlowError,
@ -101,6 +102,13 @@ async def _kr_set(username: str, value: str) -> None:
await anyio.to_thread.run_sync(keyring.set_password, _SERVICE, username, value)
async def _kr_delete(username: str) -> None:
try:
await anyio.to_thread.run_sync(keyring.delete_password, _SERVICE, username)
except keyring.errors.PasswordDeleteError:
pass
class Fingerprint(BaseModel):
"""Config-drift detection marker for OAuth MCP servers.
@ -143,13 +151,23 @@ class Fingerprint(BaseModel):
async def save(self, alias: str) -> None:
await _kr_set(_kr_username(alias, "fingerprint"), self.model_dump_json())
@classmethod
async def delete(cls, alias: str) -> None:
await _kr_delete(_kr_username(alias, "fingerprint"))
class KeyringTokenStorage(TokenStorage):
def __init__(self, alias: str) -> None:
def __init__(
self,
alias: str,
*,
fallback_client_info: OAuthClientInformationFull | None = None,
) -> None:
backend = keyring.get_keyring()
if isinstance(backend, keyring.backends.fail.Keyring):
raise MCPOAuthHeadlessError(server_alias=alias)
self._alias = alias
self._fallback_client_info = fallback_client_info
async def get_tokens(self) -> OAuthToken | None:
raw = await _kr_get(_kr_username(self._alias, "tokens"))
@ -160,10 +178,13 @@ class KeyringTokenStorage(TokenStorage):
async def set_tokens(self, tokens: OAuthToken) -> None:
await _kr_set(_kr_username(self._alias, "tokens"), tokens.model_dump_json())
async def delete_tokens(self) -> None:
await _kr_delete(_kr_username(self._alias, "tokens"))
async def get_client_info(self) -> OAuthClientInformationFull | None:
raw = await _kr_get(_kr_username(self._alias, "client_info"))
if raw is None:
return None
return self._fallback_client_info
return OAuthClientInformationFull.model_validate_json(raw)
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
@ -171,6 +192,9 @@ class KeyringTokenStorage(TokenStorage):
_kr_username(self._alias, "client_info"), client_info.model_dump_json()
)
async def delete_client_info(self) -> None:
await _kr_delete(_kr_username(self._alias, "client_info"))
_LOGO_SVG: Final = (
'<svg class="mark" viewBox="0 0 162 162" xmlns="http://www.w3.org/2000/svg" '
@ -392,10 +416,23 @@ def build_oauth_provider(
client_metadata_url = (
str(auth.client_metadata_url) if auth.client_metadata_url else None
)
fallback_client_info = None
if auth.client_id:
fallback_client_info = OAuthClientInformationFull(
client_id=auth.client_id,
redirect_uris=[redirect_uri],
scope=scope,
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
client_name=_CLIENT_NAME,
)
return OAuthClientProvider(
server_url=server.url,
client_metadata=metadata,
storage=KeyringTokenStorage(alias=server.name),
storage=KeyringTokenStorage(
alias=server.name, fallback_client_info=fallback_client_info
),
redirect_handler=redirect_handler,
callback_handler=callback_handler,
client_metadata_url=client_metadata_url,
@ -420,8 +457,6 @@ async def perform_oauth_login(
auth=provider, timeout=_LOGIN_TIMEOUT_SECONDS, verify=build_ssl_context()
) as client:
await client.get(server.url)
except OAuthTokenError as exc:
except (OAuthTokenError, OAuthFlowError) as exc:
raise MCPOAuthLoginFailed(server_alias=server.name, reason=str(exc)) from exc
except OAuthFlowError:
raise
await Fingerprint.compute(server).save(server.name)

69
vibe/core/cache_store.py Normal file
View file

@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from typing import Any, Protocol
import tomli_w
from vibe.core.logger import logger
from vibe.core.paths import CACHE_FILE
__all__ = [
"FileSystemVibeCodeCacheStore",
"InMemoryVibeCodeCacheStore",
"VibeCodeCacheStore",
]
class VibeCodeCacheStore(Protocol):
def read_section(self, section: str) -> dict[str, Any]: ...
def write_section(self, section: str, data: dict[str, Any]) -> None: ...
class InMemoryVibeCodeCacheStore:
def __init__(self) -> None:
self._sections: dict[str, dict[str, Any]] = {}
def read_section(self, section: str) -> dict[str, Any]:
return dict(self._sections.get(section, {}))
def write_section(self, section: str, data: dict[str, Any]) -> None:
self._sections.setdefault(section, {}).update(data)
class FileSystemVibeCodeCacheStore:
def __init__(self, cache_path: Path | str | None = None) -> None:
self._cache_path = (
Path(cache_path) if cache_path is not None else CACHE_FILE.path
)
def read_section(self, section: str) -> dict[str, Any]:
data = self._read_cache().get(section)
if not isinstance(data, dict):
return {}
return dict(data)
def write_section(self, section: str, data: dict[str, Any]) -> None:
existing = self._read_cache()
section_data = existing.get(section)
if not isinstance(section_data, dict):
section_data = {}
existing[section] = section_data
section_data.update(data)
try:
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
with self._cache_path.open("wb") as f:
tomli_w.dump(existing, f)
except OSError:
logger.debug(
"Failed to write cache file %s", self._cache_path, exc_info=True
)
def _read_cache(self) -> dict[str, Any]:
try:
with self._cache_path.open("rb") as f:
return tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return {}

View file

@ -36,24 +36,27 @@ from vibe.core.config._settings import (
TTSProviderConfig,
VibeConfig,
load_dotenv_values,
resolve_api_key,
resolve_theme_name,
)
from vibe.core.config.layer import (
ConfigLayer,
ConfigLayerError,
ConfigPatchApplicationError,
EmptyLayerError,
LayerImplementationError,
LayerNotLoadedError,
RawConfig,
TrustNotResolvedError,
TrustResolutionError,
UntrustedLayerError,
)
from vibe.core.config.patch import (
AppendToList,
AddOperationPatch,
ConfigPatch,
DeleteField,
PatchOp,
RemoveFromList,
SetField,
RemoveOperationPatch,
ReplaceOperationPatch,
)
from vibe.core.config.schema import (
ConfigDefinitionError,
@ -84,20 +87,21 @@ __all__ = [
"DEFAULT_VIBE_BASE_URL",
"MISSING_CONFIG_FILE_FINGERPRINT",
"THINKING_LEVELS",
"AppendToList",
"AddOperationPatch",
"ConfigDefinitionError",
"ConfigFragment",
"ConfigLayer",
"ConfigLayerError",
"ConfigPatch",
"ConfigPatchApplicationError",
"ConfigSchema",
"ConnectorConfig",
"DeleteField",
"DuplicateMergeMetadataError",
"EmptyLayerError",
"ExperimentsConfig",
"LayerConfigSnapshot",
"LayerImplementationError",
"LayerNotLoadedError",
"MCPHttp",
"MCPOAuth",
"MCPServer",
@ -113,9 +117,9 @@ __all__ = [
"ProjectContextConfig",
"ProviderConfig",
"RawConfig",
"RemoveFromList",
"RemoveOperationPatch",
"ReplaceOperationPatch",
"SessionLoggingConfig",
"SetField",
"TTSClient",
"TTSModelConfig",
"TTSProviderConfig",
@ -135,4 +139,6 @@ __all__ = [
"WithShallowMerge",
"WithUnionMerge",
"load_dotenv_values",
"resolve_api_key",
"resolve_theme_name",
]

View file

@ -11,6 +11,8 @@ from typing import Annotated, Any, ClassVar, Literal, get_args
from urllib.parse import urljoin
from dotenv import dotenv_values
import keyring
from keyring.errors import KeyringError
from mistralai.client.models import SpeechOutputFormat
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_TRACES_EXPORT_PATH,
@ -77,7 +79,26 @@ def load_dotenv_values(
for key, value in env_vars.items():
if not value:
continue
environ.update({key: value})
if environ.get(key):
# An explicit non-empty process/shell value wins over the .env file.
continue
environ[key] = value
_KEYRING_SERVICE = "vibe"
def resolve_api_key(env_key: str) -> str | None:
"""Resolve an API key value: process/.env environment first, then OS keyring."""
if not env_key:
return None
value = os.environ.get(env_key)
if value:
return value
try:
return keyring.get_password(_KEYRING_SERVICE, env_key)
except KeyringError:
return None
class MissingAPIKeyError(RuntimeError):
@ -447,6 +468,7 @@ THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel))
DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000
DEFAULT_API_TIMEOUT = 720.0
DEFAULT_API_RETRY_MAX_ELAPSED_TIME = 300.0
class ModelConfig(BaseModel):
@ -505,9 +527,6 @@ class OtelSpanExporterConfig(BaseModel):
MISTRAL_OTEL_PATH = "/telemetry"
DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_VIBE_CODE_WORKFLOW_ID = "__shared-nuage-workflow"
DEFAULT_VIBE_CODE_TASK_QUEUE = "shared-vibe-nuage"
DEFAULT_PROVIDERS = [
ProviderConfig(
name="mistral",
@ -586,6 +605,15 @@ DEFAULT_TTS_MODELS = [DEFAULT_ACTIVE_TTS_MODEL_CONFIG]
DEFAULT_THEME = "ansi-dark"
def resolve_theme_name(value: Any) -> str:
if not isinstance(value, str) or not value:
return DEFAULT_THEME
if value not in BUILTIN_THEMES:
logger.warning("Unknown theme=%s; falling back to %s", value, DEFAULT_THEME)
return DEFAULT_THEME
return value
class VibeConfig(BaseSettings):
active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias
vim_keybindings: bool = False
@ -614,16 +642,13 @@ class VibeConfig(BaseSettings):
enable_notifications: bool = True
enable_system_trust_store: bool = False
api_timeout: float = DEFAULT_API_TIMEOUT
api_retry_max_elapsed_time: float = DEFAULT_API_RETRY_MAX_ELAPSED_TIME
auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD
vibe_code_enabled: bool = Field(default=True, exclude=True)
vibe_code_base_url: str = Field(default=DEFAULT_MISTRAL_SERVER_URL, exclude=True)
vibe_code_sessions_base_url: str = Field(
default="https://chat.mistral.ai", exclude=True
)
vibe_code_workflow_id: str = Field(
default=DEFAULT_VIBE_CODE_WORKFLOW_ID, exclude=True
)
vibe_code_api_key_env_var: str = Field(
default=DEFAULT_MISTRAL_API_ENV_KEY, exclude=True
)
@ -771,7 +796,7 @@ class VibeConfig(BaseSettings):
@property
def vibe_code_api_key(self) -> str:
return os.getenv(self.vibe_code_api_key_env_var, "")
return resolve_api_key(self.vibe_code_api_key_env_var) or ""
@property
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
@ -801,7 +826,7 @@ class VibeConfig(BaseSettings):
traces_export_path,
)
if not (api_key := os.getenv(api_key_env)):
if not (api_key := resolve_api_key(api_key_env)):
logger.warning(
"OTEL tracing enabled but %s is not set; skipping.", api_key_env
)
@ -925,10 +950,12 @@ class VibeConfig(BaseSettings):
@model_validator(mode="after")
def _apply_global_auto_compact_threshold(self) -> VibeConfig:
self.models = [
model
if "auto_compact_threshold" in model.model_fields_set
else model.model_copy(
update={"auto_compact_threshold": self.auto_compact_threshold}
(
model
if "auto_compact_threshold" in model.model_fields_set
else model.model_copy(
update={"auto_compact_threshold": self.auto_compact_threshold}
)
)
for model in self.models
]
@ -957,7 +984,7 @@ class VibeConfig(BaseSettings):
try:
provider = self.get_active_provider()
api_key_env = provider.api_key_env_var
if api_key_env and not os.getenv(api_key_env):
if api_key_env and not resolve_api_key(api_key_env):
raise MissingAPIKeyError(api_key_env, provider.name)
except ValueError:
pass
@ -966,14 +993,7 @@ class VibeConfig(BaseSettings):
@field_validator("theme", mode="before")
@classmethod
def _validate_theme(cls, v: Any) -> str:
if not isinstance(v, str) or not v:
return DEFAULT_THEME
if v not in BUILTIN_THEMES:
logger.warning(
"Unknown theme=%s in config; falling back to %s", v, DEFAULT_THEME
)
return DEFAULT_THEME
return v
return resolve_theme_name(v)
@field_validator("tool_paths", mode="before")
@classmethod

View file

@ -17,17 +17,17 @@ from vibe.core.config.types import ConcurrencyConflictError
def capture_stable_file(path: Path) -> Iterator[tuple[IO[bytes], str]]:
"""Yield a file and fingerprint, raising if the path changes before exit."""
with path.open("rb") as file:
before = _create_file_fingerprint(file)
before = create_file_fingerprint(file)
yield file, before
with path.open("rb") as file:
after = _create_file_fingerprint(file)
after = create_file_fingerprint(file)
if after != before:
raise ConcurrencyConflictError(expected_fp=before, actual_fp=after)
def _create_file_fingerprint(file: IO) -> str:
def create_file_fingerprint(file: IO) -> str:
"""Return an opaque token representing the current state of a file."""
stat = os.fstat(file.fileno())
return f"{stat.st_dev}:{stat.st_ino}:{stat.st_mtime_ns}:{stat.st_size}"

View file

@ -2,13 +2,16 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any
from pydantic import BaseModel, ConfigDict
from jsonpatch import JsonPatchException, apply_patch
from jsonpointer import JsonPointerException
from pydantic import BaseModel, ConfigDict, ValidationError
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import (
MISSING_CONFIG_FILE_FINGERPRINT,
ConcurrencyConflictError,
ConflictStrategy,
LayerConfigSnapshot,
@ -52,6 +55,22 @@ class TrustNotResolvedError(ConfigLayerError):
)
class LayerNotLoadedError(ConfigLayerError):
"""Raised when a layer operation requires cached data and fingerprint."""
def __init__(self, layer_name: str) -> None:
super().__init__(
layer_name, f"Layer '{layer_name}' must be loaded before applying patches"
)
class ConfigPatchApplicationError(ConfigLayerError):
"""Raised when a patch cannot be applied to cached layer data."""
def __init__(self, layer_name: str) -> None:
super().__init__(layer_name, f"Layer '{layer_name}': failed to apply patch")
class TrustResolutionError(ConfigLayerError):
"""Raised when trust status is not resolvable."""
@ -102,6 +121,12 @@ class _InvalidateCache:
pass
@dataclass(frozen=True, slots=True)
class _ApplyPatch:
patch: ConfigPatch
on_conflict: ConflictStrategy
class ConfigLayer[S: BaseModel](ABC):
"""Each layer represents a named source that produces a sparse
dictionary of configuration values.
@ -144,6 +169,14 @@ class ConfigLayer[S: BaseModel](ABC):
"""
return
@abstractmethod
async def _save_to_store(self, _next_config: S) -> str:
"""Persist full layer data and return the store's new fingerprint.
The base class applies patches and validates the result before calling this.
"""
...
# --- Internal ---
async def _notify_trust_change(self, old: bool | None, new: bool | None) -> None:
@ -182,6 +215,10 @@ class ConfigLayer[S: BaseModel](ABC):
new_state = await self._handle_load(state, force)
case _InvalidateCache():
new_state = await self._handle_invalidate_cache(state)
case _ApplyPatch(patch=patch, on_conflict=on_conflict):
new_state = await self._handle_apply_patch(
state, patch, on_conflict
)
case _:
raise NotImplementedError(f"Unknown action: {action!r}")
@ -259,6 +296,42 @@ class ConfigLayer[S: BaseModel](ABC):
async def _handle_invalidate_cache(self, state: _LayerState[S]) -> _LayerState[S]:
return _LayerState(is_trusted=state.is_trusted, data=None, fingerprint=None)
async def _handle_apply_patch(
self, state: _LayerState[S], patch: ConfigPatch, on_conflict: ConflictStrategy
) -> _LayerState[S]:
if (
state.data is None
or state.fingerprint is None
or state.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT
):
raise LayerNotLoadedError(self.name)
match on_conflict:
case ConflictStrategy.CANCEL:
if patch.fingerprint != state.fingerprint:
raise ConcurrencyConflictError(
expected_fp=patch.fingerprint, actual_fp=state.fingerprint
)
case ConflictStrategy.REPLACE:
pass
case _:
raise ValueError(f"Unsupported conflict strategy: {on_conflict!r}")
try:
new_data = apply_patch(state.data.model_dump(), patch.to_json_patch())
validated_new_data = self.validate_output(new_data)
except (JsonPatchException, JsonPointerException, ValidationError) as e:
raise ConfigPatchApplicationError(self.name) from e
try:
new_fingerprint = await self._save_to_store(validated_new_data)
except NotImplementedError:
raise
except Exception as e:
raise LayerImplementationError(self.name, "_save_to_store") from e
return replace(state, data=validated_new_data, fingerprint=new_fingerprint)
# --- Public ---
@property
@ -317,7 +390,7 @@ class ConfigLayer[S: BaseModel](ABC):
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
"""Persist a patch to this layer's backing store."""
raise NotImplementedError
await self._dispatch(_ApplyPatch(patch=patch, on_conflict=on_conflict))
def validate_output(self, data: dict[str, Any]) -> S:
"""Validate *data* against ``output_schema``."""

View file

@ -44,5 +44,7 @@ class EnvironmentLayer(ConfigLayer[RawConfig]):
fingerprint = create_dict_fingerprint(data)
return LayerConfigSnapshot(data=data, fingerprint=fingerprint)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
raise NotImplementedError("EnvironmentLayer.apply() is not implemented (M2)")
async def _save_to_store(self, _next_config: RawConfig) -> str:
raise NotImplementedError(
"EnvironmentLayer patch persistence is not implemented yet"
)

View file

@ -5,8 +5,7 @@ from typing import Any
from vibe.core.config.fingerprint import create_dict_fingerprint
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import ConflictStrategy, LayerConfigSnapshot
from vibe.core.config.types import LayerConfigSnapshot
class OverridesLayer(ConfigLayer[RawConfig]):
@ -28,10 +27,7 @@ class OverridesLayer(ConfigLayer[RawConfig]):
fingerprint = create_dict_fingerprint(data)
return LayerConfigSnapshot(data=data, fingerprint=fingerprint)
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)")
async def _save_to_store(self, _next_config: RawConfig) -> str:
raise NotImplementedError(
"OverridesLayer patch persistence is not implemented yet"
)

View file

@ -6,12 +6,7 @@ import tomllib
from vibe.core.config.fingerprint import capture_stable_file
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import (
EMPTY_CONFIG_SNAPSHOT,
ConflictStrategy,
LayerConfigSnapshot,
)
from vibe.core.config.types import EMPTY_CONFIG_SNAPSHOT, LayerConfigSnapshot
from vibe.core.paths._vibe_home import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager
@ -73,13 +68,10 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]):
await super().revoke_trust()
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)")
async def _save_to_store(self, _next_config: RawConfig) -> str:
raise NotImplementedError(
"ProjectConfigLayer patch persistence is not implemented yet"
)
async def _find_config_file(self) -> None:
async with self._find_lock:

View file

@ -1,16 +1,15 @@
from __future__ import annotations
import os
from pathlib import Path
import tempfile
import tomllib
from vibe.core.config.fingerprint import capture_stable_file
import tomli_w
from vibe.core.config.fingerprint import capture_stable_file, create_file_fingerprint
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import (
EMPTY_CONFIG_SNAPSHOT,
ConflictStrategy,
LayerConfigSnapshot,
)
from vibe.core.config.types import EMPTY_CONFIG_SNAPSHOT, LayerConfigSnapshot
from vibe.core.paths._vibe_home import VIBE_HOME
@ -37,10 +36,29 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
return LayerConfigSnapshot(data=data, fingerprint=fingerprint)
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)")
async def _save_to_store(self, next_config: RawConfig) -> str:
if not self._path.exists():
raise FileNotFoundError(self._path)
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=self._path.parent,
prefix=f".{self._path.name}.",
suffix=".tmp",
delete=False,
) as tmp_file:
tmp_path = Path(tmp_file.name)
tomli_w.dump(next_config.model_dump(), tmp_file)
tmp_file.flush() # Flush Python buffers.
os.fsync(tmp_file.fileno()) # Flush OS buffers.
fingerprint = create_file_fingerprint(tmp_file)
tmp_path.replace(self._path)
tmp_path = None
finally:
if tmp_path is not None:
tmp_path.unlink(missing_ok=True)
return fingerprint

View file

@ -1,11 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from abc import ABC, abstractmethod
from typing import Annotated, Any
from jsonpointer import JsonPointer, JsonPointerException
from pydantic import AfterValidator, BaseModel, ConfigDict
class ConfigPatch:
"""Declarative, storage-agnostic description of a config delta."""
"""A storage-agnostic set of config changes expressed as JSON Patch operations."""
def __init__(
self, *operations: PatchOp, fingerprint: str, reason: str = ""
@ -19,83 +22,75 @@ class ConfigPatch:
self.operations.extend(operations)
return self
def to_json_patch(self) -> list[dict[str, Any]]:
return [op.to_json_patch() for op in self.operations]
def describe(self) -> list[str]:
"""Human-readable summary of each operation."""
lines: list[str] = []
for op in self.operations:
match op:
case SetField(key=key, value=value):
lines.append(f"set {key!r} = {value!r}")
case AppendToList(key=key, items=items):
lines.append(f"append to {key!r}: {list(items)!r}")
case RemoveFromList(key=key, values=values):
lines.append(f"remove from {key!r}: {list(values)!r}")
case DeleteField(key=key):
lines.append(f"delete {key!r}")
return lines
return [op.describe() for op in self.operations]
@dataclass(frozen=True, slots=True)
class SetField:
"""Set a top-level or nested field to a value."""
class _OperationPatch(BaseModel, ABC):
model_config = ConfigDict(frozen=True, extra="forbid")
@staticmethod
def _validate_json_pointer(path: str) -> str:
try:
JsonPointer(path)
except JsonPointerException as e:
raise ValueError("path must be a valid JSON Pointer") from e
return path
path: Annotated[str, AfterValidator(_validate_json_pointer)]
@abstractmethod
def to_json_patch(self) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
def describe(self) -> str:
raise NotImplementedError
class AddOperationPatch(_OperationPatch):
"""Add a value at a JSON Pointer path."""
model_config = ConfigDict(frozen=True, extra="forbid")
key: str
value: Any
def __post_init__(self) -> None:
_validate_key_path(self.key)
def to_json_patch(self) -> dict[str, Any]:
return {"op": "add", "path": self.path, "value": self.value}
def describe(self) -> str:
return f"add {self.path!r} = {self.value!r}"
@dataclass(frozen=True, slots=True)
class AppendToList:
"""Append items to a list field."""
class ReplaceOperationPatch(_OperationPatch):
"""Replace the existing value at a JSON Pointer path."""
key: str
items: tuple[Any, ...]
model_config = ConfigDict(frozen=True, extra="forbid")
def __post_init__(self) -> None:
_validate_key_path(self.key)
_validate_tuple_value("AppendToList.items", self.items)
value: Any
def to_json_patch(self) -> dict[str, Any]:
return {"op": "replace", "path": self.path, "value": self.value}
def describe(self) -> str:
return f"replace {self.path!r} = {self.value!r}"
@dataclass(frozen=True, slots=True)
class RemoveFromList:
"""Remove items from a list field by value."""
class RemoveOperationPatch(_OperationPatch):
"""Remove the existing value at a JSON Pointer path."""
key: str
values: tuple[Any, ...]
model_config = ConfigDict(frozen=True, extra="forbid")
def __post_init__(self) -> None:
_validate_key_path(self.key)
_validate_tuple_value("RemoveFromList.values", self.values)
def to_json_patch(self) -> dict[str, Any]:
return {"op": "remove", "path": self.path}
def describe(self) -> str:
return f"remove {self.path!r}"
@dataclass(frozen=True, slots=True)
class DeleteField:
"""Remove a field entirely from the config."""
key: str
def __post_init__(self) -> None:
_validate_key_path(self.key)
PatchOp = SetField | AppendToList | RemoveFromList | DeleteField
def _validate_key_path(key: object) -> None:
if not isinstance(key, str):
raise TypeError(
f"Patch operation key must be a string, got {type(key).__name__}"
)
if not key:
raise ValueError("Patch operation key must not be empty")
if any(not segment for segment in key.split(".")):
raise ValueError(
"Patch operation key must be a dot-separated path without empty segments"
)
def _validate_tuple_value(field_name: str, value: object) -> None:
if not isinstance(value, tuple):
raise TypeError(f"{field_name} must be a tuple, got {type(value).__name__}")
type PatchOp = AddOperationPatch | ReplaceOperationPatch | RemoveOperationPatch

View file

@ -10,11 +10,11 @@ from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL_CONFIG,
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG,
DEFAULT_ACTIVE_TTS_MODEL_CONFIG,
DEFAULT_API_RETRY_MAX_ELAPSED_TIME,
DEFAULT_API_TIMEOUT,
DEFAULT_AUTO_COMPACT_THRESHOLD,
DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_MISTRAL_SERVER_URL,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_THEME,
@ -23,8 +23,6 @@ from vibe.core.config._settings import (
DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
DEFAULT_VIBE_CODE_TASK_QUEUE,
DEFAULT_VIBE_CODE_WORKFLOW_ID,
ConnectorConfig,
ExperimentsConfig,
MCPServer,
@ -182,18 +180,10 @@ class VibeConfigSchema(ConfigSchema):
# Internal
vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True
vibe_code_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_MISTRAL_SERVER_URL
vibe_code_workflow_id: Annotated[str, WithReplaceMerge()] = (
DEFAULT_VIBE_CODE_WORKFLOW_ID
)
vibe_code_task_queue: Annotated[str | None, WithReplaceMerge()] = (
DEFAULT_VIBE_CODE_TASK_QUEUE
)
vibe_code_api_key_env_var: Annotated[str, WithReplaceMerge()] = (
DEFAULT_MISTRAL_API_ENV_KEY
)
vibe_code_project_name: Annotated[str | None, WithReplaceMerge()] = None
vibe_code_experimental_nuage_enabled: Annotated[bool, WithReplaceMerge()] = False
enable_otel: Annotated[bool, WithReplaceMerge()] = False
otel_endpoint: Annotated[str, WithReplaceMerge()] = ""
console_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_CONSOLE_BASE_URL
@ -229,6 +219,9 @@ class VibeConfigSchema(ConfigSchema):
enable_notifications: Annotated[bool, WithReplaceMerge()] = True
enable_system_trust_store: Annotated[bool, WithReplaceMerge()] = False
api_timeout: Annotated[float, WithReplaceMerge()] = DEFAULT_API_TIMEOUT
api_retry_max_elapsed_time: Annotated[float, WithReplaceMerge()] = (
DEFAULT_API_RETRY_MAX_ELAPSED_TIME
)
vibe_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_VIBE_BASE_URL
vibe_code_sessions_base_url: Annotated[str, WithReplaceMerge()] = (
"https://chat.mistral.ai"

View file

@ -3,8 +3,7 @@ from __future__ import annotations
import random
import time
from vibe.cli.cache import read_cache, write_cache
from vibe.core.paths import CACHE_FILE
from vibe.core.cache_store import VibeCodeCacheStore
FEEDBACK_PROBABILITY = 0.2
FEEDBACK_COOLDOWN_SECONDS = 3600
@ -14,7 +13,11 @@ _LAST_SHOWN_KEY = "last_shown_at"
def should_show_feedback(
*, telemetry_active: bool, is_mistral_model: bool, user_message_count: int
*,
telemetry_active: bool,
is_mistral_model: bool,
user_message_count: int,
cache_store: VibeCodeCacheStore,
) -> bool:
if not telemetry_active:
return False
@ -23,9 +26,7 @@ def should_show_feedback(
if user_message_count < MIN_USER_MESSAGES_FOR_FEEDBACK:
return False
last_ts = (
read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0)
)
last_ts = cache_store.read_section(_CACHE_SECTION).get(_LAST_SHOWN_KEY, 0)
if not isinstance(last_ts, int):
return False
@ -35,5 +36,5 @@ def should_show_feedback(
)
def record_feedback_asked() -> None:
write_cache(CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())})
def record_feedback_asked(cache_store: VibeCodeCacheStore) -> None:
cache_store.write_section(_CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())})

View file

@ -4,7 +4,7 @@ import base64
from functools import lru_cache
from pathlib import Path
from vibe.core.types import ImageAttachment
from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource
class ImageReadError(Exception):
@ -23,11 +23,15 @@ def _encode_cached(path_str: str, mtime_ns: int, size: int) -> str:
def _encode(att: ImageAttachment) -> str:
try:
stat = att.path.stat()
except OSError as e:
raise ImageReadError(f"Failed to stat image {att.path}: {e}") from e
return _encode_cached(str(att.path), stat.st_mtime_ns, stat.st_size)
match att.source:
case InlineImageSource(data=data):
return data
case FileImageSource(path=path):
try:
stat = path.stat()
except OSError as e:
raise ImageReadError(f"Failed to stat image {path}: {e}") from e
return _encode_cached(str(path), stat.st_mtime_ns, stat.st_size)
def to_data_uri(att: ImageAttachment) -> str:

View file

@ -201,129 +201,6 @@ class AnthropicMapper:
stop=_parse_stop_info(data.get("stop_reason"), data.get("stop_details")),
)
def parse_streaming_event(
self, event_type: str, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
handler = {
"content_block_start": self._handle_block_start,
"content_block_delta": self._handle_block_delta,
"message_delta": self._handle_message_delta,
"message_start": self._handle_message_start,
}.get(event_type)
if handler is None:
return None, current_index
return handler(data, current_index)
def _handle_block_start(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
block = data.get("content_block", {})
idx = data.get("index", current_index)
match block.get("type"):
case "tool_use":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
id=block.get("id"),
index=idx,
function=FunctionCall(
name=block.get("name"), arguments=""
),
)
],
)
)
return chunk, idx
case "thinking":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=block.get("thinking", "")
)
)
return chunk, idx
case _:
return None, idx
def _handle_block_delta(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
delta = data.get("delta", {})
idx = data.get("index", current_index)
match delta.get("type"):
case "text_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, content=delta.get("text", "")
)
)
case "thinking_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant, reasoning_content=delta.get("thinking", "")
)
)
case "signature_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
reasoning_signature=delta.get("signature", ""),
)
)
case "input_json_delta":
chunk = LLMChunk(
message=LLMMessage(
role=Role.assistant,
tool_calls=[
ToolCall(
index=idx,
function=FunctionCall(
arguments=delta.get("partial_json", "")
),
)
],
)
)
case _:
chunk = None
return chunk, idx
def _handle_message_delta(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
usage_data = data.get("usage", {})
if not usage_data:
return None, current_index
chunk = LLMChunk(
message=LLMMessage(role=Role.assistant),
usage=LLMUsage(
prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0)
),
)
return chunk, current_index
def _handle_message_start(
self, data: dict[str, Any], current_index: int
) -> tuple[LLMChunk | None, int]:
message = data.get("message", {})
usage_data = message.get("usage", {})
if not usage_data:
return None, current_index
# Total input tokens = input_tokens + cache_creation + cache_read
total_input_tokens = (
usage_data.get("input_tokens", 0)
+ usage_data.get("cache_creation_input_tokens", 0)
+ usage_data.get("cache_read_input_tokens", 0)
)
chunk = LLMChunk(
message=LLMMessage(role=Role.assistant),
usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0),
)
return chunk, current_index
STREAMING_EVENT_TYPES = {
"message_start",

View file

@ -1,7 +1,30 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.llm.backend.mistral import MistralBackend
from vibe.core.types import Backend
if TYPE_CHECKING:
from vibe.core.config import ProviderConfig
from vibe.core.llm.types import BackendLike
BACKEND_FACTORY = {Backend.MISTRAL: MistralBackend, Backend.GENERIC: GenericBackend}
def create_backend(
*,
provider: ProviderConfig,
timeout: float = 720.0,
retry_max_elapsed_time: float = 300.0,
) -> BackendLike:
factory = BACKEND_FACTORY[provider.backend]
if provider.backend == Backend.MISTRAL:
return factory(
provider=provider,
timeout=timeout,
retry_max_elapsed_time=retry_max_elapsed_time,
)
return factory(provider=provider, timeout=timeout)

View file

@ -2,12 +2,12 @@ from __future__ import annotations
from collections.abc import AsyncGenerator, Sequence
import json
import os
import types
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
import httpx
from vibe.core.config import resolve_api_key
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
@ -123,6 +123,7 @@ class OpenAIAdapter(APIAdapter):
"reasoning_state",
"injected",
"images",
"user_display_content",
},
),
field_name,
@ -266,11 +267,7 @@ class GenericBackend:
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> LLMChunk:
api_key = (
os.getenv(self._provider.api_key_env_var)
if self._provider.api_key_env_var
else None
)
api_key = resolve_api_key(self._provider.api_key_env_var)
api_style = getattr(self._provider, "api_style", "openai")
adapter = _get_adapter(api_style)
@ -334,11 +331,7 @@ class GenericBackend:
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> AsyncGenerator[LLMChunk, None]:
api_key = (
os.getenv(self._provider.api_key_env_var)
if self._provider.api_key_env_var
else None
)
api_key = resolve_api_key(self._provider.api_key_env_var)
api_style = getattr(self._provider, "api_style", "openai")
adapter = _get_adapter(api_style)

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import AsyncGenerator, Sequence
import json
import os
import types
from typing import TYPE_CHECKING, Literal, NamedTuple, cast
@ -33,6 +32,7 @@ from mistralai.client.models import (
)
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.config import resolve_api_key
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.types import (
@ -196,16 +196,17 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = {
class MistralBackend:
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
def __init__(
self,
provider: ProviderConfig,
timeout: float = 720.0,
retry_max_elapsed_time: float = 300.0,
) -> None:
self._client: Mistral | None = None
self._http_client: httpx.AsyncClient | None = None
self._provider = provider
self._mapper = MistralMapper()
self._api_key = (
os.getenv(self._provider.api_key_env_var)
if self._provider.api_key_env_var
else None
)
self._api_key = resolve_api_key(self._provider.api_key_env_var)
reasoning_field = getattr(provider, "reasoning_field_name", "reasoning_content")
if reasoning_field != "reasoning_content":
@ -223,16 +224,18 @@ class MistralBackend:
)
self._server_url = server_url
self._timeout = timeout
self._retry_max_elapsed_time = retry_max_elapsed_time
self._retry_config = self._build_retry_config()
def _build_retry_config(self) -> RetryConfig:
max_elapsed_time_ms = int(self._retry_max_elapsed_time * 1000)
return RetryConfig(
strategy="backoff",
backoff=BackoffStrategy(
initial_interval=500,
max_interval=30000,
exponent=1.5,
max_elapsed_time=300000,
max_elapsed_time=max_elapsed_time_ms,
),
retry_connection_errors=True,
)

View file

@ -1,26 +0,0 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict
_SUBMIT_INPUT_UPDATE_NAME = "__submit_input"
class AgentCompletionState(BaseModel):
content: str = ""
reasoning_content: str = ""
class InterruptSignal(BaseModel):
prompt: str
class ChatInputModel(BaseModel):
model_config = ConfigDict(title="ChatInput", extra="forbid")
message: list[Any]
class SubmitInputModel(BaseModel):
task_id: str
input: Any

View file

@ -1,209 +0,0 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Sequence
import json
from typing import Any
import httpx
from pydantic import BaseModel
from vibe.core.logger import logger
from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException
from vibe.core.nuage.streaming import StreamEvent, StreamEventsQueryParams
from vibe.core.nuage.workflow import (
SignalWorkflowResponse,
UpdateWorkflowResponse,
WorkflowExecutionListResponse,
WorkflowExecutionStatus,
)
from vibe.core.utils.http import build_ssl_context
from vibe.core.utils.sse import iter_sse_lines
class WorkflowsClient:
def __init__(
self, base_url: str, api_key: str | None = None, timeout: float = 60.0
) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._timeout = timeout
self._client: httpx.AsyncClient | None = None
self._owns_client = True
async def __aenter__(self) -> WorkflowsClient:
headers: dict[str, str] = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(
timeout=self._timeout, headers=headers, verify=build_ssl_context()
)
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
await self.aclose()
async def aclose(self) -> None:
if self._owns_client and self._client:
await self._client.aclose()
self._client = None
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
headers: dict[str, str] = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._client = httpx.AsyncClient(
timeout=self._timeout, headers=headers, verify=build_ssl_context()
)
self._owns_client = True
return self._client
def _api_url(self, endpoint: str) -> str:
return f"{self._base_url}/v1/workflows{endpoint}"
def _parse_sse_data(
self, raw_data: str, event_type: str | None
) -> StreamEvent | None:
parsed = json.loads(raw_data)
if event_type == "error" or (isinstance(parsed, dict) and "error" in parsed):
error_msg = (
parsed.get("error", "Unknown stream error")
if isinstance(parsed, dict)
else str(parsed)
)
raise WorkflowsException(
message=f"Stream error from server: {error_msg}",
code=ErrorCode.GET_EVENTS_STREAM_ERROR,
)
return StreamEvent.model_validate(parsed)
async def stream_events(
self, params: StreamEventsQueryParams
) -> AsyncGenerator[StreamEvent, None]:
endpoint = "/events/stream"
query = params.model_dump(exclude_none=True)
try:
async with self._http_client.stream(
"GET", self._api_url(endpoint), params=query
) as response:
response.raise_for_status()
async for event in self._iter_sse_events(response):
yield event
except WorkflowsException:
raise
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to stream events",
code=ErrorCode.GET_EVENTS_STREAM_ERROR,
) from exc
async def _iter_sse_events(
self, response: httpx.Response
) -> AsyncGenerator[StreamEvent, None]:
event_type: str | None = None
async for line in iter_sse_lines(response):
if line == "" or line.startswith(":"):
continue
if line.startswith("event:"):
event_type = line[len("event:") :].strip()
continue
if not line.startswith("data:"):
continue
raw_data = line[len("data:") :].strip()
try:
event = self._parse_sse_data(raw_data, event_type)
if event:
yield event
except WorkflowsException:
raise
except Exception:
logger.warning(
"Failed to parse SSE event",
exc_info=True,
extra={"event_data": raw_data},
)
finally:
event_type = None
async def signal_workflow(
self, execution_id: str, signal_name: str, input_data: BaseModel | None = None
) -> SignalWorkflowResponse:
endpoint = f"/executions/{execution_id}/signals"
try:
input_data_dict = input_data.model_dump(mode="json") if input_data else {}
request_body = {"name": signal_name, "input": input_data_dict}
response = await self._http_client.post(
self._api_url(endpoint),
json=request_body,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return SignalWorkflowResponse.model_validate(response.json())
except WorkflowsException:
raise
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to signal workflow",
code=ErrorCode.POST_EXECUTIONS_SIGNALS_ERROR,
) from exc
async def update_workflow(
self, execution_id: str, update_name: str, input_data: BaseModel | None = None
) -> UpdateWorkflowResponse:
endpoint = f"/executions/{execution_id}/updates"
try:
input_data_dict = input_data.model_dump(mode="json") if input_data else {}
request_body = {"name": update_name, "input": input_data_dict}
response = await self._http_client.post(
self._api_url(endpoint),
json=request_body,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return UpdateWorkflowResponse.model_validate(response.json())
except WorkflowsException:
raise
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to update workflow",
code=ErrorCode.POST_EXECUTIONS_UPDATES_ERROR,
) from exc
async def get_workflow_runs(
self,
workflow_identifier: str | None = None,
page_size: int = 50,
next_page_token: str | None = None,
status: Sequence[WorkflowExecutionStatus] | None = None,
user_id: str = "current",
) -> WorkflowExecutionListResponse:
params: dict[str, Any] = {"page_size": page_size, "user_id": user_id}
if workflow_identifier:
params["workflow_identifier"] = workflow_identifier
if next_page_token:
params["next_page_token"] = next_page_token
if status:
params["status"] = status
endpoint = "/runs"
try:
response = await self._http_client.get(
self._api_url(endpoint), params=params
)
response.raise_for_status()
return WorkflowExecutionListResponse.model_validate(response.json())
except Exception as exc:
raise WorkflowsException.from_api_client_error(
exc,
message="Failed to get workflow runs",
code=ErrorCode.GET_EXECUTIONS_ERROR,
) from exc

View file

@ -1,227 +0,0 @@
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Discriminator, Field, Tag
class WorkflowEventType(StrEnum):
WORKFLOW_EXECUTION_COMPLETED = "WORKFLOW_EXECUTION_COMPLETED"
WORKFLOW_EXECUTION_FAILED = "WORKFLOW_EXECUTION_FAILED"
WORKFLOW_EXECUTION_CANCELED = "WORKFLOW_EXECUTION_CANCELED"
CUSTOM_TASK_STARTED = "CUSTOM_TASK_STARTED"
CUSTOM_TASK_IN_PROGRESS = "CUSTOM_TASK_IN_PROGRESS"
CUSTOM_TASK_COMPLETED = "CUSTOM_TASK_COMPLETED"
CUSTOM_TASK_FAILED = "CUSTOM_TASK_FAILED"
CUSTOM_TASK_TIMED_OUT = "CUSTOM_TASK_TIMED_OUT"
CUSTOM_TASK_CANCELED = "CUSTOM_TASK_CANCELED"
class JSONPatchBase(BaseModel):
path: str
value: Any = None
class JSONPatchAdd(JSONPatchBase):
op: Literal["add"] = "add"
class JSONPatchReplace(JSONPatchBase):
op: Literal["replace"] = "replace"
class JSONPatchRemove(JSONPatchBase):
op: Literal["remove"] = "remove"
class JSONPatchAppend(JSONPatchBase):
op: Literal["append"] = "append"
value: str = ""
JSONPatch = Annotated[
Annotated[JSONPatchAppend, Tag("append")]
| Annotated[JSONPatchAdd, Tag("add")]
| Annotated[JSONPatchReplace, Tag("replace")]
| Annotated[JSONPatchRemove, Tag("remove")],
Discriminator("op"),
]
class JSONPatchPayload(BaseModel):
type: Literal["json_patch"] = "json_patch"
value: list[JSONPatch] = Field(default_factory=list)
class JSONPayload(BaseModel):
type: Literal["json"] = "json"
value: Any = None
Payload = Annotated[
Annotated[JSONPayload, Tag("json")]
| Annotated[JSONPatchPayload, Tag("json_patch")],
Discriminator("type"),
]
class Failure(BaseModel):
message: str
class BaseEvent(BaseModel):
event_id: str
event_timestamp: int = 0
root_workflow_exec_id: str = ""
parent_workflow_exec_id: str | None = None
workflow_exec_id: str = ""
workflow_run_id: str = ""
workflow_name: str = ""
class WorkflowExecutionFailedAttributes(BaseModel):
task_id: str = ""
failure: Failure
class WorkflowExecutionCanceledAttributes(BaseModel):
task_id: str = ""
reason: str | None = None
class WorkflowExecutionCompletedAttributes(BaseModel):
task_id: str = ""
result: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None))
class CustomTaskStartedAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
payload: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None))
class CustomTaskInProgressAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
payload: Payload
class CustomTaskCompletedAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
payload: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None))
class CustomTaskFailedAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
failure: Failure
class CustomTaskTimedOutAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
timeout_type: str | None = None
class CustomTaskCanceledAttributes(BaseModel):
custom_task_id: str
custom_task_type: str
reason: str | None = None
class WorkflowExecutionCompleted(BaseEvent):
event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED] = (
WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED
)
attributes: WorkflowExecutionCompletedAttributes
class WorkflowExecutionFailed(BaseEvent):
event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_FAILED] = (
WorkflowEventType.WORKFLOW_EXECUTION_FAILED
)
attributes: WorkflowExecutionFailedAttributes
class WorkflowExecutionCanceled(BaseEvent):
event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_CANCELED] = (
WorkflowEventType.WORKFLOW_EXECUTION_CANCELED
)
attributes: WorkflowExecutionCanceledAttributes
class CustomTaskStarted(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_STARTED] = (
WorkflowEventType.CUSTOM_TASK_STARTED
)
attributes: CustomTaskStartedAttributes
class CustomTaskInProgress(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_IN_PROGRESS] = (
WorkflowEventType.CUSTOM_TASK_IN_PROGRESS
)
attributes: CustomTaskInProgressAttributes
class CustomTaskCompleted(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_COMPLETED] = (
WorkflowEventType.CUSTOM_TASK_COMPLETED
)
attributes: CustomTaskCompletedAttributes
class CustomTaskFailed(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_FAILED] = (
WorkflowEventType.CUSTOM_TASK_FAILED
)
attributes: CustomTaskFailedAttributes
class CustomTaskTimedOut(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_TIMED_OUT] = (
WorkflowEventType.CUSTOM_TASK_TIMED_OUT
)
attributes: CustomTaskTimedOutAttributes
class CustomTaskCanceled(BaseEvent):
event_type: Literal[WorkflowEventType.CUSTOM_TASK_CANCELED] = (
WorkflowEventType.CUSTOM_TASK_CANCELED
)
attributes: CustomTaskCanceledAttributes
def _get_event_type_discriminator(v: Any) -> str:
if isinstance(v, dict):
event_type_val = v.get("event_type", "")
if isinstance(event_type_val, WorkflowEventType):
return event_type_val.value
return str(event_type_val)
event_type_attr = getattr(v, "event_type", "")
if isinstance(event_type_attr, WorkflowEventType):
return event_type_attr.value
return str(event_type_attr)
WorkflowEvent = Annotated[
Annotated[
WorkflowExecutionCompleted, Tag(WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED)
]
| Annotated[
WorkflowExecutionFailed, Tag(WorkflowEventType.WORKFLOW_EXECUTION_FAILED)
]
| Annotated[
WorkflowExecutionCanceled, Tag(WorkflowEventType.WORKFLOW_EXECUTION_CANCELED)
]
| Annotated[CustomTaskStarted, Tag(WorkflowEventType.CUSTOM_TASK_STARTED)]
| Annotated[CustomTaskInProgress, Tag(WorkflowEventType.CUSTOM_TASK_IN_PROGRESS)]
| Annotated[CustomTaskCompleted, Tag(WorkflowEventType.CUSTOM_TASK_COMPLETED)]
| Annotated[CustomTaskFailed, Tag(WorkflowEventType.CUSTOM_TASK_FAILED)]
| Annotated[CustomTaskTimedOut, Tag(WorkflowEventType.CUSTOM_TASK_TIMED_OUT)]
| Annotated[CustomTaskCanceled, Tag(WorkflowEventType.CUSTOM_TASK_CANCELED)],
Discriminator(_get_event_type_discriminator),
]

View file

@ -1,46 +0,0 @@
from __future__ import annotations
from enum import StrEnum
from http import HTTPStatus
import httpx
class ErrorCode(StrEnum):
TEMPORAL_CONNECTION_ERROR = "temporal_connection_error"
GET_EVENTS_STREAM_ERROR = "get_events_stream_error"
POST_EXECUTIONS_SIGNALS_ERROR = "post_executions_signals_error"
POST_EXECUTIONS_UPDATES_ERROR = "post_executions_updates_error"
GET_EXECUTIONS_ERROR = "get_executions_error"
class WorkflowsException(Exception):
def __init__(
self,
message: str,
status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
code: ErrorCode = ErrorCode.TEMPORAL_CONNECTION_ERROR,
) -> None:
self.status = status
self.message = message
self.code = code
def __str__(self) -> str:
return f"{self.message} (code={self.code}, status={self.status.value})"
@classmethod
def from_api_client_error(
cls,
exc: Exception,
message: str = "HTTP request failed",
code: ErrorCode = ErrorCode.TEMPORAL_CONNECTION_ERROR,
) -> WorkflowsException:
status = HTTPStatus.INTERNAL_SERVER_ERROR
if isinstance(exc, httpx.HTTPStatusError):
try:
status = HTTPStatus(exc.response.status_code)
except ValueError:
pass
if isinstance(exc, httpx.ConnectError | httpx.TimeoutException):
status = HTTPStatus.BAD_GATEWAY
return cls(message=f"{message}: {exc}", code=code, status=status)

View file

@ -1,207 +0,0 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from typing import Any
from pydantic import TypeAdapter, ValidationError
from vibe.core.agent_loop import AgentLoopStateError
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.nuage.agent_models import (
_SUBMIT_INPUT_UPDATE_NAME,
ChatInputModel,
SubmitInputModel,
)
from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.events import WorkflowEvent
from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException
from vibe.core.nuage.remote_workflow_event_translator import (
PendingInputRequest,
RemoteWorkflowEventTranslator,
)
from vibe.core.nuage.streaming import StreamEventsQueryParams
from vibe.core.nuage.workflow import WorkflowExecutionStatus
from vibe.core.tools.manager import ToolManager
from vibe.core.types import AgentStats, BaseEvent, LLMMessage, Role
_RETRYABLE_STREAM_ERRORS = ("peer closed connection", "incomplete chunked read")
_WORKFLOW_EVENT_ADAPTER = TypeAdapter(WorkflowEvent)
class RemoteEventsSource:
def __init__(self, session_id: str, config: VibeConfig) -> None:
self.session_id = session_id
self._config = config
self.messages: list[LLMMessage] = []
self.stats = AgentStats()
self._tool_manager = ToolManager(lambda: config)
self._next_start_seq = 0
self._client: WorkflowsClient | None = None
self._translator = RemoteWorkflowEventTranslator(
available_tools=self._tool_manager._all_tools,
stats=self.stats,
merge_message=self._merge_message,
)
@property
def is_waiting_for_input(self) -> bool:
return self._translator.pending_input_request is not None
@property
def _pending_input_request(self) -> PendingInputRequest | None:
return self._translator.pending_input_request
@_pending_input_request.setter
def _pending_input_request(self, value: PendingInputRequest | None) -> None:
self._translator.pending_input_request = value
@property
def _task_state(self) -> dict[str, dict[str, Any]]:
return self._translator.task_state
@property
def is_terminated(self) -> bool:
return self._translator.last_status is not None
@property
def is_failed(self) -> bool:
return self._translator.last_status == WorkflowExecutionStatus.FAILED
@property
def is_canceled(self) -> bool:
return self._translator.last_status == WorkflowExecutionStatus.CANCELED
@property
def client(self) -> WorkflowsClient:
if self._client is None:
self._client = WorkflowsClient(
base_url=self._config.vibe_code_base_url,
api_key=self._config.vibe_code_api_key,
timeout=self._config.api_timeout,
)
return self._client
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
async def attach(self) -> AsyncGenerator[BaseEvent, None]:
async for event in self._stream_remote_events(stop_on_idle_boundary=False):
yield event
for event in self._translator.flush_open_tool_calls():
yield event
async def send_prompt(self, msg: str) -> None:
pending = self._translator.pending_input_request
if pending is None:
return
if not self._is_chat_input_request(pending):
raise AgentLoopStateError(
"Remote workflow is waiting for structured input that this UI does not support."
)
await self.client.update_workflow(
self.session_id,
_SUBMIT_INPUT_UPDATE_NAME,
SubmitInputModel(
task_id=pending.task_id,
input={"message": [{"type": "text", "text": msg}]},
),
)
self._translator.pending_input_request = None
def _is_chat_input_request(self, request: PendingInputRequest) -> bool:
title = request.input_schema.get("title")
return title == ChatInputModel.model_config.get("title")
async def _stream_remote_events(
self, stop_on_idle_boundary: bool = True
) -> AsyncGenerator[BaseEvent]:
retry_count = 0
max_retry_count = 3
done = False
while not done:
params = StreamEventsQueryParams(
workflow_exec_id=self.session_id, start_seq=self._next_start_seq
)
stream = self.client.stream_events(params)
try:
async for payload in stream:
retry_count = 0
if payload.broker_sequence is not None:
self._next_start_seq = payload.broker_sequence + 1
event = self._normalize_stream_event(payload.data)
if event is None:
continue
for emitted_event in self._consume_workflow_event(event):
yield emitted_event
if self.is_terminated:
done = True
break
if stop_on_idle_boundary and self._is_idle_boundary(event):
done = True
break
else:
break
except WorkflowsException as exc:
if self._is_retryable_stream_disconnect(exc):
retry_count += 1
if retry_count > max_retry_count:
break
await asyncio.sleep(0.2 * retry_count)
continue
raise AgentLoopStateError(str(exc)) from exc
finally:
await stream.aclose()
def _normalize_stream_event(
self, event: WorkflowEvent | dict[str, Any]
) -> WorkflowEvent | None:
if not isinstance(event, dict):
return event
try:
return _WORKFLOW_EVENT_ADAPTER.validate_python(event)
except ValidationError:
return None
def _consume_workflow_event(self, event: WorkflowEvent) -> list[BaseEvent]:
try:
return self._translator.consume_workflow_event(event)
except ValidationError:
logger.warning("Failed to consume remote workflow event", exc_info=True)
return []
def _is_retryable_stream_disconnect(self, exc: WorkflowsException) -> bool:
if exc.code != ErrorCode.GET_EVENTS_STREAM_ERROR:
return False
msg = str(exc).lower()
return any(needle in msg for needle in _RETRYABLE_STREAM_ERRORS)
def _is_idle_boundary(self, event: WorkflowEvent) -> bool:
return self._translator.is_idle_boundary(event)
def _merge_message(self, message: LLMMessage) -> None:
if not self.messages:
self.messages.append(message)
return
last_message = self.messages[-1]
if (
last_message.role == message.role
and last_message.message_id == message.message_id
and message.role == Role.assistant
):
self.messages[-1] = last_message + message
return
self.messages.append(message)

View file

@ -1,137 +0,0 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
class BaseUIState(BaseModel):
toolCallId: str = ""
class FileOperation(BaseModel):
type: str = ""
uri: str = ""
content: str = ""
class FileUIState(BaseUIState):
type: Literal["file"] = "file"
operations: list[FileOperation] = []
class CommandResult(BaseModel):
status: str = ""
output: str = ""
class CommandUIState(BaseUIState):
type: Literal["command"] = "command"
command: str = ""
result: CommandResult | None = None
class GenericToolResult(BaseModel):
status: str = ""
error: str | None = None
class GenericToolUIState(BaseUIState):
model_config = ConfigDict(extra="allow")
type: Literal["generic_tool"] = "generic_tool"
arguments: dict[str, Any] = {}
result: GenericToolResult | None = None
AnyToolUIState = FileUIState | CommandUIState | GenericToolUIState
def parse_tool_ui_state(raw: dict[str, Any]) -> AnyToolUIState | None:
ui_type = raw.get("type")
if ui_type == "file":
return FileUIState.model_validate(raw)
if ui_type == "command":
return CommandUIState.model_validate(raw)
if ui_type == "generic_tool":
return GenericToolUIState.model_validate(raw)
return None
class WorkingState(BaseModel):
title: str = ""
content: str = ""
type: str = ""
toolUIState: dict[str, Any] | None = None
class ContentChunk(BaseModel):
type: str = ""
text: str = ""
class AssistantMessageState(BaseModel):
contentChunks: list[ContentChunk] = []
class AgentToolCallState(BaseModel):
model_config = ConfigDict(extra="allow")
name: str = ""
tool_call_id: str = ""
kwargs: dict[str, Any] = {}
output: Any = None
class MessageSchema(BaseModel):
model_config = ConfigDict(extra="allow")
examples: list[Any] = []
class InputSchemaProperties(BaseModel):
model_config = ConfigDict(extra="allow")
message: MessageSchema | None = None
class InputSchema(BaseModel):
model_config = ConfigDict(extra="allow")
properties: InputSchemaProperties | None = None
class PredefinedAnswersState(BaseModel):
model_config = ConfigDict(extra="allow")
input_schema: InputSchema | None = None
class WaitForInputInput(BaseModel):
model_config = ConfigDict(extra="allow")
message: Any = None
class WaitForInputPayload(BaseModel):
model_config = ConfigDict(extra="allow")
input: WaitForInputInput | None = None
class AskUserQuestion(BaseModel):
question: str
class AskUserQuestionArgs(BaseModel):
model_config = ConfigDict(extra="allow")
questions: list[AskUserQuestion] = []
class PendingInputRequest(BaseModel):
task_id: str
input_schema: dict[str, Any]
label: str | None = None
class RemoteToolArgs(BaseModel):
model_config = ConfigDict(extra="allow")
summary: str | None = None
content: str | None = None
class RemoteToolResult(BaseModel):
model_config = ConfigDict(extra="allow")
message: str | None = None

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
from __future__ import annotations
import time
from typing import Any
from pydantic import BaseModel, Field
from vibe.core.nuage.events import WorkflowEvent
class StreamEventWorkflowContext(BaseModel):
namespace: str = ""
workflow_name: str = ""
workflow_exec_id: str = ""
parent_workflow_exec_id: str | None = None
root_workflow_exec_id: str | None = None
class StreamEvent(BaseModel):
stream: str = ""
timestamp_unix_nano: int = Field(default_factory=lambda: time.time_ns())
data: WorkflowEvent | dict[str, Any]
workflow_context: StreamEventWorkflowContext = Field(
default_factory=StreamEventWorkflowContext
)
metadata: dict[str, Any] = Field(default_factory=dict)
broker_sequence: int | None = None
class StreamEventsQueryParams(BaseModel):
workflow_exec_id: str = ""
start_seq: int = 0

View file

@ -1,44 +0,0 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
class WorkflowExecutionStatus(StrEnum):
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELED = "CANCELED"
TERMINATED = "TERMINATED"
CONTINUED_AS_NEW = "CONTINUED_AS_NEW"
TIMED_OUT = "TIMED_OUT"
class WorkflowExecutionWithoutResultResponse(BaseModel):
workflow_name: str
execution_id: str
parent_execution_id: str | None = None
root_execution_id: str = ""
status: WorkflowExecutionStatus | None = None
start_time: datetime
end_time: datetime | None = None
total_duration_ms: int | None = None
class WorkflowExecutionListResponse(BaseModel):
executions: list[WorkflowExecutionWithoutResultResponse] = Field(
default_factory=list
)
next_page_token: str | None = None
class SignalWorkflowResponse(BaseModel):
message: str = "Signal accepted"
class UpdateWorkflowResponse(BaseModel):
update_name: str = ""
result: Any = None

View file

@ -1,10 +1,17 @@
from __future__ import annotations
import base64
import hashlib
import mimetypes
from pathlib import Path
from vibe.core.types import IMAGE_EXTENSIONS, ImageAttachment
from vibe.core.types import (
IMAGE_EXTENSIONS,
MAX_IMAGE_BYTES,
FileImageSource,
ImageAttachment,
InlineImageSource,
)
_DEFAULT_MIME_BY_EXT = {
".png": "image/png",
@ -14,11 +21,38 @@ _DEFAULT_MIME_BY_EXT = {
".webp": "image/webp",
}
_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
}
class ImageSnapshotError(Exception):
pass
def extension_for_mime(mime_type: str) -> str | None:
return _EXT_BY_MIME.get(mime_type)
def _check_size(data: bytes) -> None:
if len(data) > MAX_IMAGE_BYTES:
raise ImageSnapshotError(f"Image is too large: {len(data)} > {MAX_IMAGE_BYTES}")
def _persist(data: bytes, *, ext: str, session_dir: Path) -> Path:
attachments_dir = session_dir / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(data, usedforsecurity=False).hexdigest()
dest = attachments_dir / f"{digest}{ext}"
if not dest.exists():
dest.write_bytes(data)
return dest.resolve()
def snapshot_image(
source: Path, *, alias: str, session_dir: Path | None
) -> ImageAttachment:
@ -39,18 +73,44 @@ def snapshot_image(
except OSError as e:
raise ImageSnapshotError(f"Failed to read image {source_abs}: {e}") from e
_check_size(data)
# Session logging disabled: no snapshot copy is made; the attachment
# points to the original source. Resume is not possible in this mode so
# snapshot stability is not required.
if session_dir is None:
return ImageAttachment(path=source_abs, alias=alias, mime_type=mime_type)
return ImageAttachment(
source=FileImageSource(path=source_abs), alias=alias, mime_type=mime_type
)
attachments_dir = session_dir / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
return ImageAttachment(
source=FileImageSource(path=_persist(data, ext=ext, session_dir=session_dir)),
alias=alias,
mime_type=mime_type,
)
digest = hashlib.sha1(data, usedforsecurity=False).hexdigest()
dest = attachments_dir / f"{digest}{ext}"
if not dest.exists():
dest.write_bytes(data)
return ImageAttachment(path=dest.resolve(), alias=alias, mime_type=mime_type)
def snapshot_image_bytes(
data: bytes, *, alias: str, mime_type: str, session_dir: Path | None
) -> ImageAttachment:
ext = extension_for_mime(mime_type)
if ext is None:
raise ImageSnapshotError(f"Unsupported image mime type: {mime_type}")
_check_size(data)
# No session dir means logging is disabled and nothing should be written to
# disk: keep the bytes inline so the attachment outlives this call without a
# dangling file path.
if session_dir is None:
return ImageAttachment(
source=InlineImageSource(data=base64.b64encode(data).decode("ascii")),
alias=alias,
mime_type=mime_type,
)
return ImageAttachment(
source=FileImageSource(path=_persist(data, ext=ext, session_dir=session_dir)),
alias=alias,
mime_type=mime_type,
)

View file

@ -1,70 +1,26 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
import contextlib
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, NamedTuple, Protocol
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.workflow import (
WorkflowExecutionListResponse,
WorkflowExecutionStatus,
)
from vibe.core.session.session_id import shorten_session_id
from vibe.core.session.session_loader import SessionLoader
ResumeSessionSource = Literal["local", "remote"]
def can_delete_resume_session_source(source: ResumeSessionSource) -> bool:
return source == "local"
def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str:
return shorten_session_id(session_id, from_end=source == "remote")
_ACTIVE_STATUSES = [
WorkflowExecutionStatus.RUNNING,
WorkflowExecutionStatus.CONTINUED_AS_NEW,
]
class RemoteWorkflowRunsClient(Protocol):
async def get_workflow_runs(
self,
workflow_identifier: str | None = None,
page_size: int = 50,
next_page_token: str | None = None,
status: Sequence[WorkflowExecutionStatus] | None = None,
user_id: str = "current",
) -> WorkflowExecutionListResponse: ...
class RemoteResumeClient(RemoteWorkflowRunsClient, Protocol):
async def aclose(self) -> None: ...
def short_session_id(session_id: str) -> str:
return shorten_session_id(session_id)
@dataclass(frozen=True)
class ResumeSessionInfo:
session_id: str
source: ResumeSessionSource
cwd: str
title: str | None
end_time: str | None
status: str | None = None
@property
def option_id(self) -> str:
return f"{self.source}:{self.session_id}"
@property
def can_delete(self) -> bool:
return can_delete_resume_session_source(self.source)
return self.session_id
def list_local_resume_sessions(
@ -73,7 +29,6 @@ def list_local_resume_sessions(
return [
ResumeSessionInfo(
session_id=session["session_id"],
source="local",
cwd=session["cwd"],
title=session.get("title"),
end_time=session.get("end_time"),
@ -82,50 +37,11 @@ def list_local_resume_sessions(
]
async def list_remote_resume_sessions(
client: RemoteWorkflowRunsClient, workflow_id: str
) -> list[ResumeSessionInfo]:
response = await client.get_workflow_runs(
workflow_identifier=workflow_id, page_size=50, status=_ACTIVE_STATUSES
)
seen: dict[str, ResumeSessionInfo] = {}
latest_start: dict[str, datetime] = {}
for execution in response.executions:
session = ResumeSessionInfo(
session_id=execution.execution_id,
source="remote",
cwd="",
title="Vibe Code",
end_time=(
execution.end_time.isoformat()
if execution.end_time
else execution.start_time.isoformat()
),
status=execution.status,
)
prev_start = latest_start.get(execution.execution_id)
if prev_start is None or execution.start_time > prev_start:
seen[execution.execution_id] = session
latest_start[execution.execution_id] = execution.start_time
sessions = list(seen.values())
logger.debug("Remote resume listing filtered sessions: %d", len(sessions))
return sessions
def session_latest_messages(
sessions: list[ResumeSessionInfo], config: VibeConfig
) -> dict[str, str]:
messages: dict[str, str] = {}
for session in sessions:
if session.source == "remote":
status = (session.status or "RUNNING").lower()
messages[session.option_id] = (
f"{session.title or 'Remote workflow'} ({status})"
)
continue
messages[session.option_id] = (
session.title
or SessionLoader.get_first_user_message(
@ -133,91 +49,3 @@ def session_latest_messages(
)
)
return messages
class RemoteResumeResult(NamedTuple):
sessions: list[ResumeSessionInfo]
error: str | None
def _default_remote_resume_client(config: VibeConfig) -> RemoteResumeClient:
return WorkflowsClient(
base_url=config.vibe_code_base_url,
api_key=config.vibe_code_api_key,
timeout=config.api_timeout,
)
class RemoteResumeSessions:
def __init__(
self,
get_config: Callable[[], VibeConfig],
client_factory: Callable[
[VibeConfig], RemoteResumeClient
] = _default_remote_resume_client,
) -> None:
self._get_config = get_config
self._client_factory = client_factory
self._client: RemoteResumeClient | None = None
self._client_settings: tuple[str, str, float] | None = None
self._fetch_task: asyncio.Task[RemoteResumeResult] | None = None
async def _reusable_client(self, config: VibeConfig) -> RemoteResumeClient:
settings = (
config.vibe_code_base_url,
config.vibe_code_api_key,
config.api_timeout,
)
if self._client is not None and self._client_settings == settings:
return self._client
if self._client is not None:
await self._close_client()
self._client = self._client_factory(config)
self._client_settings = settings
return self._client
def start(self, timeout: float) -> asyncio.Task[RemoteResumeResult]:
if self._fetch_task is not None and not self._fetch_task.done():
self._fetch_task.cancel()
self._fetch_task = asyncio.create_task(self.fetch(timeout))
return self._fetch_task
async def fetch(self, timeout: float) -> RemoteResumeResult:
config = self._get_config()
if not config.vibe_code_enabled or not config.vibe_code_api_key:
logger.debug(
"Remote resume listing skipped: missing Vibe Code configuration"
)
return RemoteResumeResult([], None)
try:
client = await self._reusable_client(config)
sessions = await asyncio.wait_for(
list_remote_resume_sessions(client, config.vibe_code_workflow_id),
timeout=timeout,
)
except TimeoutError:
return RemoteResumeResult(
[], f"Timed out while listing remote sessions after {timeout:.0f}s."
)
except Exception as e:
return RemoteResumeResult([], f"Failed to list remote sessions: {e}")
return RemoteResumeResult(sessions, None)
async def aclose(self) -> None:
if self._fetch_task is not None and not self._fetch_task.done():
self._fetch_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._fetch_task
self._fetch_task = None
await self._close_client()
async def _close_client(self) -> None:
if self._client is None:
return
try:
await self._client.aclose()
except Exception as exc:
logger.error("Failed to close resume workflows client", exc_info=exc)
finally:
self._client = None
self._client_settings = None

View file

@ -70,7 +70,8 @@ Vibe never updates silently. With `enable_update_checks = true` (default), it
polls PyPI for `mistral-vibe` daily and prompts on the next launch when a
newer release exists; accepting runs `uv tool upgrade mistral-vibe`, then
`brew upgrade mistral-vibe` as a fallback. Disable via `enable_update_checks
= false`. Initial install: `uv tool install mistral-vibe`.
= false`. Run `vibe --check-upgrade` to check immediately, prompt to install a newer
version if one exists, and exit. Initial install: `uv tool install mistral-vibe`.
### Version
@ -92,8 +93,7 @@ current folder**: only sessions whose `cwd` matches where Vibe is launched are
listed, so the same directory shows its own history and nothing else. Switch
folders to see a different set. The explicit `--resume <SESSION_ID>` form is
**not** folder-scoped: it resolves the session by id regardless of which folder
it ran in. When Vibe Code is enabled, active **remote** sessions are listed
alongside local ones in the picker (tagged `remote`) and are not folder-scoped.
it ran in.
## Configuration (config.toml)
@ -124,6 +124,7 @@ enable_update_checks = true # Daily PyPI check; prompts on next launch whe
enable_notifications = true
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
api_timeout = 720.0 # API request timeout in seconds
api_retry_max_elapsed_time = 300.0 # Retry budget for retryable API failures in seconds
auto_compact_threshold = 200000 # Token count before auto-compaction
# Git commit behavior
@ -270,8 +271,33 @@ name = "remote-server"
transport = "http"
url = "https://mcp.example.com"
api_key_env = "MCP_API_KEY"
[[mcp_servers]]
name = "linear"
transport = "streamable-http"
url = "https://mcp.linear.app/mcp"
[mcp_servers.auth]
type = "oauth"
scopes = ["read", "write"]
# Optional: client_id = "pre-registered-public-client"
# Optional: client_metadata_url = "https://example.com/client-metadata.json"
# Optional: redirect_port = 47823
```
HTTP MCP servers can use either static auth or OAuth:
- Static auth: legacy `api_key_env` / `headers` keys still work and are
promoted to `auth.type = "static"` internally.
- OAuth auth: use `auth.type = "oauth"` with `scopes`. Vibe stores tokens
in the OS keyring under `mcp-oauth:<alias>:tokens`, dynamic client info
under `mcp-oauth:<alias>:client_info`, and config drift fingerprints under
`mcp-oauth:<alias>:fingerprint`.
- Headless environments without an OS keyring cannot store OAuth tokens; use
static auth via `api_key_env` instead.
- For SSH/remote browser callbacks, forward the loopback port:
`ssh -L 47823:127.0.0.1:47823 <host>`.
### Connectors
Mistral connectors are auto-discovered when the active provider is Mistral
@ -496,8 +522,9 @@ Tool, skill, and agent names support three matching modes:
vibe [PROMPT] # Start interactive session with optional prompt
vibe -p TEXT / --prompt TEXT # Programmatic mode using `default_agent`, one-shot, exit
vibe -p TEXT --auto-approve # Programmatic mode with all tool calls approved
vibe -p TEXT --yolo # Alias for `--auto-approve`
vibe --agent NAME # Select agent profile (falls back to `default_agent` config)
vibe --auto-approve # Shortcut for `--agent auto-approve`
vibe --auto-approve / --yolo # Shortcut for `--agent auto-approve`
vibe --workdir DIR # Change working directory
vibe --add-dir DIR # Extra working dir loaded for context (repeatable). Implicitly trusted.
vibe --trust # Trust cwd for this invocation only (not persisted)
@ -505,6 +532,7 @@ vibe -c / --continue # Continue most recent session in this termi
vibe --resume [SESSION_ID] # Resume a specific session
vibe -v / --version # Show version
vibe --setup # Run onboarding/setup
vibe --check-upgrade # Check for a Vibe update now, prompt to install it, and exit
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)
@ -551,10 +579,13 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/status` - Display agent statistics
- `/voice` - Configure voice settings
- `/mcp` - Display available MCP servers (pass a server name to list its tools)
- `/mcp status` - Display MCP auth state (`ok`, `needs_auth`, `static`, `stdio`)
- `/mcp login <alias>` - Start OAuth login for an MCP server
- `/mcp logout <alias>` - Log out from an MCP server and delete stored OAuth
secrets
- `/resume` (or `/continue`) - Browse and resume past sessions for the current
folder (plus active remote sessions when Vibe Code is enabled). The picker
header shows the folder being listed. Press `D` twice to delete a local saved
session; remote sessions and the active session cannot be deleted here.
folder. The picker header shows the folder being listed. Press `D` twice to
delete a saved session; the active session cannot be deleted here.
- `/rewind` - Rewind to a previous message
- `/loop <interval> <prompt>` - Schedule a recurring prompt (e.g. `/loop 30s ping`).
Intervals: `Ns/Nm/Nh/Nd`, minimum 30s, max 50 loops/session.

View file

@ -139,8 +139,8 @@ class ProjectContextProvider:
except Exception as e:
return f"Error getting git status: {e}"
def get_full_context(self, *, include_git_status: bool = True) -> str:
git_status = self.get_git_status() if include_git_status else ""
def get_full_context(self) -> str:
git_status = self.get_git_status()
template = UtilityPrompt.PROJECT_CONTEXT.read()
return Template(template).safe_substitute(
@ -311,7 +311,6 @@ def get_universal_system_prompt( # noqa: PLR0912
skill_manager: SkillManager,
agent_manager: AgentManager,
*,
include_git_status: bool = True,
scratchpad_dir: Path | None = None,
headless: bool = False,
experiment_manager: ExperimentManager | None = None,
@ -356,7 +355,7 @@ def get_universal_system_prompt( # noqa: PLR0912
else:
context = ProjectContextProvider(
config=config.project_context, root_path=Path.cwd()
).get_full_context(include_git_status=include_git_status)
).get_full_context()
sections.append(context)

View file

@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urljoin
@ -10,7 +9,7 @@ from urllib.parse import urljoin
import httpx
from vibe import __version__
from vibe.core.config import ProviderConfig, VibeConfig
from vibe.core.config import ProviderConfig, VibeConfig, resolve_api_key
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.logger import logger
from vibe.core.telemetry.build_metadata import build_base_metadata
@ -53,7 +52,7 @@ def get_mistral_provider_and_api_key(
if provider is None:
return None
env_var = provider.api_key_env_var
api_key = os.getenv(env_var) if env_var else None
api_key = resolve_api_key(env_var)
if api_key is None:
return None
return provider, api_key
@ -372,11 +371,6 @@ class TelemetryClient:
correlation_id=self.last_correlation_id,
)
def send_remote_resume_requested(self, *, session_id: str) -> None:
self.send_telemetry_event(
"vibe.remote_resume_requested", {"session_id": session_id}
)
def send_teleport_completed(
self, *, push_required: bool, nb_session_messages: int
) -> None:

View file

@ -60,7 +60,7 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata):
TeleportFailureStage = Literal[
"no_history", "remote_session", "git_check", "push", "workflow_start", "cancelled"
"no_history", "git_check", "push", "workflow_start", "cancelled"
]

View file

@ -34,6 +34,7 @@ if TYPE_CHECKING:
from vibe.core.hooks.models import HookConfigResult
from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.types import EntrypointMetadata, TerminalEmulator
from vibe.core.tools.mcp.pool import MCPConnectionPool
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.permissions import PermissionContext, PermissionStore
from vibe.core.types import ApprovalCallback, SwitchAgentCallback, UserInputCallback
@ -59,6 +60,7 @@ class InvokeContext:
permission_store: PermissionStore | None = field(default=None)
hook_config_result: HookConfigResult | None = field(default=None)
session_id: str | None = field(default=None)
mcp_pool: MCPConnectionPool | None = field(default=None)
terminal_emulator: TerminalEmulator | None = field(default=None)

View file

@ -26,7 +26,7 @@ from vibe.core.utils.io import (
file_write_lock,
read_safe_async,
)
from vibe.core.utils.text import snippet_start_line
from vibe.core.utils.text import snippet_start_lines
class EditArgs(BaseModel):
@ -47,11 +47,12 @@ class EditResult(BaseModel):
old_string: str
new_string: str
# UI hint for the diff renderer; not part of the serialized result contract.
_ui_start_line: int | None = PrivateAttr(default=None)
# One entry per replaced occurrence (replace_all yields several).
_ui_start_lines: list[int] = PrivateAttr(default_factory=list)
@property
def ui_start_line(self) -> int | None:
return self._ui_start_line
def ui_start_lines(self) -> list[int]:
return self._ui_start_lines
class EditConfig(BaseToolConfig):
@ -145,7 +146,9 @@ class Edit(
f"instance.\nString: {args.old_string}"
)
start_line = snippet_start_line(original, args.old_string)
start_lines = snippet_start_lines(original, args.old_string)
if not args.replace_all:
start_lines = start_lines[:1]
modified = self._apply_edit(
original, args.old_string, args.new_string, args.replace_all
@ -178,7 +181,7 @@ class Edit(
old_string=args.old_string,
new_string=args.new_string,
)
result._ui_start_line = start_line
result._ui_start_lines = start_lines
yield result
@final

View file

@ -67,6 +67,8 @@ class ReadResult(BaseModel):
content: str
num_lines: int
start_line: int
requested_offset: int | None = None
requested_limit: int = DEFAULT_LINE_LIMIT
total_lines: int | None = None
was_truncated: bool = False
@ -178,6 +180,8 @@ class Read(
content=content,
num_lines=len(selected),
start_line=start_line,
requested_offset=args.offset,
requested_limit=args.limit,
total_lines=total_lines,
was_truncated=was_truncated,
)

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import aclosing
from contextlib import aclosing, suppress
import fnmatch
from typing import ClassVar
@ -186,6 +186,9 @@ class Task(
turns_used = sum(
msg.role == Role.assistant for msg in subagent_loop.messages
)
finally:
with suppress(Exception):
await subagent_loop.aclose()
yield TaskResult(
response="".join(accumulated_response),

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import os
from typing import TYPE_CHECKING, ClassVar, final
import httpx
@ -15,7 +14,7 @@ from mistralai.client.models import (
)
from pydantic import BaseModel, Field
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, VibeConfig
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, VibeConfig, resolve_api_key
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -67,13 +66,13 @@ class WebSearch(
@classmethod
def is_available(cls, config: VibeConfig | None = None) -> bool:
if config is None:
return bool(os.getenv(DEFAULT_MISTRAL_API_ENV_KEY))
return bool(resolve_api_key(DEFAULT_MISTRAL_API_ENV_KEY))
provider = config.get_mistral_provider()
if provider is None:
return bool(os.getenv(DEFAULT_MISTRAL_API_ENV_KEY))
return bool(resolve_api_key(DEFAULT_MISTRAL_API_ENV_KEY))
return bool(os.getenv(cls._api_key_env_var(config)))
return bool(resolve_api_key(cls._api_key_env_var(config)))
@final
async def run(
@ -81,7 +80,7 @@ class WebSearch(
) -> AsyncGenerator[ToolStreamEvent | WebSearchResult, None]:
config = self._resolve_config(ctx)
api_key_env_var = self._api_key_env_var(config)
api_key = os.getenv(api_key_env_var)
api_key = resolve_api_key(api_key_env_var)
if not api_key:
raise ToolError(f"{api_key_env_var} environment variable not set.")

View file

@ -296,6 +296,7 @@ class ToolManager:
if self._mcp_integrated:
return
if not self._config.mcp_servers:
self._mcp_registry.sync_active_servers([])
return
try:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from vibe.core.tools.mcp.registry import MCPRegistry
from vibe.core.tools.mcp.pool import MCPConnectionPool
from vibe.core.tools.mcp.registry import AuthStatus, MCPRegistry
from vibe.core.tools.mcp.tools import (
MCPToolResult,
RemoteTool,
@ -17,6 +18,8 @@ from vibe.core.tools.mcp.tools import (
)
__all__ = [
"AuthStatus",
"MCPConnectionPool",
"MCPRegistry",
"MCPToolResult",
"RemoteTool",

262
vibe/core/tools/mcp/pool.py Normal file
View file

@ -0,0 +1,262 @@
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
from datetime import timedelta
import hashlib
from typing import TYPE_CHECKING, Any
import anyio
from vibe.core.logger import logger
from vibe.core.tools.mcp.tools import (
MCPToolResult,
_parse_call_result as parse_call_result,
build_stdio_params,
enter_stdio_session,
)
if TYPE_CHECKING:
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
# Errors that indicate the stdio transport (subprocess / pipe) is gone and the
# session must be respawned. Tool-level errors and timeouts are deliberately
# excluded: they are legitimate server responses, not dead connections.
_TRANSPORT_ERRORS: tuple[type[BaseException], ...] = (
anyio.ClosedResourceError,
anyio.BrokenResourceError,
BrokenPipeError,
ConnectionError,
EOFError,
)
# How long aclose waits for a connection's worker to drain and shut its session
# down gracefully before cancelling it.
_CLOSE_TIMEOUT_SEC = 5.0
def stdio_key(command: list[str], env: dict[str, str] | None, cwd: str | None) -> str:
# \0 and \x01 delimit the three identity fields so distinct inputs cannot
# collide (e.g. ["a b"] vs ["a", "b"], or command vs env boundaries).
env_part = "\0".join(f"{k}={v}" for k, v in sorted((env or {}).items()))
raw = "\0".join(command) + "\x01" + env_part + "\x01" + (cwd or "")
return hashlib.blake2s(raw.encode("utf-8"), digest_size=16).hexdigest()
@dataclass
class _Request:
tool_name: str
arguments: dict[str, Any]
call_timeout: timedelta | None
future: asyncio.Future[Any]
class _StdioConnection:
"""A single long-lived stdio MCP session owned by one dedicated task.
The MCP ``stdio_client`` and ``ClientSession`` context managers open anyio
task groups bound to the task that enters them, so the session must be
entered, used, and exited all within the same task. A single worker task
owns the session for its whole lifetime and services calls from a queue;
callers submit a request and await its future. Because the worker handles
one request at a time, calls to the same server are serialized (stateful
servers never see interleaved requests). On transport death the worker drops
the session and respawns it once before retrying the call.
"""
def __init__(
self,
params: StdioServerParameters,
init_timeout: timedelta | None,
sampling_callback: MCPSamplingHandler | None,
) -> None:
self._params = params
self._init_timeout = init_timeout
self._sampling_callback = sampling_callback
self._requests: asyncio.Queue[_Request | None] = asyncio.Queue()
self._worker: asyncio.Task[None] | None = None
self._session: ClientSession | None = None
self._stack: contextlib.AsyncExitStack | None = None
self._inflight: _Request | None = None
def _ensure_worker(self) -> None:
if self._worker is None or self._worker.done():
self._worker = asyncio.create_task(self._run())
async def call_tool(
self, tool_name: str, arguments: dict[str, Any], call_timeout: timedelta | None
) -> Any:
self._ensure_worker()
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
await self._requests.put(_Request(tool_name, arguments, call_timeout, future))
return await future
async def _run(self) -> None:
try:
while True:
req = await self._requests.get()
if req is None:
return
self._inflight = req
try:
result = await self._handle(req)
except Exception as exc:
if not req.future.done():
req.future.set_exception(exc)
self._inflight = None
else:
if not req.future.done():
req.future.set_result(result)
self._inflight = None
finally:
await self._close_session()
self._fail_pending()
async def _handle(self, req: _Request) -> Any:
session = await self._ensure_session()
try:
return await session.call_tool(
req.tool_name, req.arguments, read_timeout_seconds=req.call_timeout
)
except _TRANSPORT_ERRORS as exc:
logger.debug("MCP stdio transport died, reconnecting once: %r", exc)
await self._close_session()
session = await self._ensure_session()
return await session.call_tool(
req.tool_name, req.arguments, read_timeout_seconds=req.call_timeout
)
async def _ensure_session(self) -> ClientSession:
if self._session is not None:
return self._session
stack = contextlib.AsyncExitStack()
try:
session = await enter_stdio_session(
stack,
self._params,
init_timeout=self._init_timeout,
sampling_callback=self._sampling_callback,
)
except BaseException:
await stack.aclose()
raise
self._stack = stack
self._session = session
return session
async def _close_session(self) -> None:
stack, self._stack = self._stack, None
self._session = None
if stack is not None:
with contextlib.suppress(Exception):
await stack.aclose()
def _fail_pending(self) -> None:
err = RuntimeError("MCP stdio connection closed")
if self._inflight is not None and not self._inflight.future.done():
self._inflight.future.set_exception(err)
self._inflight = None
while not self._requests.empty():
try:
req = self._requests.get_nowait()
except asyncio.QueueEmpty:
break
if req is not None and not req.future.done():
req.future.set_exception(err)
async def aclose(self) -> None:
worker = self._worker
self._worker = None
if worker is None or worker.done():
return
await self._requests.put(None)
try:
await asyncio.wait_for(worker, _CLOSE_TIMEOUT_SEC)
except Exception as exc:
logger.debug("MCP stdio worker shutdown error: %r", exc)
if not worker.done():
worker.cancel()
with contextlib.suppress(BaseException):
await worker
class MCPConnectionPool:
"""Session-scoped pool of persistent stdio MCP connections.
Owned by an ``AgentLoop`` and created lazily in that loop's event loop on the
first call (discovery runs in a throwaway loop, so connections must not be
shared with it). Connections live until ``aclose`` is called at session end.
"""
def __init__(self) -> None:
self._conns: dict[str, _StdioConnection] = {}
self._creation_lock = asyncio.Lock()
self._loop: asyncio.AbstractEventLoop | None = None
def _bind_loop(self) -> None:
loop = asyncio.get_running_loop()
if self._loop is loop:
return
if self._loop is not None:
# Running under a different loop than the cached connections were
# created on: those connections (and their worker tasks) are dead
# here and cannot be closed on a loop that is no longer running.
logger.debug(
"MCP pool bound to a new event loop; dropping %d stale connection(s)",
len(self._conns),
)
self._conns.clear()
self._loop = loop
async def _get_or_create(
self,
key: str,
command: list[str],
env: dict[str, str] | None,
cwd: str | None,
startup_timeout_sec: float | None,
sampling_callback: MCPSamplingHandler | None,
) -> _StdioConnection:
if (conn := self._conns.get(key)) is not None:
return conn
async with self._creation_lock:
if (conn := self._conns.get(key)) is not None:
return conn
params = build_stdio_params(command, env=env, cwd=cwd)
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
conn = _StdioConnection(params, init_timeout, sampling_callback)
self._conns[key] = conn
return conn
async def call_tool(
self,
*,
command: list[str],
tool_name: str,
arguments: dict[str, Any],
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
self._bind_loop()
key = stdio_key(command, env, cwd)
conn = await self._get_or_create(
key, command, env, cwd, startup_timeout_sec, sampling_callback
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
result = await conn.call_tool(tool_name, arguments, call_timeout)
return parse_call_result("stdio:" + " ".join(command), tool_name, result)
async def aclose(self) -> None:
conns = list(self._conns.values())
self._conns.clear()
self._loop = None
await asyncio.gather(*(conn.aclose() for conn in conns), return_exceptions=True)

View file

@ -1,12 +1,26 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress
from enum import StrEnum, auto
import hashlib
from typing import TYPE_CHECKING, cast
from typing import cast
from mcp.client.auth import OAuthFlowError
from vibe.core.auth.mcp_oauth import (
Fingerprint,
KeyringTokenStorage,
MCPOAuthError,
MCPOAuthLoginFailed,
build_oauth_provider,
perform_oauth_login,
)
from vibe.core.config import MCPHttp, MCPOAuth, MCPServer, MCPStdio, MCPStreamableHttp
from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool
from vibe.core.tools.mcp.tools import (
MCPHttpOAuthRuntime,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
list_tools_http,
@ -14,8 +28,12 @@ from vibe.core.tools.mcp.tools import (
)
from vibe.core.utils import run_sync
if TYPE_CHECKING:
from vibe.core.config import MCPHttp, MCPServer, MCPStdio, MCPStreamableHttp
class AuthStatus(StrEnum):
OK = auto()
NEEDS_AUTH = auto()
STATIC = auto()
STDIO = auto()
class MCPRegistry:
@ -28,6 +46,10 @@ class MCPRegistry:
def __init__(self) -> None:
self._cache: dict[str, dict[str, type[BaseTool]]] = {}
self._cache_keys_by_alias: dict[str, set[str]] = {}
self._servers_by_alias: dict[str, MCPServer] = {}
self._needs_auth: set[str] = set()
self._oauth_locks: dict[str, asyncio.Lock] = {}
@staticmethod
def _server_key(srv: MCPServer) -> str:
@ -45,6 +67,7 @@ class MCPRegistry:
result: dict[str, type[BaseTool]] = {}
to_discover: list[tuple[str, MCPServer]] = []
self.sync_active_servers(servers)
for srv in servers:
key = self._server_key(srv)
if key in self._cache:
@ -73,10 +96,20 @@ class MCPRegistry:
continue
if result is None:
continue
self._cache[key] = result
self._store_cache_entry(key, srv.name, result)
out.update(result)
return out
def _store_cache_entry(
self, key: str, alias: str, tools: dict[str, type[BaseTool]]
) -> None:
self._cache[key] = tools
self._cache_keys_by_alias.setdefault(alias, set()).add(key)
def _drop_alias_cache(self, alias: str) -> None:
for key in self._cache_keys_by_alias.pop(alias, set()):
self._cache.pop(key, None)
async def _discover_server(
self, srv: MCPServer
) -> dict[str, type[BaseTool]] | None:
@ -99,12 +132,10 @@ class MCPRegistry:
logger.warning("MCP server '%s' missing url for http transport", srv.name)
return {}
if srv.auth.type == "oauth":
logger.warning(
"OAuth support for MCP servers is not yet enabled; coming in a future release"
)
return {}
if isinstance(srv.auth, MCPOAuth):
return await self._discover_oauth_http(srv, url=url)
self._needs_auth.discard(srv.name)
headers = srv.http_headers()
try:
remotes = await list_tools_http(
@ -137,6 +168,91 @@ class MCPRegistry:
)
return tools
async def _discover_oauth_http(
self, srv: MCPHttp | MCPStreamableHttp, *, url: str
) -> dict[str, type[BaseTool]] | None:
alias = srv.name
self._servers_by_alias[alias] = srv
lock = self.oauth_lock_for(alias)
try:
storage = KeyringTokenStorage(alias=alias)
current_fingerprint = Fingerprint.compute(srv)
saved_fingerprint = await Fingerprint.load(alias)
tokens = await storage.get_tokens()
except MCPOAuthError as exc:
logger.warning("%s", exc)
self.mark_needs_auth(alias)
return None
if saved_fingerprint != current_fingerprint:
await storage.delete_tokens()
await storage.delete_client_info()
await Fingerprint.delete(alias)
self._drop_alias_cache(alias)
self.mark_needs_auth(alias)
return None
if tokens is None:
self.mark_needs_auth(alias)
return None
async def redirect_handler(_url: str) -> None:
raise OAuthFlowError(
f"MCP server {alias!r} requires interactive OAuth login"
)
async def callback_handler() -> tuple[str, str | None]:
raise OAuthFlowError(
f"MCP server {alias!r} requires interactive OAuth login"
)
provider = build_oauth_provider(
srv, redirect_handler=redirect_handler, callback_handler=callback_handler
)
try:
remotes = await list_tools_http(
url,
headers=srv.http_headers(),
auth=provider,
startup_timeout_sec=srv.startup_timeout_sec,
)
except OAuthFlowError as exc:
await self.mark_oauth_failure(alias)
logger.warning("MCP OAuth discovery failed for %s: %s", alias, exc)
return None
except Exception as exc:
logger.warning("MCP HTTP discovery failed for %s: %s", url, exc)
return None
self._needs_auth.discard(alias)
tools: dict[str, type[BaseTool]] = {}
for remote in remotes:
try:
proxy_cls = create_mcp_http_proxy_tool_class(
url=url,
remote=remote,
alias=alias,
server_hint=srv.prompt,
headers=srv.http_headers(),
auth=provider,
oauth_runtime=MCPHttpOAuthRuntime(
lock=lock, failure_callback=self.mark_oauth_failure
),
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
sampling_enabled=srv.sampling_enabled,
)
tools[proxy_cls.get_name()] = proxy_cls
except Exception as exc:
logger.warning(
"Failed to register MCP HTTP tool '%s' from %s: %r",
getattr(remote, "name", "<unknown>"),
url,
exc,
)
return tools
async def _discover_stdio(self, srv: MCPStdio) -> dict[str, type[BaseTool]] | None:
cmd = srv.argv()
if not cmd:
@ -185,3 +301,92 @@ class MCPRegistry:
def clear(self) -> None:
"""Drop all cached entries, forcing re-discovery on next use."""
self._cache.clear()
self._cache_keys_by_alias.clear()
self._servers_by_alias.clear()
self._needs_auth.clear()
def sync_active_servers(self, servers: list[MCPServer]) -> None:
active = {srv.name: srv for srv in servers}
self._servers_by_alias = active
active_oauth_aliases = {
srv.name
for srv in servers
if srv.transport in {"http", "streamable-http"}
and isinstance(cast("MCPHttp | MCPStreamableHttp", srv).auth, MCPOAuth)
}
self._needs_auth.intersection_update(active_oauth_aliases)
@property
def needs_auth(self) -> set[str]:
return set(self._needs_auth)
def mark_needs_auth(self, alias: str) -> None:
self._drop_alias_cache(alias)
self._needs_auth.add(alias)
async def mark_oauth_failure(self, alias: str) -> None:
with suppress(MCPOAuthError):
await KeyringTokenStorage(alias=alias).delete_tokens()
self.mark_needs_auth(alias)
def oauth_lock_for(self, alias: str) -> asyncio.Lock:
if alias not in self._oauth_locks:
self._oauth_locks[alias] = asyncio.Lock()
return self._oauth_locks[alias]
def status(self) -> dict[str, AuthStatus]:
statuses: dict[str, AuthStatus] = {}
for alias, srv in self._servers_by_alias.items():
match srv.transport:
case "stdio":
statuses[alias] = AuthStatus.STDIO
case "http" | "streamable-http":
if isinstance(srv.auth, MCPOAuth):
statuses[alias] = (
AuthStatus.NEEDS_AUTH
if alias in self._needs_auth
or self._server_key(srv) not in self._cache
else AuthStatus.OK
)
else:
statuses[alias] = AuthStatus.STATIC
return statuses
def _require_oauth_server(self, alias: str) -> MCPHttp | MCPStreamableHttp:
srv = self._servers_by_alias.get(alias)
if srv is None:
raise ValueError(f"Unknown MCP server: {alias}")
if srv.transport not in {"http", "streamable-http"}:
raise ValueError(f"MCP server {alias!r} does not use HTTP transport")
http_srv = cast("MCPHttp | MCPStreamableHttp", srv)
if not isinstance(http_srv.auth, MCPOAuth):
raise ValueError(f"MCP server {alias!r} is not configured for OAuth")
return http_srv
async def login(
self, alias: str, *, on_url: Callable[[str], Awaitable[None]]
) -> None:
srv = self._require_oauth_server(alias)
async with self.oauth_lock_for(alias):
await perform_oauth_login(srv, on_url=on_url)
self._drop_alias_cache(alias)
tools = await self._discover_server(srv)
if tools is None:
self.mark_needs_auth(alias)
raise MCPOAuthLoginFailed(
server_alias=alias,
reason="login completed but tool discovery failed",
)
self._needs_auth.discard(alias)
self._store_cache_entry(self._server_key(srv), alias, tools)
async def logout(self, alias: str) -> None:
srv = self._require_oauth_server(alias)
async with self.oauth_lock_for(alias):
storage = KeyringTokenStorage(alias=alias)
await storage.delete_tokens()
await storage.delete_client_info()
await Fingerprint.delete(alias)
self._drop_alias_cache(alias)
self.mark_needs_auth(alias)
self._servers_by_alias[alias] = srv

View file

@ -1,7 +1,9 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable
import contextlib
from dataclasses import dataclass
from datetime import timedelta
import hashlib
import os
@ -13,6 +15,7 @@ import httpx
from pydantic import BaseModel, ConfigDict, Field, field_validator
from mcp import ClientSession
from mcp.client.auth import OAuthFlowError
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from vibe.core.logger import logger
@ -138,6 +141,12 @@ class RemoteTool(BaseModel):
return v
@dataclass(frozen=True)
class MCPHttpOAuthRuntime:
lock: asyncio.Lock
failure_callback: Callable[[str], Awaitable[None]]
class _MCPContentBlock(BaseModel):
model_config = ConfigDict(from_attributes=True)
text: str | None = None
@ -252,6 +261,7 @@ def create_mcp_http_proxy_tool_class(
server_hint: str | None = None,
headers: dict[str, str] | None = None,
auth: httpx.Auth | None = None,
oauth_runtime: MCPHttpOAuthRuntime | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_enabled: bool = True,
@ -278,10 +288,8 @@ def create_mcp_http_proxy_tool_class(
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_headers: ClassVar[dict[str, str]] = dict(headers or {})
# TODO(VIBE-3057+): concurrent refresh coordinated by per-alias
# asyncio.Lock in MCPRegistry (PR 4 / project decision #6) — this
# object is shared across all calls on this proxy class.
_auth: ClassVar[httpx.Auth | None] = auth
_oauth_runtime: ClassVar[MCPHttpOAuthRuntime | None] = oauth_runtime
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
_sampling_enabled: ClassVar[bool] = sampling_enabled
@ -302,19 +310,38 @@ def create_mcp_http_proxy_tool_class(
ctx.sampling_callback if ctx and self._sampling_enabled else None
)
payload = args.model_dump(exclude_none=True)
yield await call_tool_http(
self._mcp_url,
self._remote_name,
payload,
headers=self._headers,
auth=self._auth,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
if self._oauth_runtime is None:
yield await self._call_remote(payload, sampling_callback)
return
async with self._oauth_runtime.lock:
result = await self._call_remote(payload, sampling_callback)
yield result
except OAuthFlowError as exc:
if self._oauth_runtime is not None:
await self._oauth_runtime.failure_callback(self._server_name)
raise ToolError(
f"MCP server '{self._server_name}' lost authentication. "
"Stop the current turn and ask the user to run "
f"`/mcp login {self._server_name}` to re-authenticate."
) from exc
except Exception as exc:
raise ToolError(f"MCP call failed: {exc}") from exc
@classmethod
async def _call_remote(
cls, payload: dict[str, Any], sampling_callback: MCPSamplingHandler | None
) -> MCPToolResult:
return await call_tool_http(
cls._mcp_url,
cls._remote_name,
payload,
headers=cls._headers,
auth=cls._auth,
startup_timeout_sec=cls._startup_timeout_sec,
tool_timeout_sec=cls._tool_timeout_sec,
sampling_callback=sampling_callback,
)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, MCPToolResult):
@ -334,6 +361,39 @@ def create_mcp_http_proxy_tool_class(
return MCPHttpProxyTool
def build_stdio_params(
command: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None
) -> StdioServerParameters:
return StdioServerParameters(command=command[0], args=command[1:], env=env, cwd=cwd)
async def enter_stdio_session(
stack: contextlib.AsyncExitStack,
params: StdioServerParameters,
*,
init_timeout: timedelta | None,
sampling_callback: MCPSamplingHandler | None = None,
) -> ClientSession:
"""Enter the stderr-capture, stdio_client, and ClientSession contexts on *stack*.
The caller owns ``stack`` and decides when to close it. Returns an initialized
session. The one-shot helpers close the stack immediately; the connection pool
keeps it open for the session lifetime.
"""
errlog = await stack.enter_async_context(_mcp_stderr_capture())
read, write = await stack.enter_async_context(stdio_client(params, errlog=errlog))
session = await stack.enter_async_context(
ClientSession(
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
)
)
await session.initialize()
return session
async def list_tools_stdio(
command: list[str],
*,
@ -341,16 +401,10 @@ async def list_tools_stdio(
cwd: str | None = None,
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
params = StdioServerParameters(
command=command[0], args=command[1:], env=env, cwd=cwd
)
params = build_stdio_params(command, env=env, cwd=cwd)
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with (
_mcp_stderr_capture() as errlog,
stdio_client(params, errlog=errlog) as (read, write),
ClientSession(read, write, read_timeout_seconds=timeout) as session,
):
await session.initialize()
async with contextlib.AsyncExitStack() as stack:
session = await enter_stdio_session(stack, params, init_timeout=timeout)
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
@ -366,24 +420,18 @@ async def call_tool_stdio(
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
params = StdioServerParameters(
command=command[0], args=command[1:], env=env, cwd=cwd
)
params = build_stdio_params(command, env=env, cwd=cwd)
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with (
_mcp_stderr_capture() as errlog,
stdio_client(params, errlog=errlog) as (read, write),
ClientSession(
read,
write,
read_timeout_seconds=init_timeout,
async with contextlib.AsyncExitStack() as stack:
session = await enter_stdio_session(
stack,
params,
init_timeout=init_timeout,
sampling_callback=sampling_callback,
) as session,
):
await session.initialize()
)
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
@ -447,7 +495,20 @@ def create_mcp_stdio_proxy_tool_class(
ctx.sampling_callback if ctx and self._sampling_enabled else None
)
payload = args.model_dump(exclude_none=True)
result = await call_tool_stdio(
pool = ctx.mcp_pool if ctx else None
if pool is not None:
yield await pool.call_tool(
command=self._stdio_command,
tool_name=self._remote_name,
arguments=payload,
env=self._env,
cwd=self._cwd,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
return
yield await call_tool_stdio(
self._stdio_command,
self._remote_name,
payload,
@ -457,7 +518,6 @@ def create_mcp_stdio_proxy_tool_class(
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,
)
yield result
except Exception as exc:
raise ToolError(f"MCP stdio call failed: {exc!r}") from exc

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import os
import httpx
from mistralai.client import Mistral
@ -14,7 +13,11 @@ from mistralai.client.models import (
)
from mistralai.extra.realtime import UnknownRealtimeEvent
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
from vibe.core.config import (
TranscribeModelConfig,
TranscribeProviderConfig,
resolve_api_key,
)
from vibe.core.transcribe.transcribe_client_port import (
TranscribeDone,
TranscribeError,
@ -29,7 +32,7 @@ class MistralTranscribeClient:
def __init__(
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
) -> None:
self._api_key = os.getenv(provider.api_key_env_var, "")
self._api_key = resolve_api_key(provider.api_key_env_var) or ""
self._server_url = provider.api_base
self._model_name = model.name
self._audio_format = AudioFormat(

View file

@ -22,6 +22,12 @@ class WorkspaceTrustDecision(StrEnum):
DECLINE = "decline"
class WorkspaceTrustStatus(StrEnum):
TRUSTED = "trusted"
SESSION = "session"
UNTRUSTED = "untrusted"
@dataclass(frozen=True)
class WorkspaceTrustPrompt:
cwd: Path
@ -110,14 +116,19 @@ def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list
return sorted(found)
def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None:
def maybe_build_workspace_trust_prompt(
cwd: Path, *, include_explicitly_untrusted: bool = False
) -> WorkspaceTrustPrompt | None:
resolved_cwd = cwd.resolve()
if resolved_cwd == Path.home().resolve():
return None
if trusted_folders_manager.is_trusted(cwd) is True:
return None
if trusted_folders_manager.is_explicitly_untrusted(cwd):
if (
not include_explicitly_untrusted
and trusted_folders_manager.is_explicitly_untrusted(cwd)
):
return None
repo_root = find_git_repo_ancestor(cwd)
@ -131,7 +142,10 @@ def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None
resolved_repo_root is not None
and resolved_repo_root in resolved_cwd.parents
and trusted_folders_manager.is_trusted(resolved_repo_root) is not True
and not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root)
and (
include_explicitly_untrusted
or not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root)
)
)
repo_explicitly_untrusted = (
resolved_repo_root is not None
@ -238,6 +252,20 @@ class TrustedFoldersManager:
case None:
return None
def trust_status(self, path: Path) -> WorkspaceTrustStatus:
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._session_trusted:
return WorkspaceTrustStatus.SESSION
if s in self._trusted:
return WorkspaceTrustStatus.TRUSTED
if s in self._untrusted:
return WorkspaceTrustStatus.UNTRUSTED
if current.parent == current:
return WorkspaceTrustStatus.UNTRUSTED
current = current.parent
def is_explicitly_untrusted(self, path: Path) -> bool:
"""*path* literally in the untrusted list (no ancestor walk)."""
return self._normalize_path(path) in self._untrusted

View file

@ -1,20 +1,19 @@
from __future__ import annotations
import base64
import os
import httpx
from mistralai.client import Mistral
from mistralai.client.models import SpeechOutputFormat
from vibe.core.config import TTSModelConfig, TTSProviderConfig
from vibe.core.config import TTSModelConfig, TTSProviderConfig, resolve_api_key
from vibe.core.tts.tts_client_port import TTSResult
from vibe.core.utils.http import build_ssl_context
class MistralTTSClient:
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None:
self._api_key = os.getenv(provider.api_key_env_var, "")
self._api_key = resolve_api_key(provider.api_key_env_var) or ""
self._server_url = provider.api_base
self._model_name = model.name
self._voice = model.voice

View file

@ -21,8 +21,10 @@ from pydantic import (
BeforeValidator,
ConfigDict,
Field,
JsonValue,
PrivateAttr,
computed_field,
field_validator,
model_validator,
)
@ -221,14 +223,72 @@ IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".gif", "
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024
MAX_IMAGES_PER_MESSAGE: int = 8
type UserDisplayContentItem = dict[str, JsonValue]
class UserDisplayContentMetadata(BaseModel):
model_config = ConfigDict(allow_inf_nan=False, extra="forbid")
version: str = Field(min_length=1)
host: str = Field(min_length=1)
content: list[UserDisplayContentItem]
@field_validator("version")
@classmethod
def validate_version(cls, value: str) -> str:
version = value.strip()
if not version:
raise ValueError("version must not be blank")
return version
@field_validator("host")
@classmethod
def validate_host(cls, value: str) -> str:
host = value.strip()
if not host:
raise ValueError("host must not be blank")
return host
class FileImageSource(BaseModel):
model_config = ConfigDict(extra="ignore")
kind: Literal["file"] = "file"
path: Path
class InlineImageSource(BaseModel):
model_config = ConfigDict(extra="ignore")
kind: Literal["inline"] = "inline"
# Raw base64-encoded bytes (no `data:` prefix). Used when the image has no
# durable file on disk (session logging disabled): memory-only, never
# persisted to a session transcript.
data: str
class ImageAttachment(BaseModel):
model_config = ConfigDict(extra="ignore")
path: Path
source: Annotated[FileImageSource | InlineImageSource, Field(discriminator="kind")]
alias: str
mime_type: str
@model_validator(mode="before")
@classmethod
def _migrate_flat_source(cls, value: Any) -> Any:
# Accept and migrate the legacy flat shape `{path|data, ...}` from older
# session transcripts.
if not isinstance(value, dict) or "source" in value:
return value
if value.get("path") is not None:
return {**value, "source": {"kind": "file", "path": value["path"]}}
if value.get("data") is not None:
return {**value, "source": {"kind": "inline", "data": value["data"]}}
return value
class LLMMessage(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -245,6 +305,7 @@ class LLMMessage(BaseModel):
name: str | None = None
tool_call_id: str | None = None
message_id: str | None = None
user_display_content: UserDisplayContentMetadata | None = None
@model_validator(mode="before")
@classmethod
@ -273,6 +334,7 @@ class LLMMessage(BaseModel):
"images": getattr(v, "images", None),
"message_id": getattr(v, "message_id", None)
or (str(uuid4()) if role != "tool" else None),
"user_display_content": getattr(v, "user_display_content", None),
}
def __add__(self, other: LLMMessage) -> LLMMessage:
@ -343,6 +405,9 @@ class LLMMessage(BaseModel):
name=self.name,
tool_call_id=self.tool_call_id,
message_id=self.message_id,
user_display_content=self.user_display_content
if self.user_display_content is not None
else other.user_display_content,
)
@ -548,15 +613,19 @@ class MessageList(Sequence[LLMMessage]):
for hook in self._reset_hooks:
hook()
def update_system_prompt(self, new: str) -> None:
"""Update the system prompt in place.
def update_system_prompt(self, new: str, *, notify: bool = False) -> None:
"""Replace the system prompt, or insert it if none exists yet.
Called from a background thread during deferred init. A single
list-item assignment is atomic under CPython's GIL, and the
``@requires_init`` decorator ensures no ``act()`` call reads the
prompt concurrently, so no additional lock is needed here.
Under deferred init the prompt can land after messages were already
appended, so insert at the front rather than clobber slot 0.
"""
self._data[0] = LLMMessage(role=Role.system, content=new)
msg = LLMMessage(role=Role.system, content=new)
if self._data and self._data[0].role == Role.system:
self._data[0] = msg
else:
self._data.insert(0, msg)
if notify:
self._notify(msg)
@contextmanager
def silent(self) -> Iterator[None]:

View file

@ -2,11 +2,20 @@ from __future__ import annotations
def snippet_start_line(content: str, snippet: str) -> int | None:
lines = snippet_start_lines(content, snippet)
return lines[0] if lines else None
def snippet_start_lines(content: str, snippet: str) -> list[int]:
if not snippet.strip("\n"):
return None
if (pos := content.find(snippet)) == -1:
return None
return []
# Skip leading newlines so the reported line is the first content line,
# aligning the gutter with the diff (which renders the snippet stripped).
leading = len(snippet) - len(snippet.lstrip("\n"))
return content.count("\n", 0, pos + leading) + 1
lines: list[int] = []
pos = content.find(snippet)
while pos != -1:
lines.append(content.count("\n", 0, pos + leading) + 1)
# Advance past the match (non-overlapping, mirroring str.replace).
pos = content.find(snippet, pos + len(snippet))
return lines

View file

@ -3,9 +3,14 @@ from __future__ import annotations
import os
from dotenv import set_key, unset_key
import keyring
from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE
_KEYRING_SERVICE = "vibe"
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.types import Backend
@ -55,9 +60,20 @@ def persist_api_key(
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
keyring.set_password(_KEYRING_SERVICE, env_key, api_key)
except KeyringError:
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
else:
# The key is safely stored in the keyring; drop any stale plaintext copy.
try:
_remove_api_key_from_env_file(env_key)
except (OSError, ValueError) as err:
logger.error(
"Failed to remove stale plaintext API key from env file", exc_info=err
)
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(
@ -74,5 +90,20 @@ 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")
keyring_error: KeyringError | None = None
try:
keyring.delete_password(_KEYRING_SERVICE, env_key)
except (NoKeyringError, PasswordDeleteError):
# No keyring backend, or nothing stored to remove: both are no-ops for sign-out.
pass
except KeyringError as exc:
# Deletion was attempted but failed still clear the other copies, then
# surface the failure so sign-out does not look successful while the
# credential is still in the keyring.
keyring_error = exc
_remove_api_key_from_env_file(env_key)
os.environ.pop(env_key, None)
if keyring_error is not None:
raise keyring_error

View file

@ -7,17 +7,21 @@ import os
from pathlib import Path
from dotenv import dotenv_values
import keyring
from keyring.errors import KeyringError
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.paths import GLOBAL_ENV_FILE
_KEYRING_SERVICE = "vibe"
class AuthStateKind(StrEnum):
SIGNED_OUT = auto()
AUTH_NOT_REQUIRED = auto()
OS_KEYRING = auto()
VIBE_HOME_ENV_FILE = auto()
PROCESS_ENV = auto()
VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV = auto()
UNSUPPORTED_PROVIDER = auto()
@ -25,6 +29,7 @@ class AuthStateKind(StrEnum):
class _AuthEnvSnapshot:
env_key: str
current_process_has_value: bool
keyring_has_value: bool
dotenv_has_value: bool
process_env_had_value_before_dotenv_load: bool
@ -79,9 +84,15 @@ def _capture_auth_env_snapshot(
) -> _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
try:
keyring_has_value = _has_value(keyring.get_password(_KEYRING_SERVICE, env_key))
except KeyringError:
keyring_has_value = False
return _AuthEnvSnapshot(
env_key=env_key,
current_process_has_value=_has_value(resolved_environ.get(env_key)),
keyring_has_value=keyring_has_value,
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,
)
@ -108,6 +119,7 @@ def assess_auth_state(
)
if (
not auth_snapshot.current_process_has_value
and not auth_snapshot.keyring_has_value
and not auth_snapshot.dotenv_has_value
):
return _auth_state(
@ -121,31 +133,31 @@ def assess_auth_state(
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,
sign_out_available=True,
env_key=env_key,
)
if auth_snapshot.process_env_had_value_before_dotenv_load:
kind = AuthStateKind.PROCESS_ENV
sign_out_available = False
elif auth_snapshot.dotenv_has_value:
# load_dotenv_values injects the .env value into os.environ, and
# resolve_api_key reads os.environ before the keyring. So a .env entry is
# the active credential even when the keyring also holds one, and the
# reported state must reflect the .env file rather than the keyring.
kind = AuthStateKind.VIBE_HOME_ENV_FILE
sign_out_available = True
elif auth_snapshot.keyring_has_value:
kind = AuthStateKind.OS_KEYRING
sign_out_available = True
elif auth_snapshot.current_process_has_value:
kind = AuthStateKind.PROCESS_ENV
sign_out_available = False
else:
raise AssertionError("assess_auth_state reached unreachable state")
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")
return _auth_state(
kind,
can_use_active_provider=True,
sign_out_available=sign_out_available,
env_key=env_key,
)
__all__ = ["AuthState", "AuthStateKind", "assess_auth_state"]

View file

@ -1,8 +1,15 @@
from __future__ import annotations
from vibe.setup.update_prompt.theme import load_update_prompt_theme
from vibe.setup.update_prompt.update_prompt_dialog import (
UpdatePromptMode,
UpdatePromptResult,
ask_update_prompt,
)
__all__ = ["UpdatePromptResult", "ask_update_prompt"]
__all__ = [
"UpdatePromptMode",
"UpdatePromptResult",
"ask_update_prompt",
"load_update_prompt_theme",
]

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from collections.abc import Mapping
import os
from pathlib import Path
import tomllib
from typing import Any
from vibe.core.config import DEFAULT_THEME, resolve_theme_name
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.utils.io import read_safe
def _read_config_theme(config_file: Path) -> Any:
try:
config = tomllib.loads(read_safe(config_file, raise_on_error=True).text)
except FileNotFoundError:
return None
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
logger.debug(
"Failed to read update prompt theme from %s", config_file, exc_info=exc
)
return None
if not isinstance(config, dict):
return None
return config.get("theme")
def load_update_prompt_theme(
*, environ: Mapping[str, str] | None = None, config_file: Path | None = None
) -> str:
resolved_environ = os.environ if environ is None else environ
if "VIBE_THEME" in resolved_environ:
return resolve_theme_name(resolved_environ["VIBE_THEME"])
resolved_config_file = config_file or get_harness_files_manager().config_file
if resolved_config_file is None:
return DEFAULT_THEME
return resolve_theme_name(_read_config_theme(resolved_config_file))

View file

@ -24,14 +24,19 @@ class UpdatePromptResult(StrEnum):
QUIT = auto()
class UpdatePromptMode(StrEnum):
STARTUP = auto()
CHECK_UPGRADE = auto()
class UpdateChoice(StrEnum):
UPDATE = auto()
CONTINUE = auto()
_CHOICE_LABELS: dict[UpdateChoice, str] = {
UpdateChoice.UPDATE: "Update now",
UpdateChoice.CONTINUE: "Continue with current version",
_CONTINUE_LABELS: dict[UpdatePromptMode, str] = {
UpdatePromptMode.STARTUP: "Continue with current version",
UpdatePromptMode.CHECK_UPGRADE: "Cancel upgrade",
}
@ -56,11 +61,19 @@ class UpdatePromptDialog(CenterMiddle):
self.succeeded = succeeded
def __init__(
self, current_version: str, latest_version: str, **kwargs: Any
self,
current_version: str,
latest_version: str,
prompt_mode: UpdatePromptMode = UpdatePromptMode.STARTUP,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.current_version = current_version
self.latest_version = latest_version
self._choice_labels: dict[UpdateChoice, str] = {
UpdateChoice.UPDATE: "Update now",
UpdateChoice.CONTINUE: _CONTINUE_LABELS[prompt_mode],
}
self.selected: UpdateChoice = UpdateChoice.UPDATE
self._option_widgets: dict[UpdateChoice, NoMarkupStatic] = {}
self._is_updating = False
@ -78,7 +91,7 @@ class UpdatePromptDialog(CenterMiddle):
with Horizontal(id="update-options-container"):
for choice in UpdateChoice:
widget = NoMarkupStatic(
f" {_CHOICE_LABELS[choice]}", classes="update-option"
f" {self._choice_labels[choice]}", classes="update-option"
)
self._option_widgets[choice] = widget
yield widget
@ -102,7 +115,7 @@ class UpdatePromptDialog(CenterMiddle):
for choice in UpdateChoice:
widget = self._option_widgets[choice]
cursor = " " if choice == self.selected else " "
widget.update(f"{cursor}{_CHOICE_LABELS[choice]}")
widget.update(f"{cursor}{self._choice_labels[choice]}")
widget.remove_class("update-option--active")
widget.remove_class("update-option--inactive")
widget.add_class(
@ -167,12 +180,14 @@ class UpdatePromptApp(App[UpdatePromptResult]):
current_version: str,
latest_version: str,
theme: str | None = None,
prompt_mode: UpdatePromptMode = UpdatePromptMode.STARTUP,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.current_version = current_version
self.latest_version = latest_version
self._theme_name = theme
self._prompt_mode = prompt_mode
self._dialog: UpdatePromptDialog | None = None
self._update_task: asyncio.Task[None] | None = None
@ -181,7 +196,9 @@ class UpdatePromptApp(App[UpdatePromptResult]):
self.theme = self._theme_name
def compose(self) -> ComposeResult:
self._dialog = UpdatePromptDialog(self.current_version, self.latest_version)
self._dialog = UpdatePromptDialog(
self.current_version, self.latest_version, prompt_mode=self._prompt_mode
)
yield self._dialog
async def action_quit_prompt(self) -> None:
@ -215,7 +232,12 @@ class UpdatePromptApp(App[UpdatePromptResult]):
def ask_update_prompt(
current_version: str, latest_version: str, theme: str | None = None
current_version: str,
latest_version: str,
theme: str | None = None,
prompt_mode: UpdatePromptMode = UpdatePromptMode.STARTUP,
) -> UpdatePromptResult:
app = UpdatePromptApp(current_version, latest_version, theme=theme)
app = UpdatePromptApp(
current_version, latest_version, theme=theme, prompt_mode=prompt_mode
)
return app.run(inline=True) or UpdatePromptResult.CONTINUE

View file

@ -1,4 +1,5 @@
# What's new in v2.16.0
- **Better slash commands**: Slash command autocomplete now uses fuzzy search
- **Readable edit diffs**: File edit previews now include syntax highlighting, line numbers, and theme-aware colors
- **Focused resume picker**: `/resume` now shows sessions for the current folder
# What's new in v2.17.0
- **MCP OAuth login**: Authenticate OAuth-backed MCP servers from the TUI with the new `/mcp login`, `/mcp logout`, and `/mcp status` commands
- **`--check-upgrade`**: New flag to check for a Vibe update on demand, prompt to install it, and exit
- **`--yolo`**: New shorthand alias for `--auto-approve`