mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-26 10:41:14 +00:00
98 lines
4.2 KiB
Python
98 lines
4.2 KiB
Python
"""Wrap copilot-v2-generated block intent fields (``navigation_goal``,
|
|
``complete_criterion``, ``terminate_criterion``) with the user's original
|
|
chat message as "big goal" context, mirroring the TaskV2 pattern that
|
|
applies ``MINI_GOAL_TEMPLATE`` at every mini-goal construction site.
|
|
|
|
Without this wrap:
|
|
- The Skyvern verifier (``complete_verify``) has no user-intent context
|
|
when a navigation block finishes on a confirmation surface.
|
|
- The validation-block prompt (``decisive-criterion-validate.j2``) sees
|
|
only a terse criterion (often a verbatim slice of the user prompt) and
|
|
reads it as a literal string to match.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from skyvern.constants import MINI_GOAL_TEMPLATE
|
|
from skyvern.utils.yaml_loader import safe_load_no_dates
|
|
|
|
# Block fields whose value expresses the LLM's "mini goal" — what it should
|
|
# do or what it should check for. Wrapped in MINI_GOAL_TEMPLATE alongside
|
|
# the user's chat message so the downstream LLMs can reason about intent.
|
|
# navigation_goal is carried by Task, Action, Navigation, Login, and
|
|
# FileDownload blocks; complete_criterion / terminate_criterion by Validation,
|
|
# Navigation, and Login blocks.
|
|
_WRAPPABLE_FIELDS: tuple[str, ...] = (
|
|
"navigation_goal",
|
|
"complete_criterion",
|
|
"terminate_criterion",
|
|
)
|
|
|
|
# The template's constant prefix — everything before the ``{mini_goal}``
|
|
# placeholder. Presence of this prefix in a wrapped field means it was
|
|
# wrapped on a prior invocation; used for idempotency so repeated tool
|
|
# calls don't stack wrappers. Deriving from the template (rather than a
|
|
# hard-coded substring) keeps idempotency intact if the template's wording
|
|
# changes.
|
|
_WRAPPED_PREFIX = MINI_GOAL_TEMPLATE.partition("{mini_goal}")[0]
|
|
|
|
|
|
def wrap_block_goals(workflow_yaml: str, user_message: str) -> str:
|
|
"""Return ``workflow_yaml`` with each block's ``navigation_goal``,
|
|
``complete_criterion``, and ``terminate_criterion`` wrapped via
|
|
:data:`skyvern.constants.MINI_GOAL_TEMPLATE`.
|
|
|
|
Blocks whose fields are missing, empty, or already wrapped are left
|
|
untouched. Recurses into ``ForLoopBlockYAML.loop_blocks``. No-ops when
|
|
``user_message`` is empty or the YAML is malformed (malformed input is
|
|
surfaced by the downstream ``_process_workflow_yaml`` call, same as
|
|
today).
|
|
"""
|
|
if not user_message:
|
|
return workflow_yaml
|
|
# Skip the parse+dump round-trip when the YAML can't contain any wrappable
|
|
# field. False positives (field name appearing inside a value) are harmless:
|
|
# we'd fall through to the full path and mutate nothing.
|
|
if not any(field in workflow_yaml for field in _WRAPPABLE_FIELDS):
|
|
return workflow_yaml
|
|
try:
|
|
parsed = safe_load_no_dates(workflow_yaml)
|
|
except yaml.YAMLError:
|
|
return workflow_yaml
|
|
if not isinstance(parsed, dict):
|
|
return workflow_yaml
|
|
definition = parsed.get("workflow_definition")
|
|
if not isinstance(definition, dict):
|
|
return workflow_yaml
|
|
blocks = definition.get("blocks")
|
|
if not isinstance(blocks, list):
|
|
return workflow_yaml
|
|
if not _wrap_blocks_in_place(blocks, user_message):
|
|
return workflow_yaml
|
|
# parse/mutate/dump: any YAML comments in workflow_yaml are stripped on re-serialize.
|
|
return yaml.safe_dump(parsed, sort_keys=False)
|
|
|
|
|
|
def _wrap_blocks_in_place(blocks: list[Any], user_message: str) -> bool:
|
|
"""Recursively wrap every field in :data:`_WRAPPABLE_FIELDS` on every
|
|
block in ``blocks``; returns ``True`` if at least one field was mutated."""
|
|
mutated = False
|
|
for block in blocks:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
for field_name in _WRAPPABLE_FIELDS:
|
|
value = block.get(field_name)
|
|
if isinstance(value, str) and value and _WRAPPED_PREFIX not in value:
|
|
block[field_name] = MINI_GOAL_TEMPLATE.format(
|
|
mini_goal=value,
|
|
main_goal=user_message,
|
|
)
|
|
mutated = True
|
|
loop_blocks = block.get("loop_blocks")
|
|
if isinstance(loop_blocks, list):
|
|
mutated = _wrap_blocks_in_place(loop_blocks, user_message) or mutated
|
|
return mutated
|