feat(SKY-8771): frontend support for while_loop block (#5809)

This commit is contained in:
Shuchang Zheng 2026-05-04 19:05:33 -07:00 committed by GitHub
parent b9649d2734
commit b93d71b8c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1209 additions and 523 deletions

View file

@ -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": {

View file

@ -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": {

View file

@ -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));
}
}

View file

@ -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>
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&#123;&#123;&nbsp;current_value&nbsp;&#125;&#125;
</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

View file

@ -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": {

View file

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

View file

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

View file

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

View file

@ -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);

View file

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

View file

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

View file

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

View 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;
}

View file

@ -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;
}

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

@ -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()
# ---------------------------------------------------------------------------