Pedro.loop without explicit extraction (#3051)

Co-authored-by: Shuchang Zheng <shu@skyvern.com>
This commit is contained in:
PHSB
2025-07-29 15:02:40 -07:00
committed by GitHub
parent c338c97ea7
commit 083d54f018
3 changed files with 230 additions and 11 deletions

View File

@@ -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,

View File

@@ -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.

View File

@@ -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,