From 9888bd27d471085792d300079cb0fc989392f51c Mon Sep 17 00:00:00 2001 From: LawyZheng Date: Thu, 4 Dec 2025 14:51:44 +0800 Subject: [PATCH] next interation on failure (#4192) --- .../routes/workflows/editor/helpContent.ts | 4 + .../editor/nodes/ActionNode/ActionNode.tsx | 42 +++--- .../nodes/ExtractionNode/ExtractionNode.tsx | 45 +++--- .../FileDownloadNode/FileDownloadNode.tsx | 40 +++--- .../editor/nodes/LoginNode/LoginNode.tsx | 40 +++--- .../editor/nodes/LoopNode/LoopNode.tsx | 20 +++ .../workflows/editor/nodes/LoopNode/types.ts | 2 + .../nodes/NavigationNode/NavigationNode.tsx | 78 +++++----- .../editor/nodes/TaskNode/TaskNode.tsx | 76 +++++----- .../nodes/ValidationNode/ValidationNode.tsx | 45 +++--- .../components/BlockExecutionOptions.tsx | 134 ++++++++++++++++++ .../routes/workflows/editor/nodes/types.ts | 1 + .../workflows/editor/workflowEditorUtils.ts | 24 ++++ .../routes/workflows/types/workflowTypes.ts | 1 + .../workflows/types/workflowYamlTypes.ts | 1 + skyvern/forge/sdk/workflow/models/block.py | 34 ++++- skyvern/forge/sdk/workflow/service.py | 1 + skyvern/schemas/workflows.py | 3 + 18 files changed, 380 insertions(+), 211 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/components/BlockExecutionOptions.tsx diff --git a/skyvern-frontend/src/routes/workflows/editor/helpContent.ts b/skyvern-frontend/src/routes/workflows/editor/helpContent.ts index 2b8e0091..9b8285cc 100644 --- a/skyvern-frontend/src/routes/workflows/editor/helpContent.ts +++ b/skyvern-frontend/src/routes/workflows/editor/helpContent.ts @@ -23,6 +23,8 @@ export const baseHelpTooltipContent = { "If you are running multiple workflows at once, you will need to give the block an identifier to know that this TOTP goes with this block.", continueOnFailure: "Allow the workflow to continue if it encounters a failure.", + nextIterationOnFailure: + "When inside a for loop, continue to the next iteration if this block fails.", includeActionHistoryInVerification: "Include the action history in the completion verification.", } as const; @@ -72,6 +74,8 @@ 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.", + nextIterationOnFailure: + "When enabled, if any block inside the loop fails, the loop will immediately jump to the next iteration instead of stopping.", }, sendEmail: { ...baseHelpTooltipContent, diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx index 7a8c0a8b..ffdd5e88 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -23,7 +23,10 @@ import { useRerender } from "@/hooks/useRerender"; import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor"; import { WorkflowBlockInput } from "@/components/WorkflowBlockInput"; import { AppNode } from ".."; -import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils"; +import { + getAvailableOutputParameterKeys, + isNodeInsideForLoop, +} from "../../workflowEditorUtils"; import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { RunEngineSelector } from "@/components/EngineSelector"; @@ -37,6 +40,7 @@ import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuer import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { DisableCache } from "../DisableCache"; +import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; const urlTooltip = "The URL Skyvern is navigating to. Leave this field blank to pick up from where the last block left off."; @@ -64,6 +68,7 @@ function ActionNode({ id, data, type }: NodeProps) { const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const update = useUpdate({ id, editable }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -248,28 +253,19 @@ function ActionNode({ id, data, type }: NodeProps) { )} - -
-
- - -
-
- { - if (!editable) { - return; - } - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + /> ) { @@ -58,6 +61,7 @@ function ExtractionNode({ id, data, type }: NodeProps) { const rerender = useRerender({ prefix: "accordian" }); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const update = useUpdate({ id, editable }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -209,30 +213,19 @@ function ExtractionNode({ id, data, type }: NodeProps) { }} /> - -
-
- - -
-
- { - if (!editable) { - return; - } - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + /> ) { const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const update = useUpdate({ id, editable }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -279,25 +283,19 @@ function FileDownloadNode({ id, data }: NodeProps) { )} - -
-
- - -
-
- { - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + /> ) { @@ -56,6 +59,7 @@ function LoginNode({ id, data, type }: NodeProps) { const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const update = useUpdate({ id, editable }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); // Manage flippable facing state const [facing, setFacing] = useState<"front" | "back">("front"); @@ -277,25 +281,19 @@ function LoginNode({ id, data, type }: NodeProps) { )} - -
-
- - -
-
- { - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + /> ) { +
+
+ { + update({ + nextIterationOnFailure: + checked === "indeterminate" ? false : checked, + }); + }} + /> + + +
+
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts index ee1776af..25268116 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoopNode/types.ts @@ -7,6 +7,7 @@ export type LoopNodeData = NodeBaseData & { loopVariableReference: string; completeIfEmpty: boolean; continueOnFailure: boolean; + nextIterationOnFailure?: boolean; }; export type LoopNode = Node; @@ -19,6 +20,7 @@ export const loopNodeDefaultData: LoopNodeData = { loopVariableReference: "", completeIfEmpty: false, continueOnFailure: false, + nextIterationOnFailure: false, model: null, } as const; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx index 339a5579..55d3d742 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx @@ -25,7 +25,10 @@ import { errorMappingExampleValue } from "../types"; import type { NavigationNode } from "./types"; import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect"; import { AppNode } from ".."; -import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils"; +import { + getAvailableOutputParameterKeys, + isNodeInsideForLoop, +} from "../../workflowEditorUtils"; import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow"; import { RunEngineSelector } from "@/components/EngineSelector"; import { ModelSelector } from "@/components/ModelSelector"; @@ -37,6 +40,7 @@ import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuer import { useUpdate } from "@/routes/workflows/editor/useUpdate"; import { DisableCache } from "../DisableCache"; +import { BlockExecutionOptions } from "../components/BlockExecutionOptions"; import { AI_IMPROVE_CONFIGS } from "../../constants"; function NavigationNode({ id, data, type }: NodeProps) { @@ -58,6 +62,7 @@ function NavigationNode({ id, data, type }: NodeProps) { const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const update = useUpdate({ id, editable }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -287,51 +292,32 @@ function NavigationNode({ id, data, type }: NodeProps) { )} - -
-
- - -
-
- { - update({ - includeActionHistoryInVerification: checked, - }); - }} - /> -
-
-
-
- - -
-
- { - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + onIncludeActionHistoryInVerificationChange={(checked) => { + update({ + includeActionHistoryInVerification: checked, + }); + }} + /> ) { const [facing, setFacing] = useState<"front" | "back">("front"); @@ -59,6 +63,7 @@ function TaskNode({ id, data, type }: NodeProps) { const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const update = useUpdate({ id, editable }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -303,49 +308,32 @@ function TaskNode({ id, data, type }: NodeProps) { )} - -
-
- - -
-
- { - update({ - includeActionHistoryInVerification: checked, - }); - }} - /> -
-
-
-
- - -
-
- { - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + onIncludeActionHistoryInVerificationChange={(checked) => { + update({ + includeActionHistoryInVerification: checked, + }); + }} + /> ) { @@ -55,6 +58,7 @@ function ValidationNode({ id, data, type }: NodeProps) { const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id); const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id }); const update = useUpdate({ id, editable }); + const isInsideForLoop = isNodeInsideForLoop(nodes, id); useEffect(() => { setFacing(data.showCode ? "back" : "front"); @@ -212,30 +216,19 @@ function ValidationNode({ id, data, type }: NodeProps) { )} - -
-
- - -
-
- { - if (!editable) { - return; - } - update({ continueOnFailure: checked }); - }} - /> -
-
+ { + update({ continueOnFailure: checked }); + }} + onNextIterationOnFailureChange={(checked) => { + update({ nextIterationOnFailure: checked }); + }} + /> void; + onNextIterationOnFailureChange: (checked: boolean) => void; + onIncludeActionHistoryInVerificationChange?: (checked: boolean) => void; + showOptions?: { + continueOnFailure?: boolean; + nextIterationOnFailure?: boolean; + includeActionHistoryInVerification?: boolean; + }; +} + +export function BlockExecutionOptions({ + continueOnFailure, + nextIterationOnFailure = false, + includeActionHistoryInVerification = false, + editable, + isInsideForLoop, + blockType, + onContinueOnFailureChange, + onNextIterationOnFailureChange, + onIncludeActionHistoryInVerificationChange, + showOptions = { + continueOnFailure: true, + nextIterationOnFailure: true, + includeActionHistoryInVerification: false, + }, +}: BlockExecutionOptionsProps) { + const showContinueOnFailure = showOptions.continueOnFailure ?? true; + const showNextIterationOnFailure = showOptions.nextIterationOnFailure ?? true; + const showIncludeActionHistory = + showOptions.includeActionHistoryInVerification ?? false; + + return ( + <> + + {showIncludeActionHistory && + onIncludeActionHistoryInVerificationChange && ( +
+
+ + +
+
+ { + if (!editable) { + return; + } + onIncludeActionHistoryInVerificationChange(checked); + }} + /> +
+
+ )} + {showContinueOnFailure && ( +
+
+ + +
+
+ { + if (!editable) { + return; + } + onContinueOnFailureChange(checked); + }} + /> +
+
+ )} + {showNextIterationOnFailure && isInsideForLoop && ( +
+
+ + +
+
+ { + if (!editable) { + return; + } + onNextIterationOnFailureChange(checked); + }} + /> +
+
+ )} + + + ); +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index 9ec3f42d..143d397b 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -5,6 +5,7 @@ export type NodeBaseData = { debuggable: boolean; label: string; continueOnFailure: boolean; + nextIterationOnFailure?: boolean; editable: boolean; model: WorkflowModel | null; showCode?: boolean; diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 833cbb96..b216eebd 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -223,6 +223,7 @@ function convertToNode( debuggable: debuggableWorkflowBlockTypes.has(block.block_type), label: block.label, continueOnFailure: block.continue_on_failure, + nextIterationOnFailure: block.next_iteration_on_failure, editable, model: block.model, }; @@ -489,6 +490,7 @@ function convertToNode( loopValue: block.loop_over?.key ?? "", loopVariableReference: loopVariableReference, completeIfEmpty: block.complete_if_empty, + nextIterationOnFailure: block.next_iteration_on_failure, }, }; } @@ -1073,6 +1075,7 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { const base = { label: node.data.label, continue_on_failure: node.data.continueOnFailure, + next_iteration_on_failure: node.data.nextIterationOnFailure, model: node.data.model, }; switch (node.type) { @@ -1422,6 +1425,7 @@ function getOrderedChildrenBlocks( block_type: "for_loop", label: currentNode.data.label, continue_on_failure: currentNode.data.continueOnFailure, + next_iteration_on_failure: currentNode.data.nextIterationOnFailure, loop_blocks: loopChildren, loop_variable_reference: currentNode.data.loopVariableReference, complete_if_empty: currentNode.data.completeIfEmpty, @@ -1452,6 +1456,7 @@ function getWorkflowBlocksUtil( block_type: "for_loop", label: node.data.label, continue_on_failure: node.data.continueOnFailure, + next_iteration_on_failure: node.data.nextIterationOnFailure, loop_blocks: getOrderedChildrenBlocks(nodes, edges, node.id), loop_variable_reference: node.data.loopVariableReference, complete_if_empty: node.data.completeIfEmpty, @@ -1952,6 +1957,7 @@ function convertBlocksToBlockYAML( const base = { label: block.label, continue_on_failure: block.continue_on_failure, + next_iteration_on_failure: block.next_iteration_on_failure, next_block_label: block.next_block_label, }; switch (block.block_type) { @@ -2455,6 +2461,23 @@ function getLabelForWorkflowParameterType(type: WorkflowParameterValueType) { return type; } +/** + * Check if a node is inside a for loop block + * @param nodes - Array of all nodes in the workflow + * @param nodeId - ID of the node to check + * @returns true if the node is inside a for loop block, false otherwise + */ +function isNodeInsideForLoop(nodes: Array, nodeId: string): boolean { + const currentNode = nodes.find((n) => n.id === nodeId); + if (!currentNode) { + return false; + } + const parentNode = currentNode.parentId + ? nodes.find((n) => n.id === currentNode.parentId) + : null; + return parentNode?.type === "loop"; +} + export { convert, convertEchoParameters, @@ -2479,6 +2502,7 @@ export { getUpdatedParametersAfterLabelUpdateForSourceParameterKey, getWorkflowBlocks, getWorkflowErrors, + isNodeInsideForLoop, isOutputParameterKey, layout, }; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index ae9f47f3..c0e55e09 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -287,6 +287,7 @@ export type WorkflowBlockBase = { block_type: WorkflowBlockType; output_parameter: OutputParameter; continue_on_failure: boolean; + next_iteration_on_failure?: boolean; model: WorkflowModel | null; next_block_label?: string | null; }; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index 2e6ab06a..090a7780 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -145,6 +145,7 @@ export type BlockYAMLBase = { block_type: WorkflowBlockType; label: string; continue_on_failure?: boolean; + next_iteration_on_failure?: boolean; next_block_label?: string | null; }; diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py index 369bfadd..0ea4498e 100644 --- a/skyvern/forge/sdk/workflow/models/block.py +++ b/skyvern/forge/sdk/workflow/models/block.py @@ -135,6 +135,10 @@ class Block(BaseModel, abc.ABC): model: dict[str, Any] | None = None disable_cache: bool = False + # Only valid for blocks inside a for loop block + # Whether to continue to the next iteration when the block fails + next_iteration_on_failure: bool = False + @property def override_llm_key(self) -> str | None: """ @@ -1386,15 +1390,15 @@ class ForLoopBlock(Block): organization_id=organization_id, ) block_outputs.append(failure_block_result) - # If continue_on_failure is False, stop the entire loop - if not self.continue_on_failure: + # If next_iteration_on_failure is False, stop the entire loop + if not self.next_iteration_on_failure: outputs_with_loop_values.append(each_loop_output_values) return LoopBlockExecutedResult( outputs_with_loop_values=outputs_with_loop_values, block_outputs=block_outputs, last_block=current_block, ) - # If continue_on_failure is True, break out of the block loop for this iteration + # If next_iteration_on_failure is True, break out of the block loop for this iteration break if block_output.status == BlockStatus.canceled: @@ -1412,7 +1416,12 @@ class ForLoopBlock(Block): last_block=current_block, ) - if not block_output.success and not loop_block.continue_on_failure: + if ( + not block_output.success + and not loop_block.continue_on_failure + and not loop_block.next_iteration_on_failure + and not self.next_iteration_on_failure + ): LOG.info( f"ForLoopBlock: Encountered a failure processing block {block_idx} during loop {loop_idx}, terminating early", block_outputs=block_outputs, @@ -1421,6 +1430,8 @@ class ForLoopBlock(Block): loop_over_value=loop_over_value, loop_block_continue_on_failure=loop_block.continue_on_failure, failure_reason=block_output.failure_reason, + next_iteration_on_failure=loop_block.next_iteration_on_failure + or self.next_iteration_on_failure, ) outputs_with_loop_values.append(each_loop_output_values) return LoopBlockExecutedResult( @@ -1429,6 +1440,21 @@ class ForLoopBlock(Block): last_block=current_block, ) + if block_output.success or loop_block.continue_on_failure: + continue + + if loop_block.next_iteration_on_failure or self.next_iteration_on_failure: + LOG.info( + f"ForLoopBlock: Block {block_idx} during loop {loop_idx} failed but will continue to next iteration", + block_outputs=block_outputs, + loop_idx=loop_idx, + block_idx=block_idx, + loop_over_value=loop_over_value, + loop_block_next_iteration_on_failure=loop_block.next_iteration_on_failure + or self.next_iteration_on_failure, + ) + break + outputs_with_loop_values.append(each_loop_output_values) return LoopBlockExecutedResult( diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index d297f316..911d9bf2 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -2951,6 +2951,7 @@ class WorkflowService: "next_block_label": block_yaml.next_block_label, "output_parameter": output_parameter, "continue_on_failure": block_yaml.continue_on_failure, + "next_iteration_on_failure": block_yaml.next_iteration_on_failure, "model": block_yaml.model, } diff --git a/skyvern/schemas/workflows.py b/skyvern/schemas/workflows.py index a7f57569..d5688868 100644 --- a/skyvern/schemas/workflows.py +++ b/skyvern/schemas/workflows.py @@ -206,6 +206,9 @@ class BlockYAML(BaseModel, abc.ABC): ) continue_on_failure: bool = False model: dict[str, Any] | None = None + # Only valid for blocks inside a for loop block + # Whether to continue to the next iteration when the block fails + next_iteration_on_failure: bool = False @field_validator("label") @classmethod