diff --git a/skyvern-frontend/src/routes/workflows/editor/helpContent.ts b/skyvern-frontend/src/routes/workflows/editor/helpContent.ts index c1eb9f9a..084323f1 100644 --- a/skyvern-frontend/src/routes/workflows/editor/helpContent.ts +++ b/skyvern-frontend/src/routes/workflows/editor/helpContent.ts @@ -72,7 +72,7 @@ export const helpTooltips = { loop: { ...baseHelpTooltipContent, loopValue: - "Define this parameterized field with a parameter key to let Skyvern know the core value you're iterating over. Use {{ current_value }} elsewhere in the loop to get the current value for a given iteration.", + "Define the values to iterate over. Use a parameter reference or natural language (e.g., 'Extract links of the top 2 posts'). Natural language automatically creates an extraction block that generates a list of string values. Use {{ current_value }} in the loop to get the current iteration value.", }, sendEmail: { ...baseHelpTooltipContent, diff --git a/skyvern/forge/prompts/skyvern/extraction_prompt_for_nat_language_loops.j2 b/skyvern/forge/prompts/skyvern/extraction_prompt_for_nat_language_loops.j2 new file mode 100644 index 00000000..4c4f41ca --- /dev/null +++ b/skyvern/forge/prompts/skyvern/extraction_prompt_for_nat_language_loops.j2 @@ -0,0 +1,12 @@ +Analyze the current webpage and extract information based on this request: {{ natural_language_prompt }} + +You need to identify what values should be iterated over (loop_values). Each value should be the primary data that will be used in each loop iteration. + +For example: +- If the request is "go to each product page", extract product URLs as strings +- If the request is "extract the authors of the top 4 posts", extract author names as strings +- If the request is "summarize the text of each article", extract article URLs as strings +- If the request is "download each file", extract file URLs as strings +- If the request is "check if these articles are AI-related", extract article titles as strings + +Return the results in the specified schema format with loop_values containing an array of strings, where each string is the primary value to be used in the loop iteration. \ No newline at end of file diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py index 2bfe8ed3..a2386c70 100644 --- a/skyvern/forge/sdk/workflow/models/block.py +++ b/skyvern/forge/sdk/workflow/models/block.py @@ -6,11 +6,14 @@ import asyncio import csv import json import os +import random import smtplib +import string import textwrap import uuid from collections import defaultdict from dataclasses import dataclass +from datetime import datetime from email.message import EmailMessage from enum import StrEnum from pathlib import Path @@ -71,6 +74,7 @@ from skyvern.forge.sdk.workflow.models.parameter import ( AWSSecretParameter, ContextParameter, OutputParameter, + ParameterType, WorkflowParameter, ) from skyvern.schemas.runs import RunEngine @@ -82,6 +86,11 @@ LOG = structlog.get_logger() jinja_sandbox_env = SandboxedEnvironment() +def _generate_random_string(length: int = 8) -> str: + """Generate a random string for unique identifiers.""" + return "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + class BlockType(StrEnum): TASK = "task" TaskV2 = "task_v2" @@ -892,6 +901,7 @@ class ForLoopBlock(Block): def get_loop_block_context_parameters(self, workflow_run_id: str, loop_data: Any) -> list[ContextParameter]: context_parameters = [] + for loop_block in self.loop_blocks: # todo: handle the case where the loop_block is a ForLoopBlock @@ -922,17 +932,116 @@ class ForLoopBlock(Block): return context_parameters - def get_loop_over_parameter_values(self, workflow_run_context: WorkflowRunContext) -> list[Any]: + async def get_loop_over_parameter_values( + self, + workflow_run_context: WorkflowRunContext, + workflow_run_id: str, + workflow_run_block_id: str, + organization_id: str | None = None, + ) -> list[Any]: # parse the value from self.loop_variable_reference and then from self.loop_over if self.loop_variable_reference: - value_template = f"{{{{ {self.loop_variable_reference.strip(' {}')} | tojson }}}}" - try: - value_json = self.format_block_parameter_template_from_workflow_run_context( - value_template, workflow_run_context - ) - except Exception as e: - raise FailedToFormatJinjaStyleParameter(value_template, str(e)) - parameter_value = json.loads(value_json) + LOG.debug("Processing loop variable reference", loop_variable_reference=self.loop_variable_reference) + + # Check if this looks like a parameter path (contains dots and/or _output) + is_likely_parameter_path = "extracted_information." in self.loop_variable_reference + + # Try parsing as Jinja template + parameter_value = self.try_parse_jinja_template(workflow_run_context) + + if parameter_value is None and not is_likely_parameter_path: + try: + # Create and execute extraction block using the current block's workflow_id + extraction_block = self._create_initial_extraction_block(self.loop_variable_reference) + + LOG.info( + "Processing natural language loop input", + prompt=self.loop_variable_reference, + extraction_goal=extraction_block.data_extraction_goal, + ) + + extraction_result = await extraction_block.execute( + workflow_run_id=workflow_run_id, + workflow_run_block_id=workflow_run_block_id, + organization_id=organization_id, + ) + + if not extraction_result.success: + LOG.error("Extraction block failed", failure_reason=extraction_result.failure_reason) + raise ValueError(f"Extraction block failed: {extraction_result.failure_reason}") + + LOG.debug("Extraction block succeeded", output=extraction_result.output_parameter_value) + + # Store the extraction result in the workflow context + await extraction_block.record_output_parameter_value( + workflow_run_context=workflow_run_context, + workflow_run_id=workflow_run_id, + value=extraction_result.output_parameter_value, + ) + + # Get the extracted information + if not isinstance(extraction_result.output_parameter_value, dict): + LOG.error( + "Extraction result output_parameter_value is not a dict", + output_parameter_value=extraction_result.output_parameter_value, + ) + raise ValueError("Extraction result output_parameter_value is not a dictionary") + + if "extracted_information" not in extraction_result.output_parameter_value: + LOG.error( + "Extraction result missing extracted_information key", + output_parameter_value=extraction_result.output_parameter_value, + ) + raise ValueError("Extraction result missing extracted_information key") + + extracted_info = extraction_result.output_parameter_value["extracted_information"] + + # Handle different possible structures of extracted_info + if isinstance(extracted_info, list): + # If it's a list, take the first element + if len(extracted_info) > 0: + extracted_info = extracted_info[0] + else: + LOG.error("Extracted information list is empty") + raise ValueError("Extracted information list is empty") + + # At this point, extracted_info should be a dict + if not isinstance(extracted_info, dict): + LOG.error("Invalid extraction result structure - not a dict", extracted_info=extracted_info) + raise ValueError("Extraction result is not a dictionary") + + # Extract the loop values + loop_values = extracted_info.get("loop_values", []) + + if not loop_values: + LOG.error("No loop values found in extraction result") + raise ValueError("No loop values found in extraction result") + + LOG.info("Extracted loop values", count=len(loop_values), values=loop_values) + + # Update the loop variable reference to point to the extracted loop values + # We'll use a temporary key that we can reference + temp_key = f"extracted_loop_values_{_generate_random_string()}" + workflow_run_context.set_value(temp_key, loop_values) + self.loop_variable_reference = temp_key + + # Now try parsing again with the updated reference + parameter_value = self.try_parse_jinja_template(workflow_run_context) + + except Exception as e: + LOG.error("Failed to process natural language loop input", error=str(e)) + raise FailedToFormatJinjaStyleParameter(self.loop_variable_reference, str(e)) + + if parameter_value is None: + # Fall back to the original Jinja template approach + value_template = f"{{{{ {self.loop_variable_reference.strip(' {}')} | tojson }}}}" + try: + value_json = self.format_block_parameter_template_from_workflow_run_context( + value_template, workflow_run_context + ) + except Exception as e: + raise FailedToFormatJinjaStyleParameter(value_template, str(e)) + parameter_value = json.loads(value_json) elif self.loop_over is not None: if isinstance(self.loop_over, WorkflowParameter): @@ -971,6 +1080,90 @@ class ForLoopBlock(Block): # TODO (kerem): Should we raise an error here? return [parameter_value] + def try_parse_jinja_template(self, workflow_run_context: WorkflowRunContext) -> Any | None: + """Try to parse the loop variable reference as a Jinja template.""" + try: + # Try the exact reference first + try: + if self.loop_variable_reference is None: + return None + value_template = f"{{{{ {self.loop_variable_reference.strip(' {}')} | tojson }}}}" + value_json = self.format_block_parameter_template_from_workflow_run_context( + value_template, workflow_run_context + ) + parameter_value = json.loads(value_json) + if parameter_value is not None: + return parameter_value + except Exception: + pass + + # If that fails, try common access patterns for extraction results + if self.loop_variable_reference is None: + return None + access_patterns = [ + f"{self.loop_variable_reference}.extracted_information", + f"{self.loop_variable_reference}.extracted_information.results", + f"{self.loop_variable_reference}.results", + ] + + for pattern in access_patterns: + try: + value_template = f"{{{{ {pattern.strip(' {}')} | tojson }}}}" + value_json = self.format_block_parameter_template_from_workflow_run_context( + value_template, workflow_run_context + ) + parameter_value = json.loads(value_json) + if parameter_value is not None: + return parameter_value + except Exception: + continue + + return None + except Exception: + return None + + def _create_initial_extraction_block(self, natural_language_prompt: str) -> ExtractionBlock: + """Create an extraction block to process natural language input.""" + + # Create a schema that only extracts loop values + data_schema = { + "type": "object", + "properties": { + "loop_values": { + "type": "array", + "description": "Array of values to iterate over. Each value should be the primary data needed for the loop blocks.", + "items": { + "type": "string", + "description": "The primary value to be used in the loop iteration (e.g., URL, text, identifier, etc.)", + }, + } + }, + } + + # Create extraction goal that includes the natural language prompt + extraction_goal = prompt_engine.load_prompt( + "extraction_prompt_for_nat_language_loops", natural_language_prompt=natural_language_prompt + ) + + # Create a temporary output parameter using the current block's workflow_id + + output_param = OutputParameter( + output_parameter_id=str(uuid.uuid4()), + key=f"natural_lang_extraction_{_generate_random_string()}", + workflow_id=self.output_parameter.workflow_id, + created_at=datetime.now(), + modified_at=datetime.now(), + parameter_type=ParameterType.OUTPUT, + description="Natural language extraction result", + ) + + return ExtractionBlock( + label=f"natural_lang_extraction_{_generate_random_string()}", + data_extraction_goal=extraction_goal, + data_schema=data_schema, + output_parameter=output_param, + ) + async def execute_loop_helper( self, workflow_run_id: str, @@ -985,6 +1178,7 @@ class ForLoopBlock(Block): current_block: BlockTypeVar | None = None for loop_idx, loop_over_value in enumerate(loop_over_values): + LOG.info("Starting loop iteration", loop_idx=loop_idx, loop_over_value=loop_over_value) 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) @@ -1015,6 +1209,14 @@ class ForLoopBlock(Block): if workflow_run_context.has_value(block_output.output_parameter.key) else None ) + + # Log the output value for debugging + if block_output.output_parameter.key.endswith("_output"): + LOG.debug("Block output", block_type=loop_block.block_type, output_value=output_value) + + # Log URL information for goto_url blocks + if loop_block.block_type == BlockType.GOTO_URL: + LOG.info("Goto URL block executed", url=loop_block.url, loop_idx=loop_idx) each_loop_output_values.append( { "loop_value": loop_over_value, @@ -1089,7 +1291,12 @@ class ForLoopBlock(Block): ) -> BlockResult: workflow_run_context = self.get_workflow_run_context(workflow_run_id) try: - loop_over_values = self.get_loop_over_parameter_values(workflow_run_context) + loop_over_values = await self.get_loop_over_parameter_values( + workflow_run_context=workflow_run_context, + workflow_run_id=workflow_run_id, + workflow_run_block_id=workflow_run_block_id, + organization_id=organization_id, + ) except Exception as e: return await self.build_block_result( success=False,