Skyvern/skyvern/cli/mcp_tools/workflow.py

1586 lines
61 KiB
Python

"""Skyvern MCP workflow tools — CRUD and execution for Skyvern workflows.
Tools for listing, creating, updating, deleting, running, and monitoring
Skyvern workflows via the Skyvern HTTP API. These tools do not require a
browser session.
"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime
from enum import Enum
from typing import Annotated, Any, Literal
import structlog
import yaml
from pydantic import Field
from skyvern.client.errors import BadRequestError, NotFoundError
from skyvern.forge.sdk.workflow.models.parameter import ParameterType, WorkflowParameterType
from skyvern.schemas.runs import ProxyLocation
from skyvern.schemas.workflows import WorkflowCreateYAMLRequest as WorkflowCreateYAMLRequestSchema
from skyvern.utils.yaml_loader import safe_load_no_dates
from ._common import ErrorCode, Timer, make_error, make_result
from ._session import get_skyvern
from ._validation import validate_folder_id, validate_run_id, validate_workflow_id
LOG = structlog.get_logger()
_SUMMARY_TOP_LEVEL_KEY_LIMIT = 8
_SUMMARY_SCALAR_PREVIEW_LIMIT = 3
_SUMMARY_ARTIFACT_PREVIEW_LIMIT = 4
_SUMMARY_STRING_PREVIEW_LIMIT = 120
_SUMMARY_RECURSION_LIMIT = 10
_ERROR_DETAIL_LIMIT = 500
_SCREENSHOT_LIST_KEYS = frozenset({"task_screenshots", "workflow_screenshots", "screenshot_urls"})
_SCREENSHOT_ARTIFACT_ID_KEYS = frozenset({"task_screenshot_artifact_ids", "workflow_screenshot_artifact_ids"})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _coerce_timestamp(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
isoformat = getattr(value, "isoformat", None)
if callable(isoformat):
return isoformat()
LOG.debug("Unexpected timestamp type in workflow response", value_type=type(value).__name__)
return str(value)
def _serialize_workflow(wf: Any) -> dict[str, Any]:
"""Pick the fields we expose from a Workflow.
Accepts both a Fern-generated Workflow pydantic model and a plain dict
parsed from a raw httpx JSON response. Uses Any to stay decoupled from
Fern-generated client types.
"""
status = _get_value(wf, "status")
data: dict[str, Any] = {
"workflow_permanent_id": _get_value(wf, "workflow_permanent_id"),
"workflow_id": _get_value(wf, "workflow_id"),
"title": _get_value(wf, "title"),
"version": _get_value(wf, "version"),
"status": str(status) if status else None,
"description": _get_value(wf, "description"),
"is_saved_task": _get_value(wf, "is_saved_task"),
"folder_id": _get_value(wf, "folder_id"),
"created_at": _coerce_timestamp(_get_value(wf, "created_at")),
"modified_at": _coerce_timestamp(_get_value(wf, "modified_at")),
}
for caching_field in ("run_with", "code_version", "adaptive_caching"):
val = _get_value(wf, caching_field)
if val is not None:
data[caching_field] = val
return data
def _serialize_workflow_full(wf: Any) -> dict[str, Any]:
"""Like _serialize_workflow but includes the full definition."""
data = _serialize_workflow(wf)
wf_def = _get_value(wf, "workflow_definition")
if wf_def is None:
return data
if hasattr(wf_def, "model_dump"):
try:
data["workflow_definition"] = wf_def.model_dump(mode="json")
except Exception:
data["workflow_definition"] = str(wf_def)
elif isinstance(wf_def, dict):
data["workflow_definition"] = wf_def
else:
data["workflow_definition"] = str(wf_def)
return data
def _serialize_run(run: Any) -> dict[str, Any]:
"""Pick fields from a run response (GetRunResponse variant or WorkflowRunResponse).
Run responses still come from Fern SDK models; unlike workflow CRUD, this
path is not part of the raw dict bypass.
"""
data: dict[str, Any] = {
"run_id": run.run_id,
"status": str(run.status) if run.status else None,
}
for field in (
"run_type",
"step_count",
"failure_reason",
"recording_url",
"app_url",
"browser_session_id",
"run_with",
"ai_fallback",
):
val = getattr(run, field, None)
if val is not None:
data[field] = str(val) if not isinstance(val, (str, int, bool)) else val
if hasattr(run, "output") and run.output is not None:
try:
data["output"] = run.output.model_dump(mode="json") if hasattr(run.output, "model_dump") else run.output
except Exception:
data["output"] = str(run.output)
for ts_field in ("created_at", "modified_at", "started_at", "finished_at", "queued_at"):
val = getattr(run, ts_field, None)
if val is not None:
data[ts_field] = val.isoformat()
script_run = getattr(run, "script_run", None)
if script_run is not None:
data["script_run"] = script_run.model_dump(mode="json") if hasattr(script_run, "model_dump") else script_run
return data
def _get_value(obj: Any, key: str, default: Any = None) -> Any:
"""Read a field from raw workflow dicts or Fern models without enforcing requiredness.
Workflow serializers are intentionally permissive here so response shaping
does not fail when the backend adds or omits optional fields.
"""
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def _get_run_id(run: Any) -> str | None:
return _get_value(run, "run_id") or _get_value(run, "workflow_run_id")
def _jsonable(value: Any) -> Any:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json")
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, list):
return [_jsonable(item) for item in value]
if isinstance(value, tuple):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {k: _jsonable(v) for k, v in value.items()}
return value
def _truncate_preview(value: Any) -> Any:
value = _jsonable(value)
if isinstance(value, str) and len(value) > _SUMMARY_STRING_PREVIEW_LIMIT:
return f"{value[: _SUMMARY_STRING_PREVIEW_LIMIT - 3]}..."
return value
def _is_scalarish(value: Any) -> bool:
return value is None or isinstance(value, str | int | float | bool)
def _init_output_stats() -> dict[str, Any]:
return {
"has_extracted_information": False,
"nested_screenshot_count": 0,
"artifact_id_count": 0,
"artifact_ids_preview": [],
}
def _note_artifact_ids(stats: dict[str, Any], values: list[Any]) -> None:
stats["artifact_id_count"] += len(values)
preview = stats["artifact_ids_preview"]
for value in values:
if len(preview) >= _SUMMARY_ARTIFACT_PREVIEW_LIMIT:
break
value_str = str(value)
if value_str not in preview:
preview.append(value_str)
def _scan_output_value(value: Any, stats: dict[str, Any], depth: int = 0) -> None:
if depth > _SUMMARY_RECURSION_LIMIT:
return
value = _jsonable(value)
if isinstance(value, dict):
for key, nested_value in value.items():
if key == "extracted_information" and nested_value is not None:
stats["has_extracted_information"] = True
if key in _SCREENSHOT_LIST_KEYS and isinstance(nested_value, list):
stats["nested_screenshot_count"] += len(nested_value)
if key in _SCREENSHOT_ARTIFACT_ID_KEYS and isinstance(nested_value, list):
_note_artifact_ids(stats, nested_value)
_scan_output_value(nested_value, stats, depth + 1)
return
if isinstance(value, list):
for item in value:
_scan_output_value(item, stats, depth + 1)
def _summarize_output_value(output_value: Any) -> tuple[dict[str, Any], dict[str, Any]]:
if output_value is None:
return {"present": False}, _init_output_stats()
output_value = _jsonable(output_value)
stats = _init_output_stats()
_scan_output_value(output_value, stats)
summary: dict[str, Any] = {"present": True}
if isinstance(output_value, dict):
top_level_keys = list(output_value.keys())
summary["top_level_keys"] = top_level_keys[:_SUMMARY_TOP_LEVEL_KEY_LIMIT]
if len(top_level_keys) > _SUMMARY_TOP_LEVEL_KEY_LIMIT:
summary["top_level_key_count"] = len(top_level_keys)
summary["block_output_count"] = len([key for key in top_level_keys if key != "extracted_information"])
scalar_preview: dict[str, Any] = {}
for key, value in output_value.items():
if key == "extracted_information":
continue
if _is_scalarish(value):
scalar_preview[key] = _truncate_preview(value)
elif (
isinstance(value, list)
and value
and len(value) <= _SUMMARY_SCALAR_PREVIEW_LIMIT
and all(_is_scalarish(item) for item in value)
):
scalar_preview[key] = [_truncate_preview(item) for item in value]
if len(scalar_preview) >= _SUMMARY_SCALAR_PREVIEW_LIMIT:
break
if scalar_preview:
summary["scalar_preview"] = scalar_preview
elif isinstance(output_value, list):
summary["item_count"] = len(output_value)
if (
output_value
and len(output_value) <= _SUMMARY_SCALAR_PREVIEW_LIMIT
and all(_is_scalarish(item) for item in output_value)
):
summary["scalar_preview"] = [_truncate_preview(item) for item in output_value]
else:
summary["scalar_preview"] = _truncate_preview(output_value)
summary["has_extracted_information"] = stats["has_extracted_information"]
summary["nested_screenshot_count"] = stats["nested_screenshot_count"]
summary["artifact_id_count"] = stats["artifact_id_count"]
return summary, stats
def _summarize_artifacts(run: Any, output_stats: dict[str, Any]) -> dict[str, Any]:
downloaded_files = _jsonable(_get_value(run, "downloaded_files")) or []
screenshot_urls = _jsonable(_get_value(run, "screenshot_urls")) or []
summary: dict[str, Any] = {
"recording_available": bool(_get_value(run, "recording_url")),
"workflow_screenshot_count": len(screenshot_urls),
"downloaded_file_count": len(downloaded_files),
"artifact_id_count": output_stats["artifact_id_count"],
}
filenames = [
filename
for filename in (_get_value(file_info, "filename") for file_info in downloaded_files)
if isinstance(filename, str) and filename
]
if filenames:
summary["downloaded_file_names"] = filenames[:_SUMMARY_SCALAR_PREVIEW_LIMIT]
if output_stats["artifact_ids_preview"]:
summary["artifact_ids_preview"] = output_stats["artifact_ids_preview"]
return summary
def _serialize_run_summary(run: Any) -> dict[str, Any]:
run_id = _get_run_id(run)
run_type = _get_value(run, "run_type")
if run_type is None and _get_value(run, "workflow_run_id"):
run_type = "workflow_run"
output_value = _get_value(run, "output")
if output_value is None and _get_value(run, "outputs") is not None:
output_value = _get_value(run, "outputs")
output_summary, output_stats = _summarize_output_value(output_value)
summary: dict[str, Any] = {
"run_id": run_id,
"status": str(_get_value(run, "status")) if _get_value(run, "status") is not None else None,
"run_type": str(run_type) if run_type is not None else None,
"artifact_summary": _summarize_artifacts(run, output_stats),
"output_summary": output_summary,
}
failure_reason = _get_value(run, "failure_reason")
if failure_reason:
summary["failure_reason"] = failure_reason
run_with = _get_value(run, "run_with")
if run_with:
summary["run_with"] = run_with
script_run = _get_value(run, "script_run")
if script_run is not None:
sr = _jsonable(script_run)
if isinstance(sr, dict) and sr.get("ai_fallback_triggered") is not None:
summary["ai_fallback_triggered"] = sr["ai_fallback_triggered"]
workflow_title = _get_value(run, "workflow_title")
if workflow_title:
summary["workflow_title"] = workflow_title
step_count = _get_value(run, "step_count")
total_steps = _get_value(run, "total_steps")
if step_count is not None:
summary["step_count"] = step_count
elif total_steps is not None:
summary["total_steps"] = total_steps
return {key: value for key, value in summary.items() if value is not None}
def _serialize_run_full(run: Any) -> dict[str, Any]:
if not isinstance(run, dict):
return _serialize_run(run)
data: dict[str, Any] = {
"run_id": _get_run_id(run),
"status": str(_get_value(run, "status")) if _get_value(run, "status") is not None else None,
"run_type": "workflow_run" if _get_value(run, "workflow_run_id") else _get_value(run, "run_type"),
}
for field in (
"workflow_id",
"workflow_title",
"failure_reason",
"recording_url",
"screenshot_urls",
"downloaded_files",
"downloaded_file_urls",
"parameters",
"errors",
"browser_session_id",
"browser_profile_id",
"run_with",
"total_steps",
"script_run",
"ai_fallback",
):
value = _get_value(run, field)
if value is not None:
data[field] = _jsonable(value)
outputs = _get_value(run, "outputs")
if outputs is not None:
data["output"] = _jsonable(outputs)
for ts_field in ("created_at", "modified_at", "started_at", "finished_at", "queued_at"):
value = _get_value(run, ts_field)
if value is not None:
data[ts_field] = _jsonable(value)
return {key: value for key, value in data.items() if value is not None}
async def _get_workflow_run_status(
workflow_run_id: str,
*,
include_output_details: bool,
) -> dict[str, Any]:
skyvern = get_skyvern()
# The generated SDK only exposes get_run() for /v1/runs/{run_id}; wr_... IDs
# require the workflow-run detail route until a public SDK helper exists.
response = await skyvern._client_wrapper.httpx_client.request(
f"api/v1/workflows/runs/{workflow_run_id}",
method="GET",
params={"include_output_details": include_output_details},
)
if response.status_code == 404:
raise NotFoundError(body={"detail": f"Workflow run {workflow_run_id!r} not found"})
if response.status_code >= 400:
detail = ""
try:
detail = response.json().get("detail", response.text)
except Exception:
detail = response.text
raise RuntimeError(f"HTTP {response.status_code}: {detail}")
return response.json()
async def _get_workflow_by_id(workflow_id: str, version: int | None = None) -> dict[str, Any]:
"""Fetch a single workflow by ID via the Skyvern API.
The Fern-generated client has get_workflows() (list) but no get_workflow(id).
This helper isolates the private client access so the workaround is contained
in one place. Replace with ``skyvern.get_workflow(id)`` when the SDK adds it.
Raises NotFoundError on 404, or RuntimeError on other HTTP errors, so callers
can use the same ``except NotFoundError`` pattern as all other workflow tools.
"""
skyvern = get_skyvern()
params: dict[str, Any] = {}
if version is not None:
params["version"] = version
# SKY-7807: Replace with skyvern.get_workflow() when the Fern client adds it.
response = await skyvern._client_wrapper.httpx_client.request(
f"api/v1/workflows/{workflow_id}",
method="GET",
params=params,
)
if response.status_code == 404:
raise NotFoundError(body={"detail": f"Workflow {workflow_id!r} not found"})
if response.status_code >= 400:
detail = ""
try:
detail = response.json().get("detail", response.text)
except Exception:
detail = response.text
raise RuntimeError(f"HTTP {response.status_code}: {detail}")
return response.json()
# ---------------------------------------------------------------------------
# Fern-bypass raw HTTP helpers
#
# The vendored Fern SDK ``skyvern/client`` validates Workflow requests and
# responses through discriminated unions of block variants; any block_type that
# the client hasn't been regenerated for (e.g. google_sheets_read) blows up the
# MCP tool before the backend can accept it. These helpers call the backend
# directly via the underlying httpx client, pass/return plain dicts, and
# sidestep the drift entirely.
#
# The ``v1/workflows`` paths intentionally mirror the generated Fern raw client.
# The ``api/v1`` workflow routes above are non-Fern/internal routes used only
# where no public SDK equivalent exists yet.
# ---------------------------------------------------------------------------
def _extract_error_detail(response: Any) -> str:
try:
body = response.json()
except Exception:
text = str(getattr(response, "text", ""))
return text[:_ERROR_DETAIL_LIMIT] if len(text) > _ERROR_DETAIL_LIMIT else text
if isinstance(body, dict):
return str(body.get("detail") or body.get("error") or body)
return str(body)
async def _list_workflows_raw(
*,
search: str | None,
page: int,
page_size: int,
only_workflows: bool,
) -> list[dict[str, Any]]:
skyvern = get_skyvern()
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
"only_workflows": only_workflows,
}
if search is not None:
params["search_key"] = search
response = await skyvern._client_wrapper.httpx_client.request(
"v1/workflows",
method="GET",
params=params,
)
if response.status_code >= 400:
raise RuntimeError(f"HTTP {response.status_code}: {_extract_error_detail(response)}")
payload = response.json()
if not isinstance(payload, list):
raise RuntimeError(f"Unexpected workflows list payload: {type(payload).__name__}")
return payload
async def _create_workflow_raw(
*,
json_definition: dict[str, Any] | None,
yaml_definition: str | None,
folder_id: str | None,
) -> dict[str, Any]:
skyvern = get_skyvern()
body: dict[str, Any] = {}
if json_definition is not None:
body["json_definition"] = json_definition
if yaml_definition is not None:
body["yaml_definition"] = yaml_definition
params: dict[str, Any] = {}
if folder_id is not None:
params["folder_id"] = folder_id
response = await skyvern._client_wrapper.httpx_client.request(
"v1/workflows",
method="POST",
params=params,
json=body,
)
if response.status_code >= 400:
raise RuntimeError(f"HTTP {response.status_code}: {_extract_error_detail(response)}")
payload = response.json()
if not isinstance(payload, dict):
raise RuntimeError(f"Unexpected create_workflow payload: {type(payload).__name__}")
return payload
async def _update_workflow_raw(
workflow_id: str,
*,
json_definition: dict[str, Any] | None,
yaml_definition: str | None,
) -> dict[str, Any]:
skyvern = get_skyvern()
body: dict[str, Any] = {}
if json_definition is not None:
body["json_definition"] = json_definition
if yaml_definition is not None:
body["yaml_definition"] = yaml_definition
response = await skyvern._client_wrapper.httpx_client.request(
f"v1/workflows/{workflow_id}",
method="POST",
json=body,
)
if response.status_code == 404:
raise NotFoundError(body={"detail": f"Workflow {workflow_id!r} not found"})
if response.status_code >= 400:
raise RuntimeError(f"HTTP {response.status_code}: {_extract_error_detail(response)}")
payload = response.json()
if not isinstance(payload, dict):
raise RuntimeError(f"Unexpected update_workflow payload: {type(payload).__name__}")
return payload
async def _update_workflow_folder_raw(workflow_id: str, *, folder_id: str | None) -> dict[str, Any]:
skyvern = get_skyvern()
response = await skyvern._client_wrapper.httpx_client.request(
f"v1/workflows/{workflow_id}/folder",
method="PUT",
json={"folder_id": folder_id},
)
if response.status_code == 404:
raise NotFoundError(body={"detail": f"Workflow {workflow_id!r} not found"})
if response.status_code == 400:
raise BadRequestError(body={"detail": _extract_error_detail(response)})
if response.status_code >= 400:
raise RuntimeError(f"HTTP {response.status_code}: {_extract_error_detail(response)}")
payload = response.json()
if not isinstance(payload, dict):
raise RuntimeError(f"Unexpected update_workflow_folder payload: {type(payload).__name__}")
return payload
def _validate_definition_structure(json_def: dict[str, Any] | None, action: str) -> dict[str, Any] | None:
"""Validate required fields in a JSON workflow definition.
Returns a make_result error dict if validation fails, or None if valid.
Only validates JSON definitions — YAML is validated server-side.
"""
if json_def is None:
return None
if not json_def.get("title"):
return make_result(
action,
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
"Workflow definition missing 'title' field",
"Add a 'title' field to your workflow definition",
),
)
if not isinstance(json_def.get("workflow_definition"), dict):
return make_result(
action,
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
"Workflow definition missing 'workflow_definition' object",
"Add a 'workflow_definition' object with a 'blocks' list",
),
)
return None
_CODE_V2_DEFAULTS: dict[str, Any] = {
"code_version": 2,
"run_with": "code",
}
_DEFAULT_MCP_PROXY_LOCATION = ProxyLocation.RESIDENTIAL
def _deep_merge(base: Any, override: Any) -> Any:
"""Recursively merge normalized JSON-like data over the raw payload.
Unknown fields should survive normalization. Lists are merged by index so
overlapping items keep raw unknown keys even if normalization changes the
list length.
"""
if isinstance(base, dict) and isinstance(override, dict):
result = dict(base)
for key, value in override.items():
if key in result:
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
if isinstance(base, list) and isinstance(override, list):
merged: list[Any] = []
for idx in range(max(len(base), len(override))):
if idx < len(base) and idx < len(override):
merged.append(_deep_merge(base[idx], override[idx]))
elif idx < len(override):
merged.append(override[idx])
else:
merged.append(base[idx])
return merged
return override
def _normalize_json_definition(raw: Any) -> dict[str, Any]:
"""Normalize JSON workflow definitions through the shared backend schema.
The MCP tools post through raw HTTP so valid backend payloads are not
rejected by stale Fern request unions. When the backend schema accepts the
payload, merge its JSON-compatible normalization over the raw dict; when it
does not, preserve the caller's raw dict for server-side validation.
"""
if not isinstance(raw, dict):
raise TypeError("Workflow definition JSON must be an object")
if "title" not in raw:
raise ValueError("Workflow definition missing 'title' field")
if "workflow_definition" not in raw:
raise ValueError("Workflow definition missing 'workflow_definition' object")
try:
normalized = WorkflowCreateYAMLRequestSchema.model_validate(raw)
except Exception as exc:
# Internal schema is stricter than the API boundary — skip normalization
# so unknown/future fields are not rejected by MCP before the backend can
# decide.
LOG.warning("Skipping text-prompt normalization; internal schema rejected payload", error=str(exc))
return raw
merged = _deep_merge(raw, normalized.model_dump(mode="json"))
return merged
def _make_invalid_json_definition_error(exc: Exception) -> dict[str, Any]:
return make_error(
ErrorCode.INVALID_INPUT,
f"Invalid JSON definition: {exc}",
"Provide a valid JSON object for the workflow definition",
)
def _load_definition_dict(definition: str, fmt: str) -> tuple[dict[str, Any] | None, str | None]:
"""Best-effort parse of a workflow definition into a mutable dict.
Used only for tool-side default injection. On parse failure, returns
``(None, None)`` so the caller can preserve existing server-side validation
behavior.
"""
def _as_dict(value: Any, parsed_format: str) -> tuple[dict[str, Any] | None, str | None]:
return (value, parsed_format) if isinstance(value, dict) else (None, None)
if fmt == "json":
try:
return _as_dict(json.loads(definition), "json")
except (json.JSONDecodeError, TypeError):
return None, None
if fmt == "yaml":
try:
return _as_dict(safe_load_no_dates(definition), "yaml")
except yaml.YAMLError:
return None, None
try:
return _as_dict(json.loads(definition), "json")
except (json.JSONDecodeError, TypeError):
try:
return _as_dict(safe_load_no_dates(definition), "yaml")
except yaml.YAMLError:
return None, None
def _dump_definition_dict(raw: dict[str, Any], parsed_format: str) -> str:
def _coerce_enums(value: Any) -> Any:
if isinstance(value, Enum):
return value.value
if isinstance(value, dict):
return {key: _coerce_enums(item) for key, item in value.items()}
if isinstance(value, list):
return [_coerce_enums(item) for item in value]
return value
raw = _coerce_enums(raw)
if parsed_format == "json":
return json.dumps(raw)
return yaml.safe_dump(raw, sort_keys=False)
def _inject_missing_top_level_defaults(definition: str, fmt: str, defaults: dict[str, Any]) -> str:
"""Inject missing top-level keys for JSON or YAML workflow definitions."""
raw, parsed_format = _load_definition_dict(definition, fmt)
if raw is None or parsed_format is None:
return definition
changed = False
for key, value in defaults.items():
if key not in raw:
raw[key] = value
changed = True
return _dump_definition_dict(raw, parsed_format) if changed else definition
def _inject_code_v2_defaults(definition: str, fmt: str) -> str:
"""Inject Code 2.0 defaults (code_version=2, run_with=code) when not explicitly set.
Only modifies JSON definitions (or auto-detected JSON). YAML is returned unchanged.
"""
if fmt == "yaml":
return definition
try:
raw = json.loads(definition)
except (json.JSONDecodeError, TypeError):
return definition # let _parse_definition handle the error
changed = False
for key, value in _CODE_V2_DEFAULTS.items():
if key not in raw:
raw[key] = value
changed = True
return json.dumps(raw) if changed else definition
async def _inject_workflow_update_proxy_default(definition: str, fmt: str, workflow_id: str) -> str:
"""Preserve or default workflow proxy location when MCP update omits it."""
raw, parsed_format = _load_definition_dict(definition, fmt)
if raw is None or parsed_format is None or "proxy_location" in raw:
return definition
existing_workflow = await _get_workflow_by_id(workflow_id)
raw["proxy_location"] = existing_workflow.get("proxy_location") or _DEFAULT_MCP_PROXY_LOCATION
return _dump_definition_dict(raw, parsed_format)
# Parameter types that are auto-managed (credentials and secrets set via the UI) and should
# always be preserved from the existing workflow during MCP updates, regardless of what the
# caller sends. These should NEVER be modifiable via MCP — only via the UI credential picker.
# Derived from the enum to stay in sync when new secret types are added.
_AUTO_MANAGED_PARAMETER_TYPES = frozenset(pt.value for pt in ParameterType if pt.is_secret_or_credential())
_PROTECTED_WORKFLOW_PARAMETER_TYPES = frozenset(pt.value for pt in WorkflowParameterType if pt.is_credential_type())
# Login-capable credential types — subset of protected params that carry username/password data.
# Derived from the enum to stay in sync when new login-capable types are added.
_LOGIN_CREDENTIAL_PARAMETER_TYPES = frozenset(pt.value for pt in ParameterType if pt.is_login_credential())
# Runtime-only fields returned by GET /api/v1/workflows/{id} that must be stripped before
# re-injecting parameters into a YAML/JSON definition. Uses a suffix-based deny-list so
# new parameter types with the standard *_parameter_id / workflow_id / timestamp pattern
# are handled automatically.
_RUNTIME_FIELD_SUFFIXES = ("_parameter_id", "_at")
# workflow_id is the only runtime field not caught by the suffix rules above.
_RUNTIME_EXACT_FIELDS = frozenset({"workflow_id"})
def _strip_runtime_fields(param: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *param* with runtime-only fields removed."""
return {
k: v
for k, v in param.items()
if k not in _RUNTIME_EXACT_FIELDS and not any(k.endswith(s) for s in _RUNTIME_FIELD_SUFFIXES)
}
def _is_protected_update_parameter(param: Any) -> bool:
"""Return True when *param* should be preserved from the existing workflow.
This includes:
- Secret/credential parameter types managed directly by the UI.
- Workflow input parameters whose type is `credential_id`, where the
selected credential lives in `default_value`.
"""
if not isinstance(param, dict):
return False
key = param.get("key")
parameter_type = param.get("parameter_type")
if not key or not parameter_type:
return False
if parameter_type in _AUTO_MANAGED_PARAMETER_TYPES:
return True
return (
parameter_type == ParameterType.WORKFLOW.value
and param.get("workflow_parameter_type") in _PROTECTED_WORKFLOW_PARAMETER_TYPES
)
def _is_login_credential_reference(param: dict[str, Any]) -> bool:
"""Return True when *param* represents a credential reference usable by login blocks."""
parameter_type = param.get("parameter_type")
if not parameter_type:
return False
if parameter_type in _LOGIN_CREDENTIAL_PARAMETER_TYPES:
return True
return (
parameter_type == ParameterType.WORKFLOW.value
and param.get("workflow_parameter_type") in _PROTECTED_WORKFLOW_PARAMETER_TYPES
)
def _iter_blocks_flat(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return all block dicts from a block list, recursing into for_loop nested blocks."""
result: list[dict[str, Any]] = []
for block in blocks:
if not isinstance(block, dict):
continue
result.append(block)
loop_blocks = block.get("loop_blocks")
if isinstance(loop_blocks, list):
result.extend(_iter_blocks_flat(loop_blocks))
return result
async def _inject_workflow_update_parameters(definition: str, fmt: str, workflow_id: str) -> str:
"""Preserve protected credential/secret parameters during MCP workflow updates.
Credential references should NEVER be modifiable via MCP — the existing workflow's
values always win. This function:
1. Always replaces protected parameters with the existing workflow's versions
(even if the caller includes them — they may have stale/wrong data).
2. Injects credential parameter_keys into blocks using type-based matching
(login blocks always get ALL credential keys) with label-based fallback
for non-login blocks.
"""
raw, parsed_format = _load_definition_dict(definition, fmt)
if raw is None or parsed_format is None:
return definition
wf_def = raw.get("workflow_definition")
if not isinstance(wf_def, dict):
return definition
update_params: list[dict[str, Any]] = wf_def.get("parameters", [])
existing_workflow = await _get_workflow_by_id(workflow_id)
existing_wf_def = existing_workflow.get("workflow_definition")
if not isinstance(existing_wf_def, dict):
return definition
existing_params: list[dict[str, Any]] = existing_wf_def.get("parameters", [])
modified = False
# --- Step 1: Always replace protected parameters with existing values ---
# Credential/secret parameters — including workflow inputs of type credential_id —
# should NEVER be modifiable via MCP. The existing workflow's values always win,
# even if the caller includes them with different data. This means callers cannot
# swap credential references or remove credential params via MCP; those operations
# must go through the UI credential picker.
protected_keys: set[str] = set()
for param in existing_params:
if _is_protected_update_parameter(param):
protected_keys.add(param["key"])
if protected_keys:
# Remove any protected params the caller may have included (may have stale data)
update_params = [p for p in update_params if not (isinstance(p, dict) and p.get("key") in protected_keys)]
# Inject all protected params from the existing workflow, stripping runtime-only
# fields that come from the GET API response (e.g. *_parameter_id, workflow_id,
# created_at, modified_at, deleted_at) to keep the definition YAML-clean.
for param in existing_params:
if _is_protected_update_parameter(param):
update_params.append(_strip_runtime_fields(param))
modified = True
wf_def["parameters"] = update_params
# --- Step 2: Inject credential parameter keys into blocks ---
# Login blocks get credential-type keys via type-based matching (resilient to label
# renames by Claude). Non-login blocks fall back to label-based matching — so if Claude
# renames a non-login block that references aws_secret/bitwarden/etc., the key reference
# is lost. This asymmetry is accepted because login blocks are the critical path for
# credential injection; non-login secret refs are rare and still work when labels match.
all_cred_keys: set[str] = set()
login_cred_keys: set[str] = set()
for param in existing_params:
if _is_protected_update_parameter(param):
all_cred_keys.add(param["key"])
if _is_login_credential_reference(param):
login_cred_keys.add(param["key"])
if all_cred_keys:
existing_blocks: list[dict[str, Any]] = existing_wf_def.get("blocks", [])
update_blocks: list[dict[str, Any]] = wf_def.get("blocks", [])
# Build label-based map for fallback (non-login blocks)
existing_block_cred_keys: dict[str, list[str]] = {}
for block in _iter_blocks_flat(existing_blocks):
label = block.get("label")
if not label:
continue
existing_pkeys = block.get("parameter_keys") or []
cred_keys = [k for k in existing_pkeys if k in all_cred_keys]
if cred_keys:
existing_block_cred_keys[label] = cred_keys
for block in _iter_blocks_flat(update_blocks):
block_type = block.get("block_type")
label = block.get("label")
keys_to_inject: list[str] = []
if block_type == "login":
keys_to_inject = sorted(login_cred_keys)
elif label and label in existing_block_cred_keys:
keys_to_inject = sorted(existing_block_cred_keys[label])
if keys_to_inject:
block_pkeys: list[str] = list(block.get("parameter_keys") or [])
current_keys = set(block_pkeys)
for cred_key in keys_to_inject:
if cred_key not in current_keys:
block_pkeys.append(cred_key)
modified = True
block["parameter_keys"] = block_pkeys
if not modified:
return definition
return _dump_definition_dict(raw, parsed_format)
def _parse_definition(definition: str, fmt: str) -> tuple[dict[str, Any] | None, str | None, dict[str, Any] | None]:
"""Parse a workflow definition string.
Returns (json_definition, yaml_definition, error).
Exactly one of the first two will be set on success, or error on failure.
JSON input is parsed into a plain dict for raw HTTP submission.
"""
if fmt == "json":
try:
raw = json.loads(definition)
except (json.JSONDecodeError, TypeError) as e:
return None, None, _make_invalid_json_definition_error(e)
try:
return _normalize_json_definition(raw), None, None
except Exception as e:
return None, None, _make_invalid_json_definition_error(e)
elif fmt == "yaml":
return None, definition, None
else:
# auto: try JSON first, fall back to YAML
try:
raw = json.loads(definition)
except (json.JSONDecodeError, TypeError):
return None, definition, None
try:
return _normalize_json_definition(raw), None, None
except Exception as e:
return None, None, _make_invalid_json_definition_error(e)
# ---------------------------------------------------------------------------
# SKY-7807: Workflow CRUD
# ---------------------------------------------------------------------------
async def skyvern_workflow_list(
search: Annotated[str | None, "Search across workflow titles, folder names, and parameter metadata"] = None,
page: Annotated[int, Field(description="Page number (1-based)", ge=1)] = 1,
page_size: Annotated[int, Field(description="Results per page", ge=1, le=100)] = 10,
only_workflows: Annotated[bool, "Only return multi-step workflows (exclude saved tasks)"] = False,
) -> dict[str, Any]:
"""Find and browse available Skyvern workflows. Use when you need to discover what workflows exist,
search for a workflow by name, or list all workflows for an organization."""
with Timer() as timer:
try:
workflows = await _list_workflows_raw(
search=search,
page=page,
page_size=page_size,
only_workflows=only_workflows,
)
timer.mark("sdk")
except Exception as e:
return make_result(
"skyvern_workflow_list",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check your API key and Skyvern connection"),
)
return make_result(
"skyvern_workflow_list",
data={
"workflows": [_serialize_workflow(wf) for wf in workflows],
"page": page,
"page_size": page_size,
"count": len(workflows),
"has_more": len(workflows) == page_size,
"sdk_equivalent": f"await skyvern.get_workflows(search_key={search!r}, page={page}, page_size={page_size})",
},
timing_ms=timer.timing_ms,
)
async def skyvern_workflow_get(
workflow_id: Annotated[str, "Workflow permanent ID (starts with wpid_)"],
version: Annotated[int | None, "Specific version to retrieve (latest if omitted)"] = None,
) -> dict[str, Any]:
"""Get the full definition of a specific workflow. Use when you need to inspect a workflow's
blocks, parameters, and configuration before running or updating it."""
if err := validate_workflow_id(workflow_id, "skyvern_workflow_get"):
return err
with Timer() as timer:
try:
wf_data = await _get_workflow_by_id(workflow_id, version)
timer.mark("sdk")
except NotFoundError:
return make_result(
"skyvern_workflow_get",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id!r} not found",
"Verify the workflow ID with skyvern_workflow_list",
),
)
except Exception as e:
return make_result(
"skyvern_workflow_get",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check your API key and workflow ID"),
)
version_str = f", version={version}" if version is not None else ""
return make_result(
"skyvern_workflow_get",
data={
**wf_data,
"sdk_equivalent": f"# No SDK method yet — GET /api/v1/workflows/{workflow_id}{version_str}",
},
timing_ms=timer.timing_ms,
)
async def skyvern_workflow_create(
definition: Annotated[str, "Workflow definition as a YAML or JSON string"],
format: Annotated[ # noqa: A002
str, Field(description="Definition format: 'json', 'yaml', or 'auto' (tries JSON first, falls back to YAML)")
] = "auto",
folder_id: Annotated[str | None, "Folder ID (fld_...) to organize the workflow in"] = None,
) -> dict[str, Any]:
"""Create a reusable, versioned workflow from a YAML or JSON definition. For multi-page automations,
scheduling, and repeated runs — not one-off trials (use skyvern_run_task for those).
One block per step: "navigation" for actions, "extraction" for data. Do NOT use deprecated "task" type.
Call skyvern_block_schema() for block types and schemas. Use {{parameter_key}} for input references.
Defaults to Code 2.0 (run_with="code"). Blocks share a browser session automatically.
"""
if format not in ("json", "yaml", "auto"):
return make_result(
"skyvern_workflow_create",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Invalid format: {format!r}",
"Use 'json', 'yaml', or 'auto'",
),
)
# Default MCP-created workflows to the same editor defaults while preserving
# any explicit user-supplied values.
definition = _inject_code_v2_defaults(definition, format)
definition = _inject_missing_top_level_defaults(
definition,
format,
{"proxy_location": _DEFAULT_MCP_PROXY_LOCATION},
)
json_def, yaml_def, parse_err = _parse_definition(definition, format)
if parse_err is not None:
return make_result("skyvern_workflow_create", ok=False, error=parse_err)
if err := _validate_definition_structure(json_def, "skyvern_workflow_create"):
return err
with Timer() as timer:
try:
workflow = await _create_workflow_raw(
json_definition=json_def,
yaml_definition=yaml_def,
folder_id=folder_id,
)
timer.mark("sdk")
except Exception as e:
LOG.error("workflow_create_failed", error=str(e))
return make_result(
"skyvern_workflow_create",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.API_ERROR,
str(e),
"Check the workflow definition syntax and required fields (title, workflow_definition.blocks)",
),
)
LOG.info("workflow_created", workflow_id=workflow.get("workflow_permanent_id"))
data = _serialize_workflow(workflow)
fmt_label = "json_definition" if json_def is not None else "yaml_definition"
folder_str = f", folder_id={folder_id!r}" if folder_id is not None else ""
data["sdk_equivalent"] = f"await skyvern.create_workflow({fmt_label}=<definition>{folder_str})"
return make_result("skyvern_workflow_create", data=data, timing_ms=timer.timing_ms)
async def skyvern_workflow_update(
workflow_id: Annotated[str, "Workflow permanent ID (wpid_...) to update"],
definition: Annotated[str, "Updated workflow definition as a YAML or JSON string"],
format: Annotated[ # noqa: A002
str, Field(description="Definition format: 'json', 'yaml', or 'auto'")
] = "auto",
) -> dict[str, Any]:
"""Update an existing workflow's definition. Use when you need to modify a workflow's blocks,
parameters, or configuration. Creates a new version of the workflow."""
if err := validate_workflow_id(workflow_id, "skyvern_workflow_update"):
return err
if format not in ("json", "yaml", "auto"):
return make_result(
"skyvern_workflow_update",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Invalid format: {format!r}",
"Use 'json', 'yaml', or 'auto'",
),
)
try:
definition = await _inject_workflow_update_proxy_default(definition, format, workflow_id)
definition = await _inject_workflow_update_parameters(definition, format, workflow_id)
except NotFoundError:
return make_result(
"skyvern_workflow_update",
ok=False,
error=make_error(
ErrorCode.WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id!r} not found",
"Verify the workflow ID with skyvern_workflow_list",
),
)
except Exception as e:
LOG.warning("workflow_update_proxy_default_injection_failed", workflow_id=workflow_id, error=str(e))
return make_result(
"skyvern_workflow_update",
ok=False,
error=make_error(
ErrorCode.API_ERROR,
str(e),
"Check the workflow ID and Skyvern connection before retrying the update",
),
)
json_def, yaml_def, parse_err = _parse_definition(definition, format)
if parse_err is not None:
return make_result("skyvern_workflow_update", ok=False, error=parse_err)
if err := _validate_definition_structure(json_def, "skyvern_workflow_update"):
return err
with Timer() as timer:
try:
workflow = await _update_workflow_raw(
workflow_id,
json_definition=json_def,
yaml_definition=yaml_def,
)
timer.mark("sdk")
except NotFoundError:
return make_result(
"skyvern_workflow_update",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id!r} not found",
"Verify the workflow ID with skyvern_workflow_list",
),
)
except Exception as e:
return make_result(
"skyvern_workflow_update",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.API_ERROR,
str(e),
"Check the workflow ID and definition syntax",
),
)
data = _serialize_workflow(workflow)
fmt_label = "json_definition" if json_def is not None else "yaml_definition"
data["sdk_equivalent"] = f"await skyvern.update_workflow({workflow_id!r}, {fmt_label}=<definition>)"
return make_result("skyvern_workflow_update", data=data, timing_ms=timer.timing_ms)
async def skyvern_workflow_delete(
workflow_id: Annotated[str, "Workflow permanent ID (wpid_...) to delete"],
force: Annotated[bool, "Must be true to confirm deletion — prevents accidental deletes"] = False,
) -> dict[str, Any]:
"""Delete a workflow permanently. Use when you need to remove a workflow that is no longer needed.
Requires force=true to prevent accidental deletion."""
if err := validate_workflow_id(workflow_id, "skyvern_workflow_delete"):
return err
if not force:
return make_result(
"skyvern_workflow_delete",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Deletion of workflow {workflow_id!r} requires confirmation",
"Set force=true to confirm deletion. This action is irreversible.",
),
)
skyvern = get_skyvern()
with Timer() as timer:
try:
await skyvern.delete_workflow(workflow_id)
timer.mark("sdk")
except NotFoundError:
return make_result(
"skyvern_workflow_delete",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id!r} not found",
"Verify the workflow ID with skyvern_workflow_list",
),
)
except Exception as e:
LOG.error("workflow_delete_failed", workflow_id=workflow_id, error=str(e))
return make_result(
"skyvern_workflow_delete",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check the workflow ID and your permissions"),
)
LOG.info("workflow_deleted", workflow_id=workflow_id)
return make_result(
"skyvern_workflow_delete",
data={
"workflow_permanent_id": workflow_id,
"deleted": True,
"sdk_equivalent": f"await skyvern.delete_workflow({workflow_id!r})",
},
timing_ms=timer.timing_ms,
)
async def skyvern_workflow_update_folder(
workflow_id: Annotated[str, "Workflow permanent ID (wpid_...)"],
folder_id: Annotated[
str | None,
"Folder ID (fld_...) to assign, or null to remove the workflow from its folder",
] = None,
) -> dict[str, Any]:
"""Assign a workflow to a folder, or remove it from its current folder."""
if err := validate_workflow_id(workflow_id, "skyvern_workflow_update_folder"):
return err
if folder_id is not None and (err := validate_folder_id(folder_id, "skyvern_workflow_update_folder")):
return err
with Timer() as timer:
try:
workflow = await _update_workflow_folder_raw(workflow_id, folder_id=folder_id)
timer.mark("sdk")
except NotFoundError:
return make_result(
"skyvern_workflow_update_folder",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id!r} not found",
"Verify the workflow ID with skyvern_workflow_list.",
),
)
except BadRequestError as e:
return make_result(
"skyvern_workflow_update_folder",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.INVALID_INPUT,
str(e),
"Verify the folder ID with skyvern_folder_list or pass null to remove the folder assignment.",
),
)
except Exception as e:
return make_result(
"skyvern_workflow_update_folder",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.API_ERROR, str(e), "Check the workflow ID, folder ID, and your permissions."
),
)
data = _serialize_workflow(workflow)
data["sdk_equivalent"] = f"await skyvern.update_workflow_folder({workflow_id!r}, folder_id={folder_id!r})"
return make_result("skyvern_workflow_update_folder", data=data, timing_ms=timer.timing_ms)
# ---------------------------------------------------------------------------
# SKY-7808: Workflow Execution
# ---------------------------------------------------------------------------
async def skyvern_workflow_run(
workflow_id: Annotated[str, "Workflow permanent ID (wpid_...) to run"],
parameters: Annotated[str | None, Field(description="JSON string of workflow parameters")] = None,
browser_session_id: Annotated[
str | None, Field(description="Reuse an existing browser session (pbs_...) to preserve login state")
] = None,
webhook_url: Annotated[str | None, Field(description="URL for status webhook callbacks after completion")] = None,
proxy_location: Annotated[
str | None, Field(description="Geographic proxy: RESIDENTIAL, RESIDENTIAL_GB, NONE, etc.")
] = None,
wait: Annotated[bool, "Wait for the workflow to complete before returning (default: return immediately)"] = False,
timeout_seconds: Annotated[
int, Field(description="Max wait time in seconds when wait=true (default 300)", ge=10, le=3600)
] = 300,
run_with: Annotated[
str | None,
Field(
description="Execution mode override (e.g., 'code' for cached script execution). Null inherits from workflow setting."
),
] = None,
) -> dict[str, Any]:
"""Run a Skyvern workflow with parameters. Use when you need to execute an automation workflow.
Returns immediately by default (async) — set wait=true to block until completion.
Default timeout is 300s (5 minutes). For longer workflows, increase timeout_seconds
or use wait=false and poll with skyvern_workflow_status."""
if err := validate_workflow_id(workflow_id, "skyvern_workflow_run"):
return err
parsed_params: dict[str, Any] | None = None
if parameters is not None:
try:
parsed_params = json.loads(parameters)
except (json.JSONDecodeError, TypeError) as e:
return make_result(
"skyvern_workflow_run",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Invalid parameters JSON: {e}",
"Provide parameters as a valid JSON object string",
),
)
proxy: ProxyLocation | None = None
if proxy_location is not None:
try:
proxy = ProxyLocation(proxy_location)
except ValueError:
return make_result(
"skyvern_workflow_run",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Invalid proxy_location: {proxy_location!r}",
"Use RESIDENTIAL, RESIDENTIAL_GB, NONE, etc.",
),
)
skyvern = get_skyvern()
with Timer() as timer:
try:
run = await skyvern.run_workflow(
workflow_id=workflow_id,
parameters=parsed_params,
browser_session_id=browser_session_id,
webhook_url=webhook_url,
proxy_location=proxy,
wait_for_completion=wait,
timeout=timeout_seconds,
run_with=run_with,
)
timer.mark("sdk")
except asyncio.TimeoutError:
return make_result(
"skyvern_workflow_run",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.TIMEOUT,
f"Workflow did not complete within {timeout_seconds}s",
"Increase timeout_seconds or set wait=false and poll with skyvern_workflow_status",
),
)
except NotFoundError:
return make_result(
"skyvern_workflow_run",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.WORKFLOW_NOT_FOUND,
f"Workflow {workflow_id!r} not found",
"Verify the workflow ID with skyvern_workflow_list",
),
)
except Exception as e:
LOG.error("workflow_run_failed", workflow_id=workflow_id, error=str(e))
return make_result(
"skyvern_workflow_run",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check the workflow ID, parameters, and API key"),
)
LOG.info("workflow_run_started", workflow_id=workflow_id, run_id=run.run_id, wait=wait)
data = _serialize_run(run)
params_str = f", parameters={parsed_params}" if parsed_params else ""
wait_str = f", wait_for_completion=True, timeout={timeout_seconds}" if wait else ""
data["sdk_equivalent"] = f"await skyvern.run_workflow(workflow_id={workflow_id!r}{params_str}{wait_str})"
return make_result("skyvern_workflow_run", data=data, timing_ms=timer.timing_ms)
async def skyvern_workflow_status(
run_id: Annotated[str, "Run ID to check (wr_... for workflow runs, tsk_v2_... for task runs)"],
verbosity: Annotated[
Literal["summary", "full"],
Field(description="`summary` returns a compact status payload. `full` includes outputs, timestamps, and URLs."),
] = "summary",
) -> dict[str, Any]:
"""Check the status and progress of a workflow or task run. Use when you need to monitor
a running workflow, check if it completed, or retrieve its output."""
if err := validate_run_id(run_id, "skyvern_workflow_status"):
return err
if verbosity not in {"summary", "full"}:
return make_result(
"skyvern_workflow_status",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Invalid verbosity: {verbosity!r}",
"Use verbosity='summary' for compact status or verbosity='full' for full detail.",
),
)
with Timer() as timer:
try:
if run_id.startswith("wr_"):
run = await _get_workflow_run_status(
run_id,
include_output_details=verbosity == "full",
)
else:
skyvern = get_skyvern()
run = await skyvern.get_run(run_id)
timer.mark("sdk")
except NotFoundError:
return make_result(
"skyvern_workflow_status",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.RUN_NOT_FOUND,
f"Run {run_id!r} not found",
"Verify the run ID — it should start with wr_ or tsk_v2_",
),
)
except Exception as e:
return make_result(
"skyvern_workflow_status",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check the run ID and your API key"),
)
data = _serialize_run_full(run) if verbosity == "full" else _serialize_run_summary(run)
if run_id.startswith("wr_"):
data["sdk_equivalent"] = f"await skyvern_workflow_status(run_id={run_id!r}, verbosity={verbosity!r})"
else:
verbosity_arg = "" if verbosity == "summary" else f", verbosity={verbosity!r}"
data["sdk_equivalent"] = (
f"await skyvern.get_run({run_id!r}) # or skyvern_workflow_status(run_id={run_id!r}{verbosity_arg})"
)
return make_result("skyvern_workflow_status", data=data, timing_ms=timer.timing_ms)
async def skyvern_workflow_cancel(
run_id: Annotated[str, "Run ID to cancel (wr_... for workflow runs, tsk_v2_... for task runs)"],
) -> dict[str, Any]:
"""Cancel a running workflow or task. Use when you need to stop a workflow that is taking
too long, is stuck, or is no longer needed."""
if err := validate_run_id(run_id, "skyvern_workflow_cancel"):
return err
skyvern = get_skyvern()
with Timer() as timer:
try:
await skyvern.cancel_run(run_id)
timer.mark("sdk")
except NotFoundError:
return make_result(
"skyvern_workflow_cancel",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(
ErrorCode.RUN_NOT_FOUND,
f"Run {run_id!r} not found",
"Verify the run ID — it should start with wr_ or tsk_v2_",
),
)
except Exception as e:
LOG.error("workflow_cancel_failed", run_id=run_id, error=str(e))
return make_result(
"skyvern_workflow_cancel",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check the run ID and your API key"),
)
LOG.info("workflow_cancelled", run_id=run_id)
return make_result(
"skyvern_workflow_cancel",
data={
"run_id": run_id,
"cancelled": True,
"sdk_equivalent": f"await skyvern.cancel_run({run_id!r})",
},
timing_ms=timer.timing_ms,
)