Workflow Fixes (#156)

This commit is contained in:
Kerem Yilmaz
2024-04-04 19:09:19 -07:00
committed by GitHub
parent 8117395d73
commit 0800990627
11 changed files with 350 additions and 108 deletions

View File

@@ -43,7 +43,7 @@ class Settings(BaseSettings):
# S3 bucket settings
AWS_REGION: str = "us-east-1"
AWS_S3_BUCKET_DOWNLOADS: str = "skyvern-downloads"
AWS_S3_BUCKET_UPLOADS: str = "skyvern-uploads"
SKYVERN_TELEMETRY: bool = True
ANALYTICS_ID: str = "anonymous"
@@ -54,6 +54,9 @@ class Settings(BaseSettings):
BROWSER_WIDTH: int = 1920
BROWSER_HEIGHT: int = 1080
# Workflow constant parameters
WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY: str = "SKYVERN_DOWNLOAD_DIRECTORY"
#####################
# LLM Configuration #
#####################

View File

@@ -16,10 +16,11 @@ Reply in JSON format with the following keys:
[{
"reasoning": str, // The reasoning behind the action. Be specific, referencing any user information and their fields and element ids in your reasoning. Mention why you chose the action type, and why you chose the element id. Keep the reasoning short and to the point.
"confidence_float": float, // The confidence of the action. Pick a number between 0.0 and 1.0. 0.0 means no confidence, 1.0 means full confidence
"action_type": str, // It's a string enum: "CLICK", "INPUT_TEXT", "UPLOAD_FILE", "SELECT_OPTION", "WAIT", "SOLVE_CAPTCHA", "COMPLETE", "TERMINATE". "CLICK" is an element you'd like to click. "INPUT_TEXT" is an element you'd like to input text into. "UPLOAD_FILE" is an element you'd like to upload a file into. "SELECT_OPTION" is an element you'd like to select an option from. "WAIT" action should be used if there are no actions to take and there is some indication on screen that waiting could yield more actions. "WAIT" should not be used if there are actions to take. "SOLVE_CAPTCHA" should be used if there's a captcha to solve on the screen. "COMPLETE" is used when the user goal has been achieved AND if there's any data extraction goal, you should be able to get data from the page. If there is any other action to take, do not add "COMPLETE" type at all. "TERMINATE" is used to terminate the whole task with a failure when it doesn't seem like the user goal can be achieved. Do not use "TERMINATE" if waiting could lead the user towards the goal. Only return "TERMINATE" if you are on a page where the user goal cannot be achieved. All other actions are ignored when "TERMINATE" is returned.
"action_type": str, // It's a string enum: "CLICK", "INPUT_TEXT", "UPLOAD_FILE", "DOWNLOAD_FILE", "SELECT_OPTION", "WAIT", "SOLVE_CAPTCHA", "COMPLETE", "TERMINATE". "CLICK" is an element you'd like to click. "INPUT_TEXT" is an element you'd like to input text into. "UPLOAD_FILE" is an element you'd like to upload a file into. "DOWNLOAD_FILE" is an element you'd like to download a file from, and the file will be saved with the name provided in the "file_name" field. You can download multiple files in one action by returning multiple "DOWNLOAD_FILE" actions in one step. The "file_name" field should be unique for each file download action. Do not download the same file multiple times, check action history to see if the file has already been downloaded. "SELECT_OPTION" is an element you'd like to select an option from. "WAIT" action should be used if there are no actions to take and there is some indication on screen that waiting could yield more actions. "WAIT" should not be used if there are actions to take. "SOLVE_CAPTCHA" should be used if there's a captcha to solve on the screen. "COMPLETE" is used when the user goal has been achieved AND if there's any data extraction goal, you should be able to get data from the page. "TERMINATE" is used to terminate the whole task with a failure when it doesn't seem like the user goal can be achieved. Do not use "TERMINATE" if waiting could lead the user towards the goal. Only return "TERMINATE" if you are on a page where the user goal cannot be achieved. All other actions are ignored when "TERMINATE" is returned.
"id": int, // The id of the element to take action on. The id has to be one from the elements list
"text": str, // Text for INPUT_TEXT action only
"file_url": str, // The url of the file to upload if applicable. This field must be present for UPLOAD_FILE but can also be present for CLICK only if the click is to upload the file. It should be null otherwise.
"file_name": str, // The name of the file to save the downloaded file as. This field must be present for DOWNLOAD_FILE only. It should be null otherwise.
"option": { // The option to select for SELECT_OPTION action only. null if not SELECT_OPTION action
"label": str, // the label of the option if any. MAKE SURE YOU USE THIS LABEL TO SELECT THE OPTION. DO NOT PUT ANYTHING OTHER THAN A VALID OPTION LABEL HERE
"index": int, // the id corresponding to the optionIndex under the the select element.

View File

@@ -1,11 +1,13 @@
import os
import tempfile
import zipfile
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
import structlog
from skyvern.constants import SKYVERN_DIR
from skyvern.exceptions import DownloadFileMaxSizeExceeded
LOG = structlog.get_logger()
@@ -62,3 +64,7 @@ def zip_files(files_path: str, zip_file_path: str) -> str:
zipf.write(file_path, arcname)
return zip_file_path
def get_path_for_workflow_download_directory(workflow_run_id: str) -> Path:
return Path(f"{SKYVERN_DIR}/downloads/{workflow_run_id}/")

View File

@@ -469,7 +469,10 @@ async def get_workflow_run(
analytics.capture("skyvern-oss-agent-workflow-run-get")
request["agent"]
return await app.WORKFLOW_SERVICE.build_workflow_run_status_response(
workflow_id=workflow_id, workflow_run_id=workflow_run_id, organization_id=current_org.organization_id
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
last_block_result=None,
organization_id=current_org.organization_id,
)

View File

@@ -3,6 +3,7 @@ import json
import os
import smtplib
import uuid
from dataclasses import dataclass
from email.message import EmailMessage
from enum import StrEnum
from pathlib import Path
@@ -23,7 +24,7 @@ from skyvern.exceptions import (
from skyvern.forge import app
from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.api.aws import AsyncAWSClient
from skyvern.forge.sdk.api.files import download_file
from skyvern.forge.sdk.api.files import download_file, get_path_for_workflow_download_directory
from skyvern.forge.sdk.api.llm.api_handler_factory import LLMAPIHandlerFactory
from skyvern.forge.sdk.schemas.tasks import TaskStatus
from skyvern.forge.sdk.settings_manager import SettingsManager
@@ -46,9 +47,17 @@ class BlockType(StrEnum):
CODE = "code"
TEXT_PROMPT = "text_prompt"
DOWNLOAD_TO_S3 = "download_to_s3"
UPLOAD_TO_S3 = "upload_to_s3"
SEND_EMAIL = "send_email"
@dataclass(frozen=True)
class BlockResult:
success: bool
output_parameter: OutputParameter | None = None
output_parameter_value: dict[str, Any] | list | str | None = None
class Block(BaseModel, abc.ABC):
# Must be unique within workflow definition
label: str
@@ -68,12 +77,26 @@ class Block(BaseModel, abc.ABC):
return app.WORKFLOW_CONTEXT_MANAGER.aws_client
@abc.abstractmethod
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
pass
async def execute_safe(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
try:
return await self.execute(workflow_run_id, **kwargs)
except Exception:
LOG.exception(
"Block execution failed",
exc_info=True,
workflow_run_id=workflow_run_id,
block_label=self.label,
block_type=self.block_type,
)
return BlockResult(success=False)
@abc.abstractmethod
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
pass
@@ -93,8 +116,15 @@ class TaskBlock(Block):
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
return self.parameters
parameters = self.parameters
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
if self.url and workflow_run_context.has_parameter(self.url):
parameters.append(workflow_run_context.get_parameter(self.url))
return parameters
@staticmethod
async def get_task_order(workflow_run_id: str, current_retry: int) -> tuple[int, int]:
@@ -126,7 +156,7 @@ class TaskBlock(Block):
return order, retry + 1
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
current_retry = 0
# initial value for will_retry is True, so that the loop runs at least once
@@ -198,7 +228,6 @@ class TaskBlock(Block):
if not updated_task.status.is_final():
raise UnexpectedTaskStatus(task_id=updated_task.task_id, status=updated_task.status)
if updated_task.status == TaskStatus.completed:
will_retry = False
LOG.info(
f"Task completed",
task_id=updated_task.task_id,
@@ -224,7 +253,12 @@ class TaskBlock(Block):
workflow_id=workflow.workflow_id,
task_id=updated_task.task_id,
)
return self.output_parameter
return BlockResult(
success=True,
output_parameter=self.output_parameter,
output_parameter_value=updated_task.extracted_information,
)
return BlockResult(success=True)
else:
current_retry += 1
will_retry = current_retry <= self.max_retries
@@ -239,7 +273,8 @@ class TaskBlock(Block):
current_retry=current_retry,
max_retries=self.max_retries,
)
return None
return BlockResult(success=False)
class ForLoopBlock(Block):
@@ -251,15 +286,16 @@ class ForLoopBlock(Block):
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
return self.loop_block.get_all_parameters() + [self.loop_over]
return self.loop_block.get_all_parameters(workflow_run_id) + [self.loop_over]
def get_loop_block_context_parameters(self, workflow_run_id: str, loop_data: Any) -> list[ContextParameter]:
if not isinstance(loop_data, dict):
# TODO (kerem): Should we add support for other types?
raise ValueError("loop_data should be a dictionary")
loop_block_parameters = self.loop_block.get_all_parameters()
loop_block_parameters = self.loop_block.get_all_parameters(workflow_run_id)
context_parameters = [
parameter for parameter in loop_block_parameters if isinstance(parameter, ContextParameter)
]
@@ -286,7 +322,7 @@ class ForLoopBlock(Block):
# TODO (kerem): Implement this for context parameters
raise NotImplementedError
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
loop_over_values = self.get_loop_over_parameter_values(workflow_run_context)
LOG.info(
@@ -296,20 +332,29 @@ class ForLoopBlock(Block):
num_loop_over_values=len(loop_over_values),
)
outputs_with_loop_values = []
block_outputs = []
for loop_over_value in loop_over_values:
context_parameters_with_value = self.get_loop_block_context_parameters(workflow_run_id, loop_over_value)
for context_parameter in context_parameters_with_value:
workflow_run_context.set_value(context_parameter.key, context_parameter.value)
await self.loop_block.execute(workflow_run_id=workflow_run_id)
if self.loop_block.output_parameter:
try:
block_output = await self.loop_block.execute(workflow_run_id=workflow_run_id)
block_outputs.append(block_output)
except Exception as e:
LOG.error("ForLoopBlock: Failed to execute loop block", exc_info=True)
raise e
if block_output.output_parameter:
outputs_with_loop_values.append(
{
"loop_value": loop_over_value,
"output_parameter": self.loop_block.output_parameter,
"output_value": workflow_run_context.get_value(self.loop_block.output_parameter.key),
"output_parameter": block_output.output_parameter,
"output_value": workflow_run_context.get_value(block_output.output_parameter.key),
}
)
# If all block outputs are successful, the loop is successful
success = all([block_output.success for block_output in block_outputs])
if self.output_parameter:
await workflow_run_context.register_output_parameter_value_post_execution(
parameter=self.output_parameter,
@@ -320,9 +365,11 @@ class ForLoopBlock(Block):
output_parameter_id=self.output_parameter.output_parameter_id,
value=outputs_with_loop_values,
)
return self.output_parameter
return BlockResult(
success=success, output_parameter=self.output_parameter, output_parameter_value=outputs_with_loop_values
)
return None
return BlockResult(success=success)
class CodeBlock(Block):
@@ -333,10 +380,11 @@ class CodeBlock(Block):
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
return self.parameters
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
# get workflow run context
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
# get all parameters into a dictionary
@@ -362,9 +410,9 @@ class CodeBlock(Block):
output_parameter_id=self.output_parameter.output_parameter_id,
value=result,
)
return self.output_parameter
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=result)
return None
return BlockResult(success=True)
class TextPromptBlock(Block):
@@ -377,6 +425,7 @@ class TextPromptBlock(Block):
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
return self.parameters
@@ -406,7 +455,7 @@ class TextPromptBlock(Block):
LOG.info("TextPromptBlock: Received response from LLM", response=response)
return response
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
# get workflow run context
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
# get all parameters into a dictionary
@@ -430,9 +479,9 @@ class TextPromptBlock(Block):
output_parameter_id=self.output_parameter.output_parameter_id,
value=response,
)
return self.output_parameter
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=response)
return None
return BlockResult(success=True)
class DownloadToS3Block(Block):
@@ -442,7 +491,13 @@ class DownloadToS3Block(Block):
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
if self.url and workflow_run_context.has_parameter(self.url):
return [workflow_run_context.get_parameter(self.url)]
return []
async def _upload_file_to_s3(self, uri: str, file_path: str) -> None:
@@ -453,7 +508,7 @@ class DownloadToS3Block(Block):
# Clean up the temporary file since it's created with delete=False
os.unlink(file_path)
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
# get workflow run context
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
# get all parameters into a dictionary
@@ -493,10 +548,82 @@ class DownloadToS3Block(Block):
output_parameter_id=self.output_parameter.output_parameter_id,
value=uri,
)
return self.output_parameter
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=uri)
LOG.info("DownloadToS3Block: No output parameter defined, returning None")
return None
return BlockResult(success=True)
class UploadToS3Block(Block):
block_type: Literal[BlockType.UPLOAD_TO_S3] = BlockType.UPLOAD_TO_S3
# TODO (kerem): A directory upload is supported but we should also support a list of files
path: str | None = None
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
if self.path and workflow_run_context.has_parameter(self.path):
return [workflow_run_context.get_parameter(self.path)]
return []
@staticmethod
def _get_s3_uri(workflow_run_id: str, path: str) -> str:
s3_bucket = SettingsManager.get_settings().AWS_S3_BUCKET_UPLOADS
s3_key = f"{SettingsManager.get_settings().ENV}/{workflow_run_id}/{uuid.uuid4()}_{Path(path).name}"
return f"s3://{s3_bucket}/{s3_key}"
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
# get workflow run context
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
# get all parameters into a dictionary
if self.path and workflow_run_context.has_parameter(self.path) and workflow_run_context.has_value(self.path):
file_path_parameter_value = workflow_run_context.get_value(self.path)
if file_path_parameter_value:
LOG.info(
"UploadToS3Block: File path is parameterized, using parameter value",
file_path_parameter_value=file_path_parameter_value,
file_path_parameter_key=self.path,
)
self.path = file_path_parameter_value
# if the path is WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY, use the download directory for the workflow run
elif self.path == SettingsManager.get_settings().WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY:
self.path = str(get_path_for_workflow_download_directory(workflow_run_id).absolute())
if not self.path or not os.path.exists(self.path):
raise FileNotFoundError(f"UploadToS3Block: File not found at path: {self.path}")
try:
client = self.get_async_aws_client()
# is the file path a file or a directory?
if os.path.isdir(self.path):
# get all files in the directory, if there are more than 10 files, we will not upload them
files = os.listdir(self.path)
if len(files) > 10:
raise ValueError("Too many files in the directory, not uploading")
for file in files:
# if the file is a directory, we will not upload it
if os.path.isdir(os.path.join(self.path, file)):
LOG.warning("UploadToS3Block: Skipping directory", file=file)
continue
file_path = os.path.join(self.path, file)
await client.upload_file_from_path(
uri=self._get_s3_uri(workflow_run_id, file_path), file_path=file_path
)
else:
await client.upload_file_from_path(
uri=self._get_s3_uri(workflow_run_id, self.path), file_path=self.path
)
except Exception as e:
LOG.exception("UploadToS3Block: Failed to upload file to S3", file_path=self.path, exc_info=True)
raise e
LOG.info("UploadToS3Block: File(s) uploaded to S3", file_path=self.path)
return BlockResult(success=True)
class SendEmailBlock(Block):
@@ -515,8 +642,23 @@ class SendEmailBlock(Block):
def get_all_parameters(
self,
workflow_run_id: str,
) -> list[PARAMETER_TYPE]:
return [self.smtp_host, self.smtp_port, self.smtp_username, self.smtp_password]
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
parameters = [self.smtp_host, self.smtp_port, self.smtp_username, self.smtp_password]
if self.file_attachments:
for file_path in self.file_attachments:
if workflow_run_context.has_parameter(file_path):
parameters.append(workflow_run_context.get_parameter(file_path))
if self.subject and workflow_run_context.has_parameter(self.subject):
parameters.append(workflow_run_context.get_parameter(self.subject))
if self.body and workflow_run_context.has_parameter(self.body):
parameters.append(workflow_run_context.get_parameter(self.body))
return parameters
def _decrypt_smtp_parameters(self, workflow_run_context: WorkflowRunContext) -> tuple[str, int, str, str]:
obfuscated_smtp_host_value = workflow_run_context.get_value(self.smtp_host.key)
@@ -545,21 +687,44 @@ class SendEmailBlock(Block):
return smtp_host_value, smtp_port_value, smtp_username_value, smtp_password_value
def _get_file_paths(self, workflow_run_context: WorkflowRunContext) -> list[str]:
def _get_file_paths(self, workflow_run_context: WorkflowRunContext, workflow_run_id: str) -> list[str]:
file_paths = []
for file_path in self.file_attachments:
if not workflow_run_context.has_parameter(file_path):
file_paths.append(file_path)
continue
for path in self.file_attachments:
# if the file path is a parameter, get the value from the workflow run context first
if workflow_run_context.has_parameter(path):
file_path_parameter_value = workflow_run_context.get_value(path)
# if the file path is a secret, get the original secret value from the workflow run context
file_path_parameter_secret_value = workflow_run_context.get_original_secret_value_or_none(
file_path_parameter_value
)
if file_path_parameter_secret_value:
path = file_path_parameter_secret_value
else:
path = file_path_parameter_value
file_path_parameter_value = workflow_run_context.get_value(file_path)
file_path_parameter_secret_value = workflow_run_context.get_original_secret_value_or_none(
file_path_parameter_value
)
if file_path_parameter_secret_value:
file_paths.append(file_path_parameter_secret_value)
if path == SettingsManager.get_settings().WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY:
# if the path is WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY, use download directory for the workflow run
path = str(get_path_for_workflow_download_directory(workflow_run_id).absolute())
LOG.info(
"SendEmailBlock: Using download directory for the workflow run",
workflow_run_id=workflow_run_id,
file_path=path,
)
# if the file path is a directory, add all files in the directory, skip directories, limit to 10 files
if os.path.exists(path) and os.path.isdir(path):
if len(os.listdir(path)) > 10:
LOG.warning("SendEmailBlock: Too many files in the directory, not attaching to email")
continue
for file in os.listdir(path):
if os.path.isdir(os.path.join(path, file)):
LOG.warning("SendEmailBlock: Skipping directory", file=file)
continue
file_path = os.path.join(path, file)
file_paths.append(file_path)
else:
file_paths.append(file_path_parameter_value)
# covers the case where the file path is a single file, a url, or an S3 uri
file_paths.append(path)
return file_paths
@@ -577,9 +742,14 @@ class SendEmailBlock(Block):
msg["Subject"] = self.subject + f" - Workflow Run ID: {workflow_run_id}"
msg["To"] = ", ".join(self.recipients)
msg["From"] = self.sender
msg.set_content(self.body)
if self.body and workflow_run_context.has_parameter(self.body) and workflow_run_context.has_value(self.body):
# We're purposely not decrypting the body parameter value here because we don't want to expose secrets
body_parameter_value = workflow_run_context.get_value(self.body)
msg.set_content(body_parameter_value)
else:
msg.set_content(self.body)
for filename in self._get_file_paths(workflow_run_context):
for filename in self._get_file_paths(workflow_run_context, workflow_run_id):
path = None
try:
if filename.startswith("s3://"):
@@ -587,7 +757,7 @@ class SendEmailBlock(Block):
elif filename.startswith("http://") or filename.startswith("https://"):
path = await download_file(filename)
else:
LOG.error("SendEmailBlock: Looking for file locally", filename=filename)
LOG.info("SendEmailBlock: Looking for file locally", filename=filename)
if not os.path.exists(filename):
raise FileNotFoundError(f"File not found: {filename}")
if not os.path.isfile(filename):
@@ -635,7 +805,7 @@ class SendEmailBlock(Block):
return msg
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
smtp_host_value, smtp_port_value, smtp_username_value, smtp_password_value = self._decrypt_smtp_parameters(
workflow_run_context
@@ -652,47 +822,43 @@ class SendEmailBlock(Block):
smtp_host.send_message(message)
LOG.info("SendEmailBlock: Email sent")
except Exception as e:
LOG.error("SendEmailBlock: Failed to send email", error=str(e))
LOG.error("SendEmailBlock: Failed to send email", exc_info=True)
if self.output_parameter:
result_dict = {"success": False, "error": str(e)}
await workflow_run_context.register_output_parameter_value_post_execution(
parameter=self.output_parameter,
value={
"success": False,
"error": str(e),
},
value=result_dict,
)
await app.DATABASE.create_workflow_run_output_parameter(
workflow_run_id=workflow_run_id,
output_parameter_id=self.output_parameter.output_parameter_id,
value={
"success": False,
"error": str(e),
},
value=result_dict,
)
return BlockResult(
success=False, output_parameter=self.output_parameter, output_parameter_value=result_dict
)
return self.output_parameter
raise e
finally:
if smtp_host:
smtp_host.quit()
result_dict = {"success": True}
if self.output_parameter:
await workflow_run_context.register_output_parameter_value_post_execution(
parameter=self.output_parameter,
value={
"success": True,
},
value=result_dict,
)
await app.DATABASE.create_workflow_run_output_parameter(
workflow_run_id=workflow_run_id,
output_parameter_id=self.output_parameter.output_parameter_id,
value={
"success": True,
},
value=result_dict,
)
return self.output_parameter
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=result_dict)
return None
return BlockResult(success=True)
BlockSubclasses = Union[ForLoopBlock, TaskBlock, CodeBlock, TextPromptBlock, DownloadToS3Block, SendEmailBlock]
BlockSubclasses = Union[
ForLoopBlock, TaskBlock, CodeBlock, TextPromptBlock, DownloadToS3Block, UploadToS3Block, SendEmailBlock
]
BlockTypeVar = Annotated[BlockSubclasses, Field(discriminator="block_type")]

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel
from skyvern.forge.sdk.schemas.tasks import ProxyLocation
from skyvern.forge.sdk.workflow.exceptions import WorkflowDefinitionHasDuplicateBlockLabels
from skyvern.forge.sdk.workflow.models.block import BlockTypeVar
from skyvern.forge.sdk.workflow.models.block import BlockResult, BlockTypeVar
class WorkflowRequestBody(BaseModel):
@@ -93,3 +93,4 @@ class WorkflowRunStatusResponse(BaseModel):
screenshot_urls: list[str] | None = None
recording_url: str | None = None
payload: dict[str, Any] | None = None
output: BlockResult | None = None

View File

@@ -132,6 +132,12 @@ class DownloadToS3BlockYAML(BlockYAML):
url: str
class UploadToS3BlockYAML(BlockYAML):
block_type: Literal[BlockType.UPLOAD_TO_S3] = BlockType.UPLOAD_TO_S3 # type: ignore
path: str | None = None
class SendEmailBlockYAML(BlockYAML):
# There is a mypy bug with Literal. Without the type: ignore, mypy will raise an error:
# Parameter 1 of Literal[...] cannot be of type "Any"
@@ -160,7 +166,13 @@ PARAMETER_YAML_SUBCLASSES = (
PARAMETER_YAML_TYPES = Annotated[PARAMETER_YAML_SUBCLASSES, Field(discriminator="parameter_type")]
BLOCK_YAML_SUBCLASSES = (
TaskBlockYAML | ForLoopBlockYAML | CodeBlockYAML | TextPromptBlockYAML | DownloadToS3BlockYAML | SendEmailBlockYAML
TaskBlockYAML
| ForLoopBlockYAML
| CodeBlockYAML
| TextPromptBlockYAML
| DownloadToS3BlockYAML
| UploadToS3BlockYAML
| SendEmailBlockYAML
)
BLOCK_YAML_TYPES = Annotated[BLOCK_YAML_SUBCLASSES, Field(discriminator="block_type")]

View File

@@ -22,6 +22,7 @@ from skyvern.forge.sdk.models import Step
from skyvern.forge.sdk.schemas.tasks import Task, TaskStatus
from skyvern.forge.sdk.workflow.exceptions import WorkflowDefinitionHasDuplicateParameterKeys
from skyvern.forge.sdk.workflow.models.block import (
BlockResult,
BlockType,
BlockTypeVar,
CodeBlock,
@@ -30,6 +31,7 @@ from skyvern.forge.sdk.workflow.models.block import (
SendEmailBlock,
TaskBlock,
TextPromptBlock,
UploadToS3Block,
)
from skyvern.forge.sdk.workflow.models.parameter import (
AWSSecretParameter,
@@ -150,9 +152,10 @@ class WorkflowService:
)
# Execute workflow blocks
blocks = workflow.workflow_definition.blocks
try:
for block_idx, block in enumerate(blocks):
parameters = block.get_all_parameters()
block_result = None
for block_idx, block in enumerate(blocks):
try:
parameters = block.get_all_parameters(workflow_run_id)
await app.WORKFLOW_CONTEXT_MANAGER.register_block_parameters_for_workflow_run(
workflow_run_id, parameters
)
@@ -162,40 +165,36 @@ class WorkflowService:
workflow_run_id=workflow_run.workflow_run_id,
block_idx=block_idx,
)
await block.execute(workflow_run_id=workflow_run_id)
except Exception:
LOG.exception(
f"Error while executing workflow run {workflow_run.workflow_run_id}",
workflow_run_id=workflow_run.workflow_run_id,
exc_info=True,
)
block_result = await block.execute_safe(workflow_run_id=workflow_run_id)
if not block_result.success:
LOG.error(
f"Block with type {block.block_type} at index {block_idx} failed for workflow run {workflow_run_id}",
block_type=block.block_type,
workflow_run_id=workflow_run.workflow_run_id,
block_idx=block_idx,
block_result=block_result,
)
await self.mark_workflow_run_as_failed(workflow_run_id=workflow_run.workflow_run_id)
break
tasks = await self.get_tasks_by_workflow_run_id(workflow_run.workflow_run_id)
if tasks:
workflow_run = await self.handle_workflow_status(workflow_run=workflow_run, tasks=tasks)
else:
# Check if the workflow run has any workflow run output parameters
# if it does, mark the workflow run as completed, else mark it as failed
workflow_run_output_parameters = await self.get_workflow_run_output_parameters(
workflow_run_id=workflow_run.workflow_run_id
)
if workflow_run_output_parameters:
LOG.info(
f"Workflow run {workflow_run.workflow_run_id} has output parameters, marking as completed",
workflow_run_id=workflow_run.workflow_run_id,
)
await self.mark_workflow_run_as_completed(workflow_run_id=workflow_run.workflow_run_id)
else:
LOG.error(
f"Workflow run {workflow_run.workflow_run_id} has no tasks or output parameters, marking as failed",
except Exception as e:
LOG.exception(
f"Error while executing workflow run {workflow_run.workflow_run_id}",
workflow_run_id=workflow_run.workflow_run_id,
exc_info=True,
)
await self.mark_workflow_run_as_failed(workflow_run_id=workflow_run.workflow_run_id)
raise e
tasks = await self.get_tasks_by_workflow_run_id(workflow_run.workflow_run_id)
await self.mark_workflow_run_as_completed(workflow_run_id=workflow_run.workflow_run_id)
await self.send_workflow_response(
workflow=workflow,
workflow_run=workflow_run,
tasks=tasks,
# TODO: We don't persist the block result for now, but we should in the case the users want to get it later
last_block_result=block_result,
api_key=api_key,
)
return workflow_run
@@ -430,7 +429,7 @@ class WorkflowService:
return await app.DATABASE.get_tasks_by_workflow_run_id(workflow_run_id=workflow_run_id)
async def build_workflow_run_status_response(
self, workflow_id: str, workflow_run_id: str, organization_id: str
self, workflow_id: str, workflow_run_id: str, last_block_result: BlockResult | None, organization_id: str
) -> WorkflowRunStatusResponse:
workflow = await self.get_workflow(workflow_id=workflow_id)
if workflow is None:
@@ -499,6 +498,7 @@ class WorkflowService:
screenshot_urls=screenshot_urls,
recording_url=recording_url,
payload=payload,
output=last_block_result,
)
async def send_workflow_response(
@@ -506,6 +506,7 @@ class WorkflowService:
workflow: Workflow,
workflow_run: WorkflowRun,
tasks: list[Task],
last_block_result: BlockResult | None,
api_key: str | None = None,
close_browser_on_completion: bool = True,
) -> None:
@@ -523,6 +524,7 @@ class WorkflowService:
workflow_run_status_response = await self.build_workflow_run_status_response(
workflow_id=workflow.workflow_id,
workflow_run_id=workflow_run.workflow_run_id,
last_block_result=last_block_result,
organization_id=workflow.organization_id,
)
LOG.info("Built workflow run status response", workflow_run_status_response=workflow_run_status_response)
@@ -772,6 +774,12 @@ class WorkflowService:
output_parameter=output_parameter,
url=block_yaml.url,
)
elif block_yaml.block_type == BlockType.UPLOAD_TO_S3:
return UploadToS3Block(
label=block_yaml.label,
output_parameter=output_parameter,
path=block_yaml.path,
)
elif block_yaml.block_type == BlockType.SEND_EMAIL:
return SendEmailBlock(
label=block_yaml.label,

View File

@@ -14,6 +14,7 @@ class ActionType(StrEnum):
CLICK = "click"
INPUT_TEXT = "input_text"
UPLOAD_FILE = "upload_file"
DOWNLOAD_FILE = "download_file"
SELECT_OPTION = "select_option"
CHECKBOX = "checkbox"
WAIT = "wait"
@@ -69,6 +70,14 @@ class UploadFileAction(WebAction):
return f"UploadFileAction(element_id={self.element_id}, file={self.file_url}, is_upload_file_tag={self.is_upload_file_tag})"
class DownloadFileAction(WebAction):
action_type: ActionType = ActionType.DOWNLOAD_FILE
file_name: str
def __repr__(self) -> str:
return f"DownloadFileAction(element_id={self.element_id}, file_name={self.file_name})"
class NullAction(Action):
action_type: ActionType = ActionType.NULL_ACTION
@@ -149,6 +158,10 @@ def parse_actions(task: Task, json_response: List[Dict[str, Any]]) -> List[Actio
# TODO: see if the element is a file input element. if it's not, convert this action into a click action
actions.append(UploadFileAction(element_id=element_id, file_url=action["file_url"], reasoning=reasoning))
elif action_type == ActionType.DOWNLOAD_FILE:
actions.append(
DownloadFileAction(element_id=element_id, file_name=action["file_name"], reasoning=reasoning)
)
elif action_type == ActionType.SELECT_OPTION:
actions.append(
SelectOptionAction(
@@ -166,22 +179,13 @@ def parse_actions(task: Task, json_response: List[Dict[str, Any]]) -> List[Actio
elif action_type == ActionType.WAIT:
actions.append(WaitAction(reasoning=reasoning))
elif action_type == ActionType.COMPLETE:
if actions:
LOG.info(
"Navigation goal achieved, creating complete action and discarding all other actions except "
"complete action",
task_id=task.task_id,
nav_goal=task.navigation_goal,
actions=actions,
llm_response=json_response,
)
return [
actions.append(
CompleteAction(
reasoning=reasoning,
data_extraction_goal=task.data_extraction_goal,
errors=action["errors"] if "errors" in action else [],
)
]
)
elif action_type == "null":
actions.append(NullAction(reasoning=reasoning))
elif action_type == ActionType.SOLVE_CAPTCHA:
@@ -210,6 +214,7 @@ ActionTypeUnion = (
ClickAction
| InputTextAction
| UploadFileAction
| DownloadFileAction
| SelectOptionAction
| CheckboxAction
| WaitAction

View File

@@ -1,11 +1,14 @@
import asyncio
import json
import os
import re
import uuid
from typing import Any, Awaitable, Callable, List
import structlog
from playwright.async_api import Locator, Page
from skyvern.constants import SKYVERN_DIR
from skyvern.exceptions import ImaginaryFileUrl, MissingElement, MissingFileUrl, MultipleElementsFound
from skyvern.forge import app
from skyvern.forge.prompts import prompt_engine
@@ -154,6 +157,34 @@ async def handle_upload_file_action(
)
async def handle_download_file_action(
action: actions.DownloadFileAction, page: Page, scraped_page: ScrapedPage, task: Task, step: Step
) -> list[ActionResult]:
xpath = await validate_actions_in_dom(action, page, scraped_page)
file_name = f"{action.file_name or uuid.uuid4()}"
full_file_path = f"{SKYVERN_DIR}/downloads/{task.workflow_run_id or task.task_id}/{file_name}"
try:
# Start waiting for the download
async with page.expect_download() as download_info:
await asyncio.sleep(0.3)
await page.click(f"xpath={xpath}", timeout=SettingsManager.get_settings().BROWSER_ACTION_TIMEOUT_MS)
download = await download_info.value
# Create download folders if they don't exist
download_folder = f"{SKYVERN_DIR}/downloads/{task.workflow_run_id or task.task_id}"
os.makedirs(download_folder, exist_ok=True)
# Wait for the download process to complete and save the downloaded file
await download.save_as(full_file_path)
except Exception as e:
LOG.exception(
"DownloadFileAction: Failed to download file", action=action, full_file_path=full_file_path, exc_info=True
)
return [ActionFailure(e)]
return [ActionSuccess(data={"file_path": full_file_path})]
async def handle_null_action(
action: actions.NullAction, page: Page, scraped_page: ScrapedPage, task: Task, step: Step
) -> list[ActionResult]:
@@ -348,6 +379,7 @@ ActionHandler.register_action_type(ActionType.SOLVE_CAPTCHA, handle_solve_captch
ActionHandler.register_action_type(ActionType.CLICK, handle_click_action)
ActionHandler.register_action_type(ActionType.INPUT_TEXT, handle_input_text_action)
ActionHandler.register_action_type(ActionType.UPLOAD_FILE, handle_upload_file_action)
ActionHandler.register_action_type(ActionType.DOWNLOAD_FILE, handle_download_file_action)
ActionHandler.register_action_type(ActionType.NULL_ACTION, handle_null_action)
ActionHandler.register_action_type(ActionType.SELECT_OPTION, handle_select_option_action)
ActionHandler.register_action_type(ActionType.WAIT, handle_wait_action)

View File

@@ -169,7 +169,12 @@ async def scrape_web_unsafe(
scroll_y_px_old = -1.0
scroll_y_px = await scroll_to_top(page, drow_boxes=True)
# Checking max number of screenshots to prevent infinite loop
while scroll_y_px_old != scroll_y_px and len(screenshots) < SettingsManager.get_settings().MAX_NUM_SCREENSHOTS:
# We are checking the difference between the old and new scroll_y_px to determine if we have reached the end of the
# page. If the difference is less than 25, we assume we have reached the end of the page.
while (
abs(scroll_y_px_old - scroll_y_px) > 25
and len(screenshots) < SettingsManager.get_settings().MAX_NUM_SCREENSHOTS
):
screenshot = await browser_state.take_screenshot(full_page=False)
screenshots.append(screenshot)
scroll_y_px_old = scroll_y_px