mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(SKY-8771): frontend support for while_loop block (#5809)
This commit is contained in:
parent
b9649d2734
commit
b93d71b8c6
26 changed files with 1209 additions and 523 deletions
|
|
@ -12491,7 +12491,7 @@
|
|||
"condition"
|
||||
],
|
||||
"title": "WhileLoopBlock",
|
||||
"description": "Loop block driven by a runtime condition. Iterates while ``condition`` evaluates truthy.\n\nTop-of-loop semantics: the condition is evaluated *before* each iteration (including the\nfirst). If the condition is false on the first check, the body never runs and the block\nreturns success with an empty output list.\n\nSafety: the loop is capped at ``DEFAULT_MAX_LOOP_ITERATIONS`` (100). Reaching the cap is\ntreated as a failure so that a misbehaving condition can never spin forever."
|
||||
"description": "Loop block driven by a runtime condition. Iterates while ``condition`` evaluates truthy.\n\nTop-of-loop semantics: the condition is evaluated *before* each iteration (including the\nfirst). If the condition is false on the first check, the body never runs and the block\nreturns success with an empty output list.\n\nSafety: the loop is capped at ``DEFAULT_MAX_LOOP_ITERATIONS`` (500). Reaching the cap is\ntreated as a failure so that a misbehaving condition can never spin forever."
|
||||
},
|
||||
"WhileLoopBlockYAML": {
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -18431,7 +18431,7 @@
|
|||
"condition"
|
||||
],
|
||||
"title": "WhileLoopBlock",
|
||||
"description": "Loop block driven by a runtime condition. Iterates while ``condition`` evaluates truthy.\n\nTop-of-loop semantics: the condition is evaluated *before* each iteration (including the\nfirst). If the condition is false on the first check, the body never runs and the block\nreturns success with an empty output list.\n\nSafety: the loop is capped at ``DEFAULT_MAX_LOOP_ITERATIONS`` (100). Reaching the cap is\ntreated as a failure so that a misbehaving condition can never spin forever."
|
||||
"description": "Loop block driven by a runtime condition. Iterates while ``condition`` evaluates truthy.\n\nTop-of-loop semantics: the condition is evaluated *before* each iteration (including the\nfirst). If the condition is false on the first check, the body never runs and the block\nreturns success with an empty output list.\n\nSafety: the loop is capped at ``DEFAULT_MAX_LOOP_ITERATIONS`` (500). Reaching the cap is\ntreated as a failure so that a misbehaving condition can never spin forever."
|
||||
},
|
||||
"WhileLoopBlockYAML": {
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
WorkflowApiResponse,
|
||||
WorkflowBlock,
|
||||
WorkflowParameter,
|
||||
isNestedLoopWorkflowBlock,
|
||||
} from "./types/workflowTypes";
|
||||
import { WorkflowParameterInput } from "./WorkflowParameterInput";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
|
|
@ -76,8 +77,7 @@ function getLoginBlocksWithoutCredentials(
|
|||
}
|
||||
}
|
||||
|
||||
// Check nested blocks in for_loop
|
||||
if (block.block_type === "for_loop" && block.loop_blocks) {
|
||||
if (isNestedLoopWorkflowBlock(block) && block.loop_blocks) {
|
||||
result.push(...getLoginBlocksWithoutCredentials(block.loop_blocks));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||
import { CodeEditor } from "./CodeEditor";
|
||||
import { isBlockOfType } from "../workflowBlockUtils";
|
||||
import { WorkflowBlockTypes, type WorkflowBlock } from "../types/workflowTypes";
|
||||
|
||||
const WHILE_CONDITION_HELP =
|
||||
"Evaluated each iteration until false or the loop exits.";
|
||||
const BLOCK_OUTPUT_HELP =
|
||||
"Structured output from this loop container after the run.";
|
||||
|
||||
type WhileLoopPostRunFieldsLayout = "sidebar" | "stacked";
|
||||
|
||||
type WhileLoopPostRunFieldsProps = {
|
||||
layout: WhileLoopPostRunFieldsLayout;
|
||||
definitionBlock: WorkflowBlock | null;
|
||||
loopOutput: unknown;
|
||||
};
|
||||
|
||||
export function WhileLoopPostRunFields({
|
||||
layout,
|
||||
definitionBlock,
|
||||
loopOutput,
|
||||
}: WhileLoopPostRunFieldsProps) {
|
||||
const whileDefinition = isBlockOfType(
|
||||
definitionBlock,
|
||||
WorkflowBlockTypes.WhileLoop,
|
||||
)
|
||||
? definitionBlock
|
||||
: null;
|
||||
|
||||
const outputJson = JSON.stringify(loopOutput ?? null, null, 2);
|
||||
|
||||
if (layout === "sidebar") {
|
||||
return (
|
||||
<>
|
||||
{whileDefinition ? (
|
||||
<div className="flex gap-16">
|
||||
<div className="w-80">
|
||||
<h1 className="text-sm">While condition</h1>
|
||||
<h2 className="text-sm text-slate-400">{WHILE_CONDITION_HELP}</h2>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<div className="text-xs capitalize text-slate-500">
|
||||
{whileDefinition.condition.criteria_type.replace(/_/g, " ")}
|
||||
</div>
|
||||
<AutoResizingTextarea
|
||||
value={whileDefinition.condition.expression}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-16">
|
||||
<div className="w-80">
|
||||
<h1 className="text-sm">Block output</h1>
|
||||
<h2 className="text-sm text-slate-400">{BLOCK_OUTPUT_HELP}</h2>
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={outputJson}
|
||||
readOnly
|
||||
minHeight="96px"
|
||||
maxHeight="200px"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{whileDefinition ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<h1 className="text-sm">While condition</h1>
|
||||
<HelpTooltip content={WHILE_CONDITION_HELP} />
|
||||
</div>
|
||||
<div className="text-xs capitalize text-slate-500">
|
||||
{whileDefinition.condition.criteria_type.replace(/_/g, " ")}
|
||||
</div>
|
||||
<AutoResizingTextarea
|
||||
value={whileDefinition.condition.expression}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<h1 className="text-sm">Block output</h1>
|
||||
<HelpTooltip content={BLOCK_OUTPUT_HELP} />
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={outputJson}
|
||||
readOnly
|
||||
minHeight="96px"
|
||||
maxHeight="200px"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,21 @@
|
|||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery";
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { CodeEditor } from "../components/CodeEditor";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { useActiveWorkflowRunItem } from "@/routes/workflows/workflowRun/useActiveWorkflowRunItem";
|
||||
import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuery";
|
||||
import { isAction, isWorkflowRunBlock } from "../types/workflowRunTypes";
|
||||
import {
|
||||
isAction,
|
||||
isWorkflowRunBlock,
|
||||
isWorkflowRunLoopContainerBlock,
|
||||
} from "../types/workflowRunTypes";
|
||||
import { findBlockSurroundingAction } from "@/routes/workflows/workflowRun/workflowTimelineUtils";
|
||||
import { DebuggerTaskBlockParameters } from "./DebuggerTaskBlockParameters";
|
||||
import { isTaskVariantBlock, WorkflowBlockTypes } from "../types/workflowTypes";
|
||||
import { findWorkflowBlockByLabel } from "../workflowBlockUtils";
|
||||
import { WhileLoopPostRunFields } from "../components/WhileLoopPostRunFields";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProxySelector } from "@/components/ProxySelector";
|
||||
import { DebuggerSendEmailBlockParameters } from "./DebuggerSendEmailBlockInfo";
|
||||
|
|
@ -16,24 +25,19 @@ import { HelpTooltip } from "@/components/HelpTooltip";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
function DebuggerPostRunParameters() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } =
|
||||
useWorkflowRunTimelineQuery();
|
||||
const [activeItem] = useActiveWorkflowRunItem();
|
||||
const { data: workflowRun, isLoading: workflowRunIsLoading } =
|
||||
useWorkflowRunQuery();
|
||||
const parameters = workflowRun?.parameters ?? {};
|
||||
const { data: workflow, isLoading: workflowIsLoading } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
if (workflowRunIsLoading || workflowRunTimelineIsLoading) {
|
||||
return <div>Loading workflow parameters...</div>;
|
||||
}
|
||||
|
||||
if (!workflowRun || !workflowRunTimeline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveBlock() {
|
||||
const activeBlock = useMemo(() => {
|
||||
if (!workflowRunTimeline) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
if (isWorkflowRunBlock(activeItem)) {
|
||||
return activeItem;
|
||||
|
|
@ -44,9 +48,34 @@ function DebuggerPostRunParameters() {
|
|||
activeItem.action_id,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, [workflowRunTimeline, activeItem]);
|
||||
|
||||
const activeBlockLabel = activeBlock?.label ?? null;
|
||||
const definitionBlock = useMemo(() => {
|
||||
if (!workflow || !activeBlockLabel) {
|
||||
return null;
|
||||
}
|
||||
return findWorkflowBlockByLabel(
|
||||
workflow.workflow_definition.blocks,
|
||||
activeBlockLabel,
|
||||
);
|
||||
}, [workflow, activeBlockLabel]);
|
||||
|
||||
const parameters = workflowRun?.parameters ?? {};
|
||||
|
||||
if (
|
||||
workflowRunIsLoading ||
|
||||
workflowRunTimelineIsLoading ||
|
||||
workflowIsLoading
|
||||
) {
|
||||
return <div>Loading workflow parameters...</div>;
|
||||
}
|
||||
|
||||
if (!workflowRun || !workflowRunTimeline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeBlock = getActiveBlock();
|
||||
const isTaskV2 = workflowRun.task_v2 !== null;
|
||||
|
||||
const webhookCallbackUrl = isTaskV2
|
||||
|
|
@ -84,24 +113,36 @@ function DebuggerPostRunParameters() {
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeBlock && activeBlock.block_type === WorkflowBlockTypes.ForLoop ? (
|
||||
{activeBlock && isWorkflowRunLoopContainerBlock(activeBlock) ? (
|
||||
<div className="rounded bg-slate-elevation2 p-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-sm font-bold">For Loop Block Parameters</h1>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<h1 className="text-sm">Loop Values</h1>
|
||||
<HelpTooltip content="The values that are being looped over." />
|
||||
<h1 className="text-sm font-bold">Loop Block Parameters</h1>
|
||||
{activeBlock.block_type === WorkflowBlockTypes.ForLoop ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<h1 className="text-sm">Loop Values</h1>
|
||||
<HelpTooltip content="The values that are being looped over." />
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={JSON.stringify(
|
||||
activeBlock?.loop_values ?? [],
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
readOnly
|
||||
minHeight="96px"
|
||||
maxHeight="200px"
|
||||
/>
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={JSON.stringify(activeBlock?.loop_values, null, 2)}
|
||||
readOnly
|
||||
minHeight="96px"
|
||||
maxHeight="200px"
|
||||
) : (
|
||||
<WhileLoopPostRunFields
|
||||
layout="stacked"
|
||||
definitionBlock={definitionBlock}
|
||||
loopOutput={activeBlock.output}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,19 @@ export const helpTooltips = {
|
|||
...baseHelpTooltipContent,
|
||||
loopValue:
|
||||
"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.",
|
||||
loopDataSchema:
|
||||
"JSON shape for extracted items when the loop value is natural language that triggers extraction—not when you point at an existing list parameter.",
|
||||
completeIfEmpty:
|
||||
"If the list is empty, treat this loop as successful and continue the workflow instead of failing.",
|
||||
nextLoopOnFailure:
|
||||
"When an iteration fails, skip its remaining blocks and start the next iteration instead of stopping the entire loop.",
|
||||
continueOnFailure:
|
||||
"If this loop ends in failure, let the rest of the workflow continue running instead of stopping. Does not affect iteration behavior — use 'Skip Iterations that Fail' for that.",
|
||||
},
|
||||
while_loop: {
|
||||
...baseHelpTooltipContent,
|
||||
condition:
|
||||
"Define when to keep iterating. Use a Jinja expression or natural language. The condition is checked before each round—if it is false on the first check, the loop body never runs. Use {{ current_index }} inside the loop; while loops do not set {{ current_value }}.",
|
||||
nextLoopOnFailure:
|
||||
"When an iteration fails, skip its remaining blocks and start the next iteration instead of stopping the entire loop.",
|
||||
continueOnFailure:
|
||||
|
|
@ -170,6 +183,10 @@ export const helpTooltips = {
|
|||
},
|
||||
};
|
||||
|
||||
export function buildWhileLoopConditionTooltip(): string {
|
||||
return helpTooltips.while_loop.condition;
|
||||
}
|
||||
|
||||
export const placeholders = {
|
||||
task: basePlaceholderContent,
|
||||
taskv2: {
|
||||
|
|
@ -200,6 +217,7 @@ export const placeholders = {
|
|||
navigationGoal: "Login to the website using the {{ credentials }}",
|
||||
},
|
||||
loop: basePlaceholderContent,
|
||||
while_loop: basePlaceholderContent,
|
||||
sendEmail: basePlaceholderContent,
|
||||
upload: basePlaceholderContent,
|
||||
fileUpload: basePlaceholderContent,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,20 @@ import {
|
|||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import { AppNode } from "..";
|
||||
import { helpTooltips } from "../../helpContent";
|
||||
import {
|
||||
helpTooltips,
|
||||
buildWhileLoopConditionTooltip,
|
||||
} from "../../helpContent";
|
||||
import { dataSchemaExampleValue } from "../types";
|
||||
import type { LoopNode } from "./types";
|
||||
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { getLoopNodeWidth } from "../../workflowEditorUtils";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { WorkflowBlockType } from "@/routes/workflows/types/workflowTypes";
|
||||
import {
|
||||
inferBranchCriteriaTypeFromExpression,
|
||||
getLoopNodeWidth,
|
||||
} from "../../workflowEditorUtils";
|
||||
import { cn } from "@/util/utils";
|
||||
import { NodeHeader } from "../components/NodeHeader";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
|
@ -87,6 +95,9 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
|||
24;
|
||||
|
||||
const loopNodeWidth = getLoopNodeWidth(node, nodes);
|
||||
const loopKind = data.loopKind;
|
||||
const headerBlockType: WorkflowBlockType =
|
||||
loopKind === "while" ? "while_loop" : "for_loop";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -132,59 +143,172 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
|||
nodeId={id}
|
||||
totpIdentifier={null}
|
||||
totpUrl={null}
|
||||
type="for_loop" // sic: the naming is not consistent
|
||||
type={headerBlockType}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Loop Value</Label>
|
||||
<HelpTooltip content={helpTooltips["loop"]["loopValue"]} />
|
||||
</div>
|
||||
{isFirstWorkflowBlock ? (
|
||||
<div className="flex justify-end text-xs text-slate-400">
|
||||
Tip: Use the {"+"} button to add parameters!
|
||||
<Tabs
|
||||
value={loopKind}
|
||||
onValueChange={(v) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
if (v !== "for_each" && v !== "while") {
|
||||
return;
|
||||
}
|
||||
if (v === "while") {
|
||||
const expr =
|
||||
data.whileConditionExpression.trim() === ""
|
||||
? "{{ true }}"
|
||||
: data.whileConditionExpression;
|
||||
update({
|
||||
loopKind: "while",
|
||||
whileConditionExpression: expr,
|
||||
whileConditionCriteriaType:
|
||||
inferBranchCriteriaTypeFromExpression(expr),
|
||||
});
|
||||
} else {
|
||||
update({ loopKind: "for_each" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid h-9 w-full grid-cols-2">
|
||||
<TabsTrigger value="for_each" disabled={!data.editable}>
|
||||
For each
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="while" disabled={!data.editable}>
|
||||
While
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{loopKind === "for_each" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Loop Value
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={helpTooltips["loop"]["loopValue"]}
|
||||
/>
|
||||
</div>
|
||||
{isFirstWorkflowBlock ? (
|
||||
<div className="flex justify-end text-xs text-slate-400">
|
||||
Tip: Use the {"+"} button to add parameters!
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInput
|
||||
nodeId={id}
|
||||
value={data.loopVariableReference}
|
||||
onChange={(value) => {
|
||||
update({ loopVariableReference: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkflowDataSchemaInputGroup
|
||||
value={data.dataSchema}
|
||||
onChange={(value) => {
|
||||
update({ dataSchema: value });
|
||||
}}
|
||||
suggestionContext={{
|
||||
loop_variable_reference: data.loopVariableReference,
|
||||
}}
|
||||
exampleValue={dataSchemaExampleValue}
|
||||
helpTooltip="Specify a format for extracted data in JSON. Only applies when the loop value is natural language — ignored for parameter references."
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<WorkflowBlockInput
|
||||
nodeId={id}
|
||||
value={data.loopVariableReference}
|
||||
onChange={(value) => {
|
||||
update({ loopVariableReference: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkflowDataSchemaInputGroup
|
||||
value={data.dataSchema}
|
||||
onChange={(value) => {
|
||||
update({ dataSchema: value });
|
||||
}}
|
||||
suggestionContext={{
|
||||
loop_variable_reference: data.loopVariableReference,
|
||||
}}
|
||||
exampleValue={dataSchemaExampleValue}
|
||||
helpTooltip={helpTooltips["loop"]["loopDataSchema"]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={data.completeIfEmpty}
|
||||
disabled={!data.editable}
|
||||
onCheckedChange={(checked) => {
|
||||
update({
|
||||
completeIfEmpty:
|
||||
checked === "indeterminate" ? false : checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Continue if Empty
|
||||
Loop Condition
|
||||
</Label>
|
||||
<HelpTooltip content="When checked, the for loop block will successfully complete and workflow execution will continue if the loop value is empty" />
|
||||
<HelpTooltip content={buildWhileLoopConditionTooltip()} />
|
||||
</div>
|
||||
|
||||
{isFirstWorkflowBlock ? (
|
||||
<div className="flex justify-end text-xs text-slate-400">
|
||||
Tip: Use the {"+"} button to add parameters!
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInput
|
||||
nodeId={id}
|
||||
value={data.whileConditionExpression}
|
||||
onChange={(value) => {
|
||||
update({
|
||||
whileConditionExpression: value,
|
||||
whileConditionCriteriaType:
|
||||
inferBranchCriteriaTypeFromExpression(value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{loopKind === "for_each" ? (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={data.completeIfEmpty}
|
||||
disabled={!data.editable}
|
||||
onCheckedChange={(checked) => {
|
||||
update({
|
||||
completeIfEmpty:
|
||||
checked === "indeterminate" ? false : checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="text-xs text-slate-300">
|
||||
Continue if Empty
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={helpTooltips["loop"]["completeIfEmpty"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={data.continueOnFailure}
|
||||
disabled={!data.editable}
|
||||
onCheckedChange={(checked) => {
|
||||
update({
|
||||
continueOnFailure:
|
||||
checked === "indeterminate" ? false : checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="text-xs text-slate-300">
|
||||
Continue Workflow if Loop Fails
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={helpTooltips["loop"]["continueOnFailure"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={data.nextLoopOnFailure ?? false}
|
||||
disabled={!data.editable}
|
||||
onCheckedChange={(checked) => {
|
||||
update({
|
||||
nextLoopOnFailure:
|
||||
checked === "indeterminate" ? false : checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="text-xs text-slate-300">
|
||||
Skip Iterations that Fail
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={helpTooltips["loop"]["nextLoopOnFailure"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={data.continueOnFailure}
|
||||
|
|
@ -200,11 +324,9 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
|||
Continue Workflow if Loop Fails
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={helpTooltips["loop"]["continueOnFailure"]}
|
||||
content={helpTooltips["while_loop"]["continueOnFailure"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={data.nextLoopOnFailure ?? false}
|
||||
|
|
@ -220,11 +342,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
|||
Skip Iterations that Fail
|
||||
</Label>
|
||||
<HelpTooltip
|
||||
content={helpTooltips["loop"]["nextLoopOnFailure"]}
|
||||
content={helpTooltips["while_loop"]["nextLoopOnFailure"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,34 @@
|
|||
import type { Node } from "@xyflow/react";
|
||||
import { NodeBaseData } from "../types";
|
||||
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||
import {
|
||||
BranchCriteriaTypes,
|
||||
debuggableWorkflowBlockTypes,
|
||||
type BranchCriteriaType,
|
||||
} from "@/routes/workflows/types/workflowTypes";
|
||||
|
||||
export type LoopKind = "for_each" | "while";
|
||||
|
||||
export type LoopNodeData = NodeBaseData & {
|
||||
loopKind: LoopKind;
|
||||
loopValue: string;
|
||||
loopVariableReference: string;
|
||||
completeIfEmpty: boolean;
|
||||
continueOnFailure: boolean;
|
||||
nextLoopOnFailure?: boolean;
|
||||
dataSchema: string;
|
||||
whileConditionExpression: string;
|
||||
whileConditionDescription: string | null;
|
||||
whileConditionCriteriaType: BranchCriteriaType;
|
||||
_headerHeight?: number; // internal: measured header card height for layout
|
||||
};
|
||||
|
||||
export type LoopNode = Node<LoopNodeData, "loop">;
|
||||
|
||||
export const loopNodeDefaultData: LoopNodeData = {
|
||||
debuggable: debuggableWorkflowBlockTypes.has("for_loop"),
|
||||
debuggable:
|
||||
debuggableWorkflowBlockTypes.has("for_loop") ||
|
||||
debuggableWorkflowBlockTypes.has("while_loop"),
|
||||
editable: true,
|
||||
label: "",
|
||||
loopKind: "for_each",
|
||||
loopValue: "",
|
||||
loopVariableReference: "",
|
||||
completeIfEmpty: false,
|
||||
|
|
@ -25,6 +36,9 @@ export const loopNodeDefaultData: LoopNodeData = {
|
|||
nextLoopOnFailure: false,
|
||||
model: null,
|
||||
dataSchema: "null",
|
||||
whileConditionExpression: "{{ true }}",
|
||||
whileConditionDescription: null,
|
||||
whileConditionCriteriaType: BranchCriteriaTypes.Jinja2Template,
|
||||
} as const;
|
||||
|
||||
export function isLoopNode(node: Node): node is LoopNode {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import { cn } from "@/util/utils";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
import { getWorkflowBlocks } from "../../workflowEditorUtils";
|
||||
import { isLoopNode } from "../LoopNode/types";
|
||||
|
||||
interface StartSettings {
|
||||
webhookCallbackUrl: string;
|
||||
|
|
@ -124,7 +125,7 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
|||
|
||||
const parentNode = parentId ? reactFlowInstance.getNode(parentId) : null;
|
||||
const isInsideConditional = parentNode?.type === "conditional";
|
||||
const isInsideLoop = parentNode?.type === "loop";
|
||||
const loopParent = parentNode && isLoopNode(parentNode) ? parentNode : null;
|
||||
const withWorkflowSettings = data.withWorkflowSettings;
|
||||
const finallyBlockLabel = withWorkflowSettings
|
||||
? data.finallyBlockLabel
|
||||
|
|
@ -595,18 +596,26 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
|
|||
/>
|
||||
<div className="w-[30rem] rounded-lg bg-slate-elevation4 px-6 py-4 text-center text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
Start
|
||||
{isInsideLoop && (
|
||||
{loopParent ? (
|
||||
<div className="mt-4 flex gap-3 rounded-md bg-slate-800 p-3 normal-case tracking-normal">
|
||||
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
|
||||
<div className="space-y-1 text-left font-normal text-slate-400">
|
||||
Use{" "}
|
||||
<code className="text-white">
|
||||
{{ current_value }}
|
||||
</code>{" "}
|
||||
to get the current loop value for a given iteration.
|
||||
{loopParent.data.loopKind === "while" ? (
|
||||
<>
|
||||
Use{" "}
|
||||
<code className="text-white">{`{{ current_index }}`}</code> to
|
||||
get the current zero-based loop index for a given iteration.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Use{" "}
|
||||
<code className="text-white">{`{{ current_value }}`}</code> to
|
||||
get the current loop value for a given iteration.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
{isInsideConditional && (
|
||||
<div className="mt-4 rounded-md border border-dashed border-slate-500 p-4 text-center font-normal normal-case tracking-normal text-slate-300">
|
||||
Start adding blocks to be executed for the selected condition
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
|
|||
case "file_url_parser": {
|
||||
return <CursorTextIcon className={className} />;
|
||||
}
|
||||
case "for_loop": {
|
||||
case "for_loop":
|
||||
case "while_loop": {
|
||||
return <UpdateIcon className={className} />;
|
||||
}
|
||||
case "login": {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ export const workflowBlockTitle: {
|
|||
extraction: "Extraction",
|
||||
file_download: "File Download",
|
||||
file_url_parser: "File Parser",
|
||||
for_loop: "Loop",
|
||||
for_loop: "For Loop",
|
||||
while_loop: "While Loop",
|
||||
login: "Login",
|
||||
navigation: "Browser Task",
|
||||
send_email: "Send Email",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const BLOCK_TYPE_TO_NODE_TYPE: Record<string, string> = {
|
|||
send_email: "sendEmail",
|
||||
text_prompt: "textPrompt",
|
||||
for_loop: "loop",
|
||||
while_loop: "loop",
|
||||
file_url_parser: "fileParser",
|
||||
pdf_parser: "pdfParser",
|
||||
download_to_s3: "download",
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ const nodeLibraryItems: Array<{
|
|||
/>
|
||||
),
|
||||
title: "Loop Block",
|
||||
description: "Repeat blocks for each item",
|
||||
description: "Iterate over a list or while a condition stays true",
|
||||
},
|
||||
{
|
||||
nodeType: "codeBlock",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import {
|
|||
WorkflowBlockTypes,
|
||||
WorkflowParameterTypes,
|
||||
WorkflowParameterValueType,
|
||||
BranchCriteriaTypes,
|
||||
debuggableWorkflowBlockTypes,
|
||||
type AWSSecretParameter,
|
||||
type BranchCriteriaType,
|
||||
type OutputParameter,
|
||||
type Parameter,
|
||||
type WorkflowApiResponse,
|
||||
|
|
@ -18,6 +20,8 @@ import {
|
|||
type WorkflowSettings,
|
||||
type ConditionalBlock,
|
||||
type ForLoopBlock,
|
||||
type WhileLoopBlock,
|
||||
isNestedLoopWorkflowBlock,
|
||||
} from "../types/workflowTypes";
|
||||
import {
|
||||
ActionBlockYAML,
|
||||
|
|
@ -27,6 +31,7 @@ import {
|
|||
DownloadToS3BlockYAML,
|
||||
FileUrlParserBlockYAML,
|
||||
ForLoopBlockYAML,
|
||||
WhileLoopBlockYAML,
|
||||
ParameterYAML,
|
||||
SendEmailBlockYAML,
|
||||
TaskBlockYAML,
|
||||
|
|
@ -153,6 +158,64 @@ import {
|
|||
} from "./nodes/GoogleSheetsWriteNode/types";
|
||||
import { validateGoogleSheetsWriteNode } from "./nodes/GoogleSheetsWriteNode/validate";
|
||||
|
||||
/** If the trimmed expression is exactly one `{{ ... }}` wrapper, use `jinja2_template`; otherwise `prompt`. */
|
||||
export function inferBranchCriteriaTypeFromExpression(
|
||||
expression: string,
|
||||
): BranchCriteriaType {
|
||||
const stripped = expression.trim();
|
||||
const openCount = (stripped.match(/\{\{/g) ?? []).length;
|
||||
if (stripped.startsWith("{{") && stripped.endsWith("}}") && openCount === 1) {
|
||||
return BranchCriteriaTypes.Jinja2Template;
|
||||
}
|
||||
return BranchCriteriaTypes.Prompt;
|
||||
}
|
||||
|
||||
function buildWhileLoopBlockYAML(args: {
|
||||
label: string;
|
||||
continue_on_failure: boolean;
|
||||
next_loop_on_failure: boolean;
|
||||
next_block_label: string | null;
|
||||
ignore_workflow_system_prompt: boolean;
|
||||
loop_blocks: Array<BlockYAML>;
|
||||
criteria_type: BranchCriteriaType;
|
||||
expression: string;
|
||||
description: string | null;
|
||||
}): WhileLoopBlockYAML {
|
||||
return {
|
||||
block_type: "while_loop",
|
||||
label: args.label,
|
||||
continue_on_failure: args.continue_on_failure,
|
||||
next_loop_on_failure: args.next_loop_on_failure,
|
||||
next_block_label: args.next_block_label,
|
||||
ignore_workflow_system_prompt: args.ignore_workflow_system_prompt,
|
||||
loop_blocks: args.loop_blocks,
|
||||
condition: {
|
||||
criteria_type: args.criteria_type,
|
||||
expression: args.expression,
|
||||
description: args.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serializeLoopNodeWhileBranchToYAML(
|
||||
node: LoopNode,
|
||||
loopChildren: Array<BlockYAML>,
|
||||
nextBlockLabel: string | null,
|
||||
): WhileLoopBlockYAML {
|
||||
return buildWhileLoopBlockYAML({
|
||||
label: node.data.label,
|
||||
continue_on_failure: node.data.continueOnFailure,
|
||||
next_loop_on_failure: node.data.nextLoopOnFailure ?? false,
|
||||
next_block_label: nextBlockLabel,
|
||||
ignore_workflow_system_prompt:
|
||||
node.data.ignoreWorkflowSystemPrompt ?? false,
|
||||
loop_blocks: loopChildren,
|
||||
criteria_type: node.data.whileConditionCriteriaType,
|
||||
expression: node.data.whileConditionExpression,
|
||||
description: node.data.whileConditionDescription ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export const NEW_NODE_LABEL_PREFIX = "block_";
|
||||
|
||||
type ConditionalEdgeData = {
|
||||
|
|
@ -855,7 +918,9 @@ function convertToNode(
|
|||
...common,
|
||||
type: "loop",
|
||||
data: {
|
||||
...loopNodeDefaultData,
|
||||
...commonData,
|
||||
loopKind: "for_each",
|
||||
loopValue: block.loop_over?.key ?? "",
|
||||
loopVariableReference: loopVariableReference,
|
||||
completeIfEmpty: block.complete_if_empty,
|
||||
|
|
@ -869,6 +934,32 @@ function convertToNode(
|
|||
},
|
||||
};
|
||||
}
|
||||
case "while_loop": {
|
||||
const wblock = block as WhileLoopBlock;
|
||||
const rawExpr = wblock.condition.expression;
|
||||
const whileConditionExpression =
|
||||
rawExpr.trim() === "" ? "{{ true }}" : rawExpr;
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "loop",
|
||||
data: {
|
||||
...loopNodeDefaultData,
|
||||
...commonData,
|
||||
loopKind: "while",
|
||||
loopValue: "",
|
||||
loopVariableReference: "",
|
||||
completeIfEmpty: false,
|
||||
dataSchema: "null",
|
||||
whileConditionExpression,
|
||||
whileConditionDescription: wblock.condition.description ?? null,
|
||||
whileConditionCriteriaType:
|
||||
rawExpr.trim() === ""
|
||||
? inferBranchCriteriaTypeFromExpression(whileConditionExpression)
|
||||
: wblock.condition.criteria_type,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "file_url_parser": {
|
||||
return {
|
||||
...identifiers,
|
||||
|
|
@ -1096,7 +1187,7 @@ function generateNodeData(blocks: Array<WorkflowBlock>): Array<{
|
|||
const block = stack.pop()!;
|
||||
const id = nanoid();
|
||||
idMap.set(block, id);
|
||||
if (block.block_type === "for_loop") {
|
||||
if (isNestedLoopWorkflowBlock(block)) {
|
||||
stack.push(...block.loop_blocks);
|
||||
}
|
||||
}
|
||||
|
|
@ -1129,7 +1220,7 @@ function getNodeData(
|
|||
const next =
|
||||
index === blocks.length - 1 ? null : ids.get(blocks[index + 1]!)!;
|
||||
data.push({ id, previous, next, parentId, block });
|
||||
if (block.block_type === "for_loop") {
|
||||
if (isNestedLoopWorkflowBlock(block)) {
|
||||
data.push(...getNodeData(block.loop_blocks, ids, id));
|
||||
}
|
||||
});
|
||||
|
|
@ -1145,7 +1236,7 @@ function buildLabelToBlockMap(
|
|||
const traverse = (list: Array<WorkflowBlock>) => {
|
||||
list.forEach((block) => {
|
||||
map.set(block.label, block);
|
||||
if (block.block_type === "for_loop") {
|
||||
if (isNestedLoopWorkflowBlock(block)) {
|
||||
traverse(block.loop_blocks);
|
||||
}
|
||||
});
|
||||
|
|
@ -1216,7 +1307,7 @@ function reconstructConditionalStructure(
|
|||
// Process each conditional block
|
||||
blocks.forEach((block, blockIndex) => {
|
||||
if (block.block_type !== "conditional") {
|
||||
if (block.block_type === "for_loop") {
|
||||
if (isNestedLoopWorkflowBlock(block)) {
|
||||
// Recursively handle conditionals inside loops
|
||||
const recursiveResult = reconstructConditionalStructure(
|
||||
block.loop_blocks,
|
||||
|
|
@ -1619,8 +1710,8 @@ function getElements(
|
|||
});
|
||||
|
||||
const loopBlocks = data.filter(
|
||||
(d): d is typeof d & { block: ForLoopBlock } =>
|
||||
d.block.block_type === "for_loop",
|
||||
(d): d is typeof d & { block: ForLoopBlock | WhileLoopBlock } =>
|
||||
isNestedLoopWorkflowBlock(d.block),
|
||||
);
|
||||
loopBlocks.forEach((block) => {
|
||||
const loopBlock = block.block;
|
||||
|
|
@ -1660,7 +1751,7 @@ function getElements(
|
|||
).forEach((label) => branchLabels.add(label));
|
||||
});
|
||||
}
|
||||
if (child.block_type === "for_loop") {
|
||||
if (isNestedLoopWorkflowBlock(child)) {
|
||||
collectBranchLabels(child.loop_blocks);
|
||||
}
|
||||
});
|
||||
|
|
@ -2207,6 +2298,35 @@ function JSONSafeOrString(
|
|||
}
|
||||
}
|
||||
|
||||
function serializeLoopNodeToYAML(
|
||||
node: LoopNode,
|
||||
loopChildren: Array<BlockYAML>,
|
||||
nextBlockLabel: string | null,
|
||||
): ForLoopBlockYAML | WhileLoopBlockYAML {
|
||||
const loopKind = node.data.loopKind;
|
||||
if (loopKind === "while") {
|
||||
return serializeLoopNodeWhileBranchToYAML(
|
||||
node,
|
||||
loopChildren,
|
||||
nextBlockLabel,
|
||||
);
|
||||
}
|
||||
return {
|
||||
label: node.data.label,
|
||||
continue_on_failure: node.data.continueOnFailure,
|
||||
next_loop_on_failure: node.data.nextLoopOnFailure ?? false,
|
||||
next_block_label: nextBlockLabel,
|
||||
ignore_workflow_system_prompt:
|
||||
node.data.ignoreWorkflowSystemPrompt ?? false,
|
||||
loop_blocks: loopChildren,
|
||||
block_type: "for_loop",
|
||||
loop_variable_reference: node.data.loopVariableReference,
|
||||
complete_if_empty: node.data.completeIfEmpty,
|
||||
data_schema: JSONSafeOrString(node.data.dataSchema),
|
||||
loop_over_parameter_key: node.data.loopValue ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function findNextBlockLabel(
|
||||
nodeId: string,
|
||||
nodes: Array<AppNode>,
|
||||
|
|
@ -2784,17 +2904,13 @@ function getOrderedChildrenBlocks(
|
|||
);
|
||||
// Compute next_block_label for nested loops (same as regular blocks)
|
||||
const nextBlockLabel = findNextBlockLabel(currentNode.id, nodes, edges);
|
||||
children.push({
|
||||
block_type: "for_loop",
|
||||
label: currentNode.data.label,
|
||||
continue_on_failure: currentNode.data.continueOnFailure,
|
||||
next_loop_on_failure: currentNode.data.nextLoopOnFailure,
|
||||
next_block_label: nextBlockLabel,
|
||||
loop_blocks: loopChildren,
|
||||
loop_variable_reference: currentNode.data.loopVariableReference,
|
||||
complete_if_empty: currentNode.data.completeIfEmpty,
|
||||
data_schema: JSONSafeOrString(currentNode.data.dataSchema),
|
||||
});
|
||||
children.push(
|
||||
serializeLoopNodeToYAML(
|
||||
currentNode as LoopNode,
|
||||
loopChildren,
|
||||
nextBlockLabel,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.push(getWorkflowBlock(currentNode, nodes, edges));
|
||||
}
|
||||
|
|
@ -2823,17 +2939,9 @@ function getOrderedChildrenBlocks(
|
|||
if (node.type === "loop") {
|
||||
const loopChildren = getOrderedChildrenBlocks(nodes, edges, node.id);
|
||||
const nextBlockLabel = findNextBlockLabel(node.id, nodes, edges);
|
||||
children.push({
|
||||
block_type: "for_loop",
|
||||
label: node.data.label,
|
||||
continue_on_failure: node.data.continueOnFailure,
|
||||
next_loop_on_failure: node.data.nextLoopOnFailure,
|
||||
next_block_label: nextBlockLabel,
|
||||
loop_blocks: loopChildren,
|
||||
loop_variable_reference: node.data.loopVariableReference,
|
||||
complete_if_empty: node.data.completeIfEmpty,
|
||||
data_schema: JSONSafeOrString(node.data.dataSchema),
|
||||
});
|
||||
children.push(
|
||||
serializeLoopNodeToYAML(node as LoopNode, loopChildren, nextBlockLabel),
|
||||
);
|
||||
includedIds.add(node.id);
|
||||
return;
|
||||
}
|
||||
|
|
@ -2889,17 +2997,11 @@ function getWorkflowBlocksUtil(
|
|||
const nextBlockLabel = findNextBlockLabel(node.id, nodes, edges);
|
||||
|
||||
return [
|
||||
{
|
||||
block_type: "for_loop",
|
||||
label: node.data.label,
|
||||
continue_on_failure: node.data.continueOnFailure,
|
||||
next_loop_on_failure: node.data.nextLoopOnFailure,
|
||||
next_block_label: nextBlockLabel,
|
||||
loop_blocks: getOrderedChildrenBlocks(nodes, edges, node.id),
|
||||
loop_variable_reference: node.data.loopVariableReference,
|
||||
complete_if_empty: node.data.completeIfEmpty,
|
||||
data_schema: JSONSafeOrString(node.data.dataSchema),
|
||||
},
|
||||
serializeLoopNodeToYAML(
|
||||
node as LoopNode,
|
||||
getOrderedChildrenBlocks(nodes, edges, node.id),
|
||||
nextBlockLabel,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [getWorkflowBlock(node as WorkflowBlockNode, nodes, edges)];
|
||||
|
|
@ -3511,7 +3613,10 @@ function getBlocksOfType(
|
|||
): Array<BlockYAML> {
|
||||
const blocksOfType: Array<BlockYAML> = [];
|
||||
for (const block of blocks) {
|
||||
if (block.block_type === WorkflowBlockTypes.ForLoop) {
|
||||
if (
|
||||
block.block_type === WorkflowBlockTypes.ForLoop ||
|
||||
block.block_type === WorkflowBlockTypes.WhileLoop
|
||||
) {
|
||||
const subBlocks = block.loop_blocks;
|
||||
const subBlocksOfType = getBlocksOfType(subBlocks, blockType);
|
||||
blocksOfType.push(...subBlocksOfType);
|
||||
|
|
@ -3775,11 +3880,10 @@ export function upgradeWorkflowBlocksV1toV2(
|
|||
};
|
||||
|
||||
// Recursively handle loop blocks
|
||||
if (block.block_type === "for_loop") {
|
||||
const loopBlock = block as ForLoopBlock;
|
||||
if (isNestedLoopWorkflowBlock(block)) {
|
||||
return {
|
||||
...upgradedBlock,
|
||||
loop_blocks: upgradeWorkflowBlocksV1toV2(loopBlock.loop_blocks),
|
||||
loop_blocks: upgradeWorkflowBlocksV1toV2(block.loop_blocks),
|
||||
} as WorkflowBlock;
|
||||
}
|
||||
|
||||
|
|
@ -4016,6 +4120,20 @@ function convertBlocksToBlockYAML(
|
|||
};
|
||||
return blockYaml;
|
||||
}
|
||||
case "while_loop": {
|
||||
const wblock = block as WhileLoopBlock;
|
||||
const blockYaml: WhileLoopBlockYAML = {
|
||||
...base,
|
||||
block_type: "while_loop",
|
||||
loop_blocks: convertBlocksToBlockYAML(wblock.loop_blocks),
|
||||
condition: {
|
||||
criteria_type: wblock.condition.criteria_type,
|
||||
expression: wblock.condition.expression,
|
||||
description: wblock.condition.description ?? null,
|
||||
},
|
||||
};
|
||||
return blockYaml;
|
||||
}
|
||||
case "code": {
|
||||
const blockYaml: CodeBlockYAML = {
|
||||
...base,
|
||||
|
|
@ -4247,14 +4365,26 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
|
|||
|
||||
// check loop node parameters
|
||||
const loopNodes: Array<LoopNode> = nodes.filter(isLoopNode);
|
||||
const emptyLoopNodes = loopNodes.filter(
|
||||
(node: LoopNode) => node.data.loopVariableReference === "",
|
||||
const emptyForEachLoops = loopNodes.filter(
|
||||
(node: LoopNode) =>
|
||||
node.data.loopKind === "for_each" &&
|
||||
node.data.loopVariableReference === "",
|
||||
);
|
||||
if (emptyLoopNodes.length > 0) {
|
||||
emptyLoopNodes.forEach((node) => {
|
||||
if (emptyForEachLoops.length > 0) {
|
||||
emptyForEachLoops.forEach((node) => {
|
||||
errors.push(`${node.data.label}: Loop value is required.`);
|
||||
});
|
||||
}
|
||||
const whileLoopsMissingCondition = loopNodes.filter(
|
||||
(node: LoopNode) =>
|
||||
node.data.loopKind === "while" &&
|
||||
node.data.whileConditionExpression.trim() === "",
|
||||
);
|
||||
if (whileLoopsMissingCondition.length > 0) {
|
||||
whileLoopsMissingCondition.forEach((node) => {
|
||||
errors.push(`${node.data.label}: While loop condition is required.`);
|
||||
});
|
||||
}
|
||||
|
||||
// check task node json fields
|
||||
const taskNodes = nodes.filter(isTaskNode);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,12 @@ export type WorkflowRunBlock = {
|
|||
negative_descriptor?: string | null;
|
||||
};
|
||||
|
||||
export function isWorkflowRunLoopContainerBlock(block: {
|
||||
block_type: WorkflowBlockType;
|
||||
}): boolean {
|
||||
return block.block_type === "for_loop" || block.block_type === "while_loop";
|
||||
}
|
||||
|
||||
export type WorkflowRunTimelineBlockItem = {
|
||||
type: "block";
|
||||
block: WorkflowRunBlock;
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ export type Parameter =
|
|||
export type WorkflowBlock =
|
||||
| TaskBlock
|
||||
| ForLoopBlock
|
||||
| WhileLoopBlock
|
||||
| ConditionalBlock
|
||||
| TextPromptBlock
|
||||
| CodeBlock
|
||||
|
|
@ -220,6 +221,7 @@ export type WorkflowBlock =
|
|||
export const WorkflowBlockTypes = {
|
||||
Task: "task",
|
||||
ForLoop: "for_loop",
|
||||
WhileLoop: "while_loop",
|
||||
Conditional: "conditional",
|
||||
Code: "code",
|
||||
TextPrompt: "text_prompt",
|
||||
|
|
@ -269,6 +271,12 @@ export function isTaskVariantBlock(item: {
|
|||
return scriptableWorkflowBlockTypes.has(item.block_type);
|
||||
}
|
||||
|
||||
export function isNestedLoopWorkflowBlock(
|
||||
block: WorkflowBlock,
|
||||
): block is ForLoopBlock | WhileLoopBlock {
|
||||
return block.block_type === "for_loop" || block.block_type === "while_loop";
|
||||
}
|
||||
|
||||
export type WorkflowBlockType =
|
||||
(typeof WorkflowBlockTypes)[keyof typeof WorkflowBlockTypes];
|
||||
|
||||
|
|
@ -297,6 +305,7 @@ export type WorkflowBlockBase = {
|
|||
|
||||
export const BranchCriteriaTypes = {
|
||||
Jinja2Template: "jinja2_template",
|
||||
Prompt: "prompt",
|
||||
} as const;
|
||||
|
||||
export type BranchCriteriaType =
|
||||
|
|
@ -362,6 +371,12 @@ export type ForLoopBlock = WorkflowBlockBase & {
|
|||
data_schema?: Record<string, unknown> | string | null;
|
||||
};
|
||||
|
||||
export type WhileLoopBlock = WorkflowBlockBase & {
|
||||
block_type: "while_loop";
|
||||
loop_blocks: Array<WorkflowBlock>;
|
||||
condition: BranchCriteria;
|
||||
};
|
||||
|
||||
export type CodeBlock = WorkflowBlockBase & {
|
||||
block_type: "code";
|
||||
code: string;
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export type BlockYAML =
|
|||
| SendEmailBlockYAML
|
||||
| FileUrlParserBlockYAML
|
||||
| ForLoopBlockYAML
|
||||
| WhileLoopBlockYAML
|
||||
| ConditionalBlockYAML
|
||||
| ValidationBlockYAML
|
||||
| HumanInteractionBlockYAML
|
||||
|
|
@ -369,6 +370,12 @@ export type ForLoopBlockYAML = BlockYAMLBase & {
|
|||
data_schema?: Record<string, unknown> | string | null;
|
||||
};
|
||||
|
||||
export type WhileLoopBlockYAML = BlockYAMLBase & {
|
||||
block_type: "while_loop";
|
||||
loop_blocks: Array<BlockYAML>;
|
||||
condition: BranchCriteriaYAML;
|
||||
};
|
||||
|
||||
export type BranchCriteriaYAML = {
|
||||
criteria_type: string;
|
||||
expression: string;
|
||||
|
|
|
|||
30
skyvern-frontend/src/routes/workflows/workflowBlockUtils.ts
Normal file
30
skyvern-frontend/src/routes/workflows/workflowBlockUtils.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
isNestedLoopWorkflowBlock,
|
||||
type WorkflowBlock,
|
||||
type WorkflowBlockType,
|
||||
} from "./types/workflowTypes";
|
||||
|
||||
export function findWorkflowBlockByLabel(
|
||||
blocks: Array<WorkflowBlock>,
|
||||
label: string,
|
||||
): WorkflowBlock | null {
|
||||
for (const block of blocks) {
|
||||
if (block.label === label) {
|
||||
return block;
|
||||
}
|
||||
if (isNestedLoopWorkflowBlock(block) && block.loop_blocks.length > 0) {
|
||||
const nested = findWorkflowBlockByLabel(block.loop_blocks, label);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isBlockOfType<T extends WorkflowBlockType>(
|
||||
block: WorkflowBlock | null,
|
||||
type: T,
|
||||
): block is Extract<WorkflowBlock, { block_type: T }> {
|
||||
return block?.block_type === type;
|
||||
}
|
||||
|
|
@ -7,12 +7,8 @@ import { useWorkflowRunTimelineQuery } from "../hooks/useWorkflowRunTimelineQuer
|
|||
import { isAction, isWorkflowRunBlock } from "../types/workflowRunTypes";
|
||||
import { findBlockSurroundingAction } from "./workflowTimelineUtils";
|
||||
import { TaskBlockParameters } from "./TaskBlockParameters";
|
||||
import {
|
||||
isTaskVariantBlock,
|
||||
WorkflowBlockTypes,
|
||||
type WorkflowBlock,
|
||||
type WorkflowBlockType,
|
||||
} from "../types/workflowTypes";
|
||||
import { isTaskVariantBlock, WorkflowBlockTypes } from "../types/workflowTypes";
|
||||
import { findWorkflowBlockByLabel, isBlockOfType } from "../workflowBlockUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProxySelector } from "@/components/ProxySelector";
|
||||
import { SendEmailBlockParameters } from "./blockInfo/SendEmailBlockInfo";
|
||||
|
|
@ -29,6 +25,7 @@ import { PrintPageBlockParameters } from "./blockInfo/PrintPageBlockParameters";
|
|||
import { HumanInteractionBlockParameters } from "./blockInfo/HumanInteractionBlockParameters";
|
||||
import { ConditionalBlockParameters } from "./blockInfo/ConditionalBlockParameters";
|
||||
import { Taskv2BlockParameters } from "./blockInfo/Taskv2BlockParameters";
|
||||
import { WhileLoopPostRunFields } from "../components/WhileLoopPostRunFields";
|
||||
|
||||
function WorkflowPostRunParameters() {
|
||||
const { data: workflowRunTimeline, isLoading: workflowRunTimelineIsLoading } =
|
||||
|
|
@ -137,18 +134,18 @@ function WorkflowPostRunParameters() {
|
|||
{activeBlock && activeBlock.block_type === WorkflowBlockTypes.ForLoop ? (
|
||||
<div className="rounded bg-slate-elevation2 p-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-bold">Block Parameters</h1>
|
||||
<h1 className="text-sm font-bold">Block Parameters</h1>
|
||||
<div className="flex gap-16">
|
||||
<div className="w-80">
|
||||
<h1 className="text-lg">Loop Values</h1>
|
||||
<h2 className="text-base text-slate-400">
|
||||
<h1 className="text-sm">Loop Values</h1>
|
||||
<h2 className="text-sm text-slate-400">
|
||||
The values that are being looped over
|
||||
</h2>
|
||||
</div>
|
||||
<CodeEditor
|
||||
className="w-full"
|
||||
language="json"
|
||||
value={JSON.stringify(activeBlock.loop_values, null, 2)}
|
||||
value={JSON.stringify(activeBlock.loop_values ?? [], null, 2)}
|
||||
readOnly
|
||||
minHeight="96px"
|
||||
maxHeight="200px"
|
||||
|
|
@ -158,6 +155,19 @@ function WorkflowPostRunParameters() {
|
|||
</div>
|
||||
) : null}
|
||||
{activeBlock &&
|
||||
activeBlock.block_type === WorkflowBlockTypes.WhileLoop ? (
|
||||
<div className="rounded bg-slate-elevation2 p-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-sm font-bold">Block Parameters</h1>
|
||||
<WhileLoopPostRunFields
|
||||
layout="sidebar"
|
||||
definitionBlock={definitionBlock}
|
||||
loopOutput={activeBlock.output}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeBlock &&
|
||||
activeBlock.block_type === WorkflowBlockTypes.Code &&
|
||||
isBlockOfType(definitionBlock, WorkflowBlockTypes.Code) ? (
|
||||
<div className="rounded bg-slate-elevation2 p-6">
|
||||
|
|
@ -460,31 +470,3 @@ function WorkflowPostRunParameters() {
|
|||
}
|
||||
|
||||
export { WorkflowPostRunParameters };
|
||||
|
||||
function findWorkflowBlockByLabel(
|
||||
blocks: Array<WorkflowBlock>,
|
||||
label: string,
|
||||
): WorkflowBlock | null {
|
||||
for (const block of blocks) {
|
||||
if (block.label === label) {
|
||||
return block;
|
||||
}
|
||||
if (
|
||||
block.block_type === WorkflowBlockTypes.ForLoop &&
|
||||
block.loop_blocks.length > 0
|
||||
) {
|
||||
const nested = findWorkflowBlockByLabel(block.loop_blocks, label);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBlockOfType<T extends WorkflowBlockType>(
|
||||
block: WorkflowBlock | null,
|
||||
type: T,
|
||||
): block is Extract<WorkflowBlock, { block_type: T }> {
|
||||
return block?.block_type === type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -393,7 +393,9 @@ function WorkflowRunTimelineBlockItem({
|
|||
// NOTE(jdo): want to put this back; await for now
|
||||
const showDuration = false as const;
|
||||
const hasNestedChildren = subItems.length > 0;
|
||||
const isLoopBlock = block.block_type === "for_loop";
|
||||
const isForLoopBlock = block.block_type === "for_loop";
|
||||
const isWhileLoopBlock = block.block_type === "while_loop";
|
||||
const isLoopBlock = isForLoopBlock || isWhileLoopBlock;
|
||||
const isConditionalBlock = block.block_type === "conditional";
|
||||
const [childrenOpen, setChildrenOpen] = useState(true);
|
||||
|
||||
|
|
@ -561,7 +563,7 @@ function WorkflowRunTimelineBlockItem({
|
|||
{block.description}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoopBlock && (
|
||||
{isForLoopBlock && (
|
||||
<div className="min-w-0 space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs">
|
||||
<div className="text-slate-300">
|
||||
Iterable values:{" "}
|
||||
|
|
@ -593,6 +595,14 @@ function WorkflowRunTimelineBlockItem({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{isWhileLoopBlock && (
|
||||
<div className="min-w-0 rounded bg-slate-elevation5 px-3 py-2 text-xs text-slate-300">
|
||||
Iterations run:{" "}
|
||||
<span className="font-medium text-slate-200">
|
||||
{loopIterationGroups.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{block.block_type === "conditional" && block.executed_branch_id && (
|
||||
<div className="space-y-2 rounded bg-slate-elevation5 px-3 py-2 text-xs">
|
||||
{hasEvaluations(block.output) && block.output.evaluations ? (
|
||||
|
|
@ -771,7 +781,7 @@ function WorkflowRunTimelineBlockItem({
|
|||
<ChevronRightIcon className="size-4 text-slate-300 transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="text-xs text-slate-200">{`Iteration ${iterationNumber}`}</span>
|
||||
</div>
|
||||
{isValueTruncated ? (
|
||||
{isWhileLoopBlock ? null : isValueTruncated ? (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
} from "../types/workflowRunTypes";
|
||||
import { WorkflowRunOverviewActiveElement } from "./WorkflowRunOverview";
|
||||
|
||||
const containerBlockTypes = new Set(["for_loop", "conditional"]);
|
||||
const containerBlockTypes = new Set(["for_loop", "while_loop", "conditional"]);
|
||||
|
||||
function findBlockSurroundingAction(
|
||||
timeline: Array<WorkflowRunTimelineItem>,
|
||||
|
|
|
|||
|
|
@ -2712,14 +2712,20 @@ class WhileLoopBlock(Block):
|
|||
async def _evaluate_condition(
|
||||
self,
|
||||
workflow_run_context: WorkflowRunContext,
|
||||
*,
|
||||
workflow_run_id: str,
|
||||
workflow_run_block_id: str,
|
||||
organization_id: str | None,
|
||||
browser_session_id: str | None,
|
||||
) -> bool:
|
||||
"""Evaluate the loop condition. Raises on rendering errors so the caller can convert
|
||||
the failure into a block result with a clear message.
|
||||
|
||||
``current_index`` (the 0-indexed iteration counter) is read from this block's own
|
||||
metadata via the existing for_loop injection in
|
||||
:meth:`format_block_parameter_template_from_workflow_run_context`. The caller is
|
||||
responsible for writing it onto ``self.label`` before invoking this method, so
|
||||
:meth:`format_block_parameter_template_from_workflow_run_context`. ``current_value``
|
||||
holds the same integer so ``{{ current_value }}`` caps work like For Each loops.
|
||||
The caller writes both onto ``self.label`` before invoking this method, so
|
||||
condition authors can bootstrap iteration 1 with
|
||||
``{{ current_index == 0 or <body_output_ref> }}``.
|
||||
"""
|
||||
|
|
@ -2731,6 +2737,26 @@ class WhileLoopBlock(Block):
|
|||
workflow_run_context,
|
||||
),
|
||||
)
|
||||
if isinstance(self.condition, PromptBranchCriteria):
|
||||
synthetic_branch = BranchCondition(
|
||||
id=str(uuid.uuid4()),
|
||||
criteria=self.condition,
|
||||
next_block_label=None,
|
||||
is_default=False,
|
||||
)
|
||||
results, _, _, _ = await _evaluate_prompt_branch_conditions_batch(
|
||||
log_label=self.label,
|
||||
branches=[synthetic_branch],
|
||||
evaluation_context=evaluation_context,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_run_block_id=workflow_run_block_id,
|
||||
organization_id=organization_id,
|
||||
browser_session_id=browser_session_id,
|
||||
workflow_id=self.output_parameter.workflow_id,
|
||||
extraction_description_suffix="while_loop condition",
|
||||
)
|
||||
return results[0]
|
||||
|
||||
return await self.condition.evaluate(evaluation_context)
|
||||
|
||||
async def _execute_while_loop_helper(
|
||||
|
|
@ -2761,11 +2787,9 @@ class WhileLoopBlock(Block):
|
|||
# is malformed and will fail identically on the next iteration — there is no
|
||||
# forward progress to be made by retrying.
|
||||
# Expose ``current_index`` to the condition's template scope before evaluation
|
||||
# so authors can bootstrap iteration 0 with ``{{ current_index == 0 or ... }}``.
|
||||
# The for_loop pattern at line 516-521 picks this up via ``get_block_metadata``.
|
||||
# ``current_value`` and ``current_item`` are nulled for the same SKY-8835
|
||||
# reason as the body-level write below — defend against an outer for_loop's
|
||||
# values lingering on this label's bag and bleeding into the condition render.
|
||||
# so authors can bootstrap iteration 0 or cap iterations. ``current_value`` and
|
||||
# ``current_item`` stay None so Jinja matches persisted timeline rows
|
||||
# (``execute_safe(..., current_value=None)``) and outer for-loop rows cannot leak.
|
||||
condition_metadata: BlockMetadata = {
|
||||
"current_index": loop_idx,
|
||||
"current_value": None,
|
||||
|
|
@ -2774,8 +2798,14 @@ class WhileLoopBlock(Block):
|
|||
workflow_run_context.update_block_metadata(self.label, condition_metadata)
|
||||
|
||||
try:
|
||||
should_continue = await self._evaluate_condition(workflow_run_context)
|
||||
except (FailedToFormatJinjaStyleParameter, MissingJinjaVariables) as exc:
|
||||
should_continue = await self._evaluate_condition(
|
||||
workflow_run_context,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_run_block_id=workflow_run_block_id,
|
||||
organization_id=organization_id,
|
||||
browser_session_id=browser_session_id,
|
||||
)
|
||||
except (FailedToFormatJinjaStyleParameter, MissingJinjaVariables, ValueError) as exc:
|
||||
LOG.error(
|
||||
"WhileLoopBlock condition evaluation failed",
|
||||
workflow_run_id=workflow_run_id,
|
||||
|
|
@ -2898,13 +2928,9 @@ class WhileLoopBlock(Block):
|
|||
last_block=current_block,
|
||||
)
|
||||
|
||||
# ``current_value`` and ``current_item`` are explicitly nulled to defend
|
||||
# against the SKY-8835 leakage pattern: ``update_block_metadata`` merges
|
||||
# rather than replaces, so an outer for_loop's per-iteration values would
|
||||
# otherwise linger on this label's bag and bleed into child Jinja
|
||||
# expressions. Setting them to None overwrites the stale entries cleanly,
|
||||
# and child templates that reference ``current_value``/``current_item``
|
||||
# will render None rather than leak the outer loop's data.
|
||||
# ``current_index`` is the iteration counter. ``current_value`` stays None so
|
||||
# runtime matches ``execute_safe`` / timeline rows; use ``{{ current_index }}``
|
||||
# in Jinja. ``current_item`` stays None.
|
||||
metadata: BlockMetadata = {
|
||||
"current_index": loop_idx,
|
||||
"current_value": None,
|
||||
|
|
@ -2923,9 +2949,8 @@ class WhileLoopBlock(Block):
|
|||
if cond_label in conditional_wrb_ids:
|
||||
parent_wrb_id = conditional_wrb_ids[cond_label]
|
||||
|
||||
# ``current_value`` is intentionally None: while_loop has no per-iteration
|
||||
# value to display in the timeline. ``current_index`` carries the iteration
|
||||
# counter, which is the only meaningful per-iteration data.
|
||||
# ``current_value`` is None on persisted timeline rows and in block metadata;
|
||||
# iteration is available only as ``current_index``.
|
||||
block_output = await loop_block.execute_safe(
|
||||
workflow_run_id=workflow_run_id,
|
||||
parent_workflow_run_block_id=parent_wrb_id,
|
||||
|
|
@ -3141,21 +3166,6 @@ class WhileLoopBlock(Block):
|
|||
) -> BlockResult:
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
|
||||
if isinstance(self.condition, PromptBranchCriteria):
|
||||
# Prompt criteria support is deferred to a follow-up PR; the prompt evaluator
|
||||
# is currently coupled to ConditionalBlock. Reject explicitly so the user gets
|
||||
# a clear, actionable error rather than a silent fall-through.
|
||||
return await self.build_block_result(
|
||||
success=False,
|
||||
failure_reason=(
|
||||
"Prompt criteria are not yet supported in while_loop blocks. "
|
||||
"Use a Jinja2 expression like '{{ should_continue }}' instead."
|
||||
),
|
||||
status=BlockStatus.failed,
|
||||
workflow_run_block_id=workflow_run_block_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
if not self.loop_blocks:
|
||||
LOG.info(
|
||||
"No defined blocks to loop, terminating block",
|
||||
|
|
@ -6790,8 +6800,9 @@ class PromptBranchCriteria(BranchCriteria):
|
|||
criteria_type: Literal["prompt"] = "prompt"
|
||||
|
||||
async def evaluate(self, context: BranchEvaluationContext) -> bool:
|
||||
# Natural language criteria are evaluated in batch by ConditionalBlock.execute.
|
||||
raise NotImplementedError("PromptBranchCriteria is evaluated in batch, not per-branch.")
|
||||
# Evaluated via ConditionalBlock.execute (batched) or WhileLoopBlock
|
||||
# _evaluate_condition (single-branch batch helper).
|
||||
raise NotImplementedError("PromptBranchCriteria is evaluated via extraction batch helpers, not per-branch.")
|
||||
|
||||
def requires_llm(self) -> bool:
|
||||
return True
|
||||
|
|
@ -7024,7 +7035,7 @@ def _parse_single_evaluation(
|
|||
else:
|
||||
bool_result = _evaluate_truthy_string(str(result))
|
||||
LOG.warning(
|
||||
"Prompt branch evaluation returned non-boolean result",
|
||||
"Conditional branch evaluation returned non-boolean result",
|
||||
branch_index=idx,
|
||||
result=result,
|
||||
evaluated_result=bool_result,
|
||||
|
|
@ -7158,6 +7169,227 @@ class BranchCondition(BaseModel):
|
|||
return self
|
||||
|
||||
|
||||
async def _evaluate_prompt_branch_conditions_batch(
|
||||
*,
|
||||
log_label: str,
|
||||
branches: list[BranchCondition],
|
||||
evaluation_context: BranchEvaluationContext,
|
||||
workflow_run_id: str,
|
||||
workflow_run_block_id: str,
|
||||
organization_id: str | None,
|
||||
browser_session_id: str | None,
|
||||
workflow_id: str,
|
||||
extraction_description_suffix: str = "",
|
||||
) -> tuple[list[bool], list[str], str | None, dict | None]:
|
||||
if organization_id is None:
|
||||
raise ValueError("organization_id is required to evaluate natural language branches")
|
||||
|
||||
if not branches:
|
||||
return ([], [], None, None)
|
||||
|
||||
workflow_run_context = evaluation_context.workflow_run_context
|
||||
|
||||
rendered_expressions: list[str] = []
|
||||
has_any_pure_natlang = False
|
||||
|
||||
for idx, branch in enumerate(branches):
|
||||
expression = branch.criteria.expression if branch.criteria else ""
|
||||
has_jinja = "{{" in expression
|
||||
|
||||
if has_jinja:
|
||||
try:
|
||||
rendered_expression = (
|
||||
evaluation_context.template_renderer(expression)
|
||||
if evaluation_context.template_renderer
|
||||
else expression
|
||||
)
|
||||
except Exception as render_exc:
|
||||
LOG.error(
|
||||
"Conditional branch expression rendering FAILED",
|
||||
block_label=log_label,
|
||||
branch_index=idx,
|
||||
original_expression=expression,
|
||||
error=str(render_exc),
|
||||
exc_info=True,
|
||||
)
|
||||
rendered_expression = expression
|
||||
has_any_pure_natlang = True
|
||||
else:
|
||||
rendered_expression, was_patched = _make_empty_params_explicit(expression, rendered_expression)
|
||||
if was_patched:
|
||||
LOG.info(
|
||||
"Conditional branch expression patched for empty parameter(s)",
|
||||
workflow_run_id=workflow_run_id,
|
||||
block_label=log_label,
|
||||
branch_index=idx,
|
||||
original_expression=expression,
|
||||
patched_expression=rendered_expression,
|
||||
)
|
||||
else:
|
||||
rendered_expression = expression
|
||||
has_any_pure_natlang = True
|
||||
|
||||
LOG.info(
|
||||
"Conditional branch expression rendering",
|
||||
block_label=log_label,
|
||||
branch_index=idx,
|
||||
original_expression=expression,
|
||||
rendered_expression=rendered_expression,
|
||||
has_jinja=has_jinja,
|
||||
expression_changed=expression != rendered_expression,
|
||||
)
|
||||
|
||||
rendered_expressions.append(rendered_expression)
|
||||
|
||||
if has_any_pure_natlang:
|
||||
context_snapshot = evaluation_context.build_llm_safe_context_snapshot()
|
||||
context_json = json.dumps(context_snapshot, default=str)
|
||||
else:
|
||||
context_json = None
|
||||
|
||||
extraction_goal = prompt_engine.load_prompt(
|
||||
"conditional-prompt-branch-evaluation",
|
||||
conditions=rendered_expressions,
|
||||
context_json=context_json,
|
||||
)
|
||||
|
||||
data_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"evaluations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Explanation of the reasoning behind evaluating the condition.",
|
||||
},
|
||||
"result": {
|
||||
"type": "boolean",
|
||||
"description": "TRUE if the condition is satisfied, FALSE otherwise.",
|
||||
},
|
||||
},
|
||||
"required": ["reasoning", "result"],
|
||||
},
|
||||
"description": "Array of evaluation results for each condition in the same order.",
|
||||
"minItems": len(branches),
|
||||
"maxItems": len(branches),
|
||||
}
|
||||
},
|
||||
"required": ["evaluations"],
|
||||
}
|
||||
|
||||
desc_suffix = extraction_description_suffix or f"{len(branches)} conditions"
|
||||
prompt_branch_eval_id = generate_random_string()
|
||||
output_param = OutputParameter(
|
||||
output_parameter_id=str(uuid.uuid4()),
|
||||
key=f"prompt_branch_eval_{prompt_branch_eval_id}",
|
||||
workflow_id=workflow_id,
|
||||
created_at=datetime.now(),
|
||||
modified_at=datetime.now(),
|
||||
parameter_type=ParameterType.OUTPUT,
|
||||
description=f"Conditional branch evaluation results ({desc_suffix})",
|
||||
)
|
||||
extraction_block = ExtractionBlock(
|
||||
label=f"prompt_branch_eval_{prompt_branch_eval_id}",
|
||||
data_extraction_goal=extraction_goal,
|
||||
data_schema=data_schema,
|
||||
output_parameter=output_param,
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"Conditional branch ExtractionBlock created (batched)",
|
||||
block_label=log_label,
|
||||
prompt_branch_eval_id=prompt_branch_eval_id,
|
||||
num_conditions=len(branches),
|
||||
extraction_goal_preview=extraction_goal[:500] if extraction_goal else None,
|
||||
has_browser_session=browser_session_id is not None,
|
||||
has_any_pure_natlang=has_any_pure_natlang,
|
||||
has_context=context_json is not None,
|
||||
)
|
||||
|
||||
try:
|
||||
extraction_result = await extraction_block.execute(
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_run_block_id=workflow_run_block_id,
|
||||
organization_id=organization_id,
|
||||
browser_session_id=browser_session_id,
|
||||
)
|
||||
|
||||
if not extraction_result.success:
|
||||
LOG.error(
|
||||
"Conditional branch ExtractionBlock failed",
|
||||
block_label=log_label,
|
||||
failure_reason=extraction_result.failure_reason,
|
||||
)
|
||||
raise ValueError(
|
||||
f"Branch evaluation failed: "
|
||||
f"{extraction_result.failure_reason or 'Unknown error (no failure reason provided)'}"
|
||||
)
|
||||
|
||||
if workflow_run_context:
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to record conditional branch evaluation output",
|
||||
workflow_run_id=workflow_run_id,
|
||||
block_label=log_label,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
output_value = extraction_result.output_parameter_value
|
||||
|
||||
results_array: list[bool] = []
|
||||
llm_rendered_expressions: list[str] = []
|
||||
|
||||
if isinstance(output_value, list):
|
||||
output_value = {"evaluations": output_value}
|
||||
|
||||
if not isinstance(output_value, dict):
|
||||
raise ValueError(f"Unexpected output format: {type(output_value)}")
|
||||
|
||||
raw_evaluations = _find_evaluations_array(output_value)
|
||||
|
||||
for idx, evaluation in enumerate(raw_evaluations):
|
||||
bool_result, rendered_expr = _parse_single_evaluation(
|
||||
evaluation=evaluation,
|
||||
idx=idx,
|
||||
fallback_rendered_expressions=rendered_expressions,
|
||||
)
|
||||
results_array.append(bool_result)
|
||||
llm_rendered_expressions.append(rendered_expr)
|
||||
|
||||
LOG.info(
|
||||
"Conditional branch evaluation results",
|
||||
block_label=log_label,
|
||||
results=results_array,
|
||||
llm_rendered_expressions=llm_rendered_expressions,
|
||||
raw_output=output_value,
|
||||
)
|
||||
|
||||
if len(results_array) != len(branches):
|
||||
raise ValueError(
|
||||
f"Conditional branch evaluation returned {len(results_array)} results for {len(branches)} branches"
|
||||
)
|
||||
|
||||
return (results_array, llm_rendered_expressions, extraction_goal, output_value)
|
||||
|
||||
except Exception as exc:
|
||||
LOG.error(
|
||||
"Conditional branch evaluation failed",
|
||||
block_label=log_label,
|
||||
error=str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
raise ValueError(f"Conditional branch evaluation failed: {str(exc)}") from exc
|
||||
|
||||
|
||||
class ConditionalBlock(Block):
|
||||
"""Branching block that selects the next block label based on list-ordered conditions."""
|
||||
|
||||
|
|
@ -7212,235 +7444,17 @@ class ConditionalBlock(Block):
|
|||
- extraction_goal: The prompt sent to the LLM (for UI display)
|
||||
- llm_response: The raw LLM response for debugging
|
||||
"""
|
||||
if organization_id is None:
|
||||
raise ValueError("organization_id is required to evaluate natural language branches")
|
||||
|
||||
if not branches:
|
||||
return ([], [], None, None)
|
||||
|
||||
workflow_run_context = evaluation_context.workflow_run_context
|
||||
|
||||
# Step 1: Pre-render all expressions (resolve any Jinja {{ }} parts)
|
||||
rendered_expressions: list[str] = []
|
||||
has_any_pure_natlang = False
|
||||
|
||||
for idx, branch in enumerate(branches):
|
||||
expression = branch.criteria.expression if branch.criteria else ""
|
||||
has_jinja = "{{" in expression
|
||||
|
||||
if has_jinja:
|
||||
try:
|
||||
rendered_expression = (
|
||||
evaluation_context.template_renderer(expression)
|
||||
if evaluation_context.template_renderer
|
||||
else expression
|
||||
)
|
||||
except Exception as render_exc:
|
||||
LOG.error(
|
||||
"Conditional branch expression rendering FAILED",
|
||||
block_label=self.label,
|
||||
branch_index=idx,
|
||||
original_expression=expression,
|
||||
error=str(render_exc),
|
||||
exc_info=True,
|
||||
)
|
||||
rendered_expression = expression
|
||||
# Rendering failed, so this expression is effectively unresolved and must
|
||||
# take the ExtractionBlock path (with context) instead of direct LLM mode.
|
||||
has_any_pure_natlang = True
|
||||
else:
|
||||
# When a Jinja variable resolves to an empty string the rendered
|
||||
# expression becomes malformed (e.g. "if is not empty") and the
|
||||
# LLM cannot reason about emptiness correctly. Replace empty gaps
|
||||
# with an explicit "(empty value)" marker so the intent is clear.
|
||||
rendered_expression, was_patched = _make_empty_params_explicit(expression, rendered_expression)
|
||||
if was_patched:
|
||||
LOG.info(
|
||||
"Conditional branch expression patched for empty parameter(s)",
|
||||
workflow_run_id=workflow_run_id,
|
||||
block_label=self.label,
|
||||
branch_index=idx,
|
||||
original_expression=expression,
|
||||
patched_expression=rendered_expression,
|
||||
)
|
||||
else:
|
||||
rendered_expression = expression
|
||||
has_any_pure_natlang = True
|
||||
|
||||
LOG.info(
|
||||
"Conditional branch expression rendering",
|
||||
block_label=self.label,
|
||||
branch_index=idx,
|
||||
original_expression=expression,
|
||||
rendered_expression=rendered_expression,
|
||||
has_jinja=has_jinja,
|
||||
expression_changed=expression != rendered_expression,
|
||||
)
|
||||
|
||||
rendered_expressions.append(rendered_expression)
|
||||
|
||||
# Step 2: Build extraction goal with all conditions
|
||||
# Include context only if there are pure NatLang expressions that need variable resolution
|
||||
if has_any_pure_natlang:
|
||||
context_snapshot = evaluation_context.build_llm_safe_context_snapshot()
|
||||
context_json = json.dumps(context_snapshot, default=str)
|
||||
else:
|
||||
context_json = None
|
||||
|
||||
extraction_goal = prompt_engine.load_prompt(
|
||||
"conditional-prompt-branch-evaluation",
|
||||
conditions=rendered_expressions,
|
||||
context_json=context_json,
|
||||
)
|
||||
|
||||
# Step 3: Build schema for array of evaluation results
|
||||
# Order matters: reasoning -> result (chain-of-thought)
|
||||
data_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"evaluations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Explanation of the reasoning behind evaluating the condition.",
|
||||
},
|
||||
"result": {
|
||||
"type": "boolean",
|
||||
"description": "TRUE if the condition is satisfied, FALSE otherwise.",
|
||||
},
|
||||
},
|
||||
"required": ["reasoning", "result"],
|
||||
},
|
||||
"description": "Array of evaluation results for each condition in the same order.",
|
||||
"minItems": len(branches),
|
||||
"maxItems": len(branches),
|
||||
}
|
||||
},
|
||||
"required": ["evaluations"],
|
||||
}
|
||||
|
||||
# Step 4: Create and execute single ExtractionBlock.
|
||||
# Always pass the browser_session_id so page-referencing conditions
|
||||
# (e.g. "the date on the page matches X") can see the screenshot.
|
||||
# The prompt template instructs the LLM to only use page content
|
||||
# when the condition explicitly references the page, and to evaluate
|
||||
# self-contained conditions purely from the expression text.
|
||||
# NOTE: The previous approach of setting browser_session_id=None for
|
||||
# Jinja-rendered expressions (SKY-7985) was ineffective because
|
||||
# BaseTaskBlock.execute() finds the browser via workflow_run_id cache
|
||||
# regardless of browser_session_id.
|
||||
|
||||
output_param = OutputParameter(
|
||||
output_parameter_id=str(uuid.uuid4()),
|
||||
key=f"conditional_branch_eval_{generate_random_string()}",
|
||||
return await _evaluate_prompt_branch_conditions_batch(
|
||||
log_label=self.label,
|
||||
branches=branches,
|
||||
evaluation_context=evaluation_context,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_run_block_id=workflow_run_block_id,
|
||||
organization_id=organization_id,
|
||||
browser_session_id=browser_session_id,
|
||||
workflow_id=self.output_parameter.workflow_id,
|
||||
created_at=datetime.now(),
|
||||
modified_at=datetime.now(),
|
||||
parameter_type=ParameterType.OUTPUT,
|
||||
description=f"Conditional branch evaluation results ({len(branches)} conditions)",
|
||||
extraction_description_suffix=f"{len(branches)} conditions",
|
||||
)
|
||||
extraction_block = ExtractionBlock(
|
||||
label=f"conditional_branch_eval_{generate_random_string()}",
|
||||
data_extraction_goal=extraction_goal,
|
||||
data_schema=data_schema,
|
||||
output_parameter=output_param,
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"Conditional branch ExtractionBlock created (batched)",
|
||||
block_label=self.label,
|
||||
num_conditions=len(branches),
|
||||
extraction_goal_preview=extraction_goal[:500] if extraction_goal else None,
|
||||
has_browser_session=browser_session_id is not None,
|
||||
has_any_pure_natlang=has_any_pure_natlang,
|
||||
has_context=context_json is not None,
|
||||
)
|
||||
|
||||
try:
|
||||
extraction_result = await extraction_block.execute(
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_run_block_id=workflow_run_block_id,
|
||||
organization_id=organization_id,
|
||||
browser_session_id=browser_session_id,
|
||||
)
|
||||
|
||||
if not extraction_result.success:
|
||||
LOG.error(
|
||||
"Conditional branch ExtractionBlock failed",
|
||||
block_label=self.label,
|
||||
failure_reason=extraction_result.failure_reason,
|
||||
)
|
||||
raise ValueError(
|
||||
f"Branch evaluation failed: "
|
||||
f"{extraction_result.failure_reason or 'Unknown error (no failure reason provided)'}"
|
||||
)
|
||||
|
||||
if workflow_run_context:
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to record conditional branch evaluation output",
|
||||
workflow_run_id=workflow_run_id,
|
||||
block_label=self.label,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
output_value = extraction_result.output_parameter_value
|
||||
|
||||
# Step 5: Extract the evaluation results (reasoning + result)
|
||||
results_array: list[bool] = []
|
||||
llm_rendered_expressions: list[str] = []
|
||||
|
||||
if isinstance(output_value, list):
|
||||
output_value = {"evaluations": output_value}
|
||||
|
||||
if not isinstance(output_value, dict):
|
||||
raise ValueError(f"Unexpected output format: {type(output_value)}")
|
||||
|
||||
# Find evaluations array from LLM output (handles ExtractionBlock nesting)
|
||||
raw_evaluations = _find_evaluations_array(output_value)
|
||||
|
||||
# Parse each evaluation to extract result (rendered expression comes from Jinja pre-rendering)
|
||||
for idx, evaluation in enumerate(raw_evaluations):
|
||||
bool_result, rendered_expr = _parse_single_evaluation(
|
||||
evaluation=evaluation,
|
||||
idx=idx,
|
||||
fallback_rendered_expressions=rendered_expressions,
|
||||
)
|
||||
results_array.append(bool_result)
|
||||
llm_rendered_expressions.append(rendered_expr)
|
||||
|
||||
LOG.info(
|
||||
"Conditional branch evaluation results",
|
||||
block_label=self.label,
|
||||
results=results_array,
|
||||
llm_rendered_expressions=llm_rendered_expressions,
|
||||
raw_output=output_value,
|
||||
)
|
||||
|
||||
if len(results_array) != len(branches):
|
||||
raise ValueError(
|
||||
f"Prompt branch evaluation returned {len(results_array)} results for {len(branches)} branches"
|
||||
)
|
||||
|
||||
return (results_array, llm_rendered_expressions, extraction_goal, output_value)
|
||||
|
||||
except Exception as exc:
|
||||
LOG.error(
|
||||
"Conditional branch prompt evaluation failed",
|
||||
block_label=self.label,
|
||||
error=str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
raise ValueError(f"Prompt branch evaluation failed: {str(exc)}") from exc
|
||||
|
||||
async def execute( # noqa: D401
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -453,27 +453,24 @@ def block_yaml_to_block(
|
|||
loop_blocks = [block_yaml_to_block(loop_block, parameters) for loop_block in block_yaml.loop_blocks]
|
||||
|
||||
condition_yaml = block_yaml.condition
|
||||
condition: JinjaBranchCriteria | PromptBranchCriteria
|
||||
if condition_yaml.criteria_type == "jinja2_template":
|
||||
condition = JinjaBranchCriteria(
|
||||
criteria_type=condition_yaml.criteria_type,
|
||||
criteria_type = condition_yaml.criteria_type
|
||||
if criteria_type == "prompt":
|
||||
condition = PromptBranchCriteria(
|
||||
criteria_type=criteria_type,
|
||||
expression=condition_yaml.expression,
|
||||
description=condition_yaml.description,
|
||||
)
|
||||
elif condition_yaml.criteria_type == "prompt":
|
||||
condition = PromptBranchCriteria(
|
||||
criteria_type=condition_yaml.criteria_type,
|
||||
elif criteria_type == "jinja2_template":
|
||||
condition = JinjaBranchCriteria(
|
||||
criteria_type=criteria_type,
|
||||
expression=condition_yaml.expression,
|
||||
description=condition_yaml.description,
|
||||
)
|
||||
else:
|
||||
# Defensive: BranchCriteriaYAML.criteria_type is a Literal, so Pydantic
|
||||
# rejects unknown values at parse time. This branch surfaces the error
|
||||
# explicitly if the schema ever grows a third criteria type without a
|
||||
# converter update — better to fail loudly than silently coerce to Jinja.
|
||||
raise InvalidWorkflowDefinition(
|
||||
f"Unknown criteria_type {condition_yaml.criteria_type!r} for while_loop "
|
||||
f"block {block_yaml.label!r}. Supported types: jinja2_template, prompt."
|
||||
f"While loop block '{block_yaml.label}' has unsupported condition.criteria_type {criteria_type!r}. "
|
||||
"Conversion accepts only 'prompt' and 'jinja2_template', so new or unexpected YAML values fail here "
|
||||
"instead of being mapped to the wrong criteria type."
|
||||
)
|
||||
|
||||
return WhileLoopBlock(
|
||||
|
|
|
|||
58
tests/unit/fake_workflow_run_context.py
Normal file
58
tests/unit/fake_workflow_run_context.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Shared minimal duck type for ``WorkflowRunContext`` in unit tests.
|
||||
|
||||
Used by branch-criteria / Jinja evaluation tests and while-loop integration tests
|
||||
that call ``Block.format_block_parameter_template_from_workflow_run_context`` without
|
||||
booting a full workflow runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class FakeWorkflowRunContext:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
values: dict[str, Any],
|
||||
secrets: dict[str, Any] | None = None,
|
||||
include_secrets_in_templates: bool = False,
|
||||
block_metadata: dict[str, dict[str, Any]] | None = None,
|
||||
workflow_run_outputs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.values = dict(values)
|
||||
self.secrets = secrets or {}
|
||||
self.include_secrets_in_templates = include_secrets_in_templates
|
||||
self._blocks_metadata: dict[str, dict[str, Any]] = {
|
||||
label: dict(meta) for label, meta in (block_metadata or {}).items()
|
||||
}
|
||||
|
||||
self.workflow_title = "wf-title"
|
||||
self.workflow_id = "wf-id"
|
||||
self.workflow_permanent_id = "wf-perm-id"
|
||||
self.workflow_run_id = "wf-run-id"
|
||||
self.browser_session_id: str | None = None
|
||||
self.workflow_run_outputs: dict[str, Any] = dict(workflow_run_outputs or {})
|
||||
|
||||
def get_block_metadata(self, label: str | None) -> dict[str, Any]:
|
||||
if not label:
|
||||
return {}
|
||||
return dict(self._blocks_metadata.get(label, {}))
|
||||
|
||||
def update_block_metadata(self, label: str, metadata: dict[str, Any]) -> None:
|
||||
if label in self._blocks_metadata:
|
||||
self._blocks_metadata[label].update(metadata)
|
||||
else:
|
||||
self._blocks_metadata[label] = dict(metadata)
|
||||
|
||||
def has_value(self, key: str) -> bool:
|
||||
return key in self.values
|
||||
|
||||
def set_value(self, key: str, value: Any) -> None:
|
||||
self.values[key] = value
|
||||
|
||||
def build_workflow_run_summary(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def mask_secrets_in_data(self, data: Any, mask: str = "*****") -> Any:
|
||||
return data
|
||||
|
|
@ -5,31 +5,7 @@ import pytest
|
|||
from skyvern.config import settings
|
||||
from skyvern.forge.sdk.workflow.exceptions import FailedToFormatJinjaStyleParameter, MissingJinjaVariables
|
||||
from skyvern.forge.sdk.workflow.models.block import BranchEvaluationContext, JinjaBranchCriteria
|
||||
|
||||
|
||||
class FakeWorkflowRunContext:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
values: dict,
|
||||
secrets: dict | None = None,
|
||||
include_secrets_in_templates: bool = False,
|
||||
block_metadata: dict[str, dict] | None = None,
|
||||
) -> None:
|
||||
self.values = dict(values)
|
||||
self.secrets = secrets or {}
|
||||
self.include_secrets_in_templates = include_secrets_in_templates
|
||||
self._block_metadata = block_metadata or {}
|
||||
|
||||
# Minimal workflow identifiers
|
||||
self.workflow_title = "wf-title"
|
||||
self.workflow_id = "wf-id"
|
||||
self.workflow_permanent_id = "wf-perm-id"
|
||||
self.workflow_run_id = "wf-run-id"
|
||||
self.browser_session_id: str | None = None
|
||||
|
||||
def get_block_metadata(self, label: str) -> dict:
|
||||
return dict(self._block_metadata.get(label, {}))
|
||||
from tests.unit.fake_workflow_run_context import FakeWorkflowRunContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -152,3 +128,17 @@ async def test_jinja_branch_criteria_with_variable_comparison():
|
|||
# Combined logic
|
||||
criteria = JinjaBranchCriteria(expression="{{ comment_count > threshold and status == 'active' }}")
|
||||
assert await criteria.evaluate(branch_ctx) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jinja_while_style_metadata_uses_current_index():
|
||||
fake_ctx = FakeWorkflowRunContext(
|
||||
values={"params": {}},
|
||||
block_metadata={"while_blk": {"current_index": 3, "current_value": None, "current_item": None}},
|
||||
)
|
||||
branch_ctx = BranchEvaluationContext(
|
||||
workflow_run_context=fake_ctx,
|
||||
block_label="while_blk",
|
||||
)
|
||||
criteria = JinjaBranchCriteria(expression="{{ current_index < 5 }}")
|
||||
assert await criteria.evaluate(branch_ctx) is True
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
Covers schema validation, top-of-loop semantics, max-iteration safety,
|
||||
condition rendering errors, per-iteration metadata shape, cancellation
|
||||
propagation, get_all_blocks recursion, and nested-label validation.
|
||||
propagation, get_all_blocks recursion, nested-label validation, and real-Jinja
|
||||
integration for ``current_index`` in loop conditions.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
|
@ -39,6 +40,7 @@ from skyvern.schemas.workflows import (
|
|||
WhileLoopBlockYAML,
|
||||
WorkflowDefinitionYAML,
|
||||
)
|
||||
from tests.unit.fake_workflow_run_context import FakeWorkflowRunContext
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
|
@ -93,8 +95,6 @@ class TestWhileLoopBlockYAMLSchema:
|
|||
assert block.condition.criteria_type == "jinja2_template"
|
||||
|
||||
def test_prompt_condition_accepted_at_parse_time(self) -> None:
|
||||
# Forward-compat: prompt criteria parses cleanly. Execution-time rejection
|
||||
# is verified separately in the runtime tests below.
|
||||
block = WhileLoopBlockYAML(
|
||||
label="loop",
|
||||
loop_blocks=[TaskBlockYAML(label="t", url="https://example.com")],
|
||||
|
|
@ -127,6 +127,48 @@ class TestWhileLoopBlockYAMLSchema:
|
|||
assert isinstance(restored.blocks[0], WhileLoopBlockYAML)
|
||||
|
||||
|
||||
class TestWhileLoopConverterCriteriaType:
|
||||
"""block_yaml_to_block must honor condition.criteria_type (SKY-8771)."""
|
||||
|
||||
def test_jinja_type_kept_when_expression_has_multiple_jinja_segments(self) -> None:
|
||||
yaml_def = WorkflowDefinitionYAML(
|
||||
parameters=[],
|
||||
blocks=[
|
||||
WhileLoopBlockYAML(
|
||||
label="loop",
|
||||
loop_blocks=[TaskBlockYAML(label="inner", url="https://example.com")],
|
||||
condition=BranchCriteriaYAML(
|
||||
criteria_type="jinja2_template",
|
||||
expression="{{ a }} and {{ b }}",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
wf_def = convert_workflow_definition(yaml_def, workflow_id="wf_test")
|
||||
block = wf_def.blocks[0]
|
||||
assert isinstance(block, WhileLoopBlock)
|
||||
assert isinstance(block.condition, JinjaBranchCriteria)
|
||||
|
||||
def test_prompt_type_kept_when_expression_is_single_jinja_placeholder(self) -> None:
|
||||
yaml_def = WorkflowDefinitionYAML(
|
||||
parameters=[],
|
||||
blocks=[
|
||||
WhileLoopBlockYAML(
|
||||
label="loop",
|
||||
loop_blocks=[TaskBlockYAML(label="inner", url="https://example.com")],
|
||||
condition=BranchCriteriaYAML(
|
||||
criteria_type="prompt",
|
||||
expression="{{ x }}",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
wf_def = convert_workflow_definition(yaml_def, workflow_id="wf_test")
|
||||
block = wf_def.blocks[0]
|
||||
assert isinstance(block, WhileLoopBlock)
|
||||
assert isinstance(block.condition, PromptBranchCriteria)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) Validation: nested labels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -229,7 +271,7 @@ class TestExecuteTopOfLoopSemantics:
|
|||
|
||||
condition_results = iter([True, True, False])
|
||||
|
||||
async def fake_eval(_self: Any, _ctx: Any) -> bool: # type: ignore[override]
|
||||
async def fake_eval(_self: Any, _ctx: Any, **_kw: Any) -> bool: # type: ignore[override]
|
||||
return next(condition_results)
|
||||
|
||||
with (
|
||||
|
|
@ -253,9 +295,8 @@ class TestExecuteTopOfLoopSemantics:
|
|||
assert len(result.outputs_with_loop_values) == 2
|
||||
assert len(result.block_outputs) == 2
|
||||
|
||||
# Verify per-iteration metadata sets current_index 0 and 1, with
|
||||
# current_value / current_item explicitly nulled so a stale outer-loop
|
||||
# value can't leak into child Jinja expressions (SKY-8835).
|
||||
# Verify per-iteration metadata sets current_index 0 and 1; ``current_value``
|
||||
# stays None (same as persisted timeline / ``execute_safe``); ``current_item`` None.
|
||||
metadata_calls = [c.args for c in mock_context.update_block_metadata.call_args_list]
|
||||
indices_set = [args[1].get("current_index") for args in metadata_calls if isinstance(args[1], dict)]
|
||||
assert 0 in indices_set
|
||||
|
|
@ -266,11 +307,9 @@ class TestExecuteTopOfLoopSemantics:
|
|||
assert meta.get("current_item") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_explicitly_nulls_for_loop_keys(self) -> None:
|
||||
"""SKY-8771 review fix (SKY-8835 dependency): when WhileLoopBlock writes its
|
||||
per-iteration metadata, ``current_value`` and ``current_item`` must be present
|
||||
as explicit None so that ``update_block_metadata``'s merge semantics overwrite
|
||||
any stale outer for_loop values rather than letting them linger.
|
||||
async def test_metadata_overwrites_outer_loop_keys_with_while_iteration_slots(self) -> None:
|
||||
"""While-loop metadata merges the same keys as for-loops so outer rows are overwritten,
|
||||
but ``current_value`` / ``current_item`` stay ``None`` (iteration is ``current_index`` only).
|
||||
"""
|
||||
loop_block = _make_while_loop()
|
||||
inner_block = loop_block.loop_blocks[0]
|
||||
|
|
@ -283,7 +322,7 @@ class TestExecuteTopOfLoopSemantics:
|
|||
|
||||
condition_results = iter([True, False])
|
||||
|
||||
async def fake_eval(_self: Any, _ctx: Any) -> bool: # type: ignore[override]
|
||||
async def fake_eval(_self: Any, _ctx: Any, **_kw: Any) -> bool: # type: ignore[override]
|
||||
return next(condition_results)
|
||||
|
||||
with (
|
||||
|
|
@ -303,11 +342,12 @@ class TestExecuteTopOfLoopSemantics:
|
|||
organization_id="org_test",
|
||||
)
|
||||
|
||||
# Every metadata write must carry the for_loop keys with explicit None values.
|
||||
# Every metadata write must carry for-loop-shaped keys for merge overwrite.
|
||||
metadata_calls = [c.args[1] for c in mock_context.update_block_metadata.call_args_list]
|
||||
assert metadata_calls, "expected at least one metadata write"
|
||||
for meta in metadata_calls:
|
||||
assert "current_value" in meta and meta["current_value"] is None
|
||||
assert "current_value" in meta
|
||||
assert meta["current_value"] is None
|
||||
assert "current_item" in meta and meta["current_item"] is None
|
||||
assert isinstance(meta.get("current_index"), int)
|
||||
|
||||
|
|
@ -374,7 +414,7 @@ class TestExecuteMaxIterationsCap:
|
|||
|
||||
condition_results = iter([True, True, True, False])
|
||||
|
||||
async def fake_eval(_self: Any, _ctx: Any) -> bool: # type: ignore[override]
|
||||
async def fake_eval(_self: Any, _ctx: Any, **_kw: Any) -> bool: # type: ignore[override]
|
||||
return next(condition_results)
|
||||
|
||||
with (
|
||||
|
|
@ -425,7 +465,7 @@ class TestCurrentIndexWrittenBeforeCondition:
|
|||
mock_context.update_block_metadata = MagicMock()
|
||||
prior_calls_at_first_eval: list[Any] = []
|
||||
|
||||
async def fake_eval(_self: Any, ctx: Any) -> bool: # type: ignore[override]
|
||||
async def fake_eval(_self: Any, ctx: Any, **_kw: Any) -> bool: # type: ignore[override]
|
||||
if not prior_calls_at_first_eval:
|
||||
prior_calls_at_first_eval.extend(ctx.update_block_metadata.call_args_list)
|
||||
return False # exit immediately after the first check
|
||||
|
|
@ -455,13 +495,107 @@ class TestCurrentIndexWrittenBeforeCondition:
|
|||
)
|
||||
|
||||
|
||||
class TestWhileLoopJinjaCurrentIndexIntegration:
|
||||
"""Real Jinja evaluation for while conditions (no mock of ``_evaluate_condition``).
|
||||
|
||||
Documents ``current_index == 0`` combined with another predicate: ``and`` requires that
|
||||
predicate to be true on the first check or the body never runs; ``or`` runs the body
|
||||
once on iteration 0 even when the predicate is false, then exits once ``current_index``
|
||||
advances.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_index_zero_and_need_more_false_skips_body(self) -> None:
|
||||
loop_block = _make_while_loop(
|
||||
"{{ current_index == 0 and params.need_more }}",
|
||||
)
|
||||
mock_context = FakeWorkflowRunContext(values={"params": {"need_more": False}})
|
||||
|
||||
with (
|
||||
patch.object(Block, "execute_safe", new_callable=AsyncMock) as mock_execute_safe,
|
||||
patch("skyvern.forge.sdk.workflow.models.block.app") as mock_app,
|
||||
patch("skyvern.forge.sdk.workflow.models.block.skyvern_context") as mock_skyvern_ctx,
|
||||
):
|
||||
mock_skyvern_ctx.current.return_value = None
|
||||
mock_app.DATABASE.workflow_runs.create_or_update_workflow_run_output_parameter = AsyncMock()
|
||||
|
||||
result = await loop_block._execute_while_loop_helper(
|
||||
workflow_run_id="wr_test",
|
||||
workflow_run_block_id="wrb_loop",
|
||||
workflow_run_context=mock_context,
|
||||
organization_id="org_test",
|
||||
)
|
||||
|
||||
assert mock_execute_safe.call_count == 0
|
||||
assert result.outputs_with_loop_values == []
|
||||
assert result.block_outputs == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_index_zero_and_need_more_true_runs_once_then_exits(self) -> None:
|
||||
loop_block = _make_while_loop(
|
||||
"{{ current_index == 0 and params.need_more }}",
|
||||
)
|
||||
inner_block = loop_block.loop_blocks[0]
|
||||
inner_result = _make_block_result(inner_block.output_parameter)
|
||||
mock_context = FakeWorkflowRunContext(values={"params": {"need_more": True}})
|
||||
|
||||
with (
|
||||
patch.object(Block, "execute_safe", new_callable=AsyncMock, return_value=inner_result) as mock_execute_safe,
|
||||
patch("skyvern.forge.sdk.workflow.models.block.app") as mock_app,
|
||||
patch("skyvern.forge.sdk.workflow.models.block.skyvern_context") as mock_skyvern_ctx,
|
||||
):
|
||||
mock_skyvern_ctx.current.return_value = None
|
||||
mock_app.DATABASE.workflow_runs.create_or_update_workflow_run_output_parameter = AsyncMock()
|
||||
mock_app.DATABASE.observer.update_workflow_run_block = AsyncMock()
|
||||
|
||||
result = await loop_block._execute_while_loop_helper(
|
||||
workflow_run_id="wr_test",
|
||||
workflow_run_block_id="wrb_loop",
|
||||
workflow_run_context=mock_context,
|
||||
organization_id="org_test",
|
||||
)
|
||||
|
||||
assert mock_execute_safe.call_count == 1
|
||||
assert len(result.outputs_with_loop_values) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_index_zero_or_need_more_false_runs_body_once(self) -> None:
|
||||
"""``current_index == 0`` alone forces the first condition check true even when
|
||||
``params.need_more`` is false; the second check exits."""
|
||||
loop_block = _make_while_loop(
|
||||
"{{ current_index == 0 or params.need_more }}",
|
||||
)
|
||||
inner_block = loop_block.loop_blocks[0]
|
||||
inner_result = _make_block_result(inner_block.output_parameter)
|
||||
mock_context = FakeWorkflowRunContext(values={"params": {"need_more": False}})
|
||||
|
||||
with (
|
||||
patch.object(Block, "execute_safe", new_callable=AsyncMock, return_value=inner_result) as mock_execute_safe,
|
||||
patch("skyvern.forge.sdk.workflow.models.block.app") as mock_app,
|
||||
patch("skyvern.forge.sdk.workflow.models.block.skyvern_context") as mock_skyvern_ctx,
|
||||
):
|
||||
mock_skyvern_ctx.current.return_value = None
|
||||
mock_app.DATABASE.workflow_runs.create_or_update_workflow_run_output_parameter = AsyncMock()
|
||||
mock_app.DATABASE.observer.update_workflow_run_block = AsyncMock()
|
||||
|
||||
result = await loop_block._execute_while_loop_helper(
|
||||
workflow_run_id="wr_test",
|
||||
workflow_run_block_id="wrb_loop",
|
||||
workflow_run_context=mock_context,
|
||||
organization_id="org_test",
|
||||
)
|
||||
|
||||
assert mock_execute_safe.call_count == 1
|
||||
assert len(result.outputs_with_loop_values) == 1
|
||||
|
||||
|
||||
class TestExecuteConditionRenderingErrors:
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_jinja_format_returns_failure_result(self) -> None:
|
||||
loop_block = _make_while_loop()
|
||||
mock_context = MagicMock()
|
||||
|
||||
async def raise_format_error(_self: Any, _ctx: Any) -> bool: # type: ignore[override]
|
||||
async def raise_format_error(_self: Any, _ctx: Any, **_kw: Any) -> bool: # type: ignore[override]
|
||||
raise FailedToFormatJinjaStyleParameter("{{ ??? }}", "syntax error")
|
||||
|
||||
with (
|
||||
|
|
@ -489,7 +623,7 @@ class TestExecuteConditionRenderingErrors:
|
|||
loop_block = _make_while_loop()
|
||||
mock_context = MagicMock()
|
||||
|
||||
async def raise_missing(_self: Any, _ctx: Any) -> bool: # type: ignore[override]
|
||||
async def raise_missing(_self: Any, _ctx: Any, **_kw: Any) -> bool: # type: ignore[override]
|
||||
raise MissingJinjaVariables("{{ undefined_var }}", {"undefined_var"})
|
||||
|
||||
with (
|
||||
|
|
@ -556,9 +690,9 @@ class TestExecuteCancellationPropagation:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPromptCriteriaRejected:
|
||||
class TestPromptCriteriaEvaluation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_criteria_returns_clear_failure(self) -> None:
|
||||
async def test_prompt_condition_delegates_to_batch_evaluator(self) -> None:
|
||||
inner = TaskBlock(label="inner_task", output_parameter=_make_output_param("inner_task"))
|
||||
loop_block = WhileLoopBlock(
|
||||
label="my_while",
|
||||
|
|
@ -566,24 +700,23 @@ class TestPromptCriteriaRejected:
|
|||
loop_blocks=[inner],
|
||||
condition=PromptBranchCriteria(expression="dates on the page are still recent"),
|
||||
)
|
||||
|
||||
mock_context = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(Block, "get_workflow_run_context", return_value=mock_context),
|
||||
patch("skyvern.forge.sdk.workflow.models.block.app") as mock_app,
|
||||
):
|
||||
mock_app.DATABASE.observer.update_workflow_run_block = AsyncMock()
|
||||
|
||||
result = await loop_block._run_loop(
|
||||
with patch(
|
||||
"skyvern.forge.sdk.workflow.models.block._evaluate_prompt_branch_conditions_batch",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_batch:
|
||||
mock_batch.return_value = ([True], ["dates on the page are still recent"], "goal", {})
|
||||
result = await loop_block._evaluate_condition(
|
||||
mock_context,
|
||||
workflow_run_id="wr_test",
|
||||
workflow_run_block_id="wrb_loop",
|
||||
organization_id="org_test",
|
||||
browser_session_id=None,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.status == BlockStatus.failed
|
||||
assert "Prompt criteria are not yet supported" in (result.failure_reason or "")
|
||||
assert result is True
|
||||
mock_batch.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue