support templating in scripts and support block_output (#3276)

This commit is contained in:
Shuchang Zheng
2025-08-22 11:24:09 -07:00
committed by GitHub
parent 2903291dc4
commit 9d307a2933
6 changed files with 181 additions and 73 deletions

View File

@@ -35,6 +35,7 @@ from skyvern.services.script_service import ( # noqa: E402
extract, # noqa: E402 extract, # noqa: E402
generate_text, # noqa: E402 generate_text, # noqa: E402
login, # noqa: E402 login, # noqa: E402
render_template, # noqa: E402
run_script, # noqa: E402 run_script, # noqa: E402
run_task, # noqa: E402 run_task, # noqa: E402
wait, # noqa: E402 wait, # noqa: E402
@@ -51,6 +52,7 @@ __all__ = [
"extract", "extract",
"generate_text", "generate_text",
"login", "login",
"render_template",
"run_script", "run_script",
"run_task", "run_task",
"setup", "setup",

View File

@@ -102,11 +102,24 @@ def _value(value: Any) -> cst.BaseExpression:
return cst.SimpleString(repr(str(value))) return cst.SimpleString(repr(str(value)))
def _prompt_value(prompt_text: str) -> cst.BaseExpression:
"""Create a prompt value with template rendering logic if needed."""
if "{{" in prompt_text and "}}" in prompt_text:
# Generate code for: render_template(prompt_text)
return cst.Call(
func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("render_template")),
args=[cst.Arg(value=_value(prompt_text))],
)
else:
# Return the prompt as a simple string value
return _value(prompt_text)
def _generate_text_call(text_value: str, intention: str, parameter_key: str) -> cst.BaseExpression: def _generate_text_call(text_value: str, intention: str, parameter_key: str) -> cst.BaseExpression:
"""Create a generate_text function call CST expression.""" """Create a generate_text function call CST expression."""
return cst.Await( return cst.Await(
expression=cst.Call( expression=cst.Call(
func=cst.Name("generate_text"), func=cst.Attribute(value=cst.Name("skyvern"), attr=cst.Name("generate_text")),
whitespace_before_args=cst.ParenthesizedWhitespace( whitespace_before_args=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(DOUBLE_INDENT), last_line=cst.SimpleWhitespace(DOUBLE_INDENT),
@@ -433,7 +446,7 @@ def _build_run_task_statement(block_title: str, block: dict[str, Any]) -> cst.Si
args = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(INDENT), last_line=cst.SimpleWhitespace(INDENT),
@@ -474,7 +487,7 @@ def _build_download_statement(block_title: str, block: dict[str, Any]) -> cst.Si
args = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(INDENT), last_line=cst.SimpleWhitespace(INDENT),
@@ -523,7 +536,7 @@ def _build_action_statement(block_title: str, block: dict[str, Any]) -> cst.Simp
args = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(INDENT), last_line=cst.SimpleWhitespace(INDENT),
@@ -572,7 +585,7 @@ def _build_login_statement(block_title: str, block: dict[str, Any]) -> cst.Simpl
), ),
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(INDENT), last_line=cst.SimpleWhitespace(INDENT),
@@ -654,7 +667,7 @@ def _build_navigate_statement(block_title: str, block: dict[str, Any]) -> cst.Si
args = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(INDENT), last_line=cst.SimpleWhitespace(INDENT),
@@ -760,7 +773,7 @@ def _build_validate_statement(block: dict[str, Any]) -> cst.SimpleStatementLine:
args = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
), ),
@@ -810,7 +823,7 @@ def _build_for_loop_statement(block_title: str, block: dict[str, Any]) -> cst.Si
args = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),
value=_value(block.get("navigation_goal", "")), value=_prompt_value(block.get("navigation_goal", "")),
whitespace_after_arg=cst.ParenthesizedWhitespace( whitespace_after_arg=cst.ParenthesizedWhitespace(
indent=True, indent=True,
last_line=cst.SimpleWhitespace(INDENT), last_line=cst.SimpleWhitespace(INDENT),
@@ -1065,7 +1078,6 @@ async def generate_workflow_script(
names=[ names=[
cst.ImportAlias(cst.Name("RunContext")), cst.ImportAlias(cst.Name("RunContext")),
cst.ImportAlias(cst.Name("SkyvernPage")), cst.ImportAlias(cst.Name("SkyvernPage")),
cst.ImportAlias(cst.Name("generate_text")),
], ],
) )
] ]

View File

@@ -40,36 +40,72 @@ async def transform_workflow_run_to_code_gen_input(workflow_run_id: str, organiz
raise ValueError(f"Workflow {run_request.workflow_id} not found") raise ValueError(f"Workflow {run_request.workflow_id} not found")
workflow_json = workflow.model_dump() workflow_json = workflow.model_dump()
# get the tasks # get the original workflow definition blocks (with templated information)
## first, get all the workflow run blocks workflow_definition_blocks = workflow.workflow_definition.blocks
# get workflow run blocks for task execution data
workflow_run_blocks = await app.DATABASE.get_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_id=workflow_run_id, organization_id=organization_id
) )
workflow_run_blocks.sort(key=lambda x: x.created_at) workflow_run_blocks.sort(key=lambda x: x.created_at)
# Create mapping from definition blocks by label for quick lookup
definition_blocks_by_label = {block.label: block for block in workflow_definition_blocks if block.label}
workflow_block_dump = [] workflow_block_dump = []
# Hydrate blocks with task data
# TODO: support task v2
actions_by_task = {} actions_by_task = {}
for block in workflow_run_blocks:
block_dump = block.model_dump() # Loop through workflow run blocks and match to original definition blocks by label
if block.block_type == BlockType.TaskV2: for run_block in workflow_run_blocks:
if run_block.block_type == BlockType.TaskV2:
raise ValueError("TaskV2 blocks are not supported yet") raise ValueError("TaskV2 blocks are not supported yet")
if block.block_type in SCRIPT_TASK_BLOCKS and block.task_id:
task = await app.DATABASE.get_task(task_id=block.task_id, organization_id=organization_id) # Find corresponding definition block by label to get templated information
if not task: definition_block = definition_blocks_by_label.get(run_block.label) if run_block.label else None
LOG.warning(f"Task {block.task_id} not found")
continue if definition_block:
block_dump.update(task.model_dump()) # Start with the original templated definition block
actions = await app.DATABASE.get_task_actions_hydrated( final_dump = definition_block.model_dump()
task_id=block.task_id, organization_id=organization_id else:
) # Fallback to run block data if no matching definition block found
action_dumps = [] final_dump = run_block.model_dump()
for action in actions: LOG.warning(f"No matching definition block found for run block with label: {run_block.label}")
action_dump = action.model_dump()
action_dump["xpath"] = action.get_xpath() # For task blocks, add execution data while preserving templated information
action_dumps.append(action_dump) if run_block.block_type in SCRIPT_TASK_BLOCKS and run_block.task_id:
actions_by_task[block.task_id] = action_dumps task = await app.DATABASE.get_task(task_id=run_block.task_id, organization_id=organization_id)
workflow_block_dump.append(block_dump) if task:
# Add task execution data but preserve original templated fields
task_dump = task.model_dump()
# Update with execution data, but keep templated values from definition
if definition_block:
final_dump.update({k: v for k, v in task_dump.items() if k not in final_dump})
else:
final_dump.update(task_dump)
# Add run block execution metadata
final_dump.update(
{
"task_id": run_block.task_id,
"status": run_block.status,
"output": run_block.output,
}
)
# Get task actions
actions = await app.DATABASE.get_task_actions_hydrated(
task_id=run_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[run_block.task_id] = action_dumps
else:
LOG.warning(f"Task {run_block.task_id} not found")
workflow_block_dump.append(final_dump)
return CodeGenInput( return CodeGenInput(
file_name=f"{workflow_run_id}.py", file_name=f"{workflow_run_id}.py",

View File

@@ -9,7 +9,7 @@ from skyvern.forge.sdk.schemas.files import FileInfo
from skyvern.forge.sdk.schemas.task_v2 import TaskV2 from skyvern.forge.sdk.schemas.task_v2 import TaskV2
from skyvern.forge.sdk.workflow.exceptions import WorkflowDefinitionHasDuplicateBlockLabels 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 BlockTypeVar
from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter
from skyvern.schemas.runs import ProxyLocation from skyvern.schemas.runs import ProxyLocation
from skyvern.schemas.workflows import WorkflowStatus from skyvern.schemas.workflows import WorkflowStatus
from skyvern.utils.url_validators import validate_url from skyvern.utils.url_validators import validate_url
@@ -83,6 +83,12 @@ class Workflow(BaseModel):
modified_at: datetime modified_at: datetime
deleted_at: datetime | None = None deleted_at: datetime | None = None
def get_output_parameter(self, label: str) -> OutputParameter | None:
for block in self.workflow_definition.blocks:
if block.label == label:
return block.output_parameter
return None
class WorkflowRunStatus(StrEnum): class WorkflowRunStatus(StrEnum):
created = "created" created = "created"

View File

@@ -277,24 +277,7 @@ class WorkflowService:
workflow_run = await self.get_workflow_run(workflow_run_id=workflow_run_id, organization_id=organization_id) workflow_run = await self.get_workflow_run(workflow_run_id=workflow_run_id, organization_id=organization_id)
workflow = await self.get_workflow_by_permanent_id(workflow_permanent_id=workflow_run.workflow_permanent_id) workflow = await self.get_workflow_by_permanent_id(workflow_permanent_id=workflow_run.workflow_permanent_id)
close_browser_on_completion = browser_session_id is None and not workflow_run.browser_address close_browser_on_completion = browser_session_id is None and not workflow_run.browser_address
skyvern_context.current()
# Check if there's a related workflow script that should be used instead
workflow_script = await self._get_workflow_script(workflow, workflow_run)
if workflow_script is not None:
LOG.info(
"Found related workflow script, running script instead of workflow",
workflow_run_id=workflow_run_id,
workflow_id=workflow.workflow_id,
organization_id=organization_id,
workflow_script_id=workflow_script.script_id,
)
return await self._execute_workflow_script(
script_id=workflow_script.script_id,
workflow=workflow,
workflow_run=workflow_run,
api_key=api_key,
organization=organization,
)
# Set workflow run status to running, create workflow run parameters # Set workflow run status to running, create workflow run parameters
workflow_run = await self.mark_workflow_run_as_running(workflow_run_id=workflow_run.workflow_run_id) workflow_run = await self.mark_workflow_run_as_running(workflow_run_id=workflow_run.workflow_run_id)
@@ -357,6 +340,24 @@ class WorkflowService:
) )
return workflow_run return workflow_run
# Check if there's a related workflow script that should be used instead
workflow_script = await self._get_workflow_script(workflow, workflow_run)
if workflow_script is not None:
LOG.info(
"Found related workflow script, running script instead of workflow",
workflow_run_id=workflow_run_id,
workflow_id=workflow.workflow_id,
organization_id=organization_id,
workflow_script_id=workflow_script.script_id,
)
return await self._execute_workflow_script(
script_id=workflow_script.script_id,
workflow=workflow,
workflow_run=workflow_run,
api_key=api_key,
organization=organization,
)
top_level_blocks = workflow.workflow_definition.blocks top_level_blocks = workflow.workflow_definition.blocks
all_blocks = get_all_blocks(top_level_blocks) all_blocks = get_all_blocks(top_level_blocks)
@@ -2350,9 +2351,6 @@ class WorkflowService:
""" """
try: try:
# Set workflow run status to running
workflow_run = await self.mark_workflow_run_as_running(workflow_run_id=workflow_run.workflow_run_id)
# Render the cache_key_value to find the right script # Render the cache_key_value to find the right script
parameter_tuples = await app.DATABASE.get_workflow_run_parameters( parameter_tuples = await app.DATABASE.get_workflow_run_parameters(
workflow_run_id=workflow_run.workflow_run_id workflow_run_id=workflow_run.workflow_run_id
@@ -2402,7 +2400,7 @@ class WorkflowService:
workflow_run: WorkflowRun, workflow_run: WorkflowRun,
) -> None: ) -> None:
cache_key = workflow.cache_key cache_key = workflow.cache_key
rendered_cache_key_value = "" rendered_cache_key_value = "default"
# 1) Build cache_key_value from workflow run parameters via jinja # 1) Build cache_key_value from workflow run parameters via jinja
if cache_key: if cache_key:
parameter_tuples = await app.DATABASE.get_workflow_run_parameters( parameter_tuples = await app.DATABASE.get_workflow_run_parameters(

View File

@@ -9,6 +9,7 @@ from typing import Any, cast
import structlog import structlog
from fastapi import BackgroundTasks, HTTPException from fastapi import BackgroundTasks, HTTPException
from jinja2.sandbox import SandboxedEnvironment
from skyvern.constants import GET_DOWNLOADED_FILES_TIMEOUT from skyvern.constants import GET_DOWNLOADED_FILES_TIMEOUT
from skyvern.core.script_generations.constants import SCRIPT_TASK_BLOCKS from skyvern.core.script_generations.constants import SCRIPT_TASK_BLOCKS
@@ -19,10 +20,11 @@ from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.core import skyvern_context from skyvern.forge.sdk.core import skyvern_context
from skyvern.forge.sdk.schemas.files import FileInfo from skyvern.forge.sdk.schemas.files import FileInfo
from skyvern.forge.sdk.schemas.tasks import TaskOutput, TaskStatus from skyvern.forge.sdk.schemas.tasks import TaskOutput, TaskStatus
from skyvern.forge.sdk.workflow.models.block import BlockStatus, BlockType
from skyvern.schemas.scripts import CreateScriptResponse, FileNode, ScriptFileCreate from skyvern.schemas.scripts import CreateScriptResponse, FileNode, ScriptFileCreate
from skyvern.schemas.workflows import BlockStatus, BlockType
LOG = structlog.get_logger(__name__) LOG = structlog.get_logger(__name__)
jinja_sandbox_env = SandboxedEnvironment()
async def build_file_tree( async def build_file_tree(
@@ -320,20 +322,34 @@ async def _create_workflow_block_run_and_task(
async def _record_output_parameter_value( async def _record_output_parameter_value(
workflow_run_id: str, workflow_run_id: str,
workflow_id: str,
organization_id: str,
output: dict[str, Any] | list | str | None, output: dict[str, Any] | list | str | None,
label: str | None = None,
) -> None: ) -> None:
if not label:
return
# TODO support this in the future # TODO support this in the future
# workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context(workflow_run_id) workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context(workflow_run_id)
# await workflow_run_context.register_output_parameter_value_post_execution( # get the workflow
# parameter=self.output_parameter, workflow = await app.DATABASE.get_workflow(workflow_id=workflow_id, organization_id=organization_id)
# value=value, if not workflow:
# ) return
# await app.DATABASE.create_or_update_workflow_run_output_parameter(
# workflow_run_id=workflow_run_id, # get the output_paramter
# output_parameter_id=self.output_parameter.output_parameter_id, output_parameter = workflow.get_output_parameter(label)
# value=value, if not output_parameter:
# ) return
return
await workflow_run_context.register_output_parameter_value_post_execution(
parameter=output_parameter,
value=output,
)
await app.DATABASE.create_or_update_workflow_run_output_parameter(
workflow_run_id=workflow_run_id,
output_parameter_id=output_parameter.output_parameter_id,
value=output,
)
async def _update_workflow_block( async def _update_workflow_block(
@@ -341,13 +357,14 @@ async def _update_workflow_block(
status: BlockStatus, status: BlockStatus,
task_id: str | None = None, task_id: str | None = None,
task_status: TaskStatus = TaskStatus.completed, task_status: TaskStatus = TaskStatus.completed,
label: str | None = None,
failure_reason: str | None = None, failure_reason: str | None = None,
output: dict[str, Any] | list | str | None = None, output: dict[str, Any] | list | str | None = None,
) -> None: ) -> None:
"""Update the status of a workflow run block.""" """Update the status of a workflow run block."""
try: try:
context = skyvern_context.current() context = skyvern_context.current()
if not context or not context.organization_id or not context.workflow_run_id: if not context or not context.organization_id or not context.workflow_run_id or not context.workflow_id:
return return
final_output = output final_output = output
if task_id: if task_id:
@@ -385,7 +402,13 @@ async def _update_workflow_block(
status=status, status=status,
failure_reason=failure_reason, failure_reason=failure_reason,
) )
await _record_output_parameter_value(context.workflow_run_id, final_output) await _record_output_parameter_value(
context.workflow_run_id,
context.workflow_id,
context.organization_id,
final_output,
label,
)
except Exception as e: except Exception as e:
LOG.warning( LOG.warning(
@@ -429,7 +452,9 @@ async def run_task(
# Update block status to completed if workflow block was created # Update block status to completed if workflow block was created
if workflow_run_block_id: if workflow_run_block_id:
await _update_workflow_block(workflow_run_block_id, BlockStatus.completed, task_id=task_id) await _update_workflow_block(
workflow_run_block_id, BlockStatus.completed, task_id=task_id, label=cache_key
)
except Exception as e: except Exception as e:
# TODO: fallback to AI run in case of error # TODO: fallback to AI run in case of error
@@ -440,6 +465,7 @@ async def run_task(
BlockStatus.failed, BlockStatus.failed,
task_id=task_id, task_id=task_id,
task_status=TaskStatus.failed, task_status=TaskStatus.failed,
label=cache_key,
failure_reason=str(e), failure_reason=str(e),
) )
raise raise
@@ -481,7 +507,9 @@ async def download(
# Update block status to completed if workflow block was created # Update block status to completed if workflow block was created
if workflow_run_block_id: if workflow_run_block_id:
await _update_workflow_block(workflow_run_block_id, BlockStatus.completed, task_id=task_id) await _update_workflow_block(
workflow_run_block_id, BlockStatus.completed, task_id=task_id, label=cache_key
)
except Exception as e: except Exception as e:
# Update block status to failed if workflow block was created # Update block status to failed if workflow block was created
@@ -491,6 +519,7 @@ async def download(
BlockStatus.failed, BlockStatus.failed,
task_id=task_id, task_id=task_id,
task_status=TaskStatus.failed, task_status=TaskStatus.failed,
label=cache_key,
failure_reason=str(e), failure_reason=str(e),
) )
raise raise
@@ -531,7 +560,9 @@ async def action(
# Update block status to completed if workflow block was created # Update block status to completed if workflow block was created
if workflow_run_block_id: if workflow_run_block_id:
await _update_workflow_block(workflow_run_block_id, BlockStatus.completed, task_id=task_id) await _update_workflow_block(
workflow_run_block_id, BlockStatus.completed, task_id=task_id, label=cache_key
)
except Exception as e: except Exception as e:
# Update block status to failed if workflow block was created # Update block status to failed if workflow block was created
@@ -541,6 +572,7 @@ async def action(
BlockStatus.failed, BlockStatus.failed,
task_id=task_id, task_id=task_id,
task_status=TaskStatus.failed, task_status=TaskStatus.failed,
label=cache_key,
failure_reason=str(e), failure_reason=str(e),
) )
raise raise
@@ -581,7 +613,9 @@ async def login(
# Update block status to completed if workflow block was created # Update block status to completed if workflow block was created
if workflow_run_block_id: if workflow_run_block_id:
await _update_workflow_block(workflow_run_block_id, BlockStatus.completed, task_id=task_id) await _update_workflow_block(
workflow_run_block_id, BlockStatus.completed, task_id=task_id, label=cache_key
)
except Exception as e: except Exception as e:
# Update block status to failed if workflow block was created # Update block status to failed if workflow block was created
@@ -591,6 +625,7 @@ async def login(
BlockStatus.failed, BlockStatus.failed,
task_id=task_id, task_id=task_id,
task_status=TaskStatus.failed, task_status=TaskStatus.failed,
label=cache_key,
failure_reason=str(e), failure_reason=str(e),
) )
raise raise
@@ -637,6 +672,7 @@ async def extract(
BlockStatus.completed, BlockStatus.completed,
task_id=task_id, task_id=task_id,
output=output, output=output,
label=cache_key,
) )
return output return output
except Exception as e: except Exception as e:
@@ -649,6 +685,7 @@ async def extract(
task_status=TaskStatus.failed, task_status=TaskStatus.failed,
failure_reason=str(e), failure_reason=str(e),
output=output, output=output,
label=cache_key,
) )
raise raise
finally: finally:
@@ -752,3 +789,20 @@ async def generate_text(
# If anything goes wrong, fall back to the original text # If anything goes wrong, fall back to the original text
pass pass
return new_text return new_text
def render_template(template: str, data: dict[str, Any] | None = None) -> str:
"""
Refer to Block.format_block_parameter_template_from_workflow_run_context
TODO: complete this function so that block code shares the same template rendering logic
"""
template_data = data or {}
jinja_template = jinja_sandbox_env.from_string(template)
context = skyvern_context.current()
if context and context.workflow_run_id:
workflow_run_id = context.workflow_run_id
workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context(workflow_run_id)
template_data.update(workflow_run_context.values)
return jinja_template.render(template_data)