v2.10.1 (#702)
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Michel Thomazo <51709227+michelTho@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: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
228f3c65a9
commit
f71bfd3b8c
57 changed files with 2950 additions and 402 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.10.0"
|
||||
__version__ = "2.10.1"
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import aclosing
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
from typing import Any, cast, override
|
||||
from typing import Any, Protocol, cast, override
|
||||
from uuid import uuid4
|
||||
|
||||
from acp import (
|
||||
|
|
@ -57,6 +59,7 @@ from acp.schema import (
|
|||
SetSessionConfigOptionResponse,
|
||||
SseMcpServer,
|
||||
TerminalAuthMethod,
|
||||
TerminalToolCallContent,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
ToolCallProgress,
|
||||
|
|
@ -83,7 +86,9 @@ from vibe.acp.exceptions import (
|
|||
UnauthenticatedError,
|
||||
)
|
||||
from vibe.acp.session import AcpSessionLoop
|
||||
from vibe.acp.title import acp_blocks_to_title_segments
|
||||
from vibe.acp.tools.base import BaseAcpTool
|
||||
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
||||
from vibe.acp.tools.session_update import (
|
||||
resolve_kind,
|
||||
tool_call_session_update,
|
||||
|
|
@ -112,6 +117,7 @@ from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
|
|||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
ProviderConfig,
|
||||
SessionLoggingConfig,
|
||||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
|
|
@ -129,6 +135,7 @@ from vibe.core.session.saved_sessions import (
|
|||
update_saved_session_title_at_path,
|
||||
)
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.session.title_format import format_session_title
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
|
|
@ -146,6 +153,7 @@ from vibe.core.types import (
|
|||
RateLimitError as CoreRateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
SessionTitleUpdatedEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
|
|
@ -155,6 +163,18 @@ from vibe.core.utils import (
|
|||
ConversationLimitException,
|
||||
get_user_cancellation_message,
|
||||
)
|
||||
from vibe.setup.auth import (
|
||||
BrowserSignInAttempt,
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInService,
|
||||
HttpBrowserSignInGateway,
|
||||
)
|
||||
from vibe.setup.auth.api_key_persistence import (
|
||||
persist_api_key,
|
||||
resolve_api_key_provider,
|
||||
)
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
logger = logging.getLogger("vibe")
|
||||
|
||||
|
|
@ -215,13 +235,123 @@ def _resolved_user_message_id(client_message_id: str | None) -> str:
|
|||
return str(uuid4())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingBrowserSignInAttempt:
|
||||
attempt: BrowserSignInAttempt
|
||||
provider: ProviderConfig
|
||||
|
||||
|
||||
RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS = {
|
||||
BrowserSignInErrorCode.EXCHANGE_FAILED,
|
||||
BrowserSignInErrorCode.POLL_FAILED,
|
||||
}
|
||||
|
||||
|
||||
OnboardingContextLoader = Callable[[], OnboardingContext]
|
||||
ApiKeyPersister = Callable[[ProviderConfig, str], str]
|
||||
|
||||
|
||||
class BrowserSignInServiceAdapter(Protocol):
|
||||
async def authenticate(self) -> str: ...
|
||||
|
||||
async def start_attempt(self) -> BrowserSignInAttempt: ...
|
||||
|
||||
async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str: ...
|
||||
|
||||
async def aclose(self) -> None: ...
|
||||
|
||||
|
||||
BrowserSignInServiceFactory = Callable[[ProviderConfig], BrowserSignInServiceAdapter]
|
||||
|
||||
|
||||
class VibeAcpAgentLoop(AcpAgent):
|
||||
client: Client
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
onboarding_context_loader: OnboardingContextLoader | None = None,
|
||||
browser_sign_in_service_factory: BrowserSignInServiceFactory | None = None,
|
||||
api_key_persister: ApiKeyPersister = persist_api_key,
|
||||
) -> None:
|
||||
self.sessions: dict[str, AcpSessionLoop] = {}
|
||||
self.client_capabilities: ClientCapabilities | None = None
|
||||
self.client_info: Implementation | None = None
|
||||
self._pending_browser_sign_in_attempts: dict[
|
||||
str, PendingBrowserSignInAttempt
|
||||
] = {}
|
||||
self._load_onboarding_context = (
|
||||
onboarding_context_loader or OnboardingContext.load
|
||||
)
|
||||
self._browser_sign_in_service_factory = (
|
||||
browser_sign_in_service_factory or self._build_browser_sign_in_service
|
||||
)
|
||||
self._persist_api_key = api_key_persister
|
||||
|
||||
def _build_browser_auth_method(
|
||||
self, context: OnboardingContext, method_id: str
|
||||
) -> AuthMethodAgent | None:
|
||||
if not context.browser_sign_in_enabled:
|
||||
return None
|
||||
|
||||
return AuthMethodAgent(
|
||||
id=method_id,
|
||||
name="Sign in through Mistral AI Studio",
|
||||
description="Sign into Mistral Vibe through your Mistral AI Studio account.",
|
||||
)
|
||||
|
||||
def _build_terminal_auth_method(
|
||||
self, command: str, args: list[str]
|
||||
) -> TerminalAuthMethod:
|
||||
return TerminalAuthMethod(
|
||||
type="terminal",
|
||||
id="vibe-setup",
|
||||
name="Register your API Key",
|
||||
description="Register your API Key inside Mistral Vibe",
|
||||
field_meta={
|
||||
"terminal-auth": {
|
||||
"command": command,
|
||||
"args": args,
|
||||
"label": "Mistral Vibe Setup",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def _build_browser_sign_in_service(
|
||||
self, provider: ProviderConfig | None = None
|
||||
) -> BrowserSignInService:
|
||||
provider = provider or self._load_onboarding_context().provider
|
||||
if not provider.supports_browser_sign_in:
|
||||
raise InvalidRequestError(
|
||||
"Browser sign-in is not available for the configured provider."
|
||||
)
|
||||
|
||||
browser_base_url = provider.browser_auth_base_url
|
||||
api_base_url = provider.browser_auth_api_base_url
|
||||
if browser_base_url is None or api_base_url is None:
|
||||
raise ConfigurationError("Browser sign-in requires both browser auth URLs.")
|
||||
|
||||
return BrowserSignInService(
|
||||
HttpBrowserSignInGateway(
|
||||
browser_base_url=browser_base_url, api_base_url=api_base_url
|
||||
)
|
||||
)
|
||||
|
||||
def _load_enabled_browser_sign_in_context(self) -> OnboardingContext:
|
||||
context = self._load_onboarding_context()
|
||||
if not context.browser_sign_in_enabled:
|
||||
raise InvalidRequestError(
|
||||
"Browser sign-in is not available for the configured provider."
|
||||
)
|
||||
return context
|
||||
|
||||
def _supports_delegated_browser_auth(self) -> bool:
|
||||
return bool(
|
||||
self.client_capabilities
|
||||
and self.client_capabilities.field_meta
|
||||
and self.client_capabilities.field_meta.get("browser-auth-delegated")
|
||||
is True
|
||||
)
|
||||
|
||||
@override
|
||||
async def initialize(
|
||||
|
|
@ -256,25 +386,21 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
and self.client_capabilities.field_meta.get("terminal-auth") is True
|
||||
)
|
||||
|
||||
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
|
||||
[
|
||||
TerminalAuthMethod(
|
||||
type="terminal",
|
||||
id="vibe-setup",
|
||||
name="Register your API Key",
|
||||
description="Register your API Key inside Mistral Vibe",
|
||||
field_meta={
|
||||
"terminal-auth": {
|
||||
"command": command,
|
||||
"args": args,
|
||||
"label": "Mistral Vibe Setup",
|
||||
}
|
||||
},
|
||||
)
|
||||
]
|
||||
if supports_terminal_auth
|
||||
else []
|
||||
)
|
||||
context = self._load_onboarding_context()
|
||||
|
||||
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = []
|
||||
if browser_auth_method := self._build_browser_auth_method(
|
||||
context, "browser-auth"
|
||||
):
|
||||
auth_methods.append(browser_auth_method)
|
||||
if self._supports_delegated_browser_auth():
|
||||
delegated_browser_auth_method = self._build_browser_auth_method(
|
||||
context, "browser-auth-delegated"
|
||||
)
|
||||
if delegated_browser_auth_method is not None:
|
||||
auth_methods.append(delegated_browser_auth_method)
|
||||
if supports_terminal_auth:
|
||||
auth_methods.append(self._build_terminal_auth_method(command, args))
|
||||
|
||||
response = InitializeResponse(
|
||||
agent_capabilities=AgentCapabilities(
|
||||
|
|
@ -298,11 +424,116 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
return response
|
||||
|
||||
async def _authenticate_browser_auth(self, **kwargs: Any) -> AuthenticateResponse:
|
||||
action = kwargs.get("action")
|
||||
if action not in {None, "start"}:
|
||||
raise InvalidRequestError(f"Unsupported browser auth action: {action}")
|
||||
|
||||
provider = self._load_enabled_browser_sign_in_context().provider
|
||||
browser_sign_in = self._browser_sign_in_service_factory(provider)
|
||||
try:
|
||||
api_key = await browser_sign_in.authenticate()
|
||||
except BrowserSignInError as e:
|
||||
raise InternalError(str(e)) from e
|
||||
finally:
|
||||
await browser_sign_in.aclose()
|
||||
|
||||
persist_result = self._persist_api_key(
|
||||
resolve_api_key_provider(provider), api_key
|
||||
)
|
||||
return AuthenticateResponse(
|
||||
field_meta={
|
||||
"browser-auth": {"persistResult": persist_result, "status": "completed"}
|
||||
}
|
||||
)
|
||||
|
||||
async def _start_delegated_browser_auth(self) -> AuthenticateResponse:
|
||||
provider = self._load_enabled_browser_sign_in_context().provider
|
||||
browser_sign_in = self._browser_sign_in_service_factory(provider)
|
||||
try:
|
||||
attempt = await browser_sign_in.start_attempt()
|
||||
except BrowserSignInError as e:
|
||||
raise InternalError(str(e)) from e
|
||||
finally:
|
||||
await browser_sign_in.aclose()
|
||||
|
||||
self._pending_browser_sign_in_attempts[attempt.process_id] = (
|
||||
PendingBrowserSignInAttempt(attempt=attempt, provider=provider)
|
||||
)
|
||||
return AuthenticateResponse(
|
||||
field_meta={
|
||||
"browser-auth-delegated": {
|
||||
"attemptId": attempt.process_id,
|
||||
"expiresAt": attempt.expires_at.astimezone(UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
"signInUrl": attempt.sign_in_url,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def _complete_delegated_browser_auth(
|
||||
self, **kwargs: Any
|
||||
) -> AuthenticateResponse:
|
||||
attempt_id = kwargs.get("attemptId") or kwargs.get("attempt_id")
|
||||
if not isinstance(attempt_id, str) or not attempt_id:
|
||||
raise InvalidRequestError("Missing browser sign-in attempt ID.")
|
||||
|
||||
pending_attempt = self._pending_browser_sign_in_attempts.get(attempt_id)
|
||||
if pending_attempt is None:
|
||||
raise InvalidRequestError(f"Unknown browser sign-in attempt: {attempt_id}")
|
||||
|
||||
browser_sign_in = self._browser_sign_in_service_factory(
|
||||
pending_attempt.provider
|
||||
)
|
||||
try:
|
||||
api_key = await browser_sign_in.complete_attempt(pending_attempt.attempt)
|
||||
except BrowserSignInError as e:
|
||||
if e.code not in RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS:
|
||||
self._pending_browser_sign_in_attempts.pop(attempt_id, None)
|
||||
raise InvalidRequestError(str(e)) from e
|
||||
finally:
|
||||
await browser_sign_in.aclose()
|
||||
|
||||
self._pending_browser_sign_in_attempts.pop(attempt_id, None)
|
||||
persist_result = self._persist_api_key(
|
||||
resolve_api_key_provider(pending_attempt.provider), api_key
|
||||
)
|
||||
return AuthenticateResponse(
|
||||
field_meta={
|
||||
"browser-auth-delegated": {
|
||||
"attemptId": attempt_id,
|
||||
"persistResult": persist_result,
|
||||
"status": "completed",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def _authenticate_delegated_browser_auth(
|
||||
self, **kwargs: Any
|
||||
) -> AuthenticateResponse:
|
||||
action = kwargs.get("action", "start")
|
||||
if action not in {"start", "complete"}:
|
||||
raise InvalidRequestError(
|
||||
f"Unsupported delegated browser auth action: {action}"
|
||||
)
|
||||
|
||||
if action == "start":
|
||||
return await self._start_delegated_browser_auth()
|
||||
|
||||
return await self._complete_delegated_browser_auth(**kwargs)
|
||||
|
||||
@override
|
||||
async def authenticate(
|
||||
self, method_id: str, **kwargs: Any
|
||||
) -> AuthenticateResponse | None:
|
||||
raise NotImplementedMethodError("authenticate")
|
||||
if method_id == "browser-auth":
|
||||
return await self._authenticate_browser_auth(**kwargs)
|
||||
|
||||
if method_id == "browser-auth-delegated":
|
||||
return await self._authenticate_delegated_browser_auth(**kwargs)
|
||||
|
||||
raise InvalidRequestError(f"Unsupported auth method: {method_id}")
|
||||
|
||||
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
|
||||
return build_entrypoint_metadata(
|
||||
|
|
@ -834,9 +1065,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
text_prompt = SkillManager.build_skill_prompt(text_prompt, skill)
|
||||
|
||||
auto_title: str | None = None
|
||||
if session.agent_loop.session_logger.needs_initial_auto_title():
|
||||
auto_title = (
|
||||
format_session_title(acp_blocks_to_title_segments(prompt)) or None
|
||||
)
|
||||
|
||||
async def agent_loop_task() -> None:
|
||||
async for update in self._run_agent_loop(
|
||||
session, text_prompt, resolved_message_id
|
||||
session, text_prompt, resolved_message_id, auto_title=auto_title
|
||||
):
|
||||
await self.client.session_update(session_id=session.id, update=update)
|
||||
|
||||
|
|
@ -945,15 +1182,29 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
return await handler(session, text_prompt, message_id)
|
||||
|
||||
async def _run_agent_loop(
|
||||
self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None
|
||||
self,
|
||||
session: AcpSessionLoop,
|
||||
prompt: str,
|
||||
client_message_id: str | None = None,
|
||||
*,
|
||||
auto_title: str | None = None,
|
||||
) -> AsyncGenerator[SessionUpdate | UsageUpdate]:
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
|
||||
async with aclosing(
|
||||
session.agent_loop.act(rendered_prompt, client_message_id=client_message_id)
|
||||
session.agent_loop.act(
|
||||
rendered_prompt,
|
||||
client_message_id=client_message_id,
|
||||
auto_title=auto_title,
|
||||
)
|
||||
) as events:
|
||||
async for event in events:
|
||||
if isinstance(event, AssistantEvent):
|
||||
if isinstance(event, SessionTitleUpdatedEvent):
|
||||
await self._emit_session_info_update(
|
||||
session.id, title=event.title, updated_at=None
|
||||
)
|
||||
|
||||
elif isinstance(event, AssistantEvent):
|
||||
yield AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=event.content),
|
||||
|
|
@ -973,7 +1224,6 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
tool_manager=session.agent_loop.tool_manager,
|
||||
client=self.client,
|
||||
session_id=session.id,
|
||||
tool_call_id=event.tool_call_id,
|
||||
)
|
||||
|
||||
session_update = tool_call_session_update(event)
|
||||
|
|
@ -986,6 +1236,22 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
yield session_update
|
||||
self._send_usage_update(session)
|
||||
|
||||
elif isinstance(event, ToolTerminalOpenedEvent):
|
||||
# bash yielded the terminal id; surface it as an
|
||||
# in_progress update carrying the terminal block.
|
||||
yield ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="in_progress",
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=[
|
||||
TerminalToolCallContent(
|
||||
type="terminal", terminal_id=event.terminal_id
|
||||
)
|
||||
],
|
||||
field_meta={"tool_name": event.tool_name},
|
||||
)
|
||||
|
||||
elif isinstance(event, ToolStreamEvent):
|
||||
yield ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
|
|
|
|||
69
vibe/acp/title.py
Normal file
69
vibe/acp/title.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import SplitResult, urlsplit
|
||||
|
||||
from acp.helpers import ContentBlock
|
||||
|
||||
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
|
||||
|
||||
_LINE_FRAGMENT_RE = re.compile(r"^L(\d+)(?:-L(\d+))?$")
|
||||
|
||||
|
||||
def acp_blocks_to_title_segments(blocks: list[ContentBlock]) -> list[TitleSegment]:
|
||||
segments: list[TitleSegment] = []
|
||||
for block in blocks:
|
||||
if block.field_meta and block.field_meta.get("automatic"):
|
||||
continue
|
||||
if (segment := _block_to_segment(block)) is not None:
|
||||
segments.append(segment)
|
||||
return segments
|
||||
|
||||
|
||||
def _block_to_segment(block: ContentBlock) -> TitleSegment | None:
|
||||
match block.type:
|
||||
case "text":
|
||||
return TextSegment(text=block.text)
|
||||
case "resource":
|
||||
base, start, end = _parse_line_range_fragment(block.resource.uri)
|
||||
name = _basename_from_uri(base)
|
||||
if not name:
|
||||
return None
|
||||
return MentionSegment(name=name, start_line=start, end_line=end)
|
||||
case "resource_link":
|
||||
base, start, end = _parse_line_range_fragment(block.uri)
|
||||
name = block.name or _basename_from_uri(base)
|
||||
if not name:
|
||||
return None
|
||||
return MentionSegment(name=name, start_line=start, end_line=end)
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_urlsplit(uri: str) -> SplitResult | None:
|
||||
try:
|
||||
return urlsplit(uri)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_line_range_fragment(uri: str) -> tuple[str, int | None, int | None]:
|
||||
parts = _safe_urlsplit(uri)
|
||||
if parts is None or not parts.fragment:
|
||||
return uri, None, None
|
||||
match = _LINE_FRAGMENT_RE.match(parts.fragment)
|
||||
if match is None:
|
||||
return uri, None, None
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2)) if match.group(2) is not None else None
|
||||
base_uri = parts._replace(fragment="").geturl()
|
||||
return base_uri, start, end
|
||||
|
||||
|
||||
def _basename_from_uri(uri: str) -> str:
|
||||
parts = _safe_urlsplit(uri)
|
||||
if parts is None:
|
||||
return ""
|
||||
path = parts.path if parts.scheme else uri
|
||||
return Path(path).name
|
||||
|
|
@ -4,12 +4,8 @@ from abc import abstractmethod
|
|||
from typing import Annotated, cast
|
||||
|
||||
from acp import Client
|
||||
from acp.helpers import ToolCallContentVariant
|
||||
from acp.schema import ToolCallProgress
|
||||
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
|
||||
|
||||
from vibe.acp.tools.session_update import resolve_kind
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseTool, ToolError
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
|
|
@ -21,9 +17,6 @@ class AcpToolState(BaseModel):
|
|||
default=None, description="ACP Client"
|
||||
)
|
||||
session_id: str | None = Field(default=None, description="Current ACP session ID")
|
||||
tool_call_id: str | None = Field(
|
||||
default=None, description="Current ACP tool call ID"
|
||||
)
|
||||
|
||||
|
||||
class BaseAcpTool[ToolState: AcpToolState](BaseTool):
|
||||
|
|
@ -37,23 +30,17 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
|
|||
|
||||
@classmethod
|
||||
def update_tool_state(
|
||||
cls,
|
||||
*,
|
||||
tool_manager: ToolManager,
|
||||
client: Client | None,
|
||||
session_id: str | None,
|
||||
tool_call_id: str | None,
|
||||
cls, *, tool_manager: ToolManager, client: Client | None, session_id: str | None
|
||||
) -> None:
|
||||
tool_instance = cls.get_tool_instance(cls.get_name(), tool_manager)
|
||||
tool_instance.state.client = client
|
||||
tool_instance.state.session_id = session_id
|
||||
tool_instance.state.tool_call_id = tool_call_id
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _get_tool_state_class(cls) -> type[ToolState]: ...
|
||||
|
||||
def _load_state(self) -> tuple[Client, str, str | None]:
|
||||
def _load_state(self) -> tuple[Client, str]:
|
||||
if self.state.client is None:
|
||||
raise ToolError(
|
||||
"Client not available in tool state. This tool can only be used within an ACP session."
|
||||
|
|
@ -63,27 +50,4 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
|
|||
"Session ID not available in tool state. This tool can only be used within an ACP session."
|
||||
)
|
||||
|
||||
return self.state.client, self.state.session_id, self.state.tool_call_id
|
||||
|
||||
async def _send_in_progress_session_update(
|
||||
self, content: list[ToolCallContentVariant] | None = None
|
||||
) -> None:
|
||||
client, session_id, tool_call_id = self._load_state()
|
||||
if tool_call_id is None:
|
||||
return
|
||||
|
||||
tool_name = self.get_name()
|
||||
try:
|
||||
await client.session_update(
|
||||
session_id=session_id,
|
||||
update=ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=tool_call_id,
|
||||
status="in_progress",
|
||||
kind=resolve_kind(tool_name),
|
||||
content=content,
|
||||
field_meta={"tool_name": tool_name},
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update session: {e!r}")
|
||||
return self.state.client, self.state.session_id
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from pathlib import Path
|
|||
|
||||
from acp.schema import (
|
||||
ContentToolCallContent,
|
||||
TerminalToolCallContent,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
|
|
@ -15,6 +14,7 @@ from acp.schema import (
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
||||
from vibe.acp.tools.session_update import resolve_kind
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
|
|
@ -37,7 +37,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
async def run(
|
||||
self, args: BashArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
|
||||
client, session_id, _ = self._load_state()
|
||||
client, session_id = self._load_state()
|
||||
|
||||
timeout = args.timeout or self.config.default_timeout
|
||||
max_bytes = self.config.max_output_bytes
|
||||
|
|
@ -54,11 +54,14 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
|
||||
terminal_id = terminal.terminal_id
|
||||
|
||||
await self._send_in_progress_session_update([
|
||||
TerminalToolCallContent(type="terminal", terminal_id=terminal_id)
|
||||
])
|
||||
|
||||
try:
|
||||
if ctx is not None:
|
||||
yield ToolTerminalOpenedEvent(
|
||||
tool_name=self.get_name(),
|
||||
tool_call_id=ctx.tool_call_id,
|
||||
terminal_id=terminal_id,
|
||||
)
|
||||
|
||||
exit_response = await self._wait_for_terminal_exit(
|
||||
terminal_id=terminal_id, timeout=timeout, command=args.command
|
||||
)
|
||||
|
|
@ -93,7 +96,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
async def _wait_for_terminal_exit(
|
||||
self, terminal_id: str, timeout: int, command: str
|
||||
) -> WaitForTerminalExitResponse:
|
||||
client, session_id, _ = self._load_state()
|
||||
client, session_id = self._load_state()
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
|
|
|
|||
|
|
@ -114,13 +114,11 @@ class ReadFile(
|
|||
)
|
||||
|
||||
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
|
||||
client, session_id, _ = self._load_state()
|
||||
client, session_id = self._load_state()
|
||||
|
||||
line = args.offset + 1 if args.offset > 0 else None
|
||||
limit = args.limit
|
||||
|
||||
await self._send_in_progress_session_update()
|
||||
|
||||
try:
|
||||
response = await client.read_text_file(
|
||||
session_id=session_id, path=str(file_path), line=line, limit=limit
|
||||
|
|
|
|||
|
|
@ -44,9 +44,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
return AcpSearchReplaceState
|
||||
|
||||
async def _read_file(self, file_path: Path) -> ReadSafeResult:
|
||||
client, session_id, _ = self._load_state()
|
||||
|
||||
await self._send_in_progress_session_update()
|
||||
client, session_id = self._load_state()
|
||||
|
||||
try:
|
||||
response = await client.read_text_file(
|
||||
|
|
@ -74,7 +72,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
async def _write_file(
|
||||
self, file_path: Path, content: str, encoding: str, newline: str
|
||||
) -> None:
|
||||
client, session_id, _ = self._load_state()
|
||||
client, session_id = self._load_state()
|
||||
|
||||
try:
|
||||
await client.write_text_file(
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
|
|||
return AcpWriteFileState
|
||||
|
||||
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
|
||||
client, session_id, _ = self._load_state()
|
||||
|
||||
await self._send_in_progress_session_update()
|
||||
client, session_id = self._load_state()
|
||||
|
||||
try:
|
||||
await client.write_text_file(
|
||||
|
|
|
|||
8
vibe/acp/tools/events.py
Normal file
8
vibe/acp/tools/events.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
|
||||
class ToolTerminalOpenedEvent(ToolStreamEvent):
|
||||
message: str = ""
|
||||
terminal_id: str
|
||||
|
|
@ -123,7 +123,10 @@ 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.autocompletion.path_prompt import build_path_prompt_payload
|
||||
from vibe.core.autocompletion.path_prompt import (
|
||||
build_path_prompt_payload,
|
||||
build_title_segments,
|
||||
)
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
|
|
@ -140,6 +143,7 @@ from vibe.core.session.resume_sessions import (
|
|||
)
|
||||
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.session.title_format import format_session_title
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
|
||||
from vibe.core.teleport.types import (
|
||||
|
|
@ -957,7 +961,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill")
|
||||
prompt = SkillManager.build_skill_prompt(user_input, skill)
|
||||
await self._handle_user_message(prompt)
|
||||
await self._handle_user_message(prompt, title_source=user_input)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -1146,7 +1150,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _handle_user_message(self, message: str) -> None:
|
||||
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
|
||||
|
|
@ -1165,7 +1171,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._remote_manager.stop_stream()
|
||||
await self._remove_loading_widget()
|
||||
self._agent_task = asyncio.create_task(
|
||||
self._handle_agent_loop_turn(message)
|
||||
self._handle_agent_loop_turn(message, title_source=title_source)
|
||||
)
|
||||
|
||||
async def _handle_remote_user_message(self, message: str) -> None:
|
||||
|
|
@ -1366,7 +1372,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
event, loading_widget=self._loading_widget
|
||||
)
|
||||
|
||||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
async def _handle_agent_loop_turn(
|
||||
self, prompt: str, *, title_source: str | None = None
|
||||
) -> None:
|
||||
self._agent_running = True
|
||||
|
||||
await self._remove_loading_widget()
|
||||
|
|
@ -1393,10 +1401,22 @@ class VibeApp(App): # noqa: PLR0904
|
|||
message_id=message_id,
|
||||
)
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
auto_title: str | None = None
|
||||
if self.agent_loop.session_logger.needs_initial_auto_title():
|
||||
auto_title = (
|
||||
format_session_title(
|
||||
build_title_segments(
|
||||
title_source or prompt, base_dir=Path.cwd()
|
||||
)
|
||||
)
|
||||
or None
|
||||
)
|
||||
self._narrator_manager.cancel()
|
||||
self._narrator_manager.on_turn_start(rendered_prompt)
|
||||
async with aclosing(
|
||||
self.agent_loop.act(rendered_prompt, client_message_id=message_id)
|
||||
self.agent_loop.act(
|
||||
rendered_prompt, client_message_id=message_id, auto_title=auto_title
|
||||
)
|
||||
) as events:
|
||||
await self._handle_agent_loop_events(events)
|
||||
except asyncio.CancelledError:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from vibe.core.types import (
|
|||
PlanReviewEndedEvent,
|
||||
PlanReviewRequestedEvent,
|
||||
ReasoningEvent,
|
||||
SessionTitleUpdatedEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
|
|
@ -116,6 +117,8 @@ class EventHandler:
|
|||
case AgentProfileChangedEvent():
|
||||
if self.on_profile_changed:
|
||||
self.on_profile_changed()
|
||||
case SessionTitleUpdatedEvent():
|
||||
pass
|
||||
case UserMessageEvent():
|
||||
await self.finalize_streaming()
|
||||
if self.is_remote:
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ _LIST_VIEW_HELP_AUTH = (
|
|||
_DETAIL_VIEW_HELP = (
|
||||
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
|
||||
)
|
||||
_DETAIL_VIEW_HELP_NO_TOOLS = "↑↓ Navigate Backspace Back R Refresh Esc Close"
|
||||
|
||||
|
||||
class MCPApp(Container):
|
||||
|
|
@ -211,7 +212,16 @@ class MCPApp(Container):
|
|||
return
|
||||
|
||||
self._status_message = "Refreshing..."
|
||||
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS
|
||||
if self._viewing_server:
|
||||
tools_source = (
|
||||
self._index.connector_tools
|
||||
if self._viewing_kind == MCPSourceKind.CONNECTOR
|
||||
else self._index.server_tools
|
||||
)
|
||||
all_tools = tools_source.get(self._viewing_server, [])
|
||||
help = _DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS
|
||||
else:
|
||||
help = _LIST_VIEW_HELP_TOOLS
|
||||
self._set_help_text(help)
|
||||
|
||||
self._refreshing = True
|
||||
|
|
@ -516,9 +526,11 @@ class MCPApp(Container):
|
|||
self.query_one("#mcp-title", NoMarkupStatic).update(
|
||||
f"{title_prefix}: {server_name}"
|
||||
)
|
||||
self._set_help_text(_DETAIL_VIEW_HELP)
|
||||
tools_source = index.connector_tools if is_connector else index.server_tools
|
||||
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
|
||||
self._set_help_text(
|
||||
_DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS
|
||||
)
|
||||
if not all_tools:
|
||||
if (
|
||||
is_connector
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ from vibe.core.types import (
|
|||
RateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
SessionTitleUpdatedEvent,
|
||||
ToolCall,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
|
|
@ -629,7 +630,11 @@ class AgentLoop: # noqa: PLR0904
|
|||
|
||||
@requires_init
|
||||
async def act(
|
||||
self, msg: str, client_message_id: str | None = None
|
||||
self,
|
||||
msg: str,
|
||||
client_message_id: str | None = None,
|
||||
*,
|
||||
auto_title: str | None = None,
|
||||
) -> AsyncGenerator[BaseEvent, None]:
|
||||
self._clean_message_history()
|
||||
self.rewind_manager.create_checkpoint()
|
||||
|
|
@ -639,7 +644,7 @@ class AgentLoop: # noqa: PLR0904
|
|||
model_name = None
|
||||
async with agent_span(model=model_name, session_id=self.session_id):
|
||||
async for event in self._conversation_loop(
|
||||
msg, client_message_id=client_message_id
|
||||
msg, client_message_id=client_message_id, auto_title=auto_title
|
||||
):
|
||||
yield event
|
||||
|
||||
|
|
@ -848,7 +853,11 @@ class AgentLoop: # noqa: PLR0904
|
|||
return headers
|
||||
|
||||
async def _conversation_loop( # noqa: PLR0912
|
||||
self, user_msg: str, client_message_id: str | None = None
|
||||
self,
|
||||
user_msg: str,
|
||||
client_message_id: str | None = None,
|
||||
*,
|
||||
auto_title: str | None = None,
|
||||
) -> AsyncGenerator[BaseEvent]:
|
||||
user_message = LLMMessage(
|
||||
role=Role.user, content=user_msg, message_id=client_message_id
|
||||
|
|
@ -862,6 +871,11 @@ class AgentLoop: # noqa: PLR0904
|
|||
|
||||
yield UserMessageEvent(content=user_msg, message_id=user_message.message_id)
|
||||
|
||||
if auto_title is not None and self.session_logger.set_initial_auto_title(
|
||||
auto_title
|
||||
):
|
||||
yield SessionTitleUpdatedEvent(title=auto_title)
|
||||
|
||||
if self._hooks_manager:
|
||||
self._hooks_manager.reset_retry_count()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PathResource:
|
||||
|
|
@ -107,3 +109,35 @@ def _dedupe_resources(resources: list[PathResource]) -> list[PathResource]:
|
|||
seen.add(resource.path)
|
||||
unique.append(resource)
|
||||
return unique
|
||||
|
||||
|
||||
def build_title_segments(
|
||||
message: str, *, base_dir: Path | None = None
|
||||
) -> list[TitleSegment]:
|
||||
if not message:
|
||||
return []
|
||||
|
||||
resolved_base = (base_dir or Path.cwd()).resolve()
|
||||
segments: list[TitleSegment] = []
|
||||
text_buf: list[str] = []
|
||||
pos = 0
|
||||
|
||||
def flush_text() -> None:
|
||||
if text_buf:
|
||||
segments.append(TextSegment(text="".join(text_buf)))
|
||||
text_buf.clear()
|
||||
|
||||
while pos < len(message):
|
||||
if _is_path_anchor(message, pos):
|
||||
candidate, new_pos = _extract_candidate(message, pos + 1)
|
||||
if candidate and (resource := _to_resource(candidate, resolved_base)):
|
||||
flush_text()
|
||||
segments.append(MentionSegment(name=resource.path.name))
|
||||
pos = new_pos
|
||||
continue
|
||||
|
||||
text_buf.append(message[pos])
|
||||
pos += 1
|
||||
|
||||
flush_text()
|
||||
return segments
|
||||
|
|
|
|||
122
vibe/core/config/builder.py
Normal file
122
vibe/core/config/builder.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import (
|
||||
ConfigLayer,
|
||||
EmptyLayerError,
|
||||
RawConfig,
|
||||
UntrustedLayerError,
|
||||
)
|
||||
from vibe.core.config.schema import ConfigFragment, ConfigSchema, MergeFieldMetadata
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LayerData:
|
||||
name: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigBuilder[S: ConfigSchema]:
|
||||
"""Collects layers and merges them into an immutable Config[S]."""
|
||||
|
||||
def __init__(self, schema: type[S]) -> None:
|
||||
self._schema = schema
|
||||
self._layers: list[ConfigLayer[RawConfig]] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def add_layer(self, layer: ConfigLayer[RawConfig]) -> None:
|
||||
self._layers.append(layer)
|
||||
|
||||
def add_layers(self, layers: list[ConfigLayer[RawConfig]]) -> None:
|
||||
self._layers.extend(layers)
|
||||
|
||||
@property
|
||||
def layers(self) -> list[ConfigLayer[RawConfig]]:
|
||||
return self._layers
|
||||
|
||||
async def build(self, force_load: bool = False) -> S:
|
||||
"""Merge all layers and return a validated schema.
|
||||
|
||||
Untrusted and empty layers are skipped.
|
||||
Pass ``force_load=True`` to bypass caching.
|
||||
"""
|
||||
async with self._lock:
|
||||
internal_layers = self._layers.copy()
|
||||
|
||||
layer_dicts: list[_LayerData] = []
|
||||
for layer in internal_layers:
|
||||
try:
|
||||
data = await layer.load(force=force_load)
|
||||
raw = data.model_dump()
|
||||
if raw:
|
||||
layer_dicts.append(_LayerData(name=layer.name, data=raw))
|
||||
except (UntrustedLayerError, EmptyLayerError):
|
||||
continue
|
||||
|
||||
merged, origins = self._merge_fields(self._schema, layer_dicts)
|
||||
return self._schema(origins=origins, **merged)
|
||||
|
||||
def _merge_fields(
|
||||
self, schema: type[S], layer_dicts: list[_LayerData]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
accumulated: dict[str, Any] = defaultdict(dict)
|
||||
origins: dict[str, Any] = {}
|
||||
|
||||
for ld in layer_dicts:
|
||||
for key, value in ld.data.items():
|
||||
if key not in schema.model_fields:
|
||||
continue
|
||||
|
||||
field_info = schema.model_fields[key]
|
||||
annotation = field_info.annotation
|
||||
if annotation is None:
|
||||
continue
|
||||
|
||||
is_fragment = isinstance(annotation, type) and issubclass(
|
||||
annotation, ConfigFragment
|
||||
)
|
||||
if is_fragment:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
for fragment_key, fragment_value in value.items():
|
||||
if fragment_key not in annotation.model_fields:
|
||||
continue
|
||||
|
||||
fragment_field = annotation.model_fields[fragment_key]
|
||||
fragment_meta = MergeFieldMetadata.from_field(fragment_field)
|
||||
if fragment_meta is None:
|
||||
continue
|
||||
|
||||
accumulated[key][fragment_key] = (
|
||||
fragment_meta.merge_strategy.apply(
|
||||
accumulated[key].get(fragment_key),
|
||||
fragment_value,
|
||||
key_fn=self._make_key_fn(fragment_meta),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
meta = MergeFieldMetadata.from_field(field_info)
|
||||
if meta is None:
|
||||
continue
|
||||
|
||||
accumulated[key] = meta.merge_strategy.apply(
|
||||
accumulated.get(key), value, key_fn=self._make_key_fn(meta)
|
||||
)
|
||||
|
||||
return accumulated, origins
|
||||
|
||||
def _make_key_fn(
|
||||
self, merge_field_meta: MergeFieldMetadata
|
||||
) -> Callable[[Any], str] | None:
|
||||
merge_key = merge_field_meta.merge_key
|
||||
if merge_key is None:
|
||||
return None
|
||||
|
||||
return lambda item: item[merge_key]
|
||||
5
vibe/core/config/layers/__init__.py
Normal file
5
vibe/core/config/layers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config.layers.user import UserConfigLayer
|
||||
|
||||
__all__ = ["UserConfigLayer"]
|
||||
32
vibe/core/config/layers/user.py
Normal file
32
vibe/core/config/layers/user.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.paths._vibe_home import VIBE_HOME
|
||||
|
||||
|
||||
class UserConfigLayer(ConfigLayer[RawConfig]):
|
||||
"""Reads the user-level TOML config file. Always trusted.
|
||||
|
||||
Defaults to ``~/.vibe/config.toml`` (via VIBE_HOME).
|
||||
Pass an explicit ``path`` for testing.
|
||||
"""
|
||||
|
||||
def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None:
|
||||
super().__init__(name=name)
|
||||
self._path = path or (VIBE_HOME.path / "config.toml")
|
||||
|
||||
async def _check_trust(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _read_config(self) -> dict[str, Any]:
|
||||
if not self._path.exists():
|
||||
return {}
|
||||
with self._path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
|
||||
raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)")
|
||||
46
vibe/core/config/orchestrator.py
Normal file
46
vibe/core/config/orchestrator.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from vibe.core.config.builder import ConfigBuilder
|
||||
from vibe.core.config.layer import ConfigLayer, RawConfig
|
||||
from vibe.core.config.schema import ConfigSchema
|
||||
|
||||
|
||||
class ConfigOrchestrator[S: ConfigSchema]:
|
||||
"""Single entry point for config management."""
|
||||
|
||||
def __init__(self, builder: ConfigBuilder[S], config: S) -> None:
|
||||
self._builder = builder
|
||||
self._config = config
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls, *, schema: type[S], layers: list[ConfigLayer[RawConfig]]
|
||||
) -> ConfigOrchestrator[S]:
|
||||
"""Build an orchestrator from a schema and an ordered list of layers."""
|
||||
builder = ConfigBuilder[S](schema)
|
||||
builder.add_layers(layers)
|
||||
config = await builder.build()
|
||||
instance = cls(builder, config)
|
||||
return instance
|
||||
|
||||
@property
|
||||
def config(self) -> S:
|
||||
return self._config
|
||||
|
||||
def get_layer(self, name: str) -> ConfigLayer[RawConfig]:
|
||||
for layer in self._builder.layers:
|
||||
if layer.name == name:
|
||||
return layer
|
||||
raise KeyError(f"No layer named {name!r}")
|
||||
|
||||
async def reload(self) -> None:
|
||||
"""Force-reload all layers and atomically replace the config snapshot."""
|
||||
self._config = await self._builder.build(force_load=True)
|
||||
|
||||
async def apply_patch(self, patch: Any) -> None:
|
||||
raise NotImplementedError("apply_patch() is not implemented (M2)")
|
||||
|
||||
async def subscribe(self, callback: Any) -> None:
|
||||
raise NotImplementedError("subscribe() is not implemented (M3)")
|
||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from vibe.core.utils.merge import MergeStrategy
|
||||
|
|
@ -19,11 +19,25 @@ class ConfigDefinitionError(TypeError):
|
|||
class ConfigSchema(BaseModel):
|
||||
"""Base for composite config schemas composed of fragments and merge-aware fields."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
_origins: dict[str, str] = PrivateAttr(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self, *, origins: dict[str, str] | None = None, **data: object
|
||||
) -> None:
|
||||
super().__init__(**data)
|
||||
if origins:
|
||||
self._origins = origins
|
||||
|
||||
def origin_of(self, key: str) -> str | None:
|
||||
return self._origins.get(key)
|
||||
|
||||
@classmethod
|
||||
def __pydantic_on_complete__(cls) -> None:
|
||||
super().__pydantic_on_complete__()
|
||||
|
||||
if cls.__name__ == "ConfigSchema" and cls.__module__ == __name__:
|
||||
if not cls.model_fields:
|
||||
return
|
||||
|
||||
for field_name, field_info in cls.model_fields.items():
|
||||
|
|
@ -50,6 +64,8 @@ class ConfigSchema(BaseModel):
|
|||
class ConfigFragment(BaseModel):
|
||||
"""Base for domain config groups with merge-aware top-level fields."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
@classmethod
|
||||
def __pydantic_on_complete__(cls) -> None:
|
||||
super().__pydantic_on_complete__()
|
||||
|
|
|
|||
8
vibe/core/config/types.py
Normal file
8
vibe/core/config/types.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigOrigin:
|
||||
layer_name: str
|
||||
|
|
@ -18,6 +18,7 @@ from vibe.core.session.session_loader import (
|
|||
METADATA_FILENAME,
|
||||
SessionLoader,
|
||||
)
|
||||
from vibe.core.session.title_format import MAX_TITLE_LENGTH
|
||||
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
|
||||
from vibe.core.utils import is_windows, utc_now
|
||||
from vibe.core.utils.io import read_safe_async
|
||||
|
|
@ -145,7 +146,7 @@ class SessionLogger:
|
|||
title_source="auto",
|
||||
)
|
||||
|
||||
def _get_title(self, messages: Sequence[LLMMessage]) -> str:
|
||||
def _fallback_title_from_messages(self, messages: Sequence[LLMMessage]) -> str:
|
||||
first_user_message = None
|
||||
for message in messages:
|
||||
if message.role == Role.user:
|
||||
|
|
@ -153,14 +154,12 @@ class SessionLogger:
|
|||
break
|
||||
|
||||
if first_user_message is None:
|
||||
title = "Untitled session"
|
||||
else:
|
||||
MAX_TITLE_LENGTH = 50
|
||||
text = str(first_user_message.content)
|
||||
title = text[:MAX_TITLE_LENGTH]
|
||||
if len(text) > MAX_TITLE_LENGTH:
|
||||
title += "…"
|
||||
return "Untitled session"
|
||||
|
||||
text = str(first_user_message.content)
|
||||
title = text[:MAX_TITLE_LENGTH]
|
||||
if len(text) > MAX_TITLE_LENGTH:
|
||||
title += "…"
|
||||
return title
|
||||
|
||||
def _set_title_state(
|
||||
|
|
@ -183,14 +182,28 @@ class SessionLogger:
|
|||
|
||||
self._set_title_state(normalized_title, source="manual")
|
||||
|
||||
def needs_initial_auto_title(self) -> bool:
|
||||
return self.session_metadata is not None and self.session_metadata.title is None
|
||||
|
||||
def set_initial_auto_title(self, title: str) -> bool:
|
||||
if not self.needs_initial_auto_title():
|
||||
return False
|
||||
|
||||
normalized_title = title.strip()
|
||||
if not normalized_title:
|
||||
return False
|
||||
|
||||
self._set_title_state(normalized_title, source="auto")
|
||||
return True
|
||||
|
||||
def _resolve_title(self, messages: Sequence[LLMMessage]) -> str | None:
|
||||
if self.session_metadata is None:
|
||||
return self._get_title(messages)
|
||||
return self._fallback_title_from_messages(messages)
|
||||
|
||||
if self.session_metadata.title_source == "manual":
|
||||
if self.session_metadata.title is not None:
|
||||
return self.session_metadata.title
|
||||
|
||||
title = self._get_title(messages)
|
||||
title = self._fallback_title_from_messages(messages)
|
||||
self._set_title_state(title, source="auto")
|
||||
return title
|
||||
|
||||
|
|
|
|||
51
vibe/core/session/title_format.py
Normal file
51
vibe/core/session/title_format.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
MAX_TITLE_LENGTH = 50
|
||||
ELLIPSIS = "…"
|
||||
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TextSegment:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MentionSegment:
|
||||
name: str
|
||||
start_line: int | None = None
|
||||
end_line: int | None = None
|
||||
|
||||
|
||||
TitleSegment = TextSegment | MentionSegment
|
||||
|
||||
|
||||
def format_session_title(segments: Sequence[TitleSegment]) -> str:
|
||||
parts = [_render_segment(s) for s in segments]
|
||||
joined = "".join(parts)
|
||||
collapsed = _WHITESPACE_RE.sub(" ", joined).strip()
|
||||
|
||||
if not collapsed:
|
||||
return ""
|
||||
|
||||
if len(collapsed) > MAX_TITLE_LENGTH:
|
||||
return collapsed[:MAX_TITLE_LENGTH] + ELLIPSIS
|
||||
|
||||
return collapsed
|
||||
|
||||
|
||||
def _render_segment(segment: TitleSegment) -> str:
|
||||
match segment:
|
||||
case TextSegment(text=text):
|
||||
return text
|
||||
case MentionSegment(name=name, start_line=start, end_line=end):
|
||||
if start is not None and end is not None:
|
||||
return f"@{name}:{start}-{end}"
|
||||
if start is not None:
|
||||
return f"@{name}:{start}"
|
||||
return f"@{name}"
|
||||
|
|
@ -456,6 +456,10 @@ class AgentProfileChangedEvent(BaseEvent):
|
|||
agent_name: str
|
||||
|
||||
|
||||
class SessionTitleUpdatedEvent(BaseEvent):
|
||||
title: str
|
||||
|
||||
|
||||
class OutputFormat(StrEnum):
|
||||
TEXT = auto()
|
||||
JSON = auto()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.setup.auth.browser_sign_in import BrowserSignInService, BrowserSignInStatus
|
||||
from vibe.setup.auth.browser_sign_in import (
|
||||
BrowserSignInAttempt,
|
||||
BrowserSignInService,
|
||||
BrowserSignInStatus,
|
||||
)
|
||||
from vibe.setup.auth.browser_sign_in_gateway import (
|
||||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
|
|
@ -11,6 +15,7 @@ from vibe.setup.auth.browser_sign_in_gateway import (
|
|||
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
|
||||
|
||||
__all__ = [
|
||||
"BrowserSignInAttempt",
|
||||
"BrowserSignInError",
|
||||
"BrowserSignInErrorCode",
|
||||
"BrowserSignInGateway",
|
||||
|
|
|
|||
64
vibe/setup/auth/api_key_persistence.py
Normal file
64
vibe/setup/auth/api_key_persistence.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import set_key
|
||||
|
||||
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||
|
||||
|
||||
def _get_mistral_provider() -> ProviderConfig:
|
||||
return next(
|
||||
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
|
||||
)
|
||||
|
||||
|
||||
def _load_onboarding_provider() -> ProviderConfig:
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
return OnboardingContext.load().provider
|
||||
|
||||
|
||||
def resolve_api_key_provider(provider: ProviderConfig | None = None) -> ProviderConfig:
|
||||
resolved_provider = provider or _load_onboarding_provider()
|
||||
if resolved_provider.api_key_env_var:
|
||||
return resolved_provider
|
||||
return _get_mistral_provider()
|
||||
|
||||
|
||||
def persist_api_key(
|
||||
provider: ProviderConfig,
|
||||
api_key: str,
|
||||
*,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
) -> str:
|
||||
env_key = provider.api_key_env_var
|
||||
if not env_key:
|
||||
return "env_var_error:<empty>"
|
||||
try:
|
||||
os.environ[env_key] = 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}"
|
||||
if provider.backend == Backend.MISTRAL:
|
||||
try:
|
||||
telemetry = TelemetryClient(
|
||||
config_getter=VibeConfig,
|
||||
entrypoint_metadata_getter=lambda: entrypoint_metadata,
|
||||
)
|
||||
telemetry.send_onboarding_api_key_added()
|
||||
except Exception:
|
||||
pass
|
||||
return "completed"
|
||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
|
|
@ -13,7 +14,6 @@ from vibe.setup.auth.browser_sign_in_gateway import (
|
|||
BrowserSignInError,
|
||||
BrowserSignInErrorCode,
|
||||
BrowserSignInGateway,
|
||||
BrowserSignInProcess,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -30,6 +30,15 @@ SleepFn = Callable[[float], Awaitable[None]]
|
|||
NowFn = Callable[[], datetime]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSignInAttempt:
|
||||
process_id: str
|
||||
sign_in_url: str
|
||||
poll_url: str
|
||||
expires_at: datetime
|
||||
code_verifier: str
|
||||
|
||||
|
||||
class BrowserSignInService:
|
||||
_max_consecutive_poll_failures = 3
|
||||
|
||||
|
|
@ -51,38 +60,55 @@ class BrowserSignInService:
|
|||
async def aclose(self) -> None:
|
||||
await self._gateway.aclose()
|
||||
|
||||
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
|
||||
async def start_attempt(self) -> BrowserSignInAttempt:
|
||||
verifier, challenge = _generate_pkce_pair()
|
||||
process = await self._gateway.create_process(challenge)
|
||||
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER)
|
||||
self._open_browser_or_raise(process.sign_in_url)
|
||||
return BrowserSignInAttempt(
|
||||
process_id=process.process_id,
|
||||
sign_in_url=process.sign_in_url,
|
||||
poll_url=process.poll_url,
|
||||
expires_at=process.expires_at,
|
||||
code_verifier=verifier,
|
||||
)
|
||||
|
||||
async def complete_attempt(
|
||||
self,
|
||||
attempt: BrowserSignInAttempt,
|
||||
status_callback: StatusCallback | None = None,
|
||||
) -> str:
|
||||
self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN)
|
||||
exchange_token = await self._wait_for_completion(process)
|
||||
exchange_token = await self._wait_for_completion(attempt)
|
||||
self._emit(status_callback, BrowserSignInStatus.EXCHANGING)
|
||||
api_key = await self._gateway.exchange(
|
||||
process.process_id, exchange_token, verifier
|
||||
attempt.process_id, exchange_token, attempt.code_verifier
|
||||
)
|
||||
self._emit(status_callback, BrowserSignInStatus.COMPLETED)
|
||||
return api_key
|
||||
|
||||
async def _wait_for_completion(self, process: BrowserSignInProcess) -> str:
|
||||
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
|
||||
attempt = await self.start_attempt()
|
||||
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER)
|
||||
self._open_browser_or_raise(attempt.sign_in_url)
|
||||
return await self.complete_attempt(attempt, status_callback=status_callback)
|
||||
|
||||
async def _wait_for_completion(self, attempt: BrowserSignInAttempt) -> str:
|
||||
consecutive_poll_failures = 0
|
||||
while self._now() < process.expires_at:
|
||||
while self._now() < attempt.expires_at:
|
||||
try:
|
||||
payload = await self._gateway.poll(process.poll_url)
|
||||
payload = await self._gateway.poll(attempt.poll_url)
|
||||
except BrowserSignInError as err:
|
||||
if err.code is not BrowserSignInErrorCode.POLL_FAILED:
|
||||
raise
|
||||
consecutive_poll_failures += 1
|
||||
if consecutive_poll_failures >= self._max_consecutive_poll_failures:
|
||||
raise
|
||||
await self._sleep_until_next_poll_or_timeout(process.expires_at)
|
||||
await self._sleep_until_next_poll_or_timeout(attempt.expires_at)
|
||||
continue
|
||||
|
||||
consecutive_poll_failures = 0
|
||||
match payload.status:
|
||||
case "pending":
|
||||
await self._sleep_until_next_poll_or_timeout(process.expires_at)
|
||||
await self._sleep_until_next_poll_or_timeout(attempt.expires_at)
|
||||
case "completed":
|
||||
if payload.exchange_token:
|
||||
return payload.exchange_token
|
||||
|
|
|
|||
|
|
@ -188,9 +188,21 @@ class HttpBrowserSignInGateway(BrowserSignInGateway):
|
|||
json={"exchange_token": exchange_token, "code_verifier": code_verifier},
|
||||
)
|
||||
except httpx.HTTPError as err:
|
||||
logger.warning(
|
||||
"Browser sign-in exchange request failed for api_base_url=%s: %s",
|
||||
self._api_base_url,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BrowserSignInError(message, code=code) from err
|
||||
|
||||
if not response.is_success:
|
||||
logger.warning(
|
||||
"Browser sign-in exchange request returned status_code=%s for api_base_url=%s response_detail=%s",
|
||||
response.status_code,
|
||||
self._api_base_url,
|
||||
_build_safe_response_error_detail(response),
|
||||
)
|
||||
raise BrowserSignInError(message, code=code)
|
||||
|
||||
raw_payload = _response_json_or_raise(response, message=message, code=code)
|
||||
|
|
@ -298,6 +310,29 @@ def _response_json_or_raise(
|
|||
return dict(payload)
|
||||
|
||||
|
||||
def _build_safe_response_error_detail(response: httpx.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return "unavailable"
|
||||
|
||||
if not isinstance(payload, Mapping):
|
||||
return "unavailable"
|
||||
|
||||
for key in ("detail", "message", "error"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str):
|
||||
return _truncate_log_value(value)
|
||||
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def _truncate_log_value(value: str, *, max_length: int = 200) -> str:
|
||||
if len(value) <= max_length:
|
||||
return value
|
||||
return f"{value[: max_length - 3]}..."
|
||||
|
||||
|
||||
def _build_safe_url_log_details(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "unavailable"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import ClassVar
|
||||
|
||||
from dotenv import set_key
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Horizontal, Vertical
|
||||
|
|
@ -13,13 +11,13 @@ from textual.widgets import Input, Link, Static
|
|||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.auth.api_key_persistence import (
|
||||
persist_api_key,
|
||||
resolve_api_key_provider,
|
||||
)
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
PROVIDER_HELP = {
|
||||
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
|
||||
|
|
@ -29,55 +27,6 @@ CONFIG_DOCS_URL = (
|
|||
)
|
||||
|
||||
|
||||
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||
|
||||
|
||||
def persist_api_key(
|
||||
provider: ProviderConfig,
|
||||
api_key: str,
|
||||
*,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
) -> str:
|
||||
env_key = provider.api_key_env_var
|
||||
if not env_key:
|
||||
return "env_var_error:<empty>"
|
||||
try:
|
||||
os.environ[env_key] = 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}"
|
||||
if provider.backend == Backend.MISTRAL:
|
||||
try:
|
||||
telemetry = TelemetryClient(
|
||||
config_getter=VibeConfig,
|
||||
entrypoint_metadata_getter=lambda: entrypoint_metadata,
|
||||
)
|
||||
telemetry.send_onboarding_api_key_added()
|
||||
except Exception:
|
||||
pass
|
||||
return "completed"
|
||||
|
||||
|
||||
def _get_mistral_provider() -> ProviderConfig:
|
||||
return next(
|
||||
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_onboarding_provider(
|
||||
provider: ProviderConfig | None = None,
|
||||
) -> ProviderConfig:
|
||||
resolved_provider = provider or OnboardingContext.load().provider
|
||||
if resolved_provider.api_key_env_var:
|
||||
return resolved_provider
|
||||
return _get_mistral_provider()
|
||||
|
||||
|
||||
class ApiKeyScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
|
|
@ -93,7 +42,7 @@ class ApiKeyScreen(OnboardingScreen):
|
|||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.provider = _resolve_onboarding_provider(provider)
|
||||
self.provider = resolve_api_key_provider(provider)
|
||||
self._entrypoint_metadata = entrypoint_metadata
|
||||
|
||||
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ from vibe.setup.auth import (
|
|||
BrowserSignInService,
|
||||
BrowserSignInStatus,
|
||||
)
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
from vibe.setup.onboarding.screens.api_key import (
|
||||
_resolve_onboarding_provider,
|
||||
from vibe.setup.auth.api_key_persistence import (
|
||||
persist_api_key,
|
||||
resolve_api_key_provider,
|
||||
)
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
PENDING_HINT = "Press M to enter API key manually · Esc to cancel"
|
||||
ERROR_HINT = "Press R to retry · Press M to enter API key manually · Esc to cancel"
|
||||
|
|
@ -215,7 +215,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
self._worker = None
|
||||
self.app.exit(
|
||||
persist_api_key(
|
||||
_resolve_onboarding_provider(self.provider),
|
||||
resolve_api_key_provider(self.provider),
|
||||
api_key,
|
||||
entrypoint_metadata=self._entrypoint_metadata,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue