Script generation (#3157)
This commit is contained in:
0
skyvern/core/script_generations/__init__.py
Normal file
0
skyvern/core/script_generations/__init__.py
Normal file
588
skyvern/core/script_generations/generate_script.py
Normal file
588
skyvern/core/script_generations/generate_script.py
Normal 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
|
||||
24
skyvern/core/script_generations/run_initializer.py
Normal file
24
skyvern/core/script_generations/run_initializer.py
Normal 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)
|
||||
@@ -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)
|
||||
275
skyvern/core/script_generations/skyvern_page.py
Normal file
275
skyvern/core/script_generations/skyvern_page.py
Normal 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
|
||||
83
skyvern/core/script_generations/transform_workflow_run.py
Normal file
83
skyvern/core/script_generations/transform_workflow_run.py
Normal 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,
|
||||
)
|
||||
195
skyvern/core/script_generations/workflow_wrappers.py
Normal file
195
skyvern/core/script_generations/workflow_wrappers.py
Normal 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
|
||||
Reference in New Issue
Block a user