Script generation (#3157)

This commit is contained in:
Shuchang Zheng
2025-08-10 13:16:46 -07:00
committed by GitHub
parent 19d7b951bb
commit 58bd43171e
16 changed files with 708 additions and 244 deletions

View File

@@ -0,0 +1,588 @@
# skyvern_codegen_cst.py
"""
Generate a runnable Skyvern workflow script.
Example
-------
generated_code = generate_workflow_script(
file_name="workflow.py",
workflow_run_request=workflow_run_request,
workflow=workflow,
tasks=tasks,
actions_by_task=actions_by_task,
)
Path("workflow.py").write_text(src)
"""
from __future__ import annotations
import hashlib
import keyword
from enum import StrEnum
from typing import Any
import libcst as cst
import structlog
from libcst import Attribute, Call, Dict, DictElement, FunctionDef, Name, Param
from skyvern.forge import app
from skyvern.webeye.actions.action_types import ActionType
LOG = structlog.get_logger(__name__)
# --------------------------------------------------------------------- #
# 1. helpers #
# --------------------------------------------------------------------- #
ACTION_MAP = {
"click": "click",
"input_text": "input_text",
"upload_file": "upload_file",
"select_option": "select_option",
"goto": "goto",
"scroll": "scroll",
"keypress": "keypress",
"type": "type",
"move": "move",
"drag": "drag",
"solve_captcha": "solve_captcha",
"verification_code": "verification_code",
"wait": "wait",
"extract": "extract",
}
ACTIONS_WITH_XPATH = [
"click",
"input_text",
"upload_file",
"select_option",
]
INDENT = " " * 4
def _safe_name(label: str) -> str:
s = "".join(c if c.isalnum() else "_" for c in label).lower()
if not s or s[0].isdigit() or keyword.iskeyword(s):
s = f"_{s}"
while "__" in s:
s = s.replace("__", "_")
return s
def _value(value: Any) -> cst.BaseExpression:
"""Convert simple Python objects to CST expressions."""
if isinstance(value, str):
if "\n" in value:
return cst.SimpleString('"""' + value.replace('"""', '\\"\\"\\"') + '"""')
return cst.SimpleString(repr(value))
if isinstance(value, (int, float, bool)) or value is None:
return cst.parse_expression(repr(value))
if isinstance(value, dict):
return Dict(
[
DictElement(
key=_value(k),
value=_value(v),
)
for k, v in value.items()
]
)
if isinstance(value, (list, tuple)):
elts = [cst.Element(_value(v)) for v in value]
return cst.List(elts) if isinstance(value, list) else cst.Tuple(elts)
# fallback
return cst.SimpleString(repr(str(value)))
# --------------------------------------------------------------------- #
# 2. builders #
# --------------------------------------------------------------------- #
def _workflow_decorator(wf_req: dict[str, Any]) -> cst.Decorator:
"""
Build @skyvern.workflow(
title="...", totp_url=..., totp_identifier=..., webhook_callback_url=..., max_steps=...
)
"""
# helper that skips “None” so the output is concise
def kw(key: str, value: Any) -> cst.Arg | None:
if value is None:
return None
return cst.Arg(keyword=cst.Name(key), value=_value(value))
args: list = list(
filter(
None,
[
kw("title", wf_req.get("title", "")),
kw("totp_url", wf_req.get("totp_url")),
kw("totp_identifier", wf_req.get("totp_identifier")),
kw("webhook_url", wf_req.get("webhook_url")),
kw("max_steps", wf_req.get("max_steps")),
],
)
)
return cst.Decorator(
decorator=cst.Call(
func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("workflow")),
args=args,
)
)
def _make_decorator(block: dict[str, Any]) -> cst.Decorator:
bt = block["block_type"]
deco_name = {
"task": "task_block",
"file_download": "file_download_block",
"send_email": "email_block",
"wait": "wait_block",
"navigation": "navigation_block",
"for_loop": "for_loop_block",
"action": "action_block",
"extraction": "extraction_block",
"login": "login_block",
"text_prompt": "text_prompt_block",
"goto_url": "url_block",
}[bt]
kwargs = []
field_map = {
"title": "title",
"navigation_goal": "prompt",
"url": "url",
"engine": "engine",
"model": "model",
"totp_identifier": "totp_identifier",
"webhook_callback_url": "webhook_callback_url",
"max_steps_per_run": "max_steps",
"wait_sec": "seconds",
}
for src_key, kw in field_map.items():
v = block.get(src_key)
if v not in (None, "", [], {}):
if isinstance(v, StrEnum):
v = v.value
try:
kwargs.append(cst.Arg(value=_value(v), keyword=Name(kw)))
except Exception:
raise
# booleans
if block.get("complete_on_download"):
kwargs.append(cst.Arg(value=Name("True"), keyword=Name("complete_on_download")))
if block.get("download_suffix"):
kwargs.append(cst.Arg(value=_value(block["download_suffix"]), keyword=Name("download_suffix")))
return cst.Decorator(
decorator=Call(
func=Attribute(value=Name("skyvern"), attr=Name(deco_name)),
args=kwargs,
)
)
def _action_to_stmt(act: dict[str, Any]) -> cst.BaseStatement:
"""
Turn one Action dict into:
await page.<method>(xpath=..., intention=..., data=context.parameters)
"""
method = ACTION_MAP[act["action_type"]]
args: list[cst.Arg] = []
if method == "input_text":
args.append(cst.Arg(keyword=cst.Name("text"), value=_value(act["text"])))
elif method == "select_option":
args.append(cst.Arg(keyword=cst.Name("option"), value=_value(act["option"]["value"])))
elif method == "wait":
args.append(cst.Arg(keyword=cst.Name("seconds"), value=_value(act["seconds"])))
args.extend(
[
cst.Arg(
keyword=cst.Name("intention"),
value=_value(act.get("intention") or act.get("reasoning") or ""),
),
cst.Arg(
keyword=cst.Name("data"),
value=cst.Attribute(value=cst.Name("context"), attr=cst.Name("parameters")),
),
]
)
if method in ACTIONS_WITH_XPATH:
args.append(cst.Arg(keyword=cst.Name("xpath"), value=_value(act["xpath"])))
call = cst.Call(
func=cst.Attribute(value=cst.Name("page"), attr=cst.Name(method)),
args=args,
)
# await page.method(...)
await_expr = cst.Await(call)
# Wrap in a statement line: await ...
return cst.SimpleStatementLine([cst.Expr(await_expr)])
def _build_block_fn(block: dict[str, Any], actions: list[dict[str, Any]]) -> FunctionDef:
name = _safe_name(block.get("title") or block.get("label") or f"block_{block.get('workflow_run_block_id')}")
body_stmts: list[cst.BaseStatement] = []
if block.get("url"):
body_stmts.append(cst.parse_statement(f"await page.goto({repr(block['url'])})"))
for act in actions:
if act["action_type"] in [ActionType.COMPLETE, ActionType.TERMINATE, ActionType.NULL_ACTION]:
continue
body_stmts.append(_action_to_stmt(act))
if not body_stmts:
body_stmts.append(cst.parse_statement("return None"))
return FunctionDef(
name=Name(name),
params=cst.Parameters(
params=[
Param(name=Name("page"), annotation=cst.Annotation(cst.Name("SkyvernPage"))),
Param(name=Name("context"), annotation=cst.Annotation(cst.Name("RunContext"))),
]
),
decorators=[_make_decorator(block)],
body=cst.IndentedBlock(body_stmts),
returns=None,
asynchronous=cst.Asynchronous(),
)
def _build_model(workflow: dict[str, Any]) -> cst.ClassDef:
"""
class WorkflowParameters(BaseModel):
ein_info: str
company_name: str
...
"""
ann_lines: list[cst.BaseStatement] = []
for p in workflow["workflow_definition"]["parameters"]:
if p["parameter_type"] != "workflow":
continue
# ein_info: str
ann = cst.AnnAssign(
target=cst.Name(p["key"]),
annotation=cst.Annotation(cst.Name("str")),
value=None,
)
ann_lines.append(cst.SimpleStatementLine([ann]))
if not ann_lines: # no parameters
ann_lines.append(cst.SimpleStatementLine([cst.Pass()]))
return cst.ClassDef(
name=cst.Name("WorkflowParameters"),
bases=[cst.Arg(cst.Name("BaseModel"))],
body=cst.IndentedBlock(ann_lines), # ← wrap in block
)
def _build_cached_params(values: dict[str, Any]) -> cst.SimpleStatementLine:
"""
Make a CST for:
cached_parameters = WorkflowParameters(ein_info="...", ...)
"""
call = cst.Call(
func=cst.Name("WorkflowParameters"),
args=[cst.Arg(keyword=cst.Name(k), value=_value(v)) for k, v in values.items()],
)
assign = cst.Assign(
targets=[cst.AssignTarget(cst.Name("cached_parameters"))],
value=call,
)
return cst.SimpleStatementLine([assign])
def _build_run_fn(task_titles: list[str], wf_req: dict[str, Any]) -> FunctionDef:
body = [
cst.parse_statement("page, context = await skyvern.setup(parameters.model_dump())"),
*[cst.parse_statement(f"await {_safe_name(t)}(page, context)") for t in task_titles],
]
params = cst.Parameters(
params=[
Param(
name=cst.Name("parameters"),
annotation=cst.Annotation(cst.Name("WorkflowParameters")),
default=cst.Name("cached_parameters"),
),
Param(
name=cst.Name("title"),
annotation=cst.Annotation(cst.Name("str")),
default=_value(wf_req.get("title", "")),
),
Param(
name=cst.Name("webhook_url"),
annotation=cst.Annotation(cst.parse_expression("str | None")),
default=_value(wf_req.get("webhook_url")),
),
Param(
name=cst.Name("totp_url"),
annotation=cst.Annotation(cst.parse_expression("str | None")),
default=_value(wf_req.get("totp_url")),
),
Param(
name=cst.Name("totp_identifier"),
annotation=cst.Annotation(cst.parse_expression("str | None")),
default=_value(wf_req.get("totp_identifier")),
),
]
)
return FunctionDef(
name=cst.Name("run_workflow"),
asynchronous=cst.Asynchronous(),
decorators=[_workflow_decorator(wf_req)],
params=params,
body=cst.IndentedBlock(body),
)
# --------------------------------------------------------------------- #
# 3. entrypoint #
# --------------------------------------------------------------------- #
async def generate_workflow_script(
*,
file_name: str,
workflow_run_request: dict[str, Any],
workflow: dict[str, Any],
tasks: list[dict[str, Any]],
actions_by_task: dict[str, list[dict[str, Any]]],
organization_id: str | None = None,
run_id: str | None = None,
) -> str:
"""
Build a LibCST Module and emit .code (PEP-8-formatted source).
"""
# --- imports --------------------------------------------------------
imports: list[cst.BaseStatement] = [
cst.SimpleStatementLine([cst.Import(names=[cst.ImportAlias(cst.Name("asyncio"))])]),
cst.SimpleStatementLine([cst.Import(names=[cst.ImportAlias(cst.Name("pydantic"))])]),
cst.SimpleStatementLine(
[
cst.ImportFrom(
module=cst.Name("pydantic"),
names=[cst.ImportAlias(cst.Name("BaseModel"))],
)
]
),
cst.SimpleStatementLine([cst.Import(names=[cst.ImportAlias(cst.Name("skyvern"))])]),
cst.SimpleStatementLine(
[
cst.ImportFrom(
module=cst.Name("skyvern"),
names=[
cst.ImportAlias(cst.Name("RunContext")),
cst.ImportAlias(cst.Name("SkyvernPage")),
],
)
]
),
]
# --- class + cached params -----------------------------------------
model_cls = _build_model(workflow)
cached_params_stmt = _build_cached_params(workflow_run_request.get("parameters", {}))
# --- blocks ---------------------------------------------------------
block_fns = []
length_of_tasks = len(tasks)
# Create script first if organization_id is provided
script_id = None
script_revision_id = None
if organization_id:
try:
script = await app.DATABASE.create_script(
organization_id=organization_id,
run_id=run_id,
)
script_id = script.script_id
script_revision_id = script.script_revision_id
except Exception as e:
LOG.error("Failed to create script", error=str(e), exc_info=True)
# Continue without script creation if it fails
for idx, task in enumerate(tasks):
block_fn_def = _build_block_fn(task, actions_by_task.get(task.get("task_id", ""), []))
# Create script block if we have script context
if script_id and script_revision_id and organization_id:
try:
block_name = task.get("title") or task.get("label") or task.get("task_id") or f"task_{idx}"
block_description = f"Generated block for task: {block_name}"
await create_script_block(
block_fn_def=block_fn_def,
script_revision_id=script_revision_id,
script_id=script_id,
organization_id=organization_id,
block_name=block_name,
block_description=block_description,
)
except Exception as e:
LOG.error("Failed to create script block", error=str(e), exc_info=True)
# Continue without script block creation if it fails
block_fns.append(block_fn_def)
if idx < length_of_tasks - 1:
block_fns.append(cst.EmptyLine())
block_fns.append(cst.EmptyLine())
task_titles: list[str] = [
t.get("title") or t.get("label") or t.get("task_id") or f"unknown_title_{idx}" for idx, t in enumerate(tasks)
]
# --- runner ---------------------------------------------------------
run_fn = _build_run_fn(task_titles, workflow_run_request)
module = cst.Module(
body=[
*imports,
cst.EmptyLine(),
cst.EmptyLine(),
model_cls,
cst.EmptyLine(),
cst.EmptyLine(),
cached_params_stmt,
cst.EmptyLine(),
cst.EmptyLine(),
*block_fns,
cst.EmptyLine(),
cst.EmptyLine(),
run_fn,
cst.EmptyLine(),
cst.EmptyLine(),
cst.parse_statement("if __name__ == '__main__':\n asyncio.run(run_workflow())"),
]
)
# Create main script file if we have script context
if script_id and script_revision_id and organization_id:
try:
main_script_code = module.code
main_file_name = "main.py"
main_file_path = main_file_name
# Create artifact and upload to S3
artifact_id = await app.ARTIFACT_MANAGER.create_script_file_artifact(
organization_id=organization_id,
script_id=script_id,
script_version=1, # Assuming version 1 for now
file_path=main_file_path,
data=main_script_code.encode("utf-8"),
)
# Create script file record for main file
await app.DATABASE.create_script_file(
script_revision_id=script_revision_id,
script_id=script_id,
organization_id=organization_id,
file_path=main_file_path,
file_name=main_file_name,
file_type="file",
content_hash=f"sha256:{hashlib.sha256(main_script_code.encode('utf-8')).hexdigest()}",
file_size=len(main_script_code.encode("utf-8")),
mime_type="text/x-python",
artifact_id=artifact_id,
)
except Exception as e:
LOG.error("Failed to create main script file", error=str(e), exc_info=True)
# Continue without main script file creation if it fails
with open(file_name, "w") as f:
f.write(module.code)
return module.code
async def create_script_block(
block_fn_def: FunctionDef,
script_revision_id: str,
script_id: str,
organization_id: str,
block_name: str,
block_description: str | None = None,
) -> None:
"""
Create a script block in the database and save the block code to a script file.
Args:
block_fn_def: The LibCST function definition to save
script_revision_id: The script revision ID
script_id: The script ID
organization_id: The organization ID
block_name: Optional custom name for the block (defaults to function name)
block_description: Optional description for the block
"""
try:
# Step 1: Transform the block function definition to a string
block_code = block_fn_def.code
# Step 2: Use the function name as block name if not provided
if not block_name:
block_name = block_fn_def.name.value
# Step 3: Create script block in database
script_block = await app.DATABASE.create_script_block(
script_revision_id=script_revision_id,
script_id=script_id,
organization_id=organization_id,
script_block_label=block_name,
)
# Step 4: Create script file for the block
# Generate a unique filename for the block
file_name = f"{block_name}.skyvern"
file_path = f"blocks/{script_block.script_block_id}/{file_name}"
# Create artifact and upload to S3
artifact_id = await app.ARTIFACT_MANAGER.create_script_file_artifact(
organization_id=organization_id,
script_id=script_id,
script_version=1, # Assuming version 1 for now
file_path=file_path,
data=block_code.encode("utf-8"),
)
# Create script file record
script_file = await app.DATABASE.create_script_file(
script_revision_id=script_revision_id,
script_id=script_id,
organization_id=organization_id,
file_path=file_path,
file_name=file_name,
file_type="file",
content_hash=f"sha256:{hashlib.sha256(block_code.encode('utf-8')).hexdigest()}",
file_size=len(block_code.encode("utf-8")),
mime_type="text/x-python",
artifact_id=artifact_id,
)
# update script block with script file id
await app.DATABASE.update_script_block(
script_block_id=script_block.script_block_id,
organization_id=organization_id,
script_file_id=script_file.script_file_id,
)
except Exception as e:
# Log error but don't fail the entire generation process
LOG.error("Failed to create script block", error=str(e), exc_info=True)
# For now, just log the error and continue
# In production, you might want to handle this differently

View File

@@ -0,0 +1,24 @@
from typing import Any
from playwright.async_api import async_playwright
from skyvern.core.script_generations.skyvern_page import RunContext, SkyvernPage
from skyvern.forge.sdk.core import skyvern_context
from skyvern.webeye.browser_factory import BrowserContextFactory
# TODO: find a better name for this function
async def setup(parameters: dict[str, Any], generate_response: bool = False) -> tuple[SkyvernPage, RunContext]:
# set up skyvern context
skyvern_context.set(skyvern_context.SkyvernContext())
# start playwright
pw = await async_playwright().start()
(
browser_context,
_,
_,
) = await BrowserContextFactory.create_browser_context(playwright=pw)
new_page = await browser_context.new_page()
# skyvern_page = SkyvernPage(page=new_page, generate_response=generate_response)
skyvern_page = SkyvernPage(page=new_page)
return skyvern_page, RunContext(parameters=parameters, page=skyvern_page)

View File

@@ -0,0 +1,22 @@
from skyvern.core.script_generations.skyvern_page import RunContext
class ScriptRunContextManager:
"""
Manages the run context for code runs.
"""
def __init__(self) -> None:
self.run_contexts: dict[str, RunContext] = {}
"""
run_id -> RunContext
"""
def get_run_context(self, run_id: str) -> RunContext | None:
return self.run_contexts.get(run_id)
def set_run_context(self, run_id: str, run_context: RunContext) -> None:
self.run_contexts[run_id] = run_context
def delete_run_context(self, run_id: str) -> None:
self.run_contexts.pop(run_id, None)

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Callable, Literal
from playwright.async_api import Page
from skyvern.config import settings
from skyvern.forge.sdk.api.files import download_file
from skyvern.webeye.actions import handler_utils
from skyvern.webeye.actions.action_types import ActionType
class Driver(StrEnum):
PLAYWRIGHT = "playwright"
@dataclass
class ActionMetadata:
intention: str = ""
data: dict[str, Any] | str | None = None
timestamp: float | None = None # filled in by recorder
screenshot_path: str | None = None # if enabled
@dataclass
class ActionCall:
name: ActionType
args: tuple[Any, ...]
kwargs: dict[str, Any]
meta: ActionMetadata
result: Any | None = None # populated after execution
error: Exception | None = None # populated if failed
class SkyvernPage:
"""
A minimal adapter around the chosen driver that:
1. Executes real browser commands
2. Records ActionCallobjects into RunContext.trace
3. Adds retry / fallback hooks
"""
def __init__(
self,
page: Page,
driver: Driver = Driver.PLAYWRIGHT,
*,
recorder: Callable[[ActionCall], None] | None = None,
# generate_response: bool = False,
):
self.driver = driver
self.page = page # e.g. Playwright's Page
self._record = recorder or (lambda ac: None)
@staticmethod
def action_wrap(
action: ActionType,
) -> Callable:
"""
Decorator to record the action call.
TODOs:
- generate action record in db pre action
- generate screenshot post action
"""
def decorator(fn: Callable) -> Callable:
async def wrapper(
skyvern_page: SkyvernPage,
*args: Any,
intention: str = "",
data: str | dict[str, Any] = "",
**kwargs: Any,
) -> Any:
meta = ActionMetadata(intention, data)
call = ActionCall(action, args, kwargs, meta)
try:
call.result = await fn(skyvern_page, *args, **kwargs) # real driver call
return call.result
except Exception as e:
call.error = e
# LLM fallback hook could go here ...
raise
finally:
skyvern_page._record(call)
return wrapper
return decorator
async def goto(self, url: str) -> None:
await self.page.goto(url)
######### Public Interfaces #########
@action_wrap(ActionType.CLICK)
async def click(self, xpath: str, intention: str | None = None, data: str | dict[str, Any] | None = None) -> None:
# if self.generate_response:
# # TODO: get element tree
# # generate click action based on the current html
# single_click_prompt = prompt_engine.load_prompt(
# template="single-click-action",
# navigation_goal=intention,
# navigation_payload_str=data,
# current_url=self.page.url,
# elements=element_tree,
# local_datetime=datetime.now(context.tz_info).isoformat(),
# user_context=context.prompt,
# )
# json_response = await app.SINGLE_CLICK_AGENT_LLM_API_HANDLER(
# prompt=single_click_prompt,
# prompt_name="single-click-action",
# step=step,
# )
# click_actions = parse_actions(new_task, step.step_id, step.order, scraped_page, json_response["actions"])
# if not click_actions:
# raise CachedActionPlanError("No click actions to execute")
# for click_action in click_actions:
# await _handle_action(
# click_action, step, new_task, scraped_page, current_page, detailed_output, browser_state, engine
# )
locator = self.page.locator(f"xpath={xpath}")
await locator.click(timeout=5000)
@action_wrap(ActionType.INPUT_TEXT)
async def input_text(
self,
xpath: str,
text: str,
intention: str | None = None,
data: str | dict[str, Any] | None = None,
timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS,
) -> None:
# if self.generate_response:
# # TODO: regenerate text
# pass
locator = self.page.locator(f"xpath={xpath}")
await handler_utils.input_sequentially(locator, text, timeout=timeout)
@action_wrap(ActionType.UPLOAD_FILE)
async def upload_file(
self, xpath: str, file_path: str, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
# if self.generate_response:
# # TODO: regenerate file_path and xpath
# pass
file = await download_file(file_path)
await self.page.set_input_files(xpath, file)
@action_wrap(ActionType.SELECT_OPTION)
async def select_option(
self,
xpath: str,
option: str,
intention: str | None = None,
data: str | dict[str, Any] | None = None,
timeout: float = settings.BROWSER_ACTION_TIMEOUT_MS,
) -> None:
# if self.generate_response:
# # TODO: regenerate option
# pass
locator = self.page.locator(f"xpath={xpath}")
try:
await locator.click(timeout=timeout)
except Exception:
print("Failed to click before select action")
return
await locator.select_option(option, timeout=timeout)
@action_wrap(ActionType.WAIT)
async def wait(
self, seconds: float, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
await asyncio.sleep(seconds)
@action_wrap(ActionType.NULL_ACTION)
async def null_action(self, intention: str | None = None, data: str | dict[str, Any] | None = None) -> None:
return
@action_wrap(ActionType.SOLVE_CAPTCHA)
async def solve_captcha(
self, xpath: str, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
await asyncio.sleep(30)
@action_wrap(ActionType.TERMINATE)
async def terminate(
self, errors: list[str], intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
# TODO: update the workflow run status to terminated
return
@action_wrap(ActionType.COMPLETE)
async def complete(
self, data_extraction_goal: str, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
# TODO: update the workflow run status to completed
return
@action_wrap(ActionType.RELOAD_PAGE)
async def reload_page(self, intention: str | None = None, data: str | dict[str, Any] | None = None) -> None:
await self.page.reload()
return
@action_wrap(ActionType.EXTRACT)
async def extract(
self, data_extraction_goal: str, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
# TODO: extract the data
return
@action_wrap(ActionType.VERIFICATION_CODE)
async def verification_code(
self, xpath: str, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None: ...
@action_wrap(ActionType.SCROLL)
async def scroll(
self, scroll_x: int, scroll_y: int, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
await self.page.evaluate(f"window.scrollBy({scroll_x}, {scroll_y})")
@action_wrap(ActionType.KEYPRESS)
async def keypress(
self,
keys: list[str],
hold: bool = False,
duration: float = 0,
intention: str | None = None,
data: str | dict[str, Any] | None = None,
) -> None:
await handler_utils.keypress(self.page, keys, hold=hold, duration=duration)
@action_wrap(ActionType.MOVE)
async def move(
self, x: int, y: int, intention: str | None = None, data: str | dict[str, Any] | None = None
) -> None:
await self.page.mouse.move(x, y)
@action_wrap(ActionType.DRAG)
async def drag(
self,
start_x: int,
start_y: int,
path: list[tuple[int, int]],
intention: str | None = None,
data: str | dict[str, Any] | None = None,
) -> None:
await handler_utils.drag(self.page, start_x, start_y, path)
@action_wrap(ActionType.LEFT_MOUSE)
async def left_mouse(
self,
x: int,
y: int,
direction: Literal["down", "up"],
intention: str | None = None,
data: str | dict[str, Any] | None = None,
) -> None:
await handler_utils.left_mouse(self.page, x, y, direction)
class RunContext:
"""
Lives for one workflow run.
"""
def __init__(self, parameters: dict[str, Any], page: SkyvernPage) -> None:
self.parameters = parameters
self.page = page
self.trace: list[ActionCall] = []
self.prompt: str | None = None

View File

@@ -0,0 +1,83 @@
from dataclasses import dataclass
from typing import Any
import structlog
from skyvern.forge import app
from skyvern.forge.sdk.workflow.models.block import BlockType
from skyvern.services import workflow_service
LOG = structlog.get_logger(__name__)
@dataclass
class CodeGenInput:
file_name: str
workflow_run: dict[str, Any]
workflow: dict[str, Any]
workflow_blocks: list[dict[str, Any]]
actions_by_task: dict[str, list[dict[str, Any]]]
async def transform_workflow_run_to_code_gen_input(workflow_run_id: str, organization_id: str) -> CodeGenInput:
# get the workflow run request
workflow_run_resp = await workflow_service.get_workflow_run_response(
workflow_run_id=workflow_run_id, organization_id=organization_id
)
if not workflow_run_resp:
raise ValueError(f"Workflow run {workflow_run_id} not found")
run_request = workflow_run_resp.run_request
if not run_request:
raise ValueError(f"Workflow run {workflow_run_id} has no run request")
workflow_run_request_json = run_request.model_dump()
# get the workflow
workflow = await app.WORKFLOW_SERVICE.get_workflow_by_permanent_id(
workflow_permanent_id=run_request.workflow_id, organization_id=organization_id
)
if not workflow:
raise ValueError(f"Workflow {run_request.workflow_id} not found")
workflow_json = workflow.model_dump()
# get the tasks
## first, get all the workflow run blocks
workflow_run_blocks = await app.DATABASE.get_workflow_run_blocks(
workflow_run_id=workflow_run_id, organization_id=organization_id
)
workflow_run_blocks.sort(key=lambda x: x.created_at)
workflow_block_dump = []
# Hydrate blocks with task data
# TODO: support task v2
actions_by_task = {}
for block in workflow_run_blocks:
block_dump = block.model_dump()
if block.block_type == BlockType.TaskV2:
raise ValueError("TaskV2 blocks are not supported yet")
if (
block.block_type
in [BlockType.TASK, BlockType.ACTION, BlockType.EXTRACTION, BlockType.LOGIN, BlockType.NAVIGATION]
and block.task_id
):
task = await app.DATABASE.get_task(task_id=block.task_id, organization_id=organization_id)
if not task:
LOG.warning(f"Task {block.task_id} not found")
continue
block_dump.update(task.model_dump())
actions = await app.DATABASE.get_task_actions_hydrated(
task_id=block.task_id, organization_id=organization_id
)
action_dumps = []
for action in actions:
action_dump = action.model_dump()
action_dump["xpath"] = action.get_xpath()
action_dumps.append(action_dump)
actions_by_task[block.task_id] = action_dumps
workflow_block_dump.append(block_dump)
return CodeGenInput(
file_name=f"{workflow_run_id}.py",
workflow_run=workflow_run_request_json,
workflow=workflow_json,
workflow_blocks=workflow_block_dump,
actions_by_task=actions_by_task,
)

View File

@@ -0,0 +1,195 @@
from typing import Any, Callable
from skyvern import RunContext, SkyvernPage
# Build a dummy workflow decorator
def workflow(
title: str | None = None,
totp_url: str | None = None,
totp_identifier: str | None = None,
webhook_url: str | None = None,
max_steps: int | None = None,
) -> Callable:
def wrapper(func: Callable) -> Callable:
return func
return wrapper
def task_block(
prompt: str | None = None,
title: str | None = None,
url: str | None = None,
engine: str | None = None,
model: dict[str, Any] | None = None,
totp_url: str | None = None,
totp_identifier: str | None = None,
max_steps: int | None = None,
navigation_payload: str | None = None,
webhook_url: str | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def login_block(
prompt: str | None = None,
title: str | None = None,
url: str | None = None,
engine: str | None = None,
model: dict[str, Any] | None = None,
totp_url: str | None = None,
totp_identifier: str | None = None,
max_steps: int | None = None,
navigation_payload: str | None = None,
webhook_url: str | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def navigation_block(
prompt: str | None = None,
title: str | None = None,
url: str | None = None,
engine: str | None = None,
model: dict[str, Any] | None = None,
totp_url: str | None = None,
totp_identifier: str | None = None,
max_steps: int | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def action_block(
prompt: str | None = None,
title: str | None = None,
url: str | None = None,
engine: str | None = None,
model: dict[str, Any] | None = None,
totp_url: str | None = None,
totp_identifier: str | None = None,
max_steps: int | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def extraction_block(
title: str | None = None,
data_extraction_goal: str | None = None,
data_extraction_schema: dict[str, Any] | list | str | None = None,
model: dict[str, Any] | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the data_extraction_goal as prompt in the context
context.prompt = data_extraction_goal
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def url_block(
title: str | None = None,
url: str | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# No prompt to store for url_block
context.prompt = None
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def file_download_block(
prompt: str | None = None,
title: str | None = None,
url: str | None = None,
max_steps: int | None = None,
engine: str | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def email_block(prompt: str | None = None, title: str | None = None, url: str | None = None) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def wait_block(seconds: int, title: str | None = None) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# No prompt to store for wait_block
context.prompt = None
return await func(page, context, *args, **kwargs)
return wrapper
return decorator
def text_prompt_block(
prompt: str | None = None,
title: str | None = None,
json_schema: dict[str, Any] | list | str | None = None,
) -> Callable:
def decorator(func: Callable) -> Callable:
async def wrapper(page: SkyvernPage, context: RunContext, *args: Any, **kwargs: Any) -> Any:
# Store the prompt in the context
context.prompt = prompt
return await func(page, context, *args, **kwargs)
return wrapper
return decorator