v2.17.0 (#822)
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:
parent
564a14365e
commit
6bedf271ce
223 changed files with 10533 additions and 6947 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
62
vibe/acp/image_blocks.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
14
vibe/acp/user_display_content.py
Normal file
14
vibe/acp/user_display_content.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
130
vibe/cli/cli.py
130
vibe/cli/cli.py
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
69
vibe/core/cache_store.py
Normal 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 {}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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``."""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
262
vibe/core/tools/mcp/pool.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
42
vibe/setup/update_prompt/theme.py
Normal file
42
vibe/setup/update_prompt/theme.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue