SKY-7801/7802: MCP Foundation + Hybrid Browser Tools (selector + AI intent) (#4660)
This commit is contained in:
43
skyvern/cli/core/__init__.py
Normal file
43
skyvern/cli/core/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared core layer for Skyvern CLI and MCP tools.
|
||||
|
||||
This package provides reusable primitives that both MCP tools and CLI commands
|
||||
import from, preventing logic duplication across interfaces.
|
||||
"""
|
||||
|
||||
from .artifacts import get_artifact_dir, save_artifact
|
||||
from .client import get_skyvern
|
||||
from .result import Artifact, BrowserContext, ErrorCode, Timer, make_error, make_result
|
||||
from .session_manager import (
|
||||
BrowserNotAvailableError,
|
||||
SessionState,
|
||||
browser_session,
|
||||
get_current_session,
|
||||
get_page,
|
||||
no_browser_error,
|
||||
resolve_browser,
|
||||
set_current_session,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# client.py
|
||||
"get_skyvern",
|
||||
# result.py
|
||||
"Artifact",
|
||||
"BrowserContext",
|
||||
"ErrorCode",
|
||||
"Timer",
|
||||
"make_error",
|
||||
"make_result",
|
||||
# artifacts.py
|
||||
"get_artifact_dir",
|
||||
"save_artifact",
|
||||
# session_manager.py
|
||||
"BrowserNotAvailableError",
|
||||
"SessionState",
|
||||
"browser_session",
|
||||
"get_current_session",
|
||||
"get_page",
|
||||
"no_browser_error",
|
||||
"resolve_browser",
|
||||
"set_current_session",
|
||||
]
|
||||
29
skyvern/cli/core/artifacts.py
Normal file
29
skyvern/cli/core/artifacts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .result import Artifact
|
||||
|
||||
|
||||
def get_artifact_dir(session_id: str | None = None, run_id: str | None = None) -> Path:
|
||||
base = Path.home() / ".skyvern" / "artifacts" / datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
if session_id:
|
||||
return base / session_id
|
||||
if run_id:
|
||||
return base / run_id
|
||||
return base / "anonymous"
|
||||
|
||||
|
||||
def save_artifact(
|
||||
content: bytes,
|
||||
kind: str,
|
||||
filename: str,
|
||||
mime: str,
|
||||
session_id: str | None = None,
|
||||
) -> Artifact:
|
||||
dir_path = get_artifact_dir(session_id)
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
file_path = dir_path / filename
|
||||
file_path.write_bytes(content)
|
||||
return Artifact(kind=kind, path=str(file_path), mime=mime, bytes=len(content))
|
||||
32
skyvern/cli/core/client.py
Normal file
32
skyvern/cli/core/client.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
|
||||
from skyvern.client import SkyvernEnvironment
|
||||
from skyvern.config import settings
|
||||
from skyvern.library.skyvern import Skyvern
|
||||
|
||||
_skyvern_instance: ContextVar[Skyvern | None] = ContextVar("skyvern_instance", default=None)
|
||||
|
||||
|
||||
def get_skyvern() -> Skyvern:
|
||||
"""Get or create a Skyvern client instance."""
|
||||
instance = _skyvern_instance.get()
|
||||
if instance is not None:
|
||||
return instance
|
||||
|
||||
api_key = settings.SKYVERN_API_KEY or os.environ.get("SKYVERN_API_KEY")
|
||||
base_url = settings.SKYVERN_BASE_URL or os.environ.get("SKYVERN_BASE_URL")
|
||||
|
||||
if api_key:
|
||||
instance = Skyvern(
|
||||
api_key=api_key,
|
||||
environment=SkyvernEnvironment.CLOUD,
|
||||
base_url=base_url,
|
||||
)
|
||||
else:
|
||||
instance = Skyvern.local()
|
||||
|
||||
_skyvern_instance.set(instance)
|
||||
return instance
|
||||
106
skyvern/cli/core/result.py
Normal file
106
skyvern/cli/core/result.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
NO_ACTIVE_BROWSER = "NO_ACTIVE_BROWSER"
|
||||
BROWSER_NOT_FOUND = "BROWSER_NOT_FOUND"
|
||||
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
|
||||
ACTION_FAILED = "ACTION_FAILED"
|
||||
AI_FALLBACK_FAILED = "AI_FALLBACK_FAILED"
|
||||
SDK_ERROR = "SDK_ERROR"
|
||||
TIMEOUT = "TIMEOUT"
|
||||
INVALID_INPUT = "INVALID_INPUT"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Artifact:
|
||||
kind: str
|
||||
path: str
|
||||
mime: str
|
||||
bytes: int
|
||||
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"path": self.path,
|
||||
"mime": self.mime,
|
||||
"bytes": self.bytes,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserContext:
|
||||
mode: str
|
||||
session_id: str | None = None
|
||||
cdp_url: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"session_id": self.session_id,
|
||||
"cdp_url": self.cdp_url,
|
||||
}
|
||||
|
||||
|
||||
def make_result(
|
||||
action: str,
|
||||
*,
|
||||
ok: bool = True,
|
||||
browser_context: BrowserContext | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
artifacts: list[Artifact] | None = None,
|
||||
timing_ms: dict[str, int] | None = None,
|
||||
warnings: list[str] | None = None,
|
||||
error: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": ok,
|
||||
"action": action,
|
||||
"browser_context": (browser_context or BrowserContext(mode="none")).to_dict(),
|
||||
"data": data,
|
||||
"artifacts": [a.to_dict() for a in (artifacts or [])],
|
||||
"timing_ms": timing_ms or {},
|
||||
"warnings": warnings or [],
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def make_error(
|
||||
code: str,
|
||||
message: str,
|
||||
hint: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"hint": hint,
|
||||
"details": details or {},
|
||||
}
|
||||
|
||||
|
||||
class Timer:
|
||||
def __init__(self) -> None:
|
||||
self._start: float = 0
|
||||
self._marks: dict[str, int] = {}
|
||||
|
||||
def __enter__(self) -> Timer:
|
||||
self._start = time.perf_counter()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self._marks["total"] = int((time.perf_counter() - self._start) * 1000)
|
||||
|
||||
def mark(self, name: str) -> None:
|
||||
self._marks[name] = int((time.perf_counter() - self._start) * 1000)
|
||||
|
||||
@property
|
||||
def timing_ms(self) -> dict[str, int]:
|
||||
return self._marks.copy()
|
||||
153
skyvern/cli/core/session_manager.py
Normal file
153
skyvern/cli/core/session_manager.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, AsyncIterator
|
||||
|
||||
from .client import get_skyvern
|
||||
from .result import BrowserContext, ErrorCode, make_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from skyvern.library.skyvern_browser import SkyvernBrowser
|
||||
from skyvern.library.skyvern_browser_page import SkyvernBrowserPage
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
browser: SkyvernBrowser | None = None
|
||||
context: BrowserContext | None = None
|
||||
console_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
tracing_active: bool = False
|
||||
har_enabled: bool = False
|
||||
|
||||
|
||||
_current_session: ContextVar[SessionState | None] = ContextVar("mcp_session", default=None)
|
||||
|
||||
|
||||
def get_current_session() -> SessionState:
|
||||
state = _current_session.get()
|
||||
if state is None:
|
||||
state = SessionState()
|
||||
_current_session.set(state)
|
||||
return state
|
||||
|
||||
|
||||
def set_current_session(state: SessionState) -> None:
|
||||
_current_session.set(state)
|
||||
|
||||
|
||||
async def resolve_browser(
|
||||
session_id: str | None = None,
|
||||
cdp_url: str | None = None,
|
||||
local: bool = False,
|
||||
create_session: bool = False,
|
||||
timeout: int | None = None,
|
||||
headless: bool = False,
|
||||
) -> tuple[SkyvernBrowser, BrowserContext]:
|
||||
"""Resolve browser from parameters or current session.
|
||||
|
||||
Note: For MCP tools, sessions are stored in ContextVar and persist across tool calls.
|
||||
Cleanup is done via explicit skyvern_session_close() call. For scripts that need
|
||||
guaranteed cleanup, use the browser_session() context manager instead.
|
||||
"""
|
||||
skyvern = get_skyvern()
|
||||
current = get_current_session()
|
||||
|
||||
browser: SkyvernBrowser | None = None
|
||||
try:
|
||||
if session_id:
|
||||
browser = await skyvern.connect_to_cloud_browser_session(session_id)
|
||||
ctx = BrowserContext(mode="cloud_session", session_id=session_id)
|
||||
set_current_session(SessionState(browser=browser, context=ctx))
|
||||
return browser, ctx
|
||||
|
||||
if cdp_url:
|
||||
browser = await skyvern.connect_to_browser_over_cdp(cdp_url)
|
||||
ctx = BrowserContext(mode="cdp", cdp_url=cdp_url)
|
||||
set_current_session(SessionState(browser=browser, context=ctx))
|
||||
return browser, ctx
|
||||
|
||||
if local:
|
||||
browser = await skyvern.launch_local_browser(headless=headless)
|
||||
ctx = BrowserContext(mode="local")
|
||||
set_current_session(SessionState(browser=browser, context=ctx))
|
||||
return browser, ctx
|
||||
|
||||
if create_session:
|
||||
browser = await skyvern.launch_cloud_browser(timeout=timeout)
|
||||
ctx = BrowserContext(mode="cloud_session", session_id=browser.browser_session_id)
|
||||
set_current_session(SessionState(browser=browser, context=ctx))
|
||||
return browser, ctx
|
||||
except Exception:
|
||||
if browser is not None:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
set_current_session(SessionState())
|
||||
raise
|
||||
|
||||
if current.browser is not None and current.context is not None:
|
||||
return current.browser, current.context
|
||||
|
||||
raise BrowserNotAvailableError()
|
||||
|
||||
|
||||
async def get_page(
|
||||
session_id: str | None = None,
|
||||
cdp_url: str | None = None,
|
||||
) -> tuple[SkyvernBrowserPage, BrowserContext]:
|
||||
"""Get the working page from the current or specified browser session."""
|
||||
browser, ctx = await resolve_browser(session_id=session_id, cdp_url=cdp_url)
|
||||
page = await browser.get_working_page()
|
||||
return page, ctx
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def browser_session(
|
||||
session_id: str | None = None,
|
||||
cdp_url: str | None = None,
|
||||
local: bool = False,
|
||||
timeout: int | None = None,
|
||||
headless: bool = False,
|
||||
) -> AsyncIterator[tuple[SkyvernBrowser, BrowserContext]]:
|
||||
"""Context manager for browser sessions with guaranteed cleanup.
|
||||
|
||||
Use this in scripts that need guaranteed resource cleanup on error.
|
||||
MCP tools use resolve_browser() directly since sessions persist across calls.
|
||||
|
||||
Example:
|
||||
async with browser_session(local=True) as (browser, ctx):
|
||||
page = await browser.get_working_page()
|
||||
await page.goto("https://example.com")
|
||||
# Browser is automatically closed on exit or exception
|
||||
"""
|
||||
browser, ctx = await resolve_browser(
|
||||
session_id=session_id,
|
||||
cdp_url=cdp_url,
|
||||
local=local,
|
||||
create_session=not (session_id or cdp_url or local),
|
||||
timeout=timeout,
|
||||
headless=headless,
|
||||
)
|
||||
try:
|
||||
yield browser, ctx
|
||||
finally:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
set_current_session(SessionState())
|
||||
|
||||
|
||||
class BrowserNotAvailableError(Exception):
|
||||
"""Raised when no browser session is available."""
|
||||
|
||||
|
||||
def no_browser_error() -> dict[str, Any]:
|
||||
return make_error(
|
||||
ErrorCode.NO_ACTIVE_BROWSER,
|
||||
"No browser session available",
|
||||
"Create a session with skyvern_session_create, provide session_id, or cdp_url",
|
||||
)
|
||||
Reference in New Issue
Block a user