From f582f84c954880aef59e96309f86e6993a13f82b Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Wed, 10 Sep 2025 17:22:00 -0700 Subject: [PATCH] scripts gen all block support (#3407) --- skyvern/__init__.py | 10 + .../script_generations/generate_script.py | 316 ++++++++++++++++-- skyvern/forge/sdk/workflow/models/workflow.py | 6 + skyvern/services/script_service.py | 148 +++++++- 4 files changed, 448 insertions(+), 32 deletions(-) diff --git a/skyvern/__init__.py b/skyvern/__init__.py index 2199a539..c94a11fa 100644 --- a/skyvern/__init__.py +++ b/skyvern/__init__.py @@ -33,12 +33,17 @@ from skyvern.services.script_service import ( # noqa: E402 action, # noqa: E402 download, # noqa: E402 extract, # noqa: E402 + http_request, # noqa: E402 generate_text, # noqa: E402 + goto, # noqa: E402 login, # noqa: E402 + parse_file, # noqa: E402 + prompt, # noqa: E402 render_template, # noqa: E402 run_code, # noqa: E402 run_script, # noqa: E402 run_task, # noqa: E402 + send_email, # noqa: E402 wait, # noqa: E402 ) # noqa: E402 @@ -51,12 +56,17 @@ __all__ = [ "cached", "download", "extract", + "http_request", "generate_text", + "goto", "login", + "parse_file", + "prompt", "render_template", "run_code", "run_script", "run_task", + "send_email", "setup", "wait", "workflow", diff --git a/skyvern/core/script_generations/generate_script.py b/skyvern/core/script_generations/generate_script.py index 98daa369..af1d1c08 100644 --- a/skyvern/core/script_generations/generate_script.py +++ b/skyvern/core/script_generations/generate_script.py @@ -726,6 +726,14 @@ def _build_send_email_statement(block: dict[str, Any]) -> cst.SimpleStatementLin last_line=cst.SimpleWhitespace(INDENT), ), ), + cst.Arg( + keyword=cst.Name("recipients"), + value=_value(block.get("recipients", [])), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), cst.Arg( keyword=cst.Name("subject"), value=_value(block.get("subject", "")), @@ -742,17 +750,18 @@ def _build_send_email_statement(block: dict[str, Any]) -> cst.SimpleStatementLin last_line=cst.SimpleWhitespace(INDENT), ), ), + # TODO: support file attachments? + # cst.Arg( + # keyword=cst.Name("file_attachments"), + # value=_value(block.get("file_attachments", [])), + # whitespace_after_arg=cst.ParenthesizedWhitespace( + # indent=True, + # last_line=cst.SimpleWhitespace(INDENT), + # ), + # ), cst.Arg( - keyword=cst.Name("recipients"), - value=_value(block.get("recipients", [])), - whitespace_after_arg=cst.ParenthesizedWhitespace( - indent=True, - last_line=cst.SimpleWhitespace(INDENT), - ), - ), - cst.Arg( - keyword=cst.Name("attach_downloaded_files"), - value=_value(block.get("attach_downloaded_files", False)), + keyword=cst.Name("label"), + value=_value(block.get("label", "")), whitespace_after_arg=cst.ParenthesizedWhitespace( indent=True, ), @@ -861,6 +870,14 @@ def _build_goto_statement(block: dict[str, Any]) -> cst.SimpleStatementLine: cst.Arg( keyword=cst.Name("url"), value=_value(block.get("url", "")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), + cst.Arg( + keyword=cst.Name("label"), + value=_value(block.get("label") or block.get("title") or f"block_{block.get('workflow_run_block_id')}"), whitespace_after_arg=cst.ParenthesizedWhitespace( indent=True, ), @@ -937,6 +954,7 @@ def _build_file_upload_statement(block: dict[str, Any]) -> cst.SimpleStatementLi value=_value(block.get("parameters", None)), whitespace_after_arg=cst.ParenthesizedWhitespace( indent=True, + last_line=cst.SimpleWhitespace(INDENT), ), ), cst.Arg( @@ -944,6 +962,7 @@ def _build_file_upload_statement(block: dict[str, Any]) -> cst.SimpleStatementLi value=_value(block.get("storage_type", FileStorageType.S3)), whitespace_after_arg=cst.ParenthesizedWhitespace( indent=True, + last_line=cst.SimpleWhitespace(INDENT), ), ), ] @@ -964,9 +983,12 @@ def _build_file_upload_statement(block: dict[str, Any]) -> cst.SimpleStatementLi value=_value(block.get(key, "")), whitespace_after_arg=cst.ParenthesizedWhitespace( indent=True, + last_line=cst.SimpleWhitespace(INDENT), ), ) ) + _mark_last_arg_as_comma(args) + call = cst.Call( func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("upload_file")), args=args, @@ -979,6 +1001,238 @@ def _build_file_upload_statement(block: dict[str, Any]) -> cst.SimpleStatementLi return cst.SimpleStatementLine([cst.Expr(cst.Await(call))]) +def _build_file_url_parser_statement(block: dict[str, Any]) -> cst.SimpleStatementLine: + """Build a skyvern.parse_file statement.""" + args = [ + cst.Arg( + keyword=cst.Name("file_url"), + value=_value(block.get("file_url", "")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), + cst.Arg( + keyword=cst.Name("file_type"), + value=_value(str(block.get("file_type"))), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), + ] + + # Add optional parameters if they exist + if block.get("json_schema") is not None: + args.append( + cst.Arg( + keyword=cst.Name("schema"), + value=_value(block.get("json_schema")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("label") is not None: + args.append( + cst.Arg( + keyword=cst.Name("label"), + value=_value(block.get("label")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + _mark_last_arg_as_comma(args) + + call = cst.Call( + func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("parse_file")), + args=args, + whitespace_before_args=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + + return cst.SimpleStatementLine([cst.Expr(cst.Await(call))]) + + +def _build_http_request_statement(block: dict[str, Any]) -> cst.SimpleStatementLine: + """Build a skyvern.http_request statement.""" + args = [ + cst.Arg( + keyword=cst.Name("method"), + value=_value(block.get("method", "GET")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), + cst.Arg( + keyword=cst.Name("url"), + value=_value(block.get("url", "")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), + ] + + # Add optional parameters if they exist + if block.get("headers") is not None: + args.append( + cst.Arg( + keyword=cst.Name("headers"), + value=_value(block.get("headers")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("body") is not None: + args.append( + cst.Arg( + keyword=cst.Name("body"), + value=_value(block.get("body")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("timeout") is not None: + args.append( + cst.Arg( + keyword=cst.Name("timeout"), + value=_value(block.get("timeout")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("follow_redirects") is not None: + args.append( + cst.Arg( + keyword=cst.Name("follow_redirects"), + value=_value(block.get("follow_redirects")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("label") is not None: + args.append( + cst.Arg( + keyword=cst.Name("label"), + value=_value(block.get("label")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + _mark_last_arg_as_comma(args) + + call = cst.Call( + func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("http_request")), + args=args, + whitespace_before_args=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + + return cst.SimpleStatementLine([cst.Expr(cst.Await(call))]) + + +def _build_prompt_statement(block: dict[str, Any]) -> cst.SimpleStatementLine: + """Build a skyvern.prompt statement.""" + args = [ + cst.Arg( + keyword=cst.Name("prompt"), + value=_render_value(block.get("prompt", "")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ), + ] + + # Add optional parameters if they exist + if block.get("json_schema") is not None: + args.append( + cst.Arg( + keyword=cst.Name("schema"), + value=_value(block.get("json_schema")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("label") is not None: + args.append( + cst.Arg( + keyword=cst.Name("label"), + value=_value(block.get("label")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + ) + + if block.get("parameters") is not None: + args.append( + cst.Arg( + keyword=cst.Name("parameters"), + value=_value(block.get("parameters")), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + ), + ) + ) + + call = cst.Call( + func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("prompt")), + args=args, + whitespace_before_args=cst.ParenthesizedWhitespace( + indent=True, + last_line=cst.SimpleWhitespace(INDENT), + ), + ) + + return cst.SimpleStatementLine([cst.Expr(cst.Await(call))]) + + +def _mark_last_arg_as_comma(args: list[cst.Arg]) -> None: + if not args: + return + + last_arg = args.pop() + new_arg = cst.Arg( + keyword=last_arg.keyword, + value=last_arg.value, + comma=cst.Comma(), + whitespace_after_arg=cst.ParenthesizedWhitespace( + indent=True, + ), + ) + args.append(new_arg) + + def __build_base_task_statement(block_title: str, block: dict[str, Any]) -> list[cst.Arg]: args = [ cst.Arg( @@ -1081,7 +1335,7 @@ def _build_run_fn(blocks: list[dict[str, Any]], wf_req: dict[str, Any]) -> Funct elif block_type == "send_email": stmt = _build_send_email_statement(block) elif block_type == "text_prompt": - stmt = _build_validate_statement(block) + stmt = _build_prompt_statement(block) elif block_type == "wait": stmt = _build_wait_statement(block) elif block_type == "for_loop": @@ -1092,6 +1346,10 @@ def _build_run_fn(blocks: list[dict[str, Any]], wf_req: dict[str, Any]) -> Funct stmt = _build_code_statement(block) elif block_type == "file_upload": stmt = _build_file_upload_statement(block) + elif block_type == "file_url_parser": + stmt = _build_file_url_parser_statement(block) + elif block_type == "http_request": + stmt = _build_http_request_statement(block) else: # Default case for unknown block types stmt = cst.SimpleStatementLine([cst.Expr(cst.SimpleString(f"# Unknown block type: {block_type}"))]) @@ -1099,26 +1357,26 @@ def _build_run_fn(blocks: list[dict[str, Any]], wf_req: dict[str, Any]) -> Funct body.append(stmt) # Add a final validation step if not already present - has_validation = any(block.get("block_type") == "text_prompt" for block in blocks) - has_task_blocks = any(block.get("block_type") in SCRIPT_TASK_BLOCKS for block in blocks) - if not has_validation and not has_task_blocks: - # Build the final validation statement using LibCST components - args = [ - cst.Arg( - keyword=cst.Name("prompt"), - value=cst.SimpleString( - '"Your goal is to validate that the workflow completed successfully. COMPLETE if successful, TERMINATE if there are issues."' - ), - ), - ] + # has_validation = any(block.get("block_type") == "text_prompt" for block in blocks) + # has_task_blocks = any(block.get("block_type") in SCRIPT_TASK_BLOCKS for block in blocks) + # if not has_validation and not has_task_blocks: + # # Build the final validation statement using LibCST components + # args = [ + # cst.Arg( + # keyword=cst.Name("prompt"), + # value=cst.SimpleString( + # '"Your goal is to validate that the workflow completed successfully. COMPLETE if successful, TERMINATE if there are issues."' + # ), + # ), + # ] - call = cst.Call( - func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("validate")), - args=args, - ) + # call = cst.Call( + # func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("validate")), + # args=args, + # ) - validation_stmt = cst.SimpleStatementLine([cst.Expr(cst.Await(call))]) - body.append(validation_stmt) + # validation_stmt = cst.SimpleStatementLine([cst.Expr(cst.Await(call))]) + # body.append(validation_stmt) params = cst.Parameters( params=[ diff --git a/skyvern/forge/sdk/workflow/models/workflow.py b/skyvern/forge/sdk/workflow/models/workflow.py index 05d9d881..762b3ecb 100644 --- a/skyvern/forge/sdk/workflow/models/workflow.py +++ b/skyvern/forge/sdk/workflow/models/workflow.py @@ -90,6 +90,12 @@ class Workflow(BaseModel): return block.output_parameter return None + def get_parameter(self, key: str) -> PARAMETER_TYPE | None: + for parameter in self.workflow_definition.parameters: + if parameter.key == key: + return parameter + return None + class WorkflowRunStatus(StrEnum): created = "created" diff --git a/skyvern/services/script_service.py b/skyvern/services/script_service.py index 530bd71d..fc5b5cdc 100644 --- a/skyvern/services/script_service.py +++ b/skyvern/services/script_service.py @@ -27,12 +27,21 @@ from skyvern.forge.sdk.core import skyvern_context from skyvern.forge.sdk.models import Step, StepStatus from skyvern.forge.sdk.schemas.files import FileInfo from skyvern.forge.sdk.schemas.tasks import Task, TaskOutput, TaskStatus -from skyvern.forge.sdk.workflow.models.block import CodeBlock, FileUploadBlock, TaskBlock +from skyvern.forge.sdk.workflow.models.block import ( + CodeBlock, + FileParserBlock, + FileUploadBlock, + HttpRequestBlock, + SendEmailBlock, + TaskBlock, + TextPromptBlock, + UrlBlock, +) from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter from skyvern.forge.sdk.workflow.models.workflow import Workflow from skyvern.schemas.runs import RunEngine from skyvern.schemas.scripts import CreateScriptResponse, FileEncoding, FileNode, ScriptFileCreate -from skyvern.schemas.workflows import BlockStatus, BlockType, FileStorageType +from skyvern.schemas.workflows import BlockStatus, BlockType, FileStorageType, FileType LOG = structlog.get_logger(__name__) jinja_sandbox_env = SandboxedEnvironment() @@ -1323,7 +1332,7 @@ async def run_script( if parameters: await user_script.run_workflow(parameters=parameters) else: - await user_script.run_workflow() + await user_script.run_workflow(parameters={}) else: raise Exception(f"No 'run_workflow' function found in {path}") @@ -1382,6 +1391,7 @@ def render_template(template: str, data: dict[str, Any] | None = None) -> str: class BlockValidationOutput: label: str output_parameter: OutputParameter + workflow: Workflow workflow_id: str workflow_run_id: str organization_id: str @@ -1410,6 +1420,7 @@ async def _validate_and_get_output_parameter(label: str | None = None) -> BlockV return BlockValidationOutput( label=label, output_parameter=output_parameter, + workflow=workflow, workflow_id=workflow_id, workflow_run_id=workflow_run_id, organization_id=organization_id, @@ -1470,3 +1481,134 @@ async def upload_file( organization_id=block_validation_output.organization_id, browser_session_id=block_validation_output.browser_session_id, ) + + +async def send_email( + sender: str, + recipients: list[str], + subject: str, + body: str, + file_attachments: list[str] = [], + label: str | None = None, + parameters: list[PARAMETER_TYPE] | None = None, +) -> None: + block_validation_output = await _validate_and_get_output_parameter(label) + workflow = block_validation_output.workflow + smtp_host_parameter = workflow.get_parameter("smtp_host") + smtp_port_parameter = workflow.get_parameter("smtp_port") + smtp_username_parameter = workflow.get_parameter("smtp_username") + smtp_password_parameter = workflow.get_parameter("smtp_password") + if not smtp_host_parameter or not smtp_port_parameter or not smtp_username_parameter or not smtp_password_parameter: + raise Exception("SMTP host, port, username, and password parameters are required") + send_email_block = SendEmailBlock( + smtp_host=smtp_host_parameter, + smtp_port=smtp_port_parameter, + smtp_username=smtp_username_parameter, + smtp_password=smtp_password_parameter, + sender=sender, + recipients=recipients, + subject=subject, + body=body, + file_attachments=file_attachments, + label=block_validation_output.label, + output_parameter=block_validation_output.output_parameter, + parameters=parameters or [], + ) + await send_email_block.execute_safe( + workflow_run_id=block_validation_output.workflow_run_id, + organization_id=block_validation_output.organization_id, + browser_session_id=block_validation_output.browser_session_id, + ) + + +async def parse_file( + file_url: str, + file_type: FileType, + schema: dict[str, Any] | None = None, + label: str | None = None, + parameters: list[PARAMETER_TYPE] | None = None, +) -> None: + block_validation_output = await _validate_and_get_output_parameter(label) + file_parser_block = FileParserBlock( + file_url=file_url, + file_type=file_type, + json_schema=schema, + label=block_validation_output.label, + output_parameter=block_validation_output.output_parameter, + parameters=parameters or [], + ) + await file_parser_block.execute_safe( + workflow_run_id=block_validation_output.workflow_run_id, + organization_id=block_validation_output.organization_id, + browser_session_id=block_validation_output.browser_session_id, + ) + + +async def http_request( + method: str, + url: str, + headers: dict[str, str] | None = None, + body: dict[str, Any] | None = None, + timeout: int = 30, + follow_redirects: bool = True, + label: str | None = None, + parameters: list[PARAMETER_TYPE] | None = None, +) -> None: + block_validation_output = await _validate_and_get_output_parameter(label) + http_request_block = HttpRequestBlock( + method=method, + url=url, + headers=headers, + body=body, + timeout=timeout, + follow_redirects=follow_redirects, + label=block_validation_output.label, + output_parameter=block_validation_output.output_parameter, + parameters=parameters or [], + ) + await http_request_block.execute_safe( + workflow_run_id=block_validation_output.workflow_run_id, + organization_id=block_validation_output.organization_id, + browser_session_id=block_validation_output.browser_session_id, + ) + + +async def goto( + url: str, + label: str | None = None, + parameters: list[PARAMETER_TYPE] | None = None, +) -> None: + block_validation_output = await _validate_and_get_output_parameter(label) + goto_url_block = UrlBlock( + url=url, + label=block_validation_output.label, + output_parameter=block_validation_output.output_parameter, + parameters=parameters or [], + ) + await goto_url_block.execute_safe( + workflow_run_id=block_validation_output.workflow_run_id, + organization_id=block_validation_output.organization_id, + browser_session_id=block_validation_output.browser_session_id, + ) + + +async def prompt( + prompt: str, + schema: dict[str, Any] | None = None, + label: str | None = None, + parameters: list[PARAMETER_TYPE] | None = None, +) -> dict[str, Any] | list | str | None: + block_validation_output = await _validate_and_get_output_parameter(label) + prompt_block = TextPromptBlock( + prompt=prompt, + json_schema=schema, + label=block_validation_output.label, + output_parameter=block_validation_output.output_parameter, + parameters=parameters or [], + ) + result = await prompt_block.execute_safe( + workflow_run_id=block_validation_output.workflow_run_id, + organization_id=block_validation_output.organization_id, + browser_session_id=block_validation_output.browser_session_id, + ) + return result.output_parameter_value