SKY-9651: Clarify diagnostic Copilot routing (#5910)

This commit is contained in:
Andrew Neilson 2026-05-08 17:12:08 -07:00 committed by GitHub
parent be6840e6e7
commit b4d301ce6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1504 additions and 545 deletions

View file

@ -51,7 +51,7 @@ npm install @skyvern/client
```
</CodeGroup>
`pip install skyvern` is enough for Skyvern Cloud and remote API usage. If you are using `Skyvern.local()` or a local self-hosted server, install `pip install "skyvern[server]"`.
`pip install skyvern` is enough for Skyvern Cloud and remote API usage. If you are using embedded local mode with `Skyvern.local()` or `launch_local_browser()`, install `pip install "skyvern[local]"`. For a local self-hosted server, install `pip install "skyvern[server]"`.
All code snippets below run inside an async function. See the [complete example](#complete-example) for the full runnable script.
</Info>

View file

@ -42,7 +42,7 @@ npm install @skyvern/client
</CodeGroup>
<Info>
`pip install skyvern` is the lightweight Python SDK for Skyvern Cloud and remote API usage. If you are setting up a local Skyvern server, self-hosted instance, `Skyvern.local()`, or local stdio MCP, install the server extra instead: `pip install "skyvern[server]"`. For full local setup with migrations and the web UI, use Docker Compose or a source checkout.
`pip install skyvern` is the lightweight Python SDK for Skyvern Cloud and remote API usage. If you are using embedded local mode with `Skyvern.local()` or `launch_local_browser()`, install the local extra: `pip install "skyvern[local]"`. For a local Skyvern server, self-hosted instance, or local stdio MCP, install the server extra instead: `pip install "skyvern[server]"`. For full local setup with migrations and the web UI, use Docker Compose or a source checkout.
</Info>
<Accordion title="Troubleshooting: Python version errors">
@ -191,7 +191,7 @@ This is invaluable for debugging and understanding how Skyvern interprets your p
---
## Run with a local browser
## Run with a local server
You can run Skyvern with a browser on your own machine. This is useful for development, debugging, or automating internal tools on your local network.

View file

@ -5,6 +5,13 @@ slug: sdk-reference/browser-automation/launch-local-browser
Launch a local Chromium browser with CDP enabled. Only available in embedded mode (`Skyvern.local()`).
Install the local extra and Playwright's Chromium browser before launching:
```bash
pip install "skyvern[local]"
python -m playwright install chromium
```
```python
skyvern = Skyvern.local()
browser = await skyvern.launch_local_browser(headless=False)

View file

@ -33,7 +33,7 @@ npm install @skyvern/client
</CodeGroup>
<Note>
`pip install skyvern` is the lightweight SDK for Skyvern Cloud and remote API calls. Local server features such as `Skyvern.local()`, local browser control, and local stdio MCP require `pip install "skyvern[server]"`. Guided quickstart and migrations currently require Docker Compose or a source checkout.
`pip install skyvern` is the lightweight SDK for Skyvern Cloud and remote API calls. Embedded local SDK features such as `Skyvern.local()` and local browser control require `pip install "skyvern[local]"`. Local server and local stdio MCP commands require `pip install "skyvern[server]"`. Guided quickstart and migrations currently require Docker Compose or a source checkout.
</Note>
<CodeGroup>
@ -80,7 +80,8 @@ Skyvern(
timeout: float | None = None, # HTTP request timeout (seconds)
)
# Local mode (Python only - requires pip install "skyvern[server]")
# Local mode (Python only - requires pip install "skyvern[local]")
# For local browser control, also run: python -m playwright install chromium
client = Skyvern.local()
```
@ -693,6 +694,6 @@ request_options=RequestOptions(
- `browser_profile_id` works with `run_workflow` only - silently ignored by `run_task`.
- Python `download_files` does not support `wait_for_completion` - poll manually or use webhooks.
- Only workflow runs with `persist_browser_session=True` produce archives for profile creation.
- `launch_local_browser` requires Python local mode (`Skyvern.local()`) and the `skyvern[server]` extra.
- `launch_local_browser` requires Python local mode (`Skyvern.local()`) and the `skyvern[local]` extra.
- `page.agent` methods always wait for completion.
- Python-only features: `launch_local_browser`, `get_page_for`, `locator`/`AILocator`, `type`, `hover`, `scroll`, `upload_file` (page-level), form automation (`fill_form`, `fill_multipage_form`, `fill_from_mapping`, `extract_form_fields`, `validate_mapping`, `fill_autocomplete`), iframe management (`frame_switch`, `frame_main`, `frame_list`).

View file

@ -32,7 +32,7 @@ npm install @skyvern/client
</CodeGroup>
<Note>
`pip install skyvern` is the lightweight SDK for Skyvern Cloud and remote API calls. Local server features such as `Skyvern.local()`, local browser control, and local stdio MCP require `pip install "skyvern[server]"`. Guided quickstart and migrations currently require Docker Compose or a source checkout.
`pip install skyvern` is the lightweight SDK for Skyvern Cloud and remote API calls. Embedded local SDK features such as `Skyvern.local()` and local browser control require `pip install "skyvern[local]"`. Local server and local stdio MCP commands require `pip install "skyvern[server]"`. Guided quickstart and migrations currently require Docker Compose or a source checkout.
</Note>
---
@ -144,7 +144,7 @@ const skyvern = new Skyvern({
Run Skyvern entirely on your machine - no cloud, no network calls. `Skyvern.local()` reads your `.env` file, boots the engine in-process, and connects the client to it.
**Prerequisite:** Install the server extra with `pip install "skyvern[server]"` and initialize local configuration from Docker Compose or a source checkout.
**Prerequisite:** Install the local extra with `pip install "skyvern[local]"`. For local browser control, also run `python -m playwright install chromium`.
```python
from skyvern import Skyvern

View file

@ -19,6 +19,70 @@ dependencies = [
]
[project.optional-dependencies]
# Embedded local mode is currently a Forge-backed compatibility bridge. It is
# smaller than the full server install because it excludes backend process,
# Postgres, MCP server, and cloud product packages, but it is not a minimal
# browser-only runtime until local stops importing Forge internals.
local = [
"azure-storage-blob>=12.26.0",
"openai>=1.68.2",
"sqlalchemy>=2.0.29,<3",
"aiohttp>=3.13.4,<4",
"python-multipart>=0.0.27,<1",
"toml>=0.10.2,<0.11",
"jinja2>=3.1.2,<4",
"litellm==1.83.14",
"playwright>1.46.0 ; python_version >= '3.12' and python_version < '3.14'",
"playwright==1.46.0 ; python_version >= '3.11' and python_version < '3.12'",
"greenlet>3.0.3 ; python_version >= '3.12' and python_version < '3.14'",
"greenlet==3.0.3 ; python_version >= '3.11' and python_version < '3.12'",
"pillow>=10.2.0",
"starlette-context>=0.3.6,<0.6",
"fastapi>=0.136.1,<0.137",
"alembic>=1.12.1,<2",
"PyJWT[crypto]>=2.12.0,<3",
"aioboto3>=14.3.0,<15",
"asyncache>=0.3.1,<0.4",
"filetype>=1.2.0,<2",
"fuzzysearch>=0.8,<1",
"tldextract>=5.1.2,<6",
"websockets>=12.0,<15.1",
"email-validator>=2.2.0,<3",
"requests-toolbelt>=1.0.0,<2",
"aiofiles>=24.1.0,<25",
"pyotp>=2.9.0,<3",
"json5>=0.13.0,<1",
"json-repair>=0.59.5,<0.60",
"pypdf>=6.7.5,<7",
"pdfplumber>=0.11.0,<0.12",
"psutil>=7.0.0",
"tiktoken>=0.9.0",
"anthropic>=0.97.0,<0.98",
"google-cloud-aiplatform>=1.149.0,<2",
"google-auth>=2.49.2,<3",
"google-auth-oauthlib>=1.3.1,<2",
"onepassword-sdk==0.4.0",
"lark>=1.2.2,<2",
"libcst>=1.8.2,<2",
"curlparser>=0.1.0,<0.2",
"pandas>=2.3.1,<4",
"azure-identity>=1.24.0,<2",
"azure-keyvault-secrets>=4.2.0,<5",
"jsonschema>=4.23.0",
"python-calamine>=0.6.1",
"python-docx>=1.1.0",
"urllib3>=2.6.3",
"zstandard>=0.25.0",
"sse-starlette>=3.4.1,<4",
"opentelemetry-api>=1.41.1,<2",
"croniter>=1.3.8,<4",
"authlib>=1.7.1",
"pyasn1>=0.6.3",
"tornado>=6.5.5",
"aiosqlite>=0.21.0,<0.23",
]
# Server is intentionally a superset of local. Keep shared runtime deps in sync
# with local until the embedded runtime is separated from Forge/server internals.
server = [
"azure-storage-blob>=12.26.0",
"openai>=1.68.2",
@ -29,10 +93,10 @@ server = [
"python-dotenv==1.2.2",
"sqlalchemy[mypy]>=2.0.29,<3",
"aiohttp>=3.13.4,<4",
"python-multipart>=0.0.22,<1",
"python-multipart>=0.0.27,<1",
"toml>=0.10.2,<0.11",
"jinja2>=3.1.2,<4",
"uvicorn[standard]>=0.35.0",
"uvicorn[standard]>=0.44.0",
"litellm==1.83.14",
"playwright>1.46.0 ; python_version >= '3.12' and python_version < '3.14'",
"playwright==1.46.0 ; python_version >= '3.11' and python_version < '3.12'",
@ -40,7 +104,7 @@ server = [
"greenlet==3.0.3 ; python_version >= '3.11' and python_version < '3.12'",
"pillow>=10.2.0",
"starlette-context>=0.3.6,<0.6",
"fastapi>=0.136.0,<0.137",
"fastapi>=0.136.1,<0.137",
"psycopg[binary, pool]==3.1.18 ; python_version >= '3.11' and python_version < '3.13'",
"psycopg[binary, pool]>=3.2.2,<3.3.0 ; python_version >= '3.13' and python_version < '3.14'",
"alembic>=1.12.1,<2",
@ -60,14 +124,14 @@ server = [
"pyotp>=2.9.0,<3",
"asyncpg>=0.30.0,<0.32",
"json5>=0.13.0,<1",
"json-repair>=0.59.4,<0.60",
"json-repair>=0.59.5,<0.60",
"pypdf>=6.7.5,<7",
"pdfplumber>=0.11.0,<0.12",
"fastmcp>=3.2.0,<4",
"psutil>=7.0.0",
"tiktoken>=0.9.0",
"anthropic>=0.96.0,<0.97",
"google-cloud-aiplatform>=1.90.0,<2",
"anthropic>=0.97.0,<0.98",
"google-cloud-aiplatform>=1.149.0,<2",
"google-auth>=2.49.2,<3",
"google-auth-oauthlib>=1.3.1,<2",
"onepassword-sdk==0.4.0",
@ -83,12 +147,11 @@ server = [
"python-docx>=1.1.0",
"urllib3>=2.6.3",
"zstandard>=0.25.0",
"sse-starlette>=3.0.3,<4",
"sse-starlette>=3.4.1,<4",
"opentelemetry-api>=1.41.1,<2",
"croniter>=1.3.8,<4",
"authlib>=1.6.9",
"authlib>=1.7.1",
"pyasn1>=0.6.3",
"PyJWT>=2.12.0",
"tornado>=6.5.5",
"aiosqlite>=0.21.0,<0.23",
]
@ -98,8 +161,8 @@ cloud = [
# Keep workflow-copilot on the newer Agents SDK for cloud/source installs.
# uv's override-dependencies below relax LiteLLM's exact transitive pins,
# but those overrides do not ship in public wheel metadata.
"openai-agents>=0.14.4,<0.15",
"stripe>=9.7.0,<10",
"openai-agents>=0.14.8,<0.15",
"stripe>=15.0.1,<16",
"temporalio[opentelemetry]>=1.6.0,<2",
"temporalio>=1.6.0,<2",
"redis>=5.0.3,<6",
@ -137,7 +200,7 @@ dev = [
"pydevd-pycharm>=233.6745.319,<234",
"ipython>=8.17.2,<9",
"ipykernel>=6.26.0,<8",
"notebook>=7.0.6,<8",
"notebook>=7.5.6,<8",
"freezegun>=1.2.2,<2",
"fpdf>=1.7.2,<2",
"twine>=6.1.0,<7",
@ -187,7 +250,7 @@ override-dependencies = [
"openai>=2.24.0",
]
constraint-dependencies = [
"authlib>=1.6.9",
"authlib>=1.7.1",
"flask>=3.1.3",
"joserfc>=1.6.3",
"pyasn1>=0.6.3",

View file

@ -444,9 +444,7 @@ function BrowserSessions() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
Default (Microsoft Edge)
</SelectItem>
<SelectItem value="default">Default</SelectItem>
{BROWSER_TYPE_OPTIONS.map((browserType) => (
<SelectItem
key={browserType.value}

View file

@ -20,7 +20,6 @@ import { dataSchemaExampleValue } from "../types";
import type { LoopNode } from "./types";
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { WorkflowBlockType } from "@/routes/workflows/types/workflowTypes";
import {
inferBranchCriteriaTypeFromExpression,
@ -145,40 +144,49 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
totpUrl={null}
type={headerBlockType}
/>
<Tabs
value={loopKind}
onValueChange={(v) => {
if (!data.editable) {
return;
}
if (v !== "for_each" && v !== "while") {
return;
}
if (v === "while") {
const expr =
data.whileConditionExpression.trim() === ""
? "{{ true }}"
: data.whileConditionExpression;
update({
loopKind: "while",
whileConditionExpression: expr,
whileConditionCriteriaType:
inferBranchCriteriaTypeFromExpression(expr),
});
} else {
update({ loopKind: "for_each" });
}
}}
<div
role="tablist"
aria-label="Loop type"
className="grid h-9 w-full grid-cols-2 rounded-lg bg-muted p-1 text-muted-foreground"
>
<TabsList className="grid h-9 w-full grid-cols-2">
<TabsTrigger value="for_each" disabled={!data.editable}>
For each
</TabsTrigger>
<TabsTrigger value="while" disabled={!data.editable}>
While
</TabsTrigger>
</TabsList>
</Tabs>
{(["for_each", "while"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={loopKind === value}
disabled={!data.editable}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
"bg-background text-foreground shadow":
loopKind === value,
},
)}
onClick={() => {
if (!data.editable || value === loopKind) {
return;
}
if (value === "while") {
const expr =
data.whileConditionExpression.trim() === ""
? "{{ true }}"
: data.whileConditionExpression;
update({
loopKind: "while",
whileConditionExpression: expr,
whileConditionCriteriaType:
inferBranchCriteriaTypeFromExpression(expr),
});
} else {
update({ loopKind: "for_each" });
}
}}
>
{value === "for_each" ? "For each" : "While"}
</button>
))}
</div>
{loopKind === "for_each" ? (
<>
<div className="space-y-2">

View file

@ -1,6 +1,6 @@
import React from "react";
const CHUNK_LOAD_PATTERNS = [
export const CHUNK_LOAD_PATTERNS = [
/Failed to fetch dynamically imported module/i,
/Importing a module script failed/i,
/error loading dynamically imported module/i,

View file

@ -59,7 +59,13 @@ register_lazy_command(
"quickstart_app",
"One-command setup and start for Skyvern (combines init and run).",
)
register_lazy_command("browser", "skyvern.cli.commands.browser", "browser_app", "Browser automation commands.")
register_lazy_command(
"browser",
"skyvern.cli.commands.browser",
"browser_app",
"Browser automation commands.",
install_extra="local",
)
register_lazy_command(
"mcp", "skyvern.cli.mcp_commands", "mcp_app", "Switch local MCP client configs and manage optional saved profiles."
)

View file

@ -23,11 +23,13 @@ from skyvern.cli.console import console
# Module-level lazy command registry
# ---------------------------------------------------------------------------
_LAZY_COMMANDS: dict[str, tuple[str, str, str]] = {}
"""Mapping of command_name -> (module_path, attr_name, help_text)."""
_LAZY_COMMANDS: dict[str, tuple[str, str, str, str]] = {}
"""Mapping of command_name -> (module_path, attr_name, help_text, install_extra)."""
def register_lazy_command(name: str, module_path: str, attr_name: str, help_text: str) -> None:
def register_lazy_command(
name: str, module_path: str, attr_name: str, help_text: str, *, install_extra: str = "server"
) -> None:
"""Register a command/sub-app for deferred import.
Parameters
@ -40,13 +42,15 @@ def register_lazy_command(name: str, module_path: str, attr_name: str, help_text
Attribute to import from *module_path* (e.g. ``"run_app"``).
help_text:
Short help shown in ``--help`` without importing the module.
install_extra:
Optional dependency extra required when the lazy import is unavailable.
"""
_LAZY_COMMANDS[name] = (module_path, attr_name, help_text)
_LAZY_COMMANDS[name] = (module_path, attr_name, help_text, install_extra)
def _resolve_lazy_command(name: str) -> click.BaseCommand:
"""Import the module and resolve the Typer app (or Click command) for *name*."""
module_path, attr_name, _help = _LAZY_COMMANDS[name]
module_path, attr_name, _help, _install_extra = _LAZY_COMMANDS[name]
mod = importlib.import_module(module_path)
obj = getattr(mod, attr_name)
@ -80,7 +84,8 @@ def _resolve_lazy_command(name: str) -> click.BaseCommand:
class _LazyPlaceholder(click.Command):
"""A lightweight stand-in that renders help text without importing anything."""
def __init__(self, name: str, help_text: str) -> None:
def __init__(self, name: str, help_text: str, install_extra: str) -> None:
self.install_extra = install_extra
super().__init__(
name=name,
help=help_text,
@ -94,7 +99,7 @@ class _LazyPlaceholder(click.Command):
try:
real = _resolve_lazy_command(self.name or "")
except ImportError as exc:
_handle_missing_dep(exc)
_handle_missing_dep(exc, install_extra=self.install_extra)
raise # unreachable — _handle_missing_dep raises typer.Exit, but satisfies linters
return real.invoke(ctx)
@ -131,8 +136,8 @@ class LazyTyperGroup(typer.core.TyperGroup):
return resolved
except ImportError:
# Missing dep — return placeholder so --help doesn't crash.
_, _, help_text = _LAZY_COMMANDS[cmd_name]
return _LazyPlaceholder(cmd_name, help_text)
_, _, help_text, install_extra = _LAZY_COMMANDS[cmd_name]
return _LazyPlaceholder(cmd_name, help_text, install_extra)
return None
@ -147,8 +152,8 @@ class LazyTyperGroup(typer.core.TyperGroup):
continue
commands.append((name, cmd))
elif name in _LAZY_COMMANDS:
_, _, help_text = _LAZY_COMMANDS[name]
commands.append((name, _LazyPlaceholder(name, help_text)))
_, _, help_text, install_extra = _LAZY_COMMANDS[name]
commands.append((name, _LazyPlaceholder(name, help_text, install_extra)))
if not commands:
return
@ -169,14 +174,15 @@ class LazyTyperGroup(typer.core.TyperGroup):
# ---------------------------------------------------------------------------
def _handle_missing_dep(exc: ImportError) -> None:
def _handle_missing_dep(exc: ImportError, *, install_extra: str = "server") -> None:
"""Show a user-friendly error when a required dependency is missing."""
dep_name = exc.name or str(exc)
install_cmd = escape(f'pip install "skyvern[{install_extra}]"')
console.print(
Panel(
f"[bold red]This command requires a dependency that is not installed.[/bold red]\n\n"
f"Missing: [yellow]{dep_name}[/yellow]\n"
f"Run: [green]{escape('pip install skyvern[server]')}[/green]",
f"Run: [green]{install_cmd}[/green]",
title="Missing Dependency",
border_style="red",
)

View file

@ -5,8 +5,44 @@ and workflow management. Tools are registered with FastMCP and can be used by
AI assistants like Claude.
"""
from fastmcp import FastMCP
from mcp.types import ToolAnnotations
from __future__ import annotations
from typing import Any
try:
from fastmcp import FastMCP as _FastMCP
from mcp.types import ToolAnnotations
except ImportError as fastmcp_import_error:
_FASTMCP_IMPORT_ERROR: ImportError | None = fastmcp_import_error
class ToolAnnotations: # type: ignore[no-redef]
def __init__(self, **_: Any) -> None:
pass
class _FastMCP: # type: ignore[no-redef]
def __init__(self, *_: Any, **__: Any) -> None:
pass
def add_middleware(self, *_: Any, **__: Any) -> None:
return None
def tool(self, *_: Any, **__: Any) -> Any:
def decorator(func: Any) -> Any:
return func
return decorator
def prompt(self, *_: Any, **__: Any) -> Any:
def decorator(func: Any) -> Any:
return func
return decorator
def run(self, *_: Any, **__: Any) -> None:
raise _FASTMCP_IMPORT_ERROR or ImportError("fastmcp is required to run the Skyvern MCP server.")
else:
_FASTMCP_IMPORT_ERROR = None
from .blocks import (
skyvern_block_schema,
@ -109,7 +145,6 @@ from .tabs import (
skyvern_tab_switch,
skyvern_tab_wait_for_new,
)
from .telemetry import MCPTelemetryMiddleware
from .workflow import (
skyvern_workflow_cancel,
skyvern_workflow_create,
@ -123,6 +158,14 @@ from .workflow import (
)
def _add_telemetry_middleware() -> None:
if _FASTMCP_IMPORT_ERROR is not None:
return
from .telemetry import MCPTelemetryMiddleware # noqa: PLC0415
mcp.add_middleware(MCPTelemetryMiddleware())
# -- Tool annotation factories --
# Every tool registered with FastMCP must carry a human-readable `title`
# (required by the Claude Connectors Directory). We build per-tool
@ -153,7 +196,7 @@ def _web_dest(title: str) -> ToolAnnotations:
return ToolAnnotations(title=title, readOnlyHint=False, openWorldHint=True, destructiveHint=True)
mcp = FastMCP(
mcp = _FastMCP(
"Skyvern",
instructions="""\
Skyvern is the complete browser MCP for AI agents. Use Skyvern for ALL browser interactions.
@ -293,7 +336,7 @@ Use skyvern_click's resolved_selector response to get xpaths for production scri
5. NEVER create single-block workflows with long prompts split into one block per step.
""",
)
mcp.add_middleware(MCPTelemetryMiddleware())
_add_telemetry_middleware()
# -- Browser session management --
mcp.tool(tags={"session"}, annotations=_mut("Create Browser Session"))(skyvern_browser_session_create)

View file

@ -8,19 +8,92 @@ import typer
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm
from rich.text import Text
from skyvern.analytics import capture_setup_error, capture_setup_event
# Import console after skyvern.cli to ensure proper initialization
from skyvern.cli.browser import _print_classic_cdp_instructions
from skyvern.cli.console import console
from skyvern.cli.init_command import init_env # init is used directly
from skyvern.cli.llm_setup import setup_llm_providers
from skyvern.cli.utils import start_services
quickstart_app = typer.Typer(help="Quickstart command to set up and run Skyvern with one command.")
def _has_server_quickstart_extra() -> bool:
from skyvern.exceptions import SkyvernExtraNotInstalled, require_server_extra_modules # noqa: PLC0415
try:
require_server_extra_modules("skyvern quickstart", ("uvicorn",))
except SkyvernExtraNotInstalled:
return False
return True
def _print_install_path_guidance() -> None:
message = """Choose the Skyvern path for what you want to do:
Cloud/API SDK
You already have this with `pip install skyvern`.
No quickstart is required. Use `Skyvern(api_key=...)` or run `skyvern setup` for cloud MCP.
Embedded local Python SDK
Install: pip install "skyvern[local]"
Then: python -m playwright install chromium
Use: Skyvern.local(use_in_memory_db=True)
This path does not require Postgres, Docker, migrations, or `skyvern run server`.
Self-hosted local server
Install: pip install "skyvern[server]"
Then rerun: skyvern quickstart
This path sets up the local server, database, local API key, MCP, and optional UI.
"""
console.print(Panel(Text(message), title="Skyvern Quickstart", border_style="cyan"))
def _run_server_quickstart(
*,
no_postgres: bool,
database_string: str,
skip_browser_install: bool,
server_only: bool,
) -> None:
try:
from skyvern.cli.init_command import init_env # noqa: PLC0415
from skyvern.cli.utils import start_services # noqa: PLC0415
# Initialize Skyvern (pip install path)
console.print("\n[bold blue]Initializing Skyvern...[/bold blue]")
run_local = init_env(
no_postgres=no_postgres,
database_string=database_string,
skip_browser_install=skip_browser_install,
)
if run_local:
_configure_cdp_livestreaming_defaults()
# Start services
if run_local:
start_now = typer.confirm("\nDo you want to start Skyvern services now?", default=True)
if start_now:
console.print("\n[bold blue]Starting Skyvern services...[/bold blue]")
asyncio.run(start_services(server_only=server_only))
else:
console.print(
"\n[yellow]Skipping service startup. You can start services later with 'skyvern run all'[/yellow]"
)
except KeyboardInterrupt:
capture_setup_event(
"quickstart-interrupt",
success=False,
error_type="user_interrupt",
error_message="Quickstart interrupted by user",
)
console.print("\n[bold yellow]Quickstart process interrupted by user.[/bold yellow]")
raise typer.Exit(0)
except Exception as e:
capture_setup_error("quickstart-fail", e, error_type="quickstart_error")
console.print(f"[bold red]Error during quickstart: {str(e)}[/bold red]")
raise typer.Exit(1)
def check_docker() -> bool:
"""Check if Docker is installed and running."""
try:
@ -93,6 +166,8 @@ def _configure_cdp_livestreaming_defaults() -> None:
def run_docker_compose_setup() -> None:
"""Run the Docker Compose setup for Skyvern."""
from skyvern.cli.llm_setup import setup_llm_providers # noqa: PLC0415
console.print("\n[bold blue]Setting up Skyvern with Docker Compose...[/bold blue]")
capture_setup_event("docker-compose-start")
@ -184,11 +259,10 @@ def run_docker_compose_setup() -> None:
default=False,
)
if use_own_browser:
from skyvern.cli.browser import _print_classic_cdp_instructions # noqa: PLC0415
_print_classic_cdp_instructions()
confirmed = Confirm.ask(
"Have you enabled remote debugging in Chrome?",
default=False,
)
confirmed = Confirm.ask("Have you enabled remote debugging in Chrome?", default=False)
if confirmed:
from skyvern.cli.llm_setup import update_or_add_env_var
@ -266,38 +340,13 @@ def quickstart(
run_docker_compose_setup()
return
try:
# Initialize Skyvern (pip install path)
console.print("\n[bold blue]Initializing Skyvern...[/bold blue]")
run_local = init_env(
no_postgres=no_postgres,
database_string=database_string,
skip_browser_install=skip_browser_install,
)
if run_local:
_configure_cdp_livestreaming_defaults()
# Start services
if run_local:
start_now = typer.confirm("\nDo you want to start Skyvern services now?", default=True)
if start_now:
console.print("\n[bold blue]Starting Skyvern services...[/bold blue]")
asyncio.run(start_services(server_only=server_only))
else:
console.print(
"\n[yellow]Skipping service startup. You can start services later with 'skyvern run all'[/yellow]"
)
except KeyboardInterrupt:
capture_setup_event(
"quickstart-interrupt",
success=False,
error_type="user_interrupt",
error_message="Quickstart interrupted by user",
)
console.print("\n[bold yellow]Quickstart process interrupted by user.[/bold yellow]")
if not _has_server_quickstart_extra():
_print_install_path_guidance()
raise typer.Exit(0)
except Exception as e:
capture_setup_error("quickstart-fail", e, error_type="quickstart_error")
console.print(f"[bold red]Error during quickstart: {str(e)}[/bold red]")
raise typer.Exit(1)
_run_server_quickstart(
no_postgres=no_postgres,
database_string=database_string,
skip_browser_install=skip_browser_install,
server_only=server_only,
)

View file

@ -3,10 +3,10 @@ from http import HTTPStatus
from importlib.util import find_spec
from typing import NoReturn
# Representative modules that indicate the server extra is installed enough for
# server/local/browser import graphs. Keep this list intentionally small, but
# include the heavy modules users commonly have partially installed.
_SERVER_EXTRA_SENTINELS = (
# Representative modules that indicate the local extra is installed enough for
# embedded/browser import graphs. Keep this list intentionally small, but include
# the heavy modules users commonly have partially installed.
_LOCAL_EXTRA_SENTINELS = (
"fastapi",
"jinja2",
"libcst",
@ -17,15 +17,27 @@ _SERVER_EXTRA_SENTINELS = (
"starlette_context",
)
# Server installs are a superset of local installs, but embedded local mode still
# imports some Forge/API modules such as skyvern.forge.api_app. Keep the default
# server sentinels local-compatible so those imports continue to work in
# skyvern[local]. Full server entrypoints pass server-only module_names such as
# "uvicorn" when they need to fail for local-only installs.
_SERVER_EXTRA_SENTINELS = tuple(_LOCAL_EXTRA_SENTINELS)
def _missing_server_extra_dependency(module_name: str) -> bool:
_EXTRA_SUPPORT_LABELS = {
"local": "local embedded/browser support",
"server": "server support",
}
def _missing_extra_dependency(module_name: str, sentinels: tuple[str, ...]) -> bool:
root_module = module_name.split(".", maxsplit=1)[0]
if root_module in _SERVER_EXTRA_SENTINELS:
if root_module in sentinels:
return find_spec(root_module) is None
if root_module == "skyvern":
return False
# Unknown missing modules may be genuine dependency bugs, so only known
# server-extra sentinels are rewritten to the install hint.
# extra sentinels are rewritten to the install hint.
return False
@ -39,22 +51,49 @@ class SkyvernExtraNotInstalled(ImportError):
def __init__(self, feature: str, extra: str = "server"):
self.feature = feature
self.extra = extra
super().__init__(f"{feature} requires server support. Install it with `pip install skyvern[{extra}]`.")
support_label = _EXTRA_SUPPORT_LABELS.get(extra, f"{extra} support")
super().__init__(f'{feature} requires {support_label}. Install it with `pip install "skyvern[{extra}]"`.')
def raise_server_extra_required(feature: str, exc: ImportError) -> NoReturn:
if isinstance(exc, ModuleNotFoundError) and exc.name is not None and _missing_server_extra_dependency(exc.name):
raise SkyvernExtraNotInstalled(feature) from exc
def _raise_extra_required(
feature: str,
exc: ImportError,
*,
extra: str,
sentinels: tuple[str, ...],
) -> NoReturn:
if isinstance(exc, SkyvernExtraNotInstalled):
raise SkyvernExtraNotInstalled(feature, extra=extra) from exc
if isinstance(exc, ModuleNotFoundError) and exc.name is not None and _missing_extra_dependency(exc.name, sentinels):
raise SkyvernExtraNotInstalled(feature, extra=extra) from exc
raise exc
def require_server_extra_modules(feature: str, module_names: tuple[str, ...] = ()) -> None:
# Server/local/browser APIs require the full server extra, not a partial Playwright-only install.
required_modules = dict.fromkeys((*_SERVER_EXTRA_SENTINELS, *module_names))
def raise_local_extra_required(feature: str, exc: ImportError) -> NoReturn:
_raise_extra_required(feature, exc, extra="local", sentinels=_LOCAL_EXTRA_SENTINELS)
def raise_server_extra_required(feature: str, exc: ImportError) -> NoReturn:
_raise_extra_required(feature, exc, extra="server", sentinels=_SERVER_EXTRA_SENTINELS)
def _require_extra_modules(feature: str, extra: str, sentinels: tuple[str, ...], module_names: tuple[str, ...]) -> None:
required_modules = dict.fromkeys((*sentinels, *module_names))
for module_name in required_modules:
if find_spec(module_name) is None:
missing = ModuleNotFoundError(f"No module named '{module_name}'", name=module_name)
raise SkyvernExtraNotInstalled(feature) from missing
raise SkyvernExtraNotInstalled(feature, extra=extra) from missing
def require_local_extra_modules(feature: str, module_names: tuple[str, ...] = ()) -> None:
# Embedded/local browser APIs require the local extra, not a partial Playwright-only install.
_require_extra_modules(feature, "local", _LOCAL_EXTRA_SENTINELS, module_names)
def require_server_extra_modules(feature: str, module_names: tuple[str, ...] = ()) -> None:
# With no module_names, this only guards against base installs. Pass server-only
# modules when a path must discriminate between local and full server extras.
_require_extra_modules(feature, "server", _SERVER_EXTRA_SENTINELS, module_names)
class SkyvernClientException(SkyvernException):

View file

@ -3937,6 +3937,14 @@ class ForgeAgent:
await self.cleanup_browser_and_create_artifacts(
close_browser_on_completion, last_step, task, browser_session_id=browser_session_id
)
try:
await app.AGENT_FUNCTION.release_proxy_session_for_owner(task.task_id)
except Exception:
LOG.warning(
"Failed to release proxy session for task",
exc_info=True,
task_id=task.task_id,
)
# Wait for all tasks to complete before generating the links for the artifacts
with _tracer.start_as_current_span("skyvern.agent.cleanup.wait_for_upload") as _cl_wait_span:

View file

@ -14,7 +14,9 @@ from skyvern.exceptions import DisabledBlockExecutionError, StepUnableToExecuteE
from skyvern.forge import app
from skyvern.forge.async_operations import AsyncOperation
from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.api.azure import AzureClientFactory
from skyvern.forge.sdk.api.llm.exceptions import LLMProviderError
from skyvern.forge.sdk.copilot.config import CopilotConfig
from skyvern.forge.sdk.core import skyvern_context
from skyvern.forge.sdk.db.agent_db import AgentDB
from skyvern.forge.sdk.models import Step, StepStatus
@ -478,6 +480,9 @@ class AgentFunction:
"""
return AgentDB(database_string, debug_enabled=debug_enabled)
def build_azure_client_factory(self, factory: AzureClientFactory) -> AzureClientFactory:
return factory
def resolve_mcp_oauth_org_lookups(self, db: object) -> tuple[Any, Any] | None:
"""Return ``(get_organization_entities, get_valid_org_auth_token)`` callables
bound to the cloud DB's nested organizations repository.
@ -623,6 +628,15 @@ class AgentFunction:
async def post_cache_step_execution(self, task: Task, step: Step) -> None:
return
async def release_proxy_session_for_owner(self, owner_id: str) -> None:
"""Release any proxy lease held by ``owner_id``.
OSS no-op. Cloud overrides this to release the lease back to the
proxy-session pool so workflow/task cleanup doesn't have to
import cloud-only modules from the OSS-synced ``skyvern/`` tree.
"""
return None
async def should_shadow_extraction_cache_hit(self, task: Task) -> bool:
"""Cloud-overridable sample gate for extract-information shadow mode. OSS no-op."""
return False
@ -1013,6 +1027,10 @@ class AgentFunction:
"""
return ""
def get_copilot_config(self) -> CopilotConfig | None:
"""Return an optional workflow copilot config override."""
return None
def detect_ats_platform(self, url_or_domain: str | None) -> str | None:
"""Detect if a URL belongs to a known ATS platform.

View file

@ -7,7 +7,7 @@ You are a workflow builder, NOT a chatbot or search engine. Your ONLY job is to
- NEVER include scraped data, extracted content, or live website information in your response.
- If the test run produced extracted data, mention that the extraction succeeded and describe the data shape (e.g., "3 items with rank, title, URL, and points"), but do NOT include the actual values.
- The user will run the workflow themselves to get results. Your job is to build and verify the workflow, not to relay results.
- The "no chatbot" rule above scopes to questions whose answer is *data from an external site or a test-run extraction* — those still need a workflow. Questions whose answer is *Skyvern documentation* (what a block type does, how the copilot works, what a Skyvern concept means, or comparisons between Skyvern concepts) get answered inline: respond with `ASK_QUESTION` and put the explanation in `user_response`; do NOT build or run a workflow to launder the answer through `text_prompt`. Disambiguators: (1) "Can Skyvern do X on site Y?" is a request to build X — proceed with the build, not the docs lookup. (2) If the user describes a runtime symptom on their current workflow (a step or block isn't behaving as expected, a value renders wrong, a block's prompt isn't producing the desired effect) and asks for advice or alternatives, that is a debugging request, not a docs lookup — fix the workflow with `update_and_run_blocks`, not just explain.
- The "no chatbot" rule above scopes to questions whose answer is *data from an external site or a test-run extraction* — those still need a workflow. Questions whose answer is *Skyvern documentation* (what a block type does, how the copilot works, what a Skyvern concept means, or comparisons between Skyvern concepts) get answered inline: respond with `ASK_QUESTION` and put the explanation in `user_response`; do NOT build or run a workflow to launder the answer through `text_prompt`. Disambiguators: (1) "Can Skyvern do X on site Y?" is a request to build X — proceed with the build, not the docs lookup. (2) If the user explicitly asks for a fix, improvement, prompt change, or alternative for a runtime symptom on their current workflow, that is a debugging/edit request — inspect the current workflow/run evidence, then fix the workflow with `update_and_run_blocks` when the correction is clear.
{% if security_rules %}
{{ security_rules }}
@ -24,6 +24,17 @@ You have tools available in this run. Use them to build better workflows:
IMPORTANT: NEVER call the same tool more than twice in a row. If a tool succeeded, move on to the NEXT step — do not repeat the same call. Each tool call should advance your progress toward the goal.
DIAGNOSTIC / OBSERVATIONAL COMPLAINTS:
Default pure observations, diagnostic questions, and complaints about the current workflow to inspect-and-clarify, not modify-and-run.
Examples include reports about a missing newly added workflow block, a workflow not following a configured search step, whether a named block is present, why a runtime error condition did not trigger, or references to missing or unknown block labels.
For these turns:
1. Inspect the provided current workflow YAML, chat history, global context, and debugger run information first.
2. If prior run details are needed, use `get_run_results` for existing run evidence. If the user explicitly asks what is visible in the live browser, use read-only direct browser inspection.
3. Do NOT call `update_and_run_blocks` and do NOT call `run_blocks_and_collect_debug` as the default first response. Do not draft replacement blocks just because the complaint mentions a block label.
4. Respond with `ASK_QUESTION` or a concise diagnostic explanation that names what you can see and what specific information is missing before editing.
Explicit edit/debug requests remain edit requests. If the user asks "how can I improve...", "are there any prompt changes...", "what would fix...", "please change...", or otherwise asks you to modify behavior, inspect the relevant evidence and call `update_and_run_blocks` once the edit is clear.
WORKFLOW-FIRST EXECUTION PATH (INCREMENTAL):
Default path for build/debug requests:
1. Do NOT draft the full workflow upfront.
@ -123,11 +134,12 @@ When the user corrects, amends, or updates a previous request (e.g., "I meant X
4. Rename affected block labels and block titles whenever the correction changes the subject, URL, target action, or extracted data. Labels become output keys; stale labels mislead the agent's own future tool calls and Jinja templates that reference them. When you rename a label, update every related `next_block_label`, `block_labels` argument, and Jinja block reference.
DIAGNOSING OUTPUT PROBLEMS:
When the user reports a problem with workflow output (duplicates, missing data, wrong values, errors):
1. **Inspect first**: Use get_run_results to see the actual output data from the most recent run. Do NOT guess what went wrong.
2. **Analyze**: Compare the output data against the user's complaint to understand the specific issue.
3. **Fix**: Modify the workflow to address the root cause, then call update_workflow.
4. **Explain**: Tell the user what you found and what you changed.
When the user reports a problem with workflow output (duplicates, missing data, wrong values, errors), first classify the intent:
1. Pure observation or complaint: inspect with current context and get_run_results if needed, then clarify or explain what evidence is missing. Do NOT modify or run the workflow by default.
2. Explicit fix request: use get_run_results to see the actual output data from the most recent run. Do NOT guess what went wrong.
3. Analyze: Compare the output data against the user's complaint to understand the specific issue.
4. Fix: Modify the workflow to address the root cause, then call update_workflow or update_and_run_blocks as required by the workflow submission/testing rules.
5. Explain: Tell the user what you found and what you changed.
IMPORTANT WORKFLOW RULES:
* Always generate valid YAML that conforms to the Skyvern workflow schema.
@ -152,7 +164,7 @@ IMPORTANT WORKFLOW RULES:
* For blocks with specific goals (downloading files, finding specific content), set a terminate_criterion that describes when the agent should give up. Example: terminate_criterion: "Terminate if the website clearly does not contain the requested content after reviewing the page."
* Your success message to the user MUST reflect what actually happened. If you only navigated the browser but did NOT call update_workflow, do NOT tell the user the workflow was updated or is ready. Only confirm workflow changes after update_workflow succeeds.
* When the user's request requires authentication but no credential is provided, use list_credentials to find the right one.
* Workflow-improvement questions about a specific present block ("how can I improve…", "are there any prompt changes…", "what would fix…") are EDIT requests — call `update_and_run_blocks` with the block fixed. Use `ASK_QUESTION` only when information needed for the edit is genuinely missing.
* Workflow-improvement questions about a specific present block ("how can I improve…", "are there any prompt changes…", "what would fix…") are EDIT requests — call `update_and_run_blocks` with the block fixed. Pure diagnostic / observational complaint questions about a block are inspect-and-clarify requests, not edit requests; use `ASK_QUESTION` when information needed for the edit is genuinely missing.
CREDENTIAL HANDLING - CRITICAL:
* If the user's latest message in this turn contains a raw credential written inline — passwords (alone or paired with a username), API keys, OAuth tokens, `Authorization: Bearer …` headers, JWTs, session cookies, TOTP seeds, OTP / one-time codes, private keys, or credit card numbers / CVVs — you MUST NOT build, update, or run a workflow this turn; you MUST NOT pass those values via `parameters`; and you MUST NOT type, submit, paste, or replay them through `type_text`, `click`, `press_key`, `select_option`, `evaluate`, or any other direct browser tool. A bare username, email, or non-secret identifier alone is NOT a raw credential and does not trigger this rule. Credential-shaped strings rendered into the prompt from the existing workflow YAML are pre-existing workflow state — they are NOT a fresh paste and do NOT trigger this rule. Raw credentials pasted in earlier chat-history turns DO still trigger this rule whenever the user re-references or asks you to use them.

View file

@ -1,21 +1,25 @@
from __future__ import annotations
import io
import time
from enum import StrEnum
from mimetypes import add_type, guess_type
from typing import IO, Any
from typing import IO, TYPE_CHECKING, Any
from urllib.parse import urlparse
import aioboto3
import structlog
from botocore.exceptions import ClientError
from types_boto3_batch.client import BatchClient
from types_boto3_ec2.client import EC2Client
from types_boto3_ecs.client import ECSClient
from types_boto3_s3.client import S3Client
from types_boto3_secretsmanager.client import SecretsManagerClient
from skyvern.config import settings
if TYPE_CHECKING:
from types_boto3_batch.client import BatchClient
from types_boto3_ec2.client import EC2Client
from types_boto3_ecs.client import ECSClient
from types_boto3_s3.client import S3Client
from types_boto3_secretsmanager.client import SecretsManagerClient
# Register custom mime types for mimetypes guessing
add_type("application/json", ".har")
add_type("text/plain", ".log")

View file

@ -26,6 +26,7 @@ from pydantic import ValidationError
from skyvern.forge import app
from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.copilot.block_goal_wrapping import wrap_block_goals
from skyvern.forge.sdk.copilot.config import CopilotConfig
from skyvern.forge.sdk.copilot.context import COPILOT_RESPONSE_TYPES, AgentResult, CopilotContext, StructuredContext
from skyvern.forge.sdk.copilot.output_utils import (
extract_final_text,
@ -47,7 +48,6 @@ WORKFLOW_KNOWLEDGE_BASE_PATH = (
Path(__file__).resolve().parents[2] / "prompts" / "skyvern" / "workflow_knowledge_base.txt"
)
MAX_TURNS = 25
_USER_SUPPLIED_CREDENTIAL_ID_RE = re.compile(r"\bcred_[A-Za-z0-9][A-Za-z0-9_-]*\b")
_UNTESTED_DRAFT_REQUEST_RE = re.compile(
r"\b(?:"
@ -170,15 +170,18 @@ def _build_block_goal_main_goal(
def _build_system_prompt(
tool_usage_guide: str,
security_rules: str = "",
config: CopilotConfig | None = None,
security_rules: str | None = None,
) -> str:
copilot_config = config or CopilotConfig(security_rules=security_rules or "")
template = copilot_config.prompt_template.removesuffix(".j2")
workflow_knowledge_base = WORKFLOW_KNOWLEDGE_BASE_PATH.read_text(encoding="utf-8")
return prompt_engine.load_prompt(
template="workflow-copilot-agent",
template=template,
workflow_knowledge_base=workflow_knowledge_base,
current_datetime=datetime.now(timezone.utc).isoformat(),
tool_usage_guide=tool_usage_guide,
security_rules=security_rules,
security_rules=copilot_config.security_rules,
)
@ -841,6 +844,60 @@ def _build_feasibility_clarification_result(
)
_RETRIABLE_LLM_ERROR_NAMES = {
"APIConnectionError",
"APITimeoutError",
"APIError",
"InternalServerError",
"RateLimitError",
"ServiceUnavailableError",
"Timeout",
}
_RETRIABLE_LLM_ERROR_TEXT = (
"rate limit",
"timeout",
"timed out",
"temporarily unavailable",
"service unavailable",
"connection error",
"connection reset",
"internal server error",
"server error",
"overloaded",
)
_LLM_ERROR_MODULE_MARKERS = ("openai", "litellm", "anthropic")
def _iter_exception_chain(exc: BaseException) -> list[BaseException]:
chain: list[BaseException] = []
current: BaseException | None = exc
while current is not None and current not in chain:
chain.append(current)
current = current.__cause__ or current.__context__
return chain
def _is_retriable_llm_error(exc: BaseException) -> bool:
for item in _iter_exception_chain(exc):
module = type(item).__module__.lower()
name = type(item).__name__
text = str(item).lower()
if name in _RETRIABLE_LLM_ERROR_NAMES and any(marker in module for marker in _LLM_ERROR_MODULE_MARKERS):
return True
if any(marker in module for marker in _LLM_ERROR_MODULE_MARKERS) and any(
phrase in text for phrase in _RETRIABLE_LLM_ERROR_TEXT
):
return True
return False
def _fallback_llm_key(config: CopilotConfig, current_llm_key: str) -> str | None:
fallback_key = config.fallback_llm_key
if not fallback_key or fallback_key == current_llm_key:
return None
return fallback_key
async def run_copilot_agent(
stream: EventSourceStream,
organization_id: str,
@ -851,7 +908,9 @@ async def run_copilot_agent(
llm_api_handler: LLMAPIHandler | None,
api_key: str | None = None,
security_rules: str = "",
config: CopilotConfig | None = None,
) -> AgentResult:
copilot_config = config or CopilotConfig(security_rules=security_rules)
allow_untested_workflow_draft = _user_requests_untested_workflow_draft(chat_request.message)
credential_validation_result = await _credential_validation_result_for_user_message(
user_message=chat_request.message,
@ -941,36 +1000,23 @@ async def run_copilot_agent(
workflow_copilot_chat_id=chat_request.workflow_copilot_chat_id,
)
model_name, run_config, llm_key, supports_vision = resolve_model_config(llm_api_handler)
model_name, run_config, llm_key, supports_vision = resolve_model_config(
llm_api_handler,
copilot_config=copilot_config,
)
ctx.supports_vision = supports_vision
ensure_tracing_initialized()
alias_map = get_skyvern_mcp_alias_map()
overlays = _build_skyvern_mcp_overlays()
mcp_server = SkyvernOverlayMCPServer(
transport=skyvern_mcp,
overlays=overlays,
alias_map=alias_map,
allowlist=frozenset(alias_map.values()),
context_provider=lambda: ctx,
)
tool_info: list[tuple[str, str]] = [(tool.name, tool.description or "") for tool in NATIVE_TOOLS]
tool_info.extend((name, overlay.description or "") for name, overlay in overlays.items())
tool_usage_guide = _build_tool_usage_guide(tool_info)
system_prompt = _build_system_prompt(
tool_usage_guide=tool_usage_guide,
security_rules=security_rules,
)
agent = Agent(
name="workflow-copilot",
instructions=system_prompt,
tools=list(NATIVE_TOOLS),
mcp_servers=[mcp_server],
model=model_name,
config=copilot_config,
)
user_message = _build_user_context(
@ -1002,23 +1048,77 @@ async def run_copilot_agent(
)
chat_id = chat_request.workflow_copilot_chat_id or chat_request.workflow_permanent_id
session = create_copilot_session(chat_id)
model_token = _copilot_model_name.set(model_name)
async def _run_attempt(
attempt_model_name: str,
attempt_run_config: Any,
attempt_llm_key: str,
) -> RunResultStreaming:
mcp_server = SkyvernOverlayMCPServer(
transport=skyvern_mcp,
overlays=overlays,
alias_map=alias_map,
allowlist=frozenset(alias_map.values()),
context_provider=lambda: ctx,
)
agent = Agent(
name="workflow-copilot",
instructions=system_prompt,
tools=list(NATIVE_TOOLS),
mcp_servers=[mcp_server],
model=attempt_model_name,
)
session = create_copilot_session(chat_id)
model_token = _copilot_model_name.set(attempt_model_name)
try:
async with MCPServerManager([mcp_server]) as manager:
agent.mcp_servers = list(manager.active_servers)
result = await run_with_enforcement(
agent=agent,
initial_input=user_message,
ctx=ctx,
stream=stream,
max_turns=copilot_config.max_turns,
hooks=CopilotRunHooks(ctx),
run_config=attempt_run_config,
session=session,
copilot_config=copilot_config,
)
LOG.info(
"Copilot agent model attempt succeeded",
workflow_permanent_id=chat_request.workflow_permanent_id,
llm_key=attempt_llm_key,
)
return result
finally:
_copilot_model_name.reset(model_token)
session.close()
try:
with trace_context:
try:
async with MCPServerManager([mcp_server]) as manager:
agent.mcp_servers = list(manager.active_servers)
result = await run_with_enforcement(
agent=agent,
initial_input=user_message,
ctx=ctx,
stream=stream,
max_turns=MAX_TURNS,
hooks=CopilotRunHooks(ctx),
run_config=run_config,
session=session,
try:
result = await _run_attempt(model_name, run_config, llm_key)
except Exception as primary_error:
fallback_llm_key = _fallback_llm_key(copilot_config, llm_key)
if fallback_llm_key is None or not _is_retriable_llm_error(primary_error):
raise
LOG.warning(
"Copilot agent model attempt failed; retrying fallback model",
workflow_permanent_id=chat_request.workflow_permanent_id,
primary_llm_key=llm_key,
fallback_llm_key=fallback_llm_key,
error_type=type(primary_error).__name__,
)
fallback_model_name, fallback_run_config, fallback_resolved_key, fallback_supports_vision = (
resolve_model_config(
llm_api_handler,
copilot_config=copilot_config,
llm_key_override=fallback_llm_key,
)
)
ctx.supports_vision = fallback_supports_vision
result = await _run_attempt(fallback_model_name, fallback_run_config, fallback_resolved_key)
return _translate_to_agent_result(
result,
ctx,
@ -1058,6 +1158,3 @@ async def run_copilot_agent(
except Exception as e:
LOG.error("Copilot agent error", error=str(e), exc_info=True)
return _build_unexpected_error_exit_result(ctx, global_llm_context)
finally:
_copilot_model_name.reset(model_token)
session.close()

View file

@ -0,0 +1,296 @@
"""Injectable workflow copilot configuration."""
from __future__ import annotations
from dataclasses import dataclass, field
from skyvern.config import settings
DEFAULT_PROMPT_TEMPLATE = "workflow-copilot-agent.j2"
DEFAULT_MAX_TURNS = 25
DEFAULT_TOKEN_BUDGET = 90_000
SCREENSHOT_DROPPED_NUDGE = (
"Your previous screenshot was dropped from context to recover from a token-budget overflow. "
"Do NOT reason about the page from memory. Re-take the screenshot "
"(get_browser_screenshot) or call evaluate before deciding your next step."
)
POST_UPDATE_NUDGE = (
"You updated the workflow but did not test it. "
"You MUST call run_blocks_and_collect_debug (or update_and_run_blocks next time) "
"to test at least the first block before responding to the user. "
"This verifies the workflow actually works. "
"Exception: if the latest user message explicitly asked for an untested draft, "
"respond with an unvalidated draft instead of testing."
)
POST_NAVIGATE_NUDGE = (
"You navigated to a page but did not observe its content. "
"You MUST use evaluate, get_browser_screenshot, click, type_text, "
"scroll, select_option, press_key, or console_messages "
"to inspect the page before responding. Do NOT answer from memory."
)
POST_INTERMEDIATE_SUCCESS_NUDGE = (
"STOP — do NOT respond to the user yet. "
"Your workflow only covers a subset of what the user asked for. "
"You MUST add the next block now: call update_and_run_blocks with the current "
"block chain. The tool preserves verified prefix state and reruns only the "
"invalidated frontier, so passing the full chain is cheap. "
"Only respond to the user when every distinct action they requested is covered "
"by a workflow block, or you have clear evidence that continuing is infeasible."
)
POST_FAILED_TEST_NUDGE = (
"STOP — your last test run FAILED. Do NOT respond to the user yet.\n"
"1. First, call get_run_results — pass the workflow_run_id from the prior "
"update_and_run_blocks or run_blocks_and_collect_debug response to make the "
"lookup unambiguous. That returns per-block failure_reason, output, and any "
"failed-block screenshots, which is the diagnostic data you need.\n"
"2. Then decide: if the failure looks fixable (wrong goal wording, popup "
"blocking, timeout, element not found), adjust the workflow with a DIFFERENT "
"approach and call update_and_run_blocks again — the tool will rerun from "
"the earliest invalidated block so only the changed part is retested.\n"
"3. If you have now failed multiple times with genuinely different approaches "
"and the evidence strongly suggests the site cannot satisfy the request, "
"respond explaining exactly what you tried and what blocked you.\n"
"Do NOT resubmit the same workflow — you must change something substantive."
)
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE = (
"STOP — you explored the page using direct browser tools but did NOT engage "
"the workflow path. You MUST follow the WORKFLOW-FIRST EXECUTION PATH:\n"
"1. If no workflow exists yet, call update_workflow with at least a navigation "
"block for the target URL.\n"
"2. If a workflow already exists, call run_blocks_and_collect_debug to test it.\n"
"3. Use the test results to decide next steps.\n"
"Do NOT make feasibility judgments from browser exploration alone — "
"build and test workflow blocks first."
)
POST_SUSPICIOUS_SUCCESS_NUDGE = (
"STOP — your last test run completed (status=completed) but data-producing "
"blocks (extraction/text_prompt) produced no meaningful output "
"(missing, empty, or all-null fields). This is NOT a success.\n"
"1. Call get_run_results to inspect what each block actually returned.\n"
"2. If the extraction/text_prompt block returned empty, all-null, or "
"irrelevant data, the upstream block likely fetched an error page "
"(e.g. 403, CAPTCHA, 'no results'), landed on the wrong page, or the "
"data is rendered differently than expected.\n"
"3. Use get_browser_screenshot or evaluate to inspect what the workflow "
"browser actually sees — do NOT just retry extraction with a different prompt.\n"
"4. Fix the root cause — do NOT declare the workflow working based on "
"status alone. Verify the actual extracted data answers the user's question."
)
POST_REPEATED_NULL_DATA_NUDGE = (
"STOP — you have now produced multiple consecutive test runs where "
"extraction/text_prompt blocks returned all-null or empty data. "
"Re-prompting the extractor is not working — the problem is almost "
"certainly NOT how the extraction goal is worded.\n"
"You MUST now do ONE of the following before another update_workflow call:\n"
"1. Call get_browser_screenshot on the workflow's browser session to see "
"exactly what page the workflow is actually loading (it may differ from "
"what you expect — e.g. a 'no results' fallback, cookie wall, or bot block).\n"
"2. Call evaluate with JavaScript that searches for the expected content "
"on the workflow's browser — confirm whether the data is even present.\n"
"3. If the page the workflow loads genuinely does not contain the data, "
"pivot to a different URL or source entirely — do NOT keep retrying "
"extraction against the same failing page.\n"
"Do NOT call update_and_run_blocks again until you have concrete evidence "
"about what the workflow browser is actually seeing."
)
POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE = (
"STOP — this is the second run with the same frontier and the same failure "
"signature. Re-running the same change again is unlikely to help.\n"
"Before another update_and_run_blocks call, you MUST:\n"
"1. Call get_run_results to inspect the full failure evidence (per-block "
"failure_reason, action_trace, and any failed-block screenshots).\n"
"2. If the evidence is still ambiguous, use get_browser_screenshot or evaluate "
"to check what the workflow browser is actually seeing.\n"
"3. Then make a materially different change — different block ordering, a "
"different selector strategy, a different entry URL, or different parameters. "
"Changes to wording of the same prompt do not count as materially different."
)
POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE = (
"STOP — you have now attempted the same frontier with the same failure "
"signature THREE times without making progress. Do NOT call "
"update_and_run_blocks or run_blocks_and_collect_debug again on this "
"frontier.\n"
"Choose ONE:\n"
"A) Finalize now with a clear blocker explanation that references the "
"specific failure_reason and failure_categories you observed.\n"
"B) If required user input is missing (credential, ambiguous goal, "
"site-specific detail), respond with an ASK_QUESTION instead. Do not "
"retry the same repair again."
)
POST_PARAMETER_BINDING_WARN_NUDGE = (
"STOP — your last test run failed with a PARAMETER_BINDING_ERROR. "
"This is an INTERNAL workflow configuration mismatch, not a site or "
"selector problem.\n"
"The workflow definition references a parameter (by Jinja key) that is "
"not in the top-level workflow parameters list, or the list declares a "
"parameter the blocks do not use.\n"
"Do NOT retry with different selectors, URLs, or navigation changes — "
"those will not help. Instead:\n"
"1. Reconcile the workflow's top-level parameters with what the blocks "
"actually reference via {{ parameters.<key> }}.\n"
"2. Inline one-off literals rather than adding a parameter for each.\n"
"3. Then call update_and_run_blocks again with a corrected YAML and, "
"for any remaining parameters, concrete values passed via the "
"`parameters` argument."
)
POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE = (
"STOP — the target URL is unreachable and further retries cannot succeed. "
"The navigation failed with a permanent error (DNS resolution, SSL/cert, "
"or invalid URL). Do NOT retry and do NOT edit the workflow. Reply to the "
"user now: state that the URL could not be reached, quote the exact error "
"message from the last failure_reason, and ask them to verify the URL."
)
POST_PARAMETER_BINDING_STOP_NUDGE = (
"STOP — you have retried the same PARAMETER_BINDING_ERROR multiple times "
"without reconciling the workflow configuration. Do NOT call "
"update_and_run_blocks or run_blocks_and_collect_debug again until the "
"workflow parameters list matches the block references.\n"
"Choose ONE:\n"
"A) Finalize now with a blocker explanation that names the specific "
"parameter keys that are out of sync.\n"
"B) If you need missing values from the user (credential, identifier) "
"to decide what belongs in the parameters list, respond with an "
"ASK_QUESTION instead. Do not resubmit a workflow that still has the "
"same parameter-binding drift."
)
POST_PER_TOOL_BUDGET_NUDGE = (
"STOP — your last update_and_run_blocks call exceeded the per-tool-call "
"time budget while still making progress. This is NOT a site failure or "
"a wording problem — the chain you submitted is too long to complete in a "
"single tool call.\n"
"Do NOT retry the same chain. Do NOT change navigation_goal wording or "
"selectors hoping it will run faster.\n"
"1. Call get_run_results for the budgeted run. If it shows a navigation "
"block was canceled or failed, do NOT run that same label again unchanged; "
"split or replace it first.\n"
"2. Shrink the requested block_labels list to the first 1-2 unverified "
"blocks. The verified-prefix optimization will replay any earlier blocks "
"from cached state without re-running the browser, so passing a smaller "
"frontier is cheap.\n"
"3. For page-state work, prefer a code or validation block to verify DOM "
"state, active chips, checked controls, field values, or URL deltas. Use a "
"navigation block only when missing state must be changed through the UI, "
"and keep it to one atomic action.\n"
"4. Test the smaller frontier. If it succeeds, extend by one block at a "
"time on subsequent calls.\n"
"5. If your workflow only has 1-2 blocks and one block is still hitting "
"the budget, the single block is too ambitious — either narrow its scope, "
"replace it with DOM/state verification plus smaller actions, or reply "
"with a blocker explanation."
)
POST_NO_WORKFLOW_DELIVERY_NUDGE = (
"STOP — you are telling the user you created or are showing a workflow, "
"but no workflow update tool has succeeded in this turn. The user will see "
"an empty proposal. You MUST either call update_and_run_blocks with a real "
"workflow and test it, or respond with ASK_QUESTION if required input is "
'missing. Do NOT say "Here\'s the workflow" until there is an actual '
"workflow proposal behind the response."
)
PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX = (
"STOP — the target site has failed to scrape on every attempt across "
"multiple workflow shapes. Every run navigated successfully but the "
'scraper could not read the page ("failed to load the website" / '
'"page may have navigated unexpectedly"). This pattern indicates the '
"site is either blocking automated access, genuinely unresponsive in "
"this environment, or rendering content Skyvern cannot read reliably.\n"
"Do NOT retry with another workflow variation. Do NOT call "
"update_and_run_blocks or run_blocks_and_collect_debug again.\n"
"Reply to the user now: state that the site could not be loaded after "
"multiple attempts, quote the last failure_reason verbatim, keep the "
"message concise, and ask "
)
POST_PROBABLE_SITE_BLOCK_STOP_NUDGE = (
PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX
+ "whether to try a different URL, configure a proxy, or provide an alternate entry point."
)
POST_ANTI_BOT_FAILED_TEST_NUDGE = (
"STOP — your last test run failed due to an anti-bot/WAF block "
"(Access Denied, Cloudflare, Akamai, etc.).\n"
"IMPORTANT: An HTTP_REQUEST or navigation block from the SAME server IP "
"will almost certainly receive the same block. Do NOT retry with:\n"
"- A simple wait/delay block (timing does not fix IP bans)\n"
"- A raw HTTP_REQUEST to the same URL (same IP = same block)\n"
"Instead, try:\n"
"1. Set proxy_location on the workflow to route through a different IP "
"(use `RESIDENTIAL` by default, or a US-state value like `US-CA`/`US-NY`; "
"do NOT use bare country codes like `US` — they are not valid "
"ProxyLocation members).\n"
"2. If you add anti-bot handling blocks, make them conditional on visible "
"challenge evidence (for example Cloudflare, Access Denied, CAPTCHA, or "
"verify-you-are-human text). Do NOT assume every run starts on a challenge "
"page; normal, unblocked runs should proceed directly to the requested task.\n"
"3. If still blocked, explain the specific anti-bot evidence observed "
"(for example Cloudflare, CAPTCHA, verify-you-are-human text, Access "
"Denied, or a blank Just-a-moment page); describe exactly what you tried; "
"and ask whether to try a different proxy/location, entry URL, or "
"alternate source.\n"
"Do NOT resubmit the same workflow with trivial changes."
)
POST_FORMAT_NUDGE = (
"Your reply reads as a progress report, not a completed proposal. "
"If you are not ready to finalize, emit ASK_QUESTION with a specific question. "
"Otherwise, finish the workflow and present it as a completed proposal."
)
DEFAULT_ENFORCEMENT_NUDGES: dict[str, str] = {
"screenshot_dropped": SCREENSHOT_DROPPED_NUDGE,
"post_update": POST_UPDATE_NUDGE,
"post_navigate": POST_NAVIGATE_NUDGE,
"post_intermediate_success": POST_INTERMEDIATE_SUCCESS_NUDGE,
"post_failed_test": POST_FAILED_TEST_NUDGE,
"post_explore_without_workflow": POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE,
"post_suspicious_success": POST_SUSPICIOUS_SUCCESS_NUDGE,
"post_repeated_null_data": POST_REPEATED_NULL_DATA_NUDGE,
"post_repeated_frontier_failure_warn": POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE,
"post_repeated_frontier_failure_stop": POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE,
"post_parameter_binding_warn": POST_PARAMETER_BINDING_WARN_NUDGE,
"post_non_retriable_nav_error_stop": POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE,
"post_parameter_binding_stop": POST_PARAMETER_BINDING_STOP_NUDGE,
"post_per_tool_budget": POST_PER_TOOL_BUDGET_NUDGE,
"post_no_workflow_delivery": POST_NO_WORKFLOW_DELIVERY_NUDGE,
"post_probable_site_block_stop_prefix": PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX,
"post_probable_site_block_stop": POST_PROBABLE_SITE_BLOCK_STOP_NUDGE,
"post_anti_bot_failed_test": POST_ANTI_BOT_FAILED_TEST_NUDGE,
"post_format": POST_FORMAT_NUDGE,
}
def _default_enforcement_nudges() -> dict[str, str]:
return dict(DEFAULT_ENFORCEMENT_NUDGES)
def _default_fallback_llm_key() -> str | None:
return settings.SECONDARY_LLM_KEY
@dataclass(slots=True)
class CopilotConfig:
prompt_template: str = DEFAULT_PROMPT_TEMPLATE
max_turns: int = DEFAULT_MAX_TURNS
token_budget: int = DEFAULT_TOKEN_BUDGET
security_rules: str = ""
enforcement_nudges: dict[str, str] = field(default_factory=_default_enforcement_nudges)
fallback_llm_key: str | None = field(default_factory=_default_fallback_llm_key)
def nudge(self, key: str) -> str:
return self.enforcement_nudges.get(key, DEFAULT_ENFORCEMENT_NUDGES[key])

View file

@ -12,6 +12,28 @@ from typing import TYPE_CHECKING, Any
import structlog
from agents.run import Runner
from skyvern.forge.sdk.copilot import config as copilot_config_defaults
from skyvern.forge.sdk.copilot.config import (
DEFAULT_ENFORCEMENT_NUDGES,
DEFAULT_TOKEN_BUDGET,
POST_ANTI_BOT_FAILED_TEST_NUDGE,
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE,
POST_FAILED_TEST_NUDGE,
POST_NAVIGATE_NUDGE,
POST_NO_WORKFLOW_DELIVERY_NUDGE,
POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE,
POST_PARAMETER_BINDING_STOP_NUDGE,
POST_PARAMETER_BINDING_WARN_NUDGE,
POST_PER_TOOL_BUDGET_NUDGE,
POST_PROBABLE_SITE_BLOCK_STOP_NUDGE,
POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE,
POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE,
POST_REPEATED_NULL_DATA_NUDGE,
POST_SUSPICIOUS_SUCCESS_NUDGE,
POST_UPDATE_NUDGE,
SCREENSHOT_DROPPED_NUDGE,
CopilotConfig,
)
from skyvern.forge.sdk.copilot.failure_tracking import PER_TOOL_BUDGET_FAILURE_CATEGORY, normalize_failure_reason
from skyvern.forge.sdk.copilot.narration import TransitionKind
from skyvern.forge.sdk.copilot.output_utils import (
@ -31,6 +53,9 @@ if TYPE_CHECKING:
LOG = structlog.get_logger()
POST_FORMAT_NUDGE = copilot_config_defaults.POST_FORMAT_NUDGE
POST_INTERMEDIATE_SUCCESS_NUDGE = copilot_config_defaults.POST_INTERMEDIATE_SUCCESS_NUDGE
MAX_POST_UPDATE_NUDGES = 2
MAX_INTERMEDIATE_NUDGES = 8
MAX_FAILED_TEST_NUDGES = 2
@ -70,12 +95,7 @@ MAX_ITERATIONS = 50
SCREENSHOT_SENTINEL = "[copilot:screenshot] "
NUDGE_SENTINEL = "[copilot:nudge] "
SCREENSHOT_PLACEHOLDER = SCREENSHOT_SENTINEL + "[prior screenshot removed to save context]"
SCREENSHOT_DROPPED_NUDGE = (
"Your previous screenshot was dropped from context to recover from a token-budget overflow. "
"Do NOT reason about the page from memory. Re-take the screenshot "
"(get_browser_screenshot) or call evaluate before deciding your next step."
)
TOKEN_BUDGET = 90_000
TOKEN_BUDGET = DEFAULT_TOKEN_BUDGET
# OpenAI detail=high cost per resized image. If we support other providers,
# pull from model config — this value will silently over/undercount otherwise.
# See screenshot_utils.resize_screenshot_b64 for the dimension contract this
@ -93,241 +113,6 @@ _TOOL_OUTPUT_TRUNCATION_SUFFIX = "\n... [older tool output truncated]"
# the two paths stay in sync if the wording ever changes.
_TOOL_OUTPUT_HEAD_TRUNCATION_SUFFIX = "\n... [truncated]"
POST_UPDATE_NUDGE = (
"You updated the workflow but did not test it. "
"You MUST call run_blocks_and_collect_debug (or update_and_run_blocks next time) "
"to test at least the first block before responding to the user. "
"This verifies the workflow actually works. "
"Exception: if the latest user message explicitly asked for an untested draft, "
"respond with an unvalidated draft instead of testing."
)
POST_NAVIGATE_NUDGE = (
"You navigated to a page but did not observe its content. "
"You MUST use evaluate, get_browser_screenshot, click, type_text, "
"scroll, select_option, press_key, or console_messages "
"to inspect the page before responding. Do NOT answer from memory."
)
POST_INTERMEDIATE_SUCCESS_NUDGE = (
"STOP — do NOT respond to the user yet. "
"Your workflow only covers a subset of what the user asked for. "
"You MUST add the next block now: call update_and_run_blocks with the current "
"block chain. The tool preserves verified prefix state and reruns only the "
"invalidated frontier, so passing the full chain is cheap. "
"Only respond to the user when every distinct action they requested is covered "
"by a workflow block, or you have clear evidence that continuing is infeasible."
)
POST_FAILED_TEST_NUDGE = (
"STOP — your last test run FAILED. Do NOT respond to the user yet.\n"
"1. First, call get_run_results — pass the workflow_run_id from the prior "
"update_and_run_blocks or run_blocks_and_collect_debug response to make the "
"lookup unambiguous. That returns per-block failure_reason, output, and any "
"failed-block screenshots, which is the diagnostic data you need.\n"
"2. Then decide: if the failure looks fixable (wrong goal wording, popup "
"blocking, timeout, element not found), adjust the workflow with a DIFFERENT "
"approach and call update_and_run_blocks again — the tool will rerun from "
"the earliest invalidated block so only the changed part is retested.\n"
"3. If you have now failed multiple times with genuinely different approaches "
"and the evidence strongly suggests the site cannot satisfy the request, "
"respond explaining exactly what you tried and what blocked you.\n"
"Do NOT resubmit the same workflow — you must change something substantive."
)
POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE = (
"STOP — you explored the page using direct browser tools but did NOT engage "
"the workflow path. You MUST follow the WORKFLOW-FIRST EXECUTION PATH:\n"
"1. If no workflow exists yet, call update_workflow with at least a navigation "
"block for the target URL.\n"
"2. If a workflow already exists, call run_blocks_and_collect_debug to test it.\n"
"3. Use the test results to decide next steps.\n"
"Do NOT make feasibility judgments from browser exploration alone — "
"build and test workflow blocks first."
)
POST_SUSPICIOUS_SUCCESS_NUDGE = (
"STOP — your last test run completed (status=completed) but data-producing "
"blocks (extraction/text_prompt) produced no meaningful output "
"(missing, empty, or all-null fields). This is NOT a success.\n"
"1. Call get_run_results to inspect what each block actually returned.\n"
"2. If the extraction/text_prompt block returned empty, all-null, or "
"irrelevant data, the upstream block likely fetched an error page "
"(e.g. 403, CAPTCHA, 'no results'), landed on the wrong page, or the "
"data is rendered differently than expected.\n"
"3. Use get_browser_screenshot or evaluate to inspect what the workflow "
"browser actually sees — do NOT just retry extraction with a different prompt.\n"
"4. Fix the root cause — do NOT declare the workflow working based on "
"status alone. Verify the actual extracted data answers the user's question."
)
POST_REPEATED_NULL_DATA_NUDGE = (
"STOP — you have now produced multiple consecutive test runs where "
"extraction/text_prompt blocks returned all-null or empty data. "
"Re-prompting the extractor is not working — the problem is almost "
"certainly NOT how the extraction goal is worded.\n"
"You MUST now do ONE of the following before another update_workflow call:\n"
"1. Call get_browser_screenshot on the workflow's browser session to see "
"exactly what page the workflow is actually loading (it may differ from "
"what you expect — e.g. a 'no results' fallback, cookie wall, or bot block).\n"
"2. Call evaluate with JavaScript that searches for the expected content "
"on the workflow's browser — confirm whether the data is even present.\n"
"3. If the page the workflow loads genuinely does not contain the data, "
"pivot to a different URL or source entirely — do NOT keep retrying "
"extraction against the same failing page.\n"
"Do NOT call update_and_run_blocks again until you have concrete evidence "
"about what the workflow browser is actually seeing."
)
POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE = (
"STOP — this is the second run with the same frontier and the same failure "
"signature. Re-running the same change again is unlikely to help.\n"
"Before another update_and_run_blocks call, you MUST:\n"
"1. Call get_run_results to inspect the full failure evidence (per-block "
"failure_reason, action_trace, and any failed-block screenshots).\n"
"2. If the evidence is still ambiguous, use get_browser_screenshot or evaluate "
"to check what the workflow browser is actually seeing.\n"
"3. Then make a materially different change — different block ordering, a "
"different selector strategy, a different entry URL, or different parameters. "
"Changes to wording of the same prompt do not count as materially different."
)
POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE = (
"STOP — you have now attempted the same frontier with the same failure "
"signature THREE times without making progress. Do NOT call "
"update_and_run_blocks or run_blocks_and_collect_debug again on this "
"frontier.\n"
"Choose ONE:\n"
"A) Finalize now with a clear blocker explanation that references the "
"specific failure_reason and failure_categories you observed.\n"
"B) If required user input is missing (credential, ambiguous goal, "
"site-specific detail), respond with an ASK_QUESTION instead. Do not "
"retry the same repair again."
)
POST_PARAMETER_BINDING_WARN_NUDGE = (
"STOP — your last test run failed with a PARAMETER_BINDING_ERROR. "
"This is an INTERNAL workflow configuration mismatch, not a site or "
"selector problem.\n"
"The workflow definition references a parameter (by Jinja key) that is "
"not in the top-level workflow parameters list, or the list declares a "
"parameter the blocks do not use.\n"
"Do NOT retry with different selectors, URLs, or navigation changes — "
"those will not help. Instead:\n"
"1. Reconcile the workflow's top-level parameters with what the blocks "
"actually reference via {{ parameters.<key> }}.\n"
"2. Inline one-off literals rather than adding a parameter for each.\n"
"3. Then call update_and_run_blocks again with a corrected YAML and, "
"for any remaining parameters, concrete values passed via the "
"`parameters` argument."
)
POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE = (
"STOP — the target URL is unreachable and further retries cannot succeed. "
"The navigation failed with a permanent error (DNS resolution, SSL/cert, "
"or invalid URL). Do NOT retry and do NOT edit the workflow. Reply to the "
"user now: state that the URL could not be reached, quote the exact error "
"message from the last failure_reason, and ask them to verify the URL."
)
POST_PARAMETER_BINDING_STOP_NUDGE = (
"STOP — you have retried the same PARAMETER_BINDING_ERROR multiple times "
"without reconciling the workflow configuration. Do NOT call "
"update_and_run_blocks or run_blocks_and_collect_debug again until the "
"workflow parameters list matches the block references.\n"
"Choose ONE:\n"
"A) Finalize now with a blocker explanation that names the specific "
"parameter keys that are out of sync.\n"
"B) If you need missing values from the user (credential, identifier) "
"to decide what belongs in the parameters list, respond with an "
"ASK_QUESTION instead. Do not resubmit a workflow that still has the "
"same parameter-binding drift."
)
POST_PER_TOOL_BUDGET_NUDGE = (
"STOP — your last update_and_run_blocks call exceeded the per-tool-call "
"time budget while still making progress. This is NOT a site failure or "
"a wording problem — the chain you submitted is too long to complete in a "
"single tool call.\n"
"Do NOT retry the same chain. Do NOT change navigation_goal wording or "
"selectors hoping it will run faster.\n"
"1. Call get_run_results for the budgeted run. If it shows a navigation "
"block was canceled or failed, do NOT run that same label again unchanged; "
"split or replace it first.\n"
"2. Shrink the requested block_labels list to the first 1-2 unverified "
"blocks. The verified-prefix optimization will replay any earlier blocks "
"from cached state without re-running the browser, so passing a smaller "
"frontier is cheap.\n"
"3. For page-state work, prefer a code or validation block to verify DOM "
"state, active chips, checked controls, field values, or URL deltas. Use a "
"navigation block only when missing state must be changed through the UI, "
"and keep it to one atomic action.\n"
"4. Test the smaller frontier. If it succeeds, extend by one block at a "
"time on subsequent calls.\n"
"5. If your workflow only has 1-2 blocks and one block is still hitting "
"the budget, the single block is too ambitious — either narrow its scope, "
"replace it with DOM/state verification plus smaller actions, or reply "
"with a blocker explanation."
)
POST_NO_WORKFLOW_DELIVERY_NUDGE = (
"STOP — you are telling the user you created or are showing a workflow, "
"but no workflow update tool has succeeded in this turn. The user will see "
"an empty proposal. You MUST either call update_and_run_blocks with a real "
"workflow and test it, or respond with ASK_QUESTION if required input is "
'missing. Do NOT say "Here\'s the workflow" until there is an actual '
"workflow proposal behind the response."
)
_PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX = (
"STOP — the target site has failed to scrape on every attempt across "
"multiple workflow shapes. Every run navigated successfully but the "
'scraper could not read the page ("failed to load the website" / '
'"page may have navigated unexpectedly"). This pattern indicates the '
"site is either blocking automated access, genuinely unresponsive in "
"this environment, or rendering content Skyvern cannot read reliably.\n"
"Do NOT retry with another workflow variation. Do NOT call "
"update_and_run_blocks or run_blocks_and_collect_debug again.\n"
"Reply to the user now: state that the site could not be loaded after "
"multiple attempts, quote the last failure_reason verbatim, keep the "
"message concise, and ask "
)
POST_PROBABLE_SITE_BLOCK_STOP_NUDGE = (
_PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX
+ "whether to try a different URL, configure a proxy, or provide an alternate entry point."
)
POST_ANTI_BOT_FAILED_TEST_NUDGE = (
"STOP — your last test run failed due to an anti-bot/WAF block "
"(Access Denied, Cloudflare, Akamai, etc.).\n"
"IMPORTANT: An HTTP_REQUEST or navigation block from the SAME server IP "
"will almost certainly receive the same block. Do NOT retry with:\n"
"- A simple wait/delay block (timing does not fix IP bans)\n"
"- A raw HTTP_REQUEST to the same URL (same IP = same block)\n"
"Instead, try:\n"
"1. Set proxy_location on the workflow to route through a different IP "
"(use `RESIDENTIAL` by default, or a US-state value like `US-CA`/`US-NY`; "
"do NOT use bare country codes like `US` — they are not valid "
"ProxyLocation members).\n"
"2. If you add anti-bot handling blocks, make them conditional on visible "
"challenge evidence (for example Cloudflare, Access Denied, CAPTCHA, or "
"verify-you-are-human text). Do NOT assume every run starts on a challenge "
"page; normal, unblocked runs should proceed directly to the requested task.\n"
"3. If still blocked, explain the specific anti-bot evidence observed "
"(for example Cloudflare, CAPTCHA, verify-you-are-human text, Access "
"Denied, or a blank Just-a-moment page); describe exactly what you tried; "
"and ask whether to try a different proxy/location, entry URL, or "
"alternate source.\n"
"Do NOT resubmit the same workflow with trivial changes."
)
POST_FORMAT_NUDGE = (
"Your reply reads as a progress report, not a completed proposal. "
"If you are not ready to finalize, emit ASK_QUESTION with a specific question. "
"Otherwise, finish the workflow and present it as a completed proposal."
)
# A REPLY matching any of these is almost certainly the agent leaking internal
# iteration state instead of finalizing or asking a specific question.
_PROGRESS_NARRATION_PATTERNS = [
@ -389,8 +174,8 @@ def _probable_site_block_proxy_options(ctx: Any, *, include_whether: bool = True
return f"whether to {options}" if include_whether else options
def _probable_site_block_stop_nudge(ctx: Any) -> str:
return _PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX + _probable_site_block_proxy_options(ctx)
def _probable_site_block_stop_nudge(ctx: Any, config: CopilotConfig | None = None) -> str:
return _nudge(config, "post_probable_site_block_stop_prefix") + _probable_site_block_proxy_options(ctx)
def _single_line_failure_reason(ctx: Any) -> str:
@ -477,6 +262,12 @@ _ACTION_CATEGORIES: list[list[str]] = [
_SEQUENTIAL_CONNECTORS = [" and then ", " then ", " after that ", " next ", " followed by ", " afterward "]
def _nudge(config: CopilotConfig | None, key: str) -> str:
if config is None:
return DEFAULT_ENFORCEMENT_NUDGES[key]
return config.nudge(key)
def _goal_likely_needs_more_blocks(user_message: Any, block_count: int) -> bool:
"""Return True when the goal likely requires more blocks than currently exist."""
if block_count >= MIN_BLOCKS_FOR_AUTO_COMPLETE:
@ -492,7 +283,7 @@ def _goal_likely_needs_more_blocks(user_message: Any, block_count: int) -> bool:
return block_count < estimated_min_blocks
def _response_coverage_nudge(ctx: Any, parsed: dict[str, Any]) -> str | None:
def _response_coverage_nudge(ctx: Any, parsed: dict[str, Any], config: CopilotConfig | None = None) -> str | None:
"""Peek at the model's final output and return a nudge for coverage gaps
or progress-narration format. ASK_QUESTION is always let through so the
agent can request missing credentials or disambiguation.
@ -511,7 +302,7 @@ def _response_coverage_nudge(ctx: Any, parsed: dict[str, Any]) -> str | None:
nudge_count = getattr(ctx, "no_workflow_nudge_count", 0)
if nudge_count < MAX_NO_WORKFLOW_NUDGES:
ctx.no_workflow_nudge_count = nudge_count + 1
return POST_NO_WORKFLOW_DELIVERY_NUDGE
return _nudge(config, "post_no_workflow_delivery")
workflow_tested_ok = (
getattr(ctx, "last_test_ok", None) is True
@ -527,13 +318,13 @@ def _response_coverage_nudge(ctx: Any, parsed: dict[str, Any]) -> str | None:
nudge_count = getattr(ctx, "coverage_nudge_count", 0)
if nudge_count < MAX_INTERMEDIATE_NUDGES:
ctx.coverage_nudge_count = nudge_count + 1
return POST_INTERMEDIATE_SUCCESS_NUDGE
return _nudge(config, "post_intermediate_success")
if _is_progress_narration(parsed.get("user_response")):
nudge_count = getattr(ctx, "format_nudge_count", 0)
if nudge_count < MAX_FORMAT_NUDGES:
ctx.format_nudge_count = nudge_count + 1
return POST_FORMAT_NUDGE
return _nudge(config, "post_format")
return None
@ -639,7 +430,7 @@ def _get_int(ctx: Any, name: str, default: int = 0) -> int:
return value if isinstance(value, int) else default
def _repeated_frontier_failure_nudge(ctx: Any) -> str | None:
def _repeated_frontier_failure_nudge(ctx: Any, config: CopilotConfig | None = None) -> str | None:
"""Emit each escalation level at most once per streak. The streak itself
keeps climbing on further identical failures (incremented elsewhere by
update_repeated_failure_state), so the stop nudge fires naturally on the
@ -659,16 +450,26 @@ def _repeated_frontier_failure_nudge(ctx: Any) -> str | None:
is_param_binding = top_category == "PARAMETER_BINDING_ERROR"
if streak >= REPEATED_FRONTIER_STREAK_STOP_AT and emitted < REPEATED_FRONTIER_STREAK_STOP_AT:
return POST_PARAMETER_BINDING_STOP_NUDGE if is_param_binding else POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE
return _nudge(
config,
"post_parameter_binding_stop" if is_param_binding else "post_repeated_frontier_failure_stop",
)
if streak >= REPEATED_FRONTIER_STREAK_ESCALATE_AT and emitted < REPEATED_FRONTIER_STREAK_ESCALATE_AT:
return POST_PARAMETER_BINDING_WARN_NUDGE if is_param_binding else POST_REPEATED_FRONTIER_FAILURE_WARN_NUDGE
return _nudge(
config,
"post_parameter_binding_warn" if is_param_binding else "post_repeated_frontier_failure_warn",
)
return None
_STOP_LEVEL_FRONTIER_NUDGES = frozenset({POST_REPEATED_FRONTIER_FAILURE_STOP_NUDGE, POST_PARAMETER_BINDING_STOP_NUDGE})
def _is_stop_level_frontier_nudge(nudge: str, config: CopilotConfig | None = None) -> bool:
return nudge in {
_nudge(config, "post_repeated_frontier_failure_stop"),
_nudge(config, "post_parameter_binding_stop"),
}
def _non_retriable_nav_error_nudge(ctx: Any) -> tuple[str, str] | None:
def _non_retriable_nav_error_nudge(ctx: Any, config: CopilotConfig | None = None) -> tuple[str, str] | None:
"""Emit POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE at most once per distinct
non-retriable nav-error signature. Returns ``(nudge, signature)`` when it
should fire, ``None`` otherwise. Signature normalization is shared with
@ -681,15 +482,19 @@ def _non_retriable_nav_error_nudge(ctx: Any) -> tuple[str, str] | None:
last_emitted = getattr(ctx, "non_retriable_nav_error_last_emitted_signature", None)
if signature == last_emitted:
return None
return POST_NON_RETRIABLE_NAV_ERROR_STOP_NUDGE, signature
return _nudge(config, "post_non_retriable_nav_error_stop"), signature
def _check_enforcement(ctx: Any, result: RunResultStreaming | None = None) -> str | None:
def _check_enforcement(
ctx: Any,
result: RunResultStreaming | None = None,
config: CopilotConfig | None = None,
) -> str | None:
# Terminal failure-mode signals must pre-empt tool-call hygiene nudges.
# A permanent navigation error (DNS / cert / SSL / invalid URL) cannot be
# resolved by observing a prior navigate or by testing an updated
# workflow against the same bad URL, so let it speak first.
non_retriable = _non_retriable_nav_error_nudge(ctx)
non_retriable = _non_retriable_nav_error_nudge(ctx, config)
if non_retriable is not None:
nudge_msg, signature = non_retriable
ctx.non_retriable_nav_error_last_emitted_signature = signature
@ -697,32 +502,32 @@ def _check_enforcement(ctx: Any, result: RunResultStreaming | None = None) -> st
if ctx.navigate_called and not ctx.observation_after_navigate and not ctx.navigate_enforcement_done:
ctx.navigate_enforcement_done = True
return POST_NAVIGATE_NUDGE
return _nudge(config, "post_navigate")
if _needs_explore_without_workflow_nudge(ctx):
ctx.explore_without_workflow_nudge_count += 1
return POST_EXPLORE_WITHOUT_WORKFLOW_NUDGE
return _nudge(config, "post_explore_without_workflow")
if (
ctx.update_workflow_called
and not ctx.test_after_update_done
and getattr(ctx, "allow_untested_workflow_draft", False) is not True
):
return POST_UPDATE_NUDGE
return _nudge(config, "post_update")
# A budget-trip is a structural problem (chain too long), not a
# workflow-shape problem — emit the targeted "split the chain" advice
# before the generic repeated-frontier and failed-test paths can fire.
if _needs_per_tool_budget_nudge(ctx):
ctx.per_tool_budget_nudge_count = _get_int(ctx, "per_tool_budget_nudge_count") + 1
return POST_PER_TOOL_BUDGET_NUDGE
return _nudge(config, "post_per_tool_budget")
repeated_frontier_nudge = _repeated_frontier_failure_nudge(ctx)
repeated_frontier_nudge = _repeated_frontier_failure_nudge(ctx, config)
if repeated_frontier_nudge is not None:
# Latch the emitted level so each escalation fires at most once per streak.
ctx.repeated_failure_nudge_emitted_at_streak = (
REPEATED_FRONTIER_STREAK_STOP_AT
if repeated_frontier_nudge in _STOP_LEVEL_FRONTIER_NUDGES
if _is_stop_level_frontier_nudge(repeated_frontier_nudge, config)
else REPEATED_FRONTIER_STREAK_ESCALATE_AT
)
return repeated_frontier_nudge
@ -731,31 +536,31 @@ def _check_enforcement(ctx: Any, result: RunResultStreaming | None = None) -> st
# resets it on every new run; if the agent ignores the nudge and answers
# without rerunning, we want _check_enforcement to re-emit the nudge.
if _needs_repeated_null_data_nudge(ctx):
return POST_REPEATED_NULL_DATA_NUDGE
return _nudge(config, "post_repeated_null_data")
if _needs_suspicious_success_nudge(ctx):
ctx.suspicious_success_nudge_count = getattr(ctx, "suspicious_success_nudge_count", 0) + 1
return POST_SUSPICIOUS_SUCCESS_NUDGE
return _nudge(config, "post_suspicious_success")
# Checked before the generic failed-test nudge so a scrape-wall streak
# emits the specific STOP text and does not also consume a
# failed_test_nudge_count slot.
if _needs_probable_site_block_stop_nudge(ctx):
ctx.probable_site_block_stop_nudge_count = getattr(ctx, "probable_site_block_stop_nudge_count", 0) + 1
return _probable_site_block_stop_nudge(ctx)
return _probable_site_block_stop_nudge(ctx, config)
if _needs_failed_test_nudge(ctx):
ctx.failed_test_nudge_count += 1
if getattr(ctx, "last_test_anti_bot", None):
return POST_ANTI_BOT_FAILED_TEST_NUDGE
return POST_FAILED_TEST_NUDGE
return _nudge(config, "post_anti_bot_failed_test")
return _nudge(config, "post_failed_test")
# Response-time gate: peek at the model's final output to tell ASK_QUESTION
# (always allowed) from a REPLY with a coverage gap or progress-narration.
# Only runs when no state-based nudge fired.
if result is not None:
parsed = parse_final_response(extract_final_text(result))
return _response_coverage_nudge(ctx, parsed)
return _response_coverage_nudge(ctx, parsed, config)
return None
@ -1051,9 +856,36 @@ _NUDGE_TYPE_BY_MESSAGE: dict[str, str] = {
}
def _nudge_type_for_log(nudge: str) -> str:
if nudge.startswith(_PROBABLE_SITE_BLOCK_STOP_NUDGE_PREFIX):
_NUDGE_TYPE_BY_KEY: dict[str, str] = {
"post_update": "post_update",
"post_navigate": "post_navigate",
"post_explore_without_workflow": "explore_without_workflow",
"post_suspicious_success": "suspicious_success",
"post_repeated_null_data": "repeated_null_data",
"post_repeated_frontier_failure_warn": "repeated_frontier_failure_warn",
"post_repeated_frontier_failure_stop": "repeated_frontier_failure_stop",
"post_non_retriable_nav_error_stop": "non_retriable_nav_error_stop",
"post_parameter_binding_warn": "parameter_binding_warn",
"post_parameter_binding_stop": "parameter_binding_stop",
"post_anti_bot_failed_test": "anti_bot_block",
"post_probable_site_block_stop": "probable_site_block_stop",
"post_probable_site_block_stop_prefix": "probable_site_block_stop",
"post_per_tool_budget": "per_tool_budget_split",
"post_no_workflow_delivery": "no_workflow_delivery",
"post_failed_test": "post_failed_test",
"screenshot_dropped": "screenshot_dropped_on_recovery",
"post_intermediate_success": "intermediate_success",
"post_format": "format",
}
def _nudge_type_for_log(nudge: str, config: CopilotConfig | None = None) -> str:
nudge_by_key = config.enforcement_nudges if config is not None else DEFAULT_ENFORCEMENT_NUDGES
if nudge.startswith(nudge_by_key["post_probable_site_block_stop_prefix"]):
return "probable_site_block_stop"
for key, value in nudge_by_key.items():
if value == nudge:
return _NUDGE_TYPE_BY_KEY.get(key, key)
return _NUDGE_TYPE_BY_MESSAGE.get(nudge, "intermediate_success")
@ -1224,6 +1056,7 @@ async def run_with_enforcement(
) -> RunResultStreaming:
"""Run agent with enforcement nudges, preserving conversation history."""
session = runner_kwargs.pop("session", None)
copilot_config = runner_kwargs.pop("copilot_config", None) or CopilotConfig()
current_input: str | list = initial_input
start_time = time.monotonic()
ctx.copilot_run_start_monotonic = start_time
@ -1250,8 +1083,12 @@ async def run_with_enforcement(
if isinstance(current_input, list):
est = estimate_tokens(current_input)
LOG.info("Token estimate before model call", tokens=est, iteration=iteration)
if est > TOKEN_BUDGET:
LOG.warning("Token estimate exceeds budget, aggressively pruning", tokens=est, budget=TOKEN_BUDGET)
if est > copilot_config.token_budget:
LOG.warning(
"Token estimate exceeds budget, aggressively pruning",
tokens=est,
budget=copilot_config.token_budget,
)
current_input = aggressive_prune(current_input)
tracked_stream = _SendTrackingStream(stream)
@ -1300,7 +1137,7 @@ async def run_with_enforcement(
if images_stripped:
# The agent could otherwise reason about the page from
# memory on the next turn; warn it explicitly.
pending_recovery_nudge = SCREENSHOT_DROPPED_NUDGE
pending_recovery_nudge = _nudge(copilot_config, "screenshot_dropped")
tracked_stream = _SendTrackingStream(stream)
try:
result = await _run_streamed_with_deadline(
@ -1336,13 +1173,13 @@ async def run_with_enforcement(
nudge: str | None = pending_recovery_nudge
pending_recovery_nudge = None
else:
nudge = _check_enforcement(ctx, result)
nudge = _check_enforcement(ctx, result, copilot_config)
if nudge is None:
_consume_pending_screenshots(ctx)
_maybe_raise_non_retriable_nav(ctx)
return result
if nudge == POST_UPDATE_NUDGE:
if nudge == _nudge(copilot_config, "post_update"):
if ctx.post_update_nudge_count >= MAX_POST_UPDATE_NUDGES:
LOG.warning(
"Enforcement exhausted post-update nudges, allowing response",
@ -1353,7 +1190,7 @@ async def run_with_enforcement(
return result
ctx.post_update_nudge_count += 1
nudge_type = _nudge_type_for_log(nudge)
nudge_type = _nudge_type_for_log(nudge, copilot_config)
LOG.info("Enforcement nudge", nudge_type=nudge_type, iteration=iteration)
# OpenAI rejects images in tool messages, so a queued post-run

View file

@ -27,9 +27,11 @@ from skyvern.config import settings
from skyvern.forge.sdk.api.llm.config_registry import LLMConfigRegistry
from skyvern.forge.sdk.api.llm.exceptions import InvalidLLMConfigError
from skyvern.forge.sdk.api.llm.litellm_transport import configure_litellm_transport
from skyvern.forge.sdk.copilot.config import CopilotConfig
from skyvern.forge.sdk.copilot.session_factory import (
copilot_call_model_input_filter,
copilot_session_input_callback,
make_copilot_call_model_input_filter,
)
from skyvern.forge.sdk.copilot.tracing_setup import is_tracing_enabled
from skyvern.schemas.llm import LLMConfig, LLMRouterConfig
@ -121,14 +123,19 @@ def _degrade_router_to_direct(llm_key: str, config: LLMRouterConfig) -> LLMConfi
)
def resolve_model_config(llm_api_handler: Any) -> tuple[str, RunConfig, str, bool]:
def resolve_model_config(
llm_api_handler: Any,
*,
copilot_config: CopilotConfig | None = None,
llm_key_override: str | None = None,
) -> tuple[str, RunConfig, str, bool]:
"""Map Skyvern llm_key to OpenAI Agents SDK model string + RunConfig.
Returns (model_name, run_config, llm_key, supports_vision).
"""
configure_litellm_transport()
llm_key = getattr(llm_api_handler, "llm_key", None) or settings.LLM_KEY
llm_key = llm_key_override or getattr(llm_api_handler, "llm_key", None) or settings.LLM_KEY
config = LLMConfigRegistry.get_config(llm_key)
if isinstance(config, LLMRouterConfig):
@ -201,7 +208,11 @@ def resolve_model_config(llm_api_handler: Any) -> tuple[str, RunConfig, str, boo
model_settings=model_settings,
tracing_disabled=not is_tracing_enabled(),
session_input_callback=copilot_session_input_callback,
call_model_input_filter=copilot_call_model_input_filter,
call_model_input_filter=(
make_copilot_call_model_input_filter(copilot_config.token_budget)
if copilot_config is not None
else copilot_call_model_input_filter
),
)
return config.model_name, run_config, llm_key, config.supports_vision

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import structlog
@ -146,7 +147,18 @@ def copilot_session_input_callback(
return [history_items[0]] + pruned_middle + list(recent) + list(new_items)
def make_copilot_call_model_input_filter(token_budget: int) -> Callable[[CallModelData[Any]], ModelInputData]:
def _filter(data: CallModelData[Any]) -> ModelInputData:
return _copilot_call_model_input_filter(data, token_budget=token_budget)
return _filter
def copilot_call_model_input_filter(data: CallModelData[Any]) -> ModelInputData:
return _copilot_call_model_input_filter(data, token_budget=TOKEN_BUDGET)
def _copilot_call_model_input_filter(data: CallModelData[Any], *, token_budget: int) -> ModelInputData:
"""Token-budget enforcement applied just before each model call.
Graduated pruning:
@ -173,7 +185,7 @@ def copilot_call_model_input_filter(data: CallModelData[Any]) -> ModelInputData:
items = _compact_tool_items(items)
est = estimate_tokens(items)
if est <= TOKEN_BUDGET:
if est <= token_budget:
LOG.info("Within budget after tool trim", tokens=est)
return ModelInputData(input=items, instructions=model_data.instructions)
@ -187,7 +199,7 @@ def copilot_call_model_input_filter(data: CallModelData[Any]) -> ModelInputData:
]
est = estimate_tokens(items)
if est <= TOKEN_BUDGET:
if est <= token_budget:
LOG.info("Within budget after screenshot drop", tokens=est)
return ModelInputData(input=items, instructions=model_data.instructions)
@ -195,12 +207,12 @@ def copilot_call_model_input_filter(data: CallModelData[Any]) -> ModelInputData:
items = [_truncate_tool_output(item, TOOL_OUTPUT_TRUNCATE_EMERGENCY) for item in items]
est = estimate_tokens(items)
if est <= TOKEN_BUDGET:
if est <= token_budget:
LOG.info("Within budget after emergency truncation", tokens=est)
return ModelInputData(input=items, instructions=model_data.instructions)
# Layer 4: Aggressive prune as last resort
LOG.warning("Aggressive prune needed", tokens=est, budget=TOKEN_BUDGET)
LOG.warning("Aggressive prune needed", tokens=est, budget=token_budget)
items = aggressive_prune(items)
est = estimate_tokens(items)

View file

@ -3645,6 +3645,11 @@ async def run_blocks_tool(
The workflow must be saved before running blocks.
Block labels must match labels in the saved workflow.
For a diagnostic / observational complaint about the current workflow,
this tool is not the first response. Follow the system prompt's
inspect-and-clarify path first: inspect current workflow context and
existing run evidence before deciding whether a fresh run is needed.
Pass runtime values for workflow parameters via the `parameters` dict
keys must match the workflow parameter `key` field. When the user has
supplied concrete non-secret values in their message (names, emails, IDs),
@ -3741,6 +3746,11 @@ async def update_and_run_blocks_tool(
Use this instead of calling update_workflow and run_blocks_and_collect_debug separately.
The workflow must validate successfully before blocks are run.
For a diagnostic / observational complaint about the current workflow,
this tool is not the first response. Follow the system prompt's
inspect-and-clarify path first, and only update/run when the user asked
for an edit or the inspected evidence makes the correction clear.
Pass runtime values for workflow parameters via the `parameters` dict
keys must match the workflow parameter `key` field. When the user has
supplied concrete non-secret values in their message (names, emails, IDs),

View file

@ -146,6 +146,8 @@ from skyvern.webeye.actions.actions import Action
LOG = structlog.get_logger()
FORCE_TASK_V1_MAX_STEPS = 25
_create_from_prompt_adapter: TypeAdapter[CreateFromPromptRequest] = TypeAdapter(CreateFromPromptRequest)
@ -188,6 +190,33 @@ async def run_task(
await PermissionCheckerFactory.get_instance().check(current_org, browser_session_id=run_request.browser_session_id)
await app.RATE_LIMITER.rate_limit_submit_run(current_org.organization_id)
if (
run_request.engine == RunEngine.skyvern_v2
and not run_request.publish_workflow
and await app.EXPERIMENTATION_PROVIDER.is_feature_enabled_cached(
"FORCE_TASK_V1",
current_org.organization_id,
properties={"organization_id": current_org.organization_id},
)
):
cap = FORCE_TASK_V1_MAX_STEPS
if current_org.max_steps_per_run is not None:
cap = min(cap, current_org.max_steps_per_run)
log_extra: dict[str, Any] = {}
if run_request.run_with:
log_extra["dropped_run_with"] = run_request.run_with
LOG.info(
"FORCE_TASK_V1 flag set; routing to v1 engine",
organization_id=current_org.organization_id,
requested_max_steps=run_request.max_steps,
org_max_steps_per_run=current_org.max_steps_per_run,
effective_cap=cap,
**log_extra,
)
run_request.engine = RunEngine.skyvern_v1
if not run_request.max_steps or run_request.max_steps > cap:
run_request.max_steps = cap
if run_request.engine in CUA_ENGINES or run_request.engine == RunEngine.skyvern_v1:
# create task v1
# if there's no url, call task generation first to generate the url, data schema if any

View file

@ -23,6 +23,7 @@ from skyvern.forge.sdk.api.llm.exceptions import LLMProviderError
from skyvern.forge.sdk.artifact.models import Artifact, ArtifactType
from skyvern.forge.sdk.copilot.agent import run_copilot_agent
from skyvern.forge.sdk.copilot.attribution import is_copilot_born_initial_write
from skyvern.forge.sdk.copilot.config import CopilotConfig
from skyvern.forge.sdk.copilot.context import AgentResult
from skyvern.forge.sdk.copilot.output_utils import truncate_output
from skyvern.forge.sdk.core import skyvern_context
@ -1330,7 +1331,7 @@ async def _new_copilot_chat_post(
)
return
security_rules = app.AGENT_FUNCTION.get_copilot_security_rules()
copilot_config = app.AGENT_FUNCTION.get_copilot_config() or CopilotConfig()
# Spawn the cancel watcher only after the chat row exists; cancels
# that land during pre-agent setup are not user-cancellable
@ -1360,7 +1361,7 @@ async def _new_copilot_chat_post(
debug_run_info_text=debug_run_info_text,
llm_api_handler=llm_api_handler,
api_key=api_key,
security_rules=security_rules,
config=copilot_config,
)
if getattr(agent_result, "cancelled", False):

View file

@ -5086,6 +5086,14 @@ class WorkflowService:
organization_id=workflow_run.organization_id,
child_workflow_run_ids=child_workflow_run_ids,
)
try:
await app.AGENT_FUNCTION.release_proxy_session_for_owner(workflow_run.workflow_run_id)
except Exception:
LOG.warning(
"Failed to release proxy session for workflow run",
exc_info=True,
workflow_run_id=workflow_run.workflow_run_id,
)
if browser_state:
await self.persist_video_data(
browser_state, workflow, workflow_run, close_browser_on_completion=close_browser_on_completion

View file

@ -3,9 +3,9 @@ from __future__ import annotations
# ruff: noqa: E402
from typing import Any, Callable
from skyvern.exceptions import require_server_extra_modules
from skyvern.exceptions import require_local_extra_modules
require_server_extra_modules("skyvern.library.ai_locator")
require_local_extra_modules("skyvern.library.ai_locator")
from playwright.async_api import Locator, Page

View file

@ -225,9 +225,9 @@ class Skyvern(AsyncSkyvern):
try:
from skyvern.library.embedded_server_factory import create_embedded_server # noqa: PLC0415
except ImportError as exc:
from skyvern.exceptions import raise_server_extra_required # noqa: PLC0415
from skyvern.exceptions import raise_local_extra_required # noqa: PLC0415
raise_server_extra_required("Skyvern.local()", exc)
raise_local_extra_required("Skyvern.local()", exc)
from skyvern.utils.env_paths import resolve_backend_env_path # noqa: PLC0415
@ -652,14 +652,14 @@ class Skyvern(AsyncSkyvern):
async def _get_playwright(self) -> Playwright:
if self._playwright is None:
from skyvern.exceptions import raise_server_extra_required # noqa: PLC0415
from skyvern.exceptions import raise_local_extra_required # noqa: PLC0415
# Cloud-API-only SDK users can instantiate Skyvern without server extras,
# so browser startup keeps its own extra hint at the Playwright boundary.
try:
from playwright.async_api import async_playwright # noqa: PLC0415
except ImportError as exc:
raise_server_extra_required("Browser APIs", exc)
raise_local_extra_required("Browser APIs", exc)
self._playwright = await async_playwright().start()
return self._playwright

View file

@ -1,9 +1,9 @@
# ruff: noqa: E402
from typing import TYPE_CHECKING, Any
from skyvern.exceptions import require_server_extra_modules
from skyvern.exceptions import require_local_extra_modules
require_server_extra_modules("skyvern.library.skyvern_browser")
require_local_extra_modules("skyvern.library.skyvern_browser")
from playwright.async_api import BrowserContext, Page

View file

@ -1,9 +1,9 @@
# ruff: noqa: E402
from typing import TYPE_CHECKING, Any
from skyvern.exceptions import require_server_extra_modules
from skyvern.exceptions import require_local_extra_modules
require_server_extra_modules("skyvern.library.skyvern_browser_page")
require_local_extra_modules("skyvern.library.skyvern_browser_page")
from playwright.async_api import Frame, Page

View file

@ -4,9 +4,9 @@ from typing import Any, Literal, overload
import structlog
from skyvern.exceptions import require_server_extra_modules
from skyvern.exceptions import require_local_extra_modules
require_server_extra_modules("skyvern.library.skyvern_browser_page_agent")
require_local_extra_modules("skyvern.library.skyvern_browser_page_agent")
from playwright.async_api import Page # noqa: E402

View file

@ -3,9 +3,9 @@ from typing import TYPE_CHECKING, Any
import structlog
from skyvern.exceptions import require_server_extra_modules
from skyvern.exceptions import require_local_extra_modules
require_server_extra_modules("skyvern.library.skyvern_browser_page_ai")
require_local_extra_modules("skyvern.library.skyvern_browser_page_ai")
from playwright.async_api import Page

View file

@ -1,9 +1,9 @@
# ruff: noqa: E402
from typing import Any, Pattern
from skyvern.exceptions import require_server_extra_modules
from skyvern.exceptions import require_local_extra_modules
require_server_extra_modules("skyvern.library.skyvern_locator")
require_local_extra_modules("skyvern.library.skyvern_locator")
from playwright.async_api import Locator

View file

@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from skyvern.forge.sdk.copilot import agent as agent_module
from skyvern.forge.sdk.copilot.config import CopilotConfig
def _ctx(**overrides):
@ -1147,3 +1148,104 @@ class TestResponseTypeClassificationRuleReachesAgent:
assert "explicitly asks for an untested draft" in prompt
assert "workflow was drafted without testing as requested" in prompt
assert prompt.index("RESPONSE-TYPE CLASSIFICATION") < prompt.index("**Option 1: Reply to the user**")
class TestCopilotConfig:
def test_system_prompt_uses_custom_security_rules(self) -> None:
prompt = agent_module._build_system_prompt(
tool_usage_guide="",
config=CopilotConfig(security_rules="CUSTOM SECURITY RULE"),
)
assert "CUSTOM SECURITY RULE" in prompt
def test_retriable_llm_error_detects_openai_rate_limit(self) -> None:
class FakeRateLimitError(Exception):
pass
FakeRateLimitError.__module__ = "openai"
assert agent_module._is_retriable_llm_error(FakeRateLimitError("rate limit"))
def test_fallback_key_skips_missing_or_same_key(self) -> None:
assert agent_module._fallback_llm_key(CopilotConfig(fallback_llm_key=None), "PRIMARY") is None
assert agent_module._fallback_llm_key(CopilotConfig(fallback_llm_key="PRIMARY"), "PRIMARY") is None
assert agent_module._fallback_llm_key(CopilotConfig(fallback_llm_key="SECONDARY"), "PRIMARY") == "SECONDARY"
@pytest.mark.asyncio
async def test_run_copilot_agent_retries_retriable_failure_with_fallback(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
class FakeRateLimitError(Exception):
pass
FakeRateLimitError.__module__ = "openai"
async def fake_feasibility_gate(**_kwargs):
return SimpleNamespace(verdict="proceed", question=None, rationale=None)
class FakeMCPServerManager:
def __init__(self, servers):
self.active_servers = servers
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
resolved_keys: list[str] = []
def fake_resolve_model_config(_handler, *, copilot_config=None, llm_key_override=None):
del copilot_config
key = llm_key_override or "PRIMARY"
resolved_keys.append(key)
return f"model-{key}", object(), key, True
run_with_enforcement = AsyncMock(
side_effect=[
FakeRateLimitError("rate limit"),
_fake_run_result({"type": "REPLY", "user_response": "ok", "goal_reached": True}),
]
)
monkeypatch.setattr(
"skyvern.forge.sdk.copilot.feasibility_gate.run_feasibility_gate",
fake_feasibility_gate,
)
monkeypatch.setattr(
"skyvern.forge.sdk.copilot.agent._resolve_live_browser_session_id",
AsyncMock(return_value=None),
)
monkeypatch.setattr("agents.mcp.MCPServerManager", FakeMCPServerManager)
monkeypatch.setattr(
"skyvern.forge.sdk.copilot.model_resolver.resolve_model_config",
fake_resolve_model_config,
)
monkeypatch.setattr(
"skyvern.forge.sdk.copilot.enforcement.run_with_enforcement",
run_with_enforcement,
)
result = await agent_module.run_copilot_agent(
stream=MagicMock(),
organization_id="org-1",
chat_request=SimpleNamespace(
message="build it",
workflow_id="wf-1",
workflow_permanent_id="wfp-1",
workflow_copilot_chat_id="chat-1",
workflow_yaml="",
browser_session_id=None,
),
chat_history=[],
global_llm_context=None,
debug_run_info_text="",
llm_api_handler=SimpleNamespace(llm_key="PRIMARY"),
api_key="sk-test",
config=CopilotConfig(fallback_llm_key="SECONDARY"),
)
assert result.user_response == "ok"
assert resolved_keys == ["PRIMARY", "SECONDARY"]
assert run_with_enforcement.await_count == 2

View file

@ -149,6 +149,36 @@ class TestModelResolver:
assert run_config.model_settings.temperature == 0.5
assert run_config.model_settings.max_tokens == 4096
def test_llm_key_override_wins_for_fallback_attempt(self, monkeypatch: pytest.MonkeyPatch) -> None:
from skyvern.schemas.llm import LLMConfig
seen_keys: list[str] = []
def fake_get_config(key: str) -> LLMConfig:
seen_keys.append(key)
return LLMConfig(
model_name=f"openai/{key.lower()}",
required_env_vars=[],
supports_vision=True,
add_assistant_prefix=False,
)
monkeypatch.setattr(
"skyvern.forge.sdk.copilot.model_resolver.LLMConfigRegistry.get_config",
fake_get_config,
)
from skyvern.forge.sdk.copilot.model_resolver import resolve_model_config
handler = MagicMock()
handler.llm_key = "PRIMARY_KEY"
model_name, _, llm_key, _ = resolve_model_config(handler, llm_key_override="FALLBACK_KEY")
assert model_name == "openai/fallback_key"
assert llm_key == "FALLBACK_KEY"
assert seen_keys == ["FALLBACK_KEY"]
def test_maps_basic_config_with_tracing_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
from skyvern.schemas.llm import LLMConfig

View file

@ -9,7 +9,9 @@ from skyvern.exceptions import (
SkyvernHTTPException,
UnknownErrorWhileCreatingBrowserContext,
get_user_facing_exception_message,
raise_local_extra_required,
raise_server_extra_required,
require_local_extra_modules,
require_server_extra_modules,
)
@ -72,7 +74,7 @@ def test_raise_server_extra_required_translates_when_server_extra_missing(monkey
)
missing = ModuleNotFoundError("No module named 'starlette_context'", name="starlette_context")
with pytest.raises(SkyvernExtraNotInstalled, match=r"pip install skyvern\[server\]"):
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[server\]"'):
raise_server_extra_required("skyvern.library.skyvern_browser", missing)
@ -83,10 +85,28 @@ def test_raise_server_extra_required_translates_missing_server_marker(monkeypatc
)
missing = ModuleNotFoundError("No module named 'playwright'", name="playwright")
with pytest.raises(SkyvernExtraNotInstalled, match=r"pip install skyvern\[server\]"):
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[server\]"'):
raise_server_extra_required("skyvern.library.skyvern_browser", missing)
def test_raise_local_extra_required_translates_missing_local_marker(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"skyvern.exceptions.find_spec",
lambda module_name: None if module_name == "playwright" else object(),
)
missing = ModuleNotFoundError("No module named 'playwright'", name="playwright")
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[local\]"'):
raise_local_extra_required("Browser APIs", missing)
def test_raise_local_extra_required_rewrites_nested_server_guard() -> None:
missing = SkyvernExtraNotInstalled("skyvern.forge.api_app", extra="server")
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[local\]"'):
raise_local_extra_required("Skyvern.local()", missing)
def test_raise_server_extra_required_preserves_installed_marker_submodule_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -147,7 +167,7 @@ def test_require_server_extra_modules_requires_server_sentinels(monkeypatch: pyt
lambda module_name: None if module_name == "sqlalchemy" else object(),
)
with pytest.raises(SkyvernExtraNotInstalled, match=r"pip install skyvern\[server\]"):
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[server\]"'):
require_server_extra_modules("skyvern.library.skyvern_browser_page")
@ -157,10 +177,33 @@ def test_require_server_extra_modules_catches_partial_server_graph(monkeypatch:
lambda module_name: None if module_name == "jinja2" else object(),
)
with pytest.raises(SkyvernExtraNotInstalled, match=r"pip install skyvern\[server\]"):
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[server\]"'):
require_server_extra_modules("skyvern.library.skyvern_browser_page")
def test_require_server_extra_modules_discriminates_from_local_with_explicit_modules(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"skyvern.exceptions.find_spec",
lambda module_name: None if module_name == "uvicorn" else object(),
)
require_server_extra_modules("skyvern.forge.api_app")
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[server\]"'):
require_server_extra_modules("skyvern.forge", ("uvicorn",))
def test_require_local_extra_modules_requires_local_sentinels(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"skyvern.exceptions.find_spec",
lambda module_name: None if module_name == "playwright" else object(),
)
with pytest.raises(SkyvernExtraNotInstalled, match=r'pip install "skyvern\[local\]"'):
require_local_extra_modules("skyvern.library.skyvern_browser_page")
def test_browser_connection_error_connect_over_cdp_websocket() -> None:
"""The exact error from SKY-8578: connect_over_cdp fails with 502 Bad Gateway."""
raw_error = (

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from typer.testing import CliRunner
import skyvern.cli.quickstart as quickstart_module
def test_quickstart_without_server_extra_prints_install_paths(monkeypatch) -> None:
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: False)
result = CliRunner().invoke(quickstart_module.quickstart_app, [])
assert result.exit_code == 0
assert "Cloud/API SDK" in result.output
assert 'pip install "skyvern[local]"' in result.output
assert "Skyvern.local(use_in_memory_db=True)" in result.output
assert 'pip install "skyvern[server]"' in result.output
assert "Postgres" in result.output
assert "Missing Dependency" not in result.output
assert "Missing:" not in result.output
def test_quickstart_with_server_extra_preserves_existing_flow(monkeypatch) -> None:
calls = []
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: True)
monkeypatch.setattr(
quickstart_module,
"_run_server_quickstart",
lambda **kwargs: calls.append(kwargs),
)
result = CliRunner().invoke(
quickstart_module.quickstart_app,
[
"--no-postgres",
"--database-string",
"postgresql+psycopg://user/db",
"--skip-browser-install",
"--server-only",
],
)
assert result.exit_code == 0
assert calls == [
{
"no_postgres": True,
"database_string": "postgresql+psycopg://user/db",
"skip_browser_install": True,
"server_only": True,
}
]

View file

@ -0,0 +1,54 @@
"""Prompt and tool-description guards for diagnostic copilot turns."""
from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.copilot.tools import run_blocks_tool, update_and_run_blocks_tool
_AGENT_TEMPLATE_DEFAULTS = dict(
workflow_knowledge_base="test kb",
current_datetime="2026-01-01T00:00:00Z",
tool_usage_guide="",
security_rules="",
)
def _render_agent_prompt() -> str:
return prompt_engine.load_prompt("workflow-copilot-agent", **_AGENT_TEMPLATE_DEFAULTS)
class TestDiagnosticObservationIntent:
"""Pin SKY-9651: observational complaints inspect first instead of edit/run by default."""
def test_agent_prompt_has_diagnostic_observation_section(self) -> None:
rendered = _render_agent_prompt()
assert "DIAGNOSTIC / OBSERVATIONAL COMPLAINTS" in rendered
assert "inspect-and-clarify" in rendered
assert "Do NOT call `update_and_run_blocks`" in rendered
assert "do NOT call `run_blocks_and_collect_debug`" in rendered
def test_agent_prompt_names_failed_repro_shapes(self) -> None:
rendered = _render_agent_prompt()
for phrase in (
"missing newly added workflow block",
"not following a configured search step",
"whether a named block is present",
"runtime error condition did not trigger",
"missing or unknown block labels",
):
assert phrase in rendered
def test_agent_prompt_preserves_explicit_edit_requests(self) -> None:
rendered = _render_agent_prompt()
assert "Explicit edit/debug requests remain edit requests" in rendered
assert "how can I improve" in rendered
assert "what would fix" in rendered
assert "call `update_and_run_blocks`" in rendered
def test_block_running_tools_defer_diagnostic_complaints(self) -> None:
for tool in (run_blocks_tool, update_and_run_blocks_tool):
desc = tool.description # type: ignore[attr-defined]
assert "diagnostic / observational complaint" in desc
assert "inspect-and-clarify" in desc
assert "not the first response" in desc

219
uv.lock generated
View file

@ -15,7 +15,7 @@ resolution-markers = [
[manifest]
constraints = [
{ name = "authlib", specifier = ">=1.6.9" },
{ name = "authlib", specifier = ">=1.7.1" },
{ name = "flask", specifier = ">=3.1.3" },
{ name = "joserfc", specifier = ">=1.6.3" },
{ name = "pyasn1", specifier = ">=0.6.3" },
@ -253,7 +253,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.96.0"
version = "0.97.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -265,9 +265,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/7e/672f533dee813028d2c699bfd2a7f52c9118d7353680d9aa44b9e23f717f/anthropic-0.96.0.tar.gz", hash = "sha256:9de947b737f39452f68aa520f1c2239d44119c9b73b0fb6d4e6ca80f00279ee6", size = 658210, upload-time = "2026-04-16T14:28:02.846Z" }
sdist = { url = "https://files.pythonhosted.org/packages/14/93/f66ea8bfe39f2e6bb9da8e27fa5457ad2520e8f7612dfc547b17fad55c4d/anthropic-0.97.0.tar.gz", hash = "sha256:021e79fd8e21e90ad94dc5ba2bbbd8b1599f424f5b1fab6c06204009cab764be", size = 669502, upload-time = "2026-04-23T20:52:34.445Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/5a/72f33204064b6e87601a71a6baf8d855769f8a0c1eaae8d06a1094872371/anthropic-0.96.0-py3-none-any.whl", hash = "sha256:9a6e335a354602a521cd9e777e92bfd46ba6e115bf9bbfe6135311e8fb2015b2", size = 635930, upload-time = "2026-04-16T14:28:01.436Z" },
{ url = "https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl", hash = "sha256:8a1a472dfabcfc0c52ff6a3eecf724ac7e07107a2f6e2367be55ceb42f5d5613", size = 662126, upload-time = "2026-04-23T20:52:32.377Z" },
]
[[package]]
@ -425,15 +425,15 @@ wheels = [
[[package]]
name = "authlib"
version = "1.7.0"
version = "1.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/f2/e05664d5275ce811fd4e9df0a2b3f0086ee19a8a80358d95499fa82fd50c/authlib-1.7.1.tar.gz", hash = "sha256:8c09b0f9d080c823e594b52316af70f79a1fa4eed64d0363a076233c04ef063a", size = 175884, upload-time = "2026-05-04T08:11:25.033Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" },
{ url = "https://files.pythonhosted.org/packages/e0/82/730650ee5e5b598b7bfdc291b784bc2f6fe02a5671695485403365101088/authlib-1.7.1-py2.py3-none-any.whl", hash = "sha256:8470f4aa6b5590ac41bd81d6e6ee12448ce36a0da0af19bbed69fb53fb4e8ad9", size = 258826, upload-time = "2026-05-04T08:11:23.208Z" },
]
[[package]]
@ -1101,7 +1101,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.136.0"
version = "0.136.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@ -1110,9 +1110,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
]
[[package]]
@ -1428,7 +1428,7 @@ wheels = [
[[package]]
name = "google-cloud-aiplatform"
version = "1.148.1"
version = "1.149.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
@ -1444,9 +1444,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" }
sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" },
]
[[package]]
@ -2230,11 +2230,11 @@ wheels = [
[[package]]
name = "json-repair"
version = "0.59.4"
version = "0.59.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/67/eba7fad54ff6f5cce6db4e01f596fc68156b5c7e864af0aa07ad48e880a1/json_repair-0.59.5.tar.gz", hash = "sha256:bb886ee054e99066be8a337b67a986b6a50d79be9a5ad37ae81966e698990784", size = 48632, upload-time = "2026-04-24T11:41:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/0529dee460b745b93f6abc97b56b7527314c5167ba29ab7a5bd5c08de01f/json_repair-0.59.5-py3-none-any.whl", hash = "sha256:6869965bd1cc1aaaa04dc85865c26fbb76d9a2d83a20010f5eae2563b1567827", size = 47282, upload-time = "2026-04-24T11:41:36.653Z" },
]
[[package]]
@ -2432,7 +2432,7 @@ wheels = [
[[package]]
name = "jupyterlab"
version = "4.5.6"
version = "4.5.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-lru" },
@ -2449,9 +2449,9 @@ dependencies = [
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/d5/730628e03fff2e8a8e8ccdaedde1489ab1309f9a4fa2536248884e30b7c7/jupyterlab-4.5.6.tar.gz", hash = "sha256:642fe2cfe7f0f5922a8a558ba7a0d246c7bc133b708dfe43f7b3a826d163cf42", size = 23970670, upload-time = "2026-03-11T14:17:04.531Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/1b/dad6fdcc658ed7af26fdf3841e7394072c9549a8b896c381ab49dd11e2d9/jupyterlab-4.5.6-py3-none-any.whl", hash = "sha256:d6b3dac883aa4d9993348e0f8e95b24624f75099aed64eab6a4351a9cdd1e580", size = 12447124, upload-time = "2026-03-11T14:17:00.229Z" },
{ url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" },
]
[[package]]
@ -3215,7 +3215,7 @@ wheels = [
[[package]]
name = "notebook"
version = "7.5.5"
version = "7.5.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jupyter-server" },
@ -3224,9 +3224,9 @@ dependencies = [
{ name = "notebook-shim" },
{ name = "tornado" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/6d/41052c48d6f6349ca0a7c4d1f6a78464de135e6d18f5829ba2510e62184c/notebook-7.5.5.tar.gz", hash = "sha256:dc0bfab0f2372c8278c457423d3256c34154ac2cc76bf20e9925260c461013c3", size = 14169167, upload-time = "2026-03-11T16:32:51.922Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/aa/cbd1deb9f07446241e88f8d5fecccd95b249bca0b4e5482214a4d1714c49/notebook-7.5.5-py3-none-any.whl", hash = "sha256:a7c14dbeefa6592e87f72290ca982e0c10f5bbf3786be2a600fda9da2764a2b8", size = 14578929, upload-time = "2026-03-11T16:32:48.021Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" },
]
[[package]]
@ -3336,7 +3336,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.32.0"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -3348,14 +3348,14 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" },
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
]
[[package]]
name = "openai-agents"
version = "0.14.4"
version = "0.14.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "griffelib" },
@ -3367,9 +3367,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/f2/bc697eecb3efcdce4e2b5b003fc078a3c5693a1f114a9d4ca8ce327e25ec/openai_agents-0.14.4.tar.gz", hash = "sha256:897dd2b0d4d3da871753bf1db8742452f4a77736f3f63e73c8db544919c5917e", size = 5304591, upload-time = "2026-04-21T19:28:41.061Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/8a/d36ab647f05e790ec97dda9e4c0eb39d8840269d6a5194887b5dec92bd0d/openai_agents-0.14.8.tar.gz", hash = "sha256:fe1cb58b4150a07292a94f15d8fd5217ee9195bd6bcd8a6a46fdb1d9b08a70b7", size = 5314520, upload-time = "2026-04-29T03:40:07.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/aa/7b8b94150096df7fbc5602347815d6acc769daff72cd6a10011e91f25bbf/openai_agents-0.14.4-py3-none-any.whl", hash = "sha256:4f10b87432cd73f436accf01c762d34df977d32f0a1b49300c39eed080784f4c", size = 815389, upload-time = "2026-04-21T19:28:39.015Z" },
{ url = "https://files.pythonhosted.org/packages/af/6e/1e9adcedcde7b163579b88a68f765a4915be4ead0713270386d9432cfd2f/openai_agents-0.14.8-py3-none-any.whl", hash = "sha256:2937ef582ccaa45d59e89839ed8948cb2a6d808bc9940f0881793c21f37f7776", size = 817332, upload-time = "2026-04-29T03:40:05.68Z" },
]
[[package]]
@ -4916,11 +4916,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.26"
version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
@ -5514,6 +5514,64 @@ dependencies = [
]
[package.optional-dependencies]
local = [
{ name = "aioboto3" },
{ name = "aiofiles" },
{ name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "asyncache" },
{ name = "authlib" },
{ name = "azure-identity" },
{ name = "azure-keyvault-secrets" },
{ name = "azure-storage-blob" },
{ name = "croniter" },
{ name = "curlparser" },
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "filetype" },
{ name = "fuzzysearch" },
{ name = "google-auth" },
{ name = "google-auth-oauthlib" },
{ name = "google-cloud-aiplatform" },
{ name = "greenlet", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "greenlet", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "jinja2" },
{ name = "json-repair" },
{ name = "json5" },
{ name = "jsonschema" },
{ name = "lark" },
{ name = "libcst" },
{ name = "litellm" },
{ name = "onepassword-sdk" },
{ name = "openai" },
{ name = "opentelemetry-api" },
{ name = "pandas" },
{ name = "pdfplumber" },
{ name = "pillow" },
{ name = "playwright", version = "1.46.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "playwright", version = "1.58.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "psutil" },
{ name = "pyasn1" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyotp" },
{ name = "pypdf" },
{ name = "python-calamine" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "requests-toolbelt" },
{ name = "sqlalchemy" },
{ name = "sse-starlette" },
{ name = "starlette-context" },
{ name = "tiktoken" },
{ name = "tldextract" },
{ name = "toml" },
{ name = "tornado" },
{ name = "urllib3" },
{ name = "websockets" },
{ name = "zstandard" },
]
server = [
{ name = "aioboto3" },
{ name = "aiofiles" },
@ -5635,90 +5693,145 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aioboto3", marker = "extra == 'local'", specifier = ">=14.3.0,<15" },
{ name = "aioboto3", marker = "extra == 'server'", specifier = ">=14.3.0,<15" },
{ name = "aiofiles", marker = "extra == 'local'", specifier = ">=24.1.0,<25" },
{ name = "aiofiles", marker = "extra == 'server'", specifier = ">=24.1.0,<25" },
{ name = "aiohttp", marker = "extra == 'local'", specifier = ">=3.13.4,<4" },
{ name = "aiohttp", marker = "extra == 'server'", specifier = ">=3.13.4,<4" },
{ name = "aioredlock", marker = "extra == 'server'", specifier = ">=0.7.3,<0.8" },
{ name = "aiosqlite", marker = "extra == 'local'", specifier = ">=0.21.0,<0.23" },
{ name = "aiosqlite", marker = "extra == 'server'", specifier = ">=0.21.0,<0.23" },
{ name = "alembic", marker = "extra == 'local'", specifier = ">=1.12.1,<2" },
{ name = "alembic", marker = "extra == 'server'", specifier = ">=1.12.1,<2" },
{ name = "anthropic", marker = "extra == 'server'", specifier = ">=0.96.0,<0.97" },
{ name = "anthropic", marker = "extra == 'local'", specifier = ">=0.97.0,<0.98" },
{ name = "anthropic", marker = "extra == 'server'", specifier = ">=0.97.0,<0.98" },
{ name = "asyncache", marker = "extra == 'local'", specifier = ">=0.3.1,<0.4" },
{ name = "asyncache", marker = "extra == 'server'", specifier = ">=0.3.1,<0.4" },
{ name = "asyncpg", marker = "extra == 'server'", specifier = ">=0.30.0,<0.32" },
{ name = "authlib", marker = "extra == 'server'", specifier = ">=1.6.9" },
{ name = "authlib", marker = "extra == 'local'", specifier = ">=1.7.1" },
{ name = "authlib", marker = "extra == 'server'", specifier = ">=1.7.1" },
{ name = "azure-identity", marker = "extra == 'local'", specifier = ">=1.24.0,<2" },
{ name = "azure-identity", marker = "extra == 'server'", specifier = ">=1.24.0,<2" },
{ name = "azure-keyvault-secrets", marker = "extra == 'local'", specifier = ">=4.2.0,<5" },
{ name = "azure-keyvault-secrets", marker = "extra == 'server'", specifier = ">=4.2.0,<5" },
{ name = "azure-storage-blob", marker = "extra == 'local'", specifier = ">=12.26.0" },
{ name = "azure-storage-blob", marker = "extra == 'server'", specifier = ">=12.26.0" },
{ name = "cachetools", specifier = ">=5.3.2,<6" },
{ name = "croniter", marker = "extra == 'local'", specifier = ">=1.3.8,<4" },
{ name = "croniter", marker = "extra == 'server'", specifier = ">=1.3.8,<4" },
{ name = "curlparser", marker = "extra == 'local'", specifier = ">=0.1.0,<0.2" },
{ name = "curlparser", marker = "extra == 'server'", specifier = ">=0.1.0,<0.2" },
{ name = "email-validator", marker = "extra == 'local'", specifier = ">=2.2.0,<3" },
{ name = "email-validator", marker = "extra == 'server'", specifier = ">=2.2.0,<3" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0,<0.137" },
{ name = "fastapi", marker = "extra == 'local'", specifier = ">=0.136.1,<0.137" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.1,<0.137" },
{ name = "fastmcp", marker = "extra == 'server'", specifier = ">=3.2.0,<4" },
{ name = "filetype", marker = "extra == 'local'", specifier = ">=1.2.0,<2" },
{ name = "filetype", marker = "extra == 'server'", specifier = ">=1.2.0,<2" },
{ name = "fuzzysearch", marker = "extra == 'local'", specifier = ">=0.8,<1" },
{ name = "fuzzysearch", marker = "extra == 'server'", specifier = ">=0.8,<1" },
{ name = "google-auth", marker = "extra == 'local'", specifier = ">=2.49.2,<3" },
{ name = "google-auth", marker = "extra == 'server'", specifier = ">=2.49.2,<3" },
{ name = "google-auth-oauthlib", marker = "extra == 'local'", specifier = ">=1.3.1,<2" },
{ name = "google-auth-oauthlib", marker = "extra == 'server'", specifier = ">=1.3.1,<2" },
{ name = "google-cloud-aiplatform", marker = "extra == 'server'", specifier = ">=1.90.0,<2" },
{ name = "google-cloud-aiplatform", marker = "extra == 'local'", specifier = ">=1.149.0,<2" },
{ name = "google-cloud-aiplatform", marker = "extra == 'server'", specifier = ">=1.149.0,<2" },
{ name = "greenlet", marker = "python_full_version == '3.11.*' and extra == 'local'", specifier = "==3.0.3" },
{ name = "greenlet", marker = "python_full_version == '3.11.*' and extra == 'server'", specifier = "==3.0.3" },
{ name = "greenlet", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'local'", specifier = ">3.0.3" },
{ name = "greenlet", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'server'", specifier = ">3.0.3" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
{ name = "jinja2", marker = "extra == 'local'", specifier = ">=3.1.2,<4" },
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.2,<4" },
{ name = "json-repair", marker = "extra == 'server'", specifier = ">=0.59.4,<0.60" },
{ name = "json-repair", marker = "extra == 'local'", specifier = ">=0.59.5,<0.60" },
{ name = "json-repair", marker = "extra == 'server'", specifier = ">=0.59.5,<0.60" },
{ name = "json5", marker = "extra == 'local'", specifier = ">=0.13.0,<1" },
{ name = "json5", marker = "extra == 'server'", specifier = ">=0.13.0,<1" },
{ name = "jsonschema", marker = "extra == 'local'", specifier = ">=4.23.0" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" },
{ name = "lark", marker = "extra == 'local'", specifier = ">=1.2.2,<2" },
{ name = "lark", marker = "extra == 'server'", specifier = ">=1.2.2,<2" },
{ name = "libcst", marker = "extra == 'local'", specifier = ">=1.8.2,<2" },
{ name = "libcst", marker = "extra == 'server'", specifier = ">=1.8.2,<2" },
{ name = "litellm", marker = "extra == 'local'", specifier = "==1.83.14" },
{ name = "litellm", marker = "extra == 'server'", specifier = "==1.83.14" },
{ name = "onepassword-sdk", marker = "extra == 'local'", specifier = "==0.4.0" },
{ name = "onepassword-sdk", marker = "extra == 'server'", specifier = "==0.4.0" },
{ name = "openai", marker = "extra == 'local'", specifier = ">=1.68.2" },
{ name = "openai", marker = "extra == 'server'", specifier = ">=1.68.2" },
{ name = "openai-agents", marker = "extra == 'server'", specifier = ">=0.10.5,<0.15" },
{ name = "opentelemetry-api", marker = "extra == 'local'", specifier = ">=1.41.1,<2" },
{ name = "opentelemetry-api", marker = "extra == 'server'", specifier = ">=1.41.1,<2" },
{ name = "orjson", specifier = ">=3.9.10,<4" },
{ name = "pandas", marker = "extra == 'local'", specifier = ">=2.3.1,<4" },
{ name = "pandas", marker = "extra == 'server'", specifier = ">=2.3.1,<4" },
{ name = "pdfplumber", marker = "extra == 'local'", specifier = ">=0.11.0,<0.12" },
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.11.0,<0.12" },
{ name = "pillow", marker = "extra == 'local'", specifier = ">=10.2.0" },
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.2.0" },
{ name = "playwright", marker = "python_full_version == '3.11.*' and extra == 'local'", specifier = "==1.46.0" },
{ name = "playwright", marker = "python_full_version == '3.11.*' and extra == 'server'", specifier = "==1.46.0" },
{ name = "playwright", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'local'", specifier = ">1.46.0" },
{ name = "playwright", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'server'", specifier = ">1.46.0" },
{ name = "posthog", marker = "extra == 'server'", specifier = ">=3.7.0,<4" },
{ name = "psutil", marker = "extra == 'local'", specifier = ">=7.0.0" },
{ name = "psutil", marker = "extra == 'server'", specifier = ">=7.0.0" },
{ name = "psycopg", extras = ["binary", "pool"], marker = "python_full_version >= '3.11' and python_full_version < '3.13' and extra == 'server'", specifier = "==3.1.18" },
{ name = "psycopg", extras = ["binary", "pool"], marker = "python_full_version == '3.13.*' and extra == 'server'", specifier = ">=3.2.2,<3.3.0" },
{ name = "pyasn1", marker = "extra == 'local'", specifier = ">=0.6.3" },
{ name = "pyasn1", marker = "extra == 'server'", specifier = ">=0.6.3" },
{ name = "pydantic", specifier = ">=2.5.2,<3" },
{ name = "pydantic-settings", specifier = ">=2.1.0,<3" },
{ name = "pyjwt", marker = "extra == 'server'", specifier = ">=2.12.0" },
{ name = "pyjwt", extras = ["crypto"], marker = "extra == 'local'", specifier = ">=2.12.0,<3" },
{ name = "pyjwt", extras = ["crypto"], marker = "extra == 'server'", specifier = ">=2.12.0,<3" },
{ name = "pyotp", marker = "extra == 'local'", specifier = ">=2.9.0,<3" },
{ name = "pyotp", marker = "extra == 'server'", specifier = ">=2.9.0,<3" },
{ name = "pypdf", marker = "extra == 'local'", specifier = ">=6.7.5,<7" },
{ name = "pypdf", marker = "extra == 'server'", specifier = ">=6.7.5,<7" },
{ name = "python-calamine", marker = "extra == 'local'", specifier = ">=0.6.1" },
{ name = "python-calamine", marker = "extra == 'server'", specifier = ">=0.6.1" },
{ name = "python-docx", marker = "extra == 'local'", specifier = ">=1.1.0" },
{ name = "python-docx", marker = "extra == 'server'", specifier = ">=1.1.0" },
{ name = "python-dotenv", specifier = ">=1.0.0,<2" },
{ name = "python-dotenv", marker = "extra == 'server'", specifier = "==1.2.2" },
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.22,<1" },
{ name = "python-multipart", marker = "extra == 'local'", specifier = ">=0.0.27,<1" },
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.27,<1" },
{ name = "requests-toolbelt", marker = "extra == 'local'", specifier = ">=1.0.0,<2" },
{ name = "requests-toolbelt", marker = "extra == 'server'", specifier = ">=1.0.0,<2" },
{ name = "rich", specifier = ">=13.7.0,<14" },
{ name = "sqlalchemy", marker = "extra == 'local'", specifier = ">=2.0.29,<3" },
{ name = "sqlalchemy", extras = ["mypy"], marker = "extra == 'server'", specifier = ">=2.0.29,<3" },
{ name = "sse-starlette", marker = "extra == 'server'", specifier = ">=3.0.3,<4" },
{ name = "sse-starlette", marker = "extra == 'local'", specifier = ">=3.4.1,<4" },
{ name = "sse-starlette", marker = "extra == 'server'", specifier = ">=3.4.1,<4" },
{ name = "starlette-context", marker = "extra == 'local'", specifier = ">=0.3.6,<0.6" },
{ name = "starlette-context", marker = "extra == 'server'", specifier = ">=0.3.6,<0.6" },
{ name = "structlog", specifier = ">=23.2.0,<24" },
{ name = "tiktoken", marker = "extra == 'local'", specifier = ">=0.9.0" },
{ name = "tiktoken", marker = "extra == 'server'", specifier = ">=0.9.0" },
{ name = "tldextract", marker = "extra == 'local'", specifier = ">=5.1.2,<6" },
{ name = "tldextract", marker = "extra == 'server'", specifier = ">=5.1.2,<6" },
{ name = "toml", marker = "extra == 'local'", specifier = ">=0.10.2,<0.11" },
{ name = "toml", marker = "extra == 'server'", specifier = ">=0.10.2,<0.11" },
{ name = "tornado", marker = "extra == 'local'", specifier = ">=6.5.5" },
{ name = "tornado", marker = "extra == 'server'", specifier = ">=6.5.5" },
{ name = "typer", specifier = ">=0.16.0" },
{ name = "types-boto3", extras = ["full"], marker = "extra == 'server'", specifier = ">=1.38.31,<2" },
{ name = "types-toml", marker = "extra == 'server'", specifier = ">=0.10.8.7,<0.11" },
{ name = "urllib3", marker = "extra == 'local'", specifier = ">=2.6.3" },
{ name = "urllib3", marker = "extra == 'server'", specifier = ">=2.6.3" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.35.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.44.0" },
{ name = "websockets", marker = "extra == 'local'", specifier = ">=12.0,<15.1" },
{ name = "websockets", marker = "extra == 'server'", specifier = ">=12.0,<15.1" },
{ name = "zstandard", marker = "extra == 'local'", specifier = ">=0.25.0" },
{ name = "zstandard", marker = "extra == 'server'", specifier = ">=0.25.0" },
]
provides-extras = ["server"]
provides-extras = ["local", "server"]
[package.metadata.requires-dev]
cloud = [
{ name = "kr8s", specifier = ">=0.20.14,<1" },
{ name = "openai-agents", specifier = ">=0.14.4,<0.15" },
{ name = "openai-agents", specifier = ">=0.14.8,<0.15" },
{ name = "opentelemetry-distro", specifier = ">=0.60b1,<1" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.41.1,<2" },
{ name = "opentelemetry-instrumentation-aiohttp-client", specifier = ">=0.60b1,<1" },
@ -5735,7 +5848,7 @@ cloud = [
{ name = "opentelemetry-instrumentation-urllib3", specifier = ">=0.60b1,<1" },
{ name = "pyrate-limiter", specifier = ">=3.7.0,<4" },
{ name = "redis", specifier = ">=5.0.3,<6" },
{ name = "stripe", specifier = ">=9.7.0,<10" },
{ name = "stripe", specifier = ">=15.0.1,<16" },
{ name = "temporalio", specifier = ">=1.6.0,<2" },
{ name = "temporalio", extras = ["opentelemetry"], specifier = ">=1.6.0,<2" },
]
@ -5752,7 +5865,7 @@ dev = [
{ name = "isort", specifier = ">=5.13.2" },
{ name = "moto", extras = ["s3", "server"], specifier = ">=5.1.5,<6" },
{ name = "mypy", specifier = ">=1.18.2,<2" },
{ name = "notebook", specifier = ">=7.0.6,<8" },
{ name = "notebook", specifier = ">=7.5.6,<8" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" },
{ name = "pre-commit", specifier = ">=4.2.0,<5" },
@ -5840,15 +5953,15 @@ mypy = [
[[package]]
name = "sse-starlette"
version = "3.3.4"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/9a/f35932a8c0eb6b2287b66fa65a0321df8c84e4e355a659c1841a37c39fdb/sse_starlette-3.4.1.tar.gz", hash = "sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555", size = 35127, upload-time = "2026-04-26T13:32:32.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
{ url = "https://files.pythonhosted.org/packages/ff/07/45c21ed03d708c477367305726b89919b020a3a2a01f72aaf5ad941caf35/sse_starlette-3.4.1-py3-none-any.whl", hash = "sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0", size = 16487, upload-time = "2026-04-26T13:32:30.819Z" },
]
[[package]]
@ -5892,15 +6005,15 @@ wheels = [
[[package]]
name = "stripe"
version = "9.12.0"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/7c/edcc5afbbd1a8827f3435f4ab02bd5c43bf89abec9bc562633988a483cd8/stripe-9.12.0.tar.gz", hash = "sha256:cbc526abd0f001c920c323ba7c40cce3dee1647d920e9dbecae3488f37367524", size = 1277151, upload-time = "2024-06-17T16:21:47.167Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/b4/bd4baf56cfb9ec7ee66b0d33611e196afaabd12ddcc2322f29449649ec5f/stripe-15.0.1.tar.gz", hash = "sha256:3c04e8cc9ec8d9542f9dd998e2a4361dbe3d0b6150ca17d3f352546cf22c5565", size = 1490326, upload-time = "2026-04-01T23:52:21.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/a0/507b3197ca7c2edefa97258548a1a699751aa13f09dbcf3da09784814107/stripe-9.12.0-py2.py3-none-any.whl", hash = "sha256:14dd20ef1e6386a52e1f7aea07701fc6a80223706d72e3509e4966adb599e138", size = 1516569, upload-time = "2024-06-17T16:21:38.886Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ee/4537ba64d6c2f8bc58667f4381429c804bbc2e854b5857337cbc39add620/stripe-15.0.1-py3-none-any.whl", hash = "sha256:0a525e4550b5bf7fb44ff682266b9d46cfb38dd6f1f97a85d361c452f69e6b4b", size = 2132160, upload-time = "2026-04-01T23:52:18.647Z" },
]
[[package]]
@ -6276,15 +6389,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.46.0"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]
[package.optional-dependencies]