This commit is contained in:
Nicholas Tindle
2025-10-03 13:25:39 -05:00
parent f64c309fd4
commit 155b496678
8 changed files with 141 additions and 46 deletions

View File

@@ -16,9 +16,9 @@ class OptionalBlockConditions(BaseModel):
default=False,
description="Skip block if any required credentials are missing",
)
input_flag: Optional[str] = Field(
default=None,
description="Name of boolean agent input field that controls skip behavior",
check_skip_input: bool = Field(
default=True,
description="Check the standard 'skip' input to control skip behavior",
)
kv_flag: Optional[str] = Field(
default=None,

View File

@@ -168,11 +168,11 @@ async def should_skip_node(
if conditions.on_missing_credentials:
conditions_met.append(False)
# Check input flag
if conditions.input_flag and conditions.input_flag in input_data:
flag_value = input_data.get(conditions.input_flag, False)
if flag_value is True: # Skip if flag is True
skip_reasons.append(f"Input flag '{conditions.input_flag}' is true")
# Check standard skip_run_block input (automatically added for optional blocks)
if conditions.check_skip_input and "skip_run_block" in input_data:
skip_value = input_data.get("skip_run_block", False)
if skip_value is True: # Skip if input is True
skip_reasons.append("Skip input is true")
conditions_met.append(True)
else:
conditions_met.append(False)

View File

@@ -193,14 +193,14 @@ class TestShouldSkipNode:
assert should_skip is False
assert reason == ""
async def test_skip_on_input_flag_true(
async def test_skip_on_skip_input_true(
self, mock_node, mock_creds_manager, user_context
):
"""Test skipping when input flag is true."""
"""Test skipping when skip_run_block input is true."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"input_flag": "skip_this_block"},
"conditions": {"check_skip_input": True},
}
}
@@ -209,20 +209,20 @@ class TestShouldSkipNode:
creds_manager=mock_creds_manager,
user_id="test_user",
user_context=user_context,
input_data={"skip_this_block": True},
input_data={"skip_run_block": True},
graph_id="test_graph_id",
)
assert should_skip is True
assert "Input flag 'skip_this_block' is true" in reason
assert "Skip input is true" in reason
async def test_no_skip_on_input_flag_false(
async def test_no_skip_on_skip_input_false(
self, mock_node, mock_creds_manager, user_context
):
"""Test no skip when input flag is false."""
"""Test no skip when skip_run_block input is false."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"input_flag": "skip_this_block"},
"conditions": {"check_skip_input": True},
}
}
@@ -231,7 +231,7 @@ class TestShouldSkipNode:
creds_manager=mock_creds_manager,
user_id="test_user",
user_context=user_context,
input_data={"skip_this_block": False},
input_data={"skip_run_block": False},
graph_id="test_graph_id",
)
assert should_skip is False
@@ -246,7 +246,7 @@ class TestShouldSkipNode:
"enabled": True,
"conditions": {
"on_missing_credentials": True,
"input_flag": "skip_block",
"check_skip_input": True,
"operator": "or",
},
}
@@ -264,12 +264,12 @@ class TestShouldSkipNode:
user_context=user_context,
input_data={
"credentials": {"id": "cred_123"},
"skip_block": True,
"skip_run_block": True,
},
graph_id="test_graph_id",
)
assert should_skip is True # OR: at least one condition met
assert "Input flag 'skip_block' is true" in reason
assert "Skip input is true" in reason
async def test_skip_with_and_operator(
self, mock_node, mock_creds_manager, user_context
@@ -280,7 +280,7 @@ class TestShouldSkipNode:
"enabled": True,
"conditions": {
"on_missing_credentials": True,
"input_flag": "skip_block",
"check_skip_input": True,
"operator": "and",
},
}
@@ -298,7 +298,7 @@ class TestShouldSkipNode:
user_context=user_context,
input_data={
"credentials": {"id": "cred_123"},
"skip_block": False,
"skip_run_block": False,
},
graph_id="test_graph_id",
)
@@ -312,7 +312,7 @@ class TestShouldSkipNode:
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"input_flag": "skip_this"},
"conditions": {"check_skip_input": True},
"skip_message": "Custom skip message for testing",
}
}
@@ -322,7 +322,7 @@ class TestShouldSkipNode:
creds_manager=mock_creds_manager,
user_id="test_user",
user_context=user_context,
input_data={"skip_this": True},
input_data={"skip_run_block": True},
graph_id="test_graph_id",
)
assert should_skip is True
@@ -403,7 +403,7 @@ class TestShouldSkipNode:
"enabled": True,
"conditions": {
"kv_flag": "enable_integration",
"input_flag": "force_skip",
"check_skip_input": True,
"operator": "or",
},
}
@@ -417,17 +417,17 @@ class TestShouldSkipNode:
return_value=False
)
# Even though KV flag is False, input_flag is True so it should skip (OR operator)
# Even though KV flag is False, skip_run_block is True so it should skip (OR operator)
should_skip, reason = await should_skip_node(
node=mock_node,
creds_manager=mock_creds_manager,
user_id="test_user",
user_context=user_context,
input_data={"force_skip": True},
input_data={"skip_run_block": True},
graph_id="test_graph_id",
)
assert should_skip is True
assert "Input flag 'force_skip' is true" in reason
assert "Skip input is true" in reason
@pytest.mark.asyncio

View File

@@ -130,6 +130,22 @@ export const CustomNode = React.memo(
let subGraphID = "";
const isOptional = data.metadata?.optional?.enabled || false;
// Automatically add skip_run_block input for optional blocks
if (isOptional && !data.inputSchema.properties?.skip_run_block) {
data.inputSchema = {
...data.inputSchema,
properties: {
skip_run_block: {
type: "boolean",
title: "Skip Block",
description: "When true, this block will be skipped during execution",
default: false,
},
...data.inputSchema.properties,
},
};
}
if (data.uiType === BlockUIType.AGENT) {
// Display the graph's schema instead AgentExecutorBlock's schema.
data.inputSchema = data.hardcodedValues?.input_schema || {};
@@ -819,20 +835,20 @@ export const CustomNode = React.memo(
</span>
</ContextMenu.Item>
<div className="pl-12 text-xs text-gray-500 dark:text-gray-400 space-y-1 py-1">
{data.metadata?.optional?.conditions?.check_skip_input !== false && (
<div> Has skip input handle</div>
)}
{data.metadata?.optional?.conditions?.on_missing_credentials && (
<div> Skip on missing credentials</div>
)}
{data.metadata?.optional?.conditions?.input_flag && (
<div> Input flag: {data.metadata.optional.conditions.input_flag}</div>
)}
{data.metadata?.optional?.conditions?.kv_flag && (
<div> KV flag: {data.metadata.optional.conditions.kv_flag}</div>
)}
{data.metadata?.optional?.conditions?.operator === 'and' && (
<div> Using AND operator</div>
)}
{!data.metadata?.optional?.conditions?.on_missing_credentials &&
!data.metadata?.optional?.conditions?.input_flag &&
{data.metadata?.optional?.conditions?.check_skip_input === false &&
!data.metadata?.optional?.conditions?.on_missing_credentials &&
!data.metadata?.optional?.conditions?.kv_flag && (
<div> No conditions set</div>
)}
@@ -1189,23 +1205,23 @@ export const CustomNode = React.memo(
</label>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium dark:text-gray-100">
Input Flag (boolean agent input)
</label>
<div className="flex items-center space-x-2">
<input
type="text"
value={data.metadata?.optional?.conditions?.input_flag || ''}
type="checkbox"
id="check_skip_input"
checked={data.metadata?.optional?.conditions?.check_skip_input !== false}
onChange={(e) => {
const conditions = data.metadata?.optional?.conditions || {};
saveOptionalConditions({
...conditions,
input_flag: e.target.value || undefined,
check_skip_input: e.target.checked,
});
}}
placeholder="e.g., skip_linear"
className="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
className="h-4 w-4"
/>
<label htmlFor="check_skip_input" className="dark:text-gray-100">
Add skip input handle (skip_run_block)
</label>
</div>
<div className="space-y-2">

View File

@@ -1,5 +1,6 @@
import React, { useCallback } from "react";
import { Node } from "@xyflow/react";
import { CustomNodeData } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import type {
CredentialsMetaInput,
GraphMeta,
@@ -17,6 +18,7 @@ interface RunInputDialogProps {
isOpen: boolean;
doClose: () => void;
graph: GraphMeta;
nodes?: Node<CustomNodeData>[];
doRun?: (
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
@@ -33,6 +35,7 @@ export function RunnerInputDialog({
isOpen,
doClose,
graph,
nodes,
doRun,
doCreateSchedule,
}: RunInputDialogProps) {
@@ -79,6 +82,7 @@ export function RunnerInputDialog({
<AgentRunDraftView
className="p-0"
graph={graph}
nodes={nodes}
doRun={doRun ? handleRun : undefined}
onRun={doRun ? undefined : doClose}
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}

View File

@@ -98,6 +98,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
isOpen={isRunInputDialogOpen}
doClose={() => setIsRunInputDialogOpen(false)}
graph={graph}
nodes={nodes}
doRun={saveAndRun}
doCreateSchedule={createRunSchedule}
/>

View File

@@ -43,9 +43,11 @@ import {
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
import { Node } from "@xyflow/react";
export function AgentRunDraftView({
graph,
nodes,
agentPreset,
doRun: _doRun,
onRun,
@@ -59,6 +61,7 @@ export function AgentRunDraftView({
recommendedScheduleCron,
}: {
graph: GraphMeta;
nodes?: Node<any>[];
agentActions?: ButtonAction[];
recommendedScheduleCron?: string | null;
doRun?: (
@@ -146,12 +149,82 @@ export function AgentRunDraftView({
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
const availableCredentials = new Set(Object.keys(inputCredentials));
const allCredentials = new Set(Object.keys(agentCredentialsInputFields));
let allCredentials = new Set(Object.keys(agentCredentialsInputFields));
// Filter out credentials for optional blocks with on_missing_credentials
if (nodes) {
const optionalBlocksWithMissingCreds = nodes.filter(node => {
const optional = node.data?.metadata?.optional;
return optional?.enabled === true &&
optional?.conditions?.on_missing_credentials === true;
});
// If we have optional blocks that can skip on missing credentials,
// we'll be more lenient with credential validation
if (optionalBlocksWithMissingCreds.length > 0) {
// Filter out credentials that might belong to optional blocks
const filteredCredentials = new Set<string>();
for (const credKey of allCredentials) {
let belongsToOptionalBlock = false;
// Check each optional block to see if it might use this credential
for (const node of optionalBlocksWithMissingCreds) {
// Check if the node's input schema has credential fields
const credFields = node.data.inputSchema?.properties || {};
// Look for credential fields in the block's input schema
for (const [fieldName, fieldSchema] of Object.entries(credFields)) {
// Check if this is a credentials field (type checking)
const isCredentialField =
fieldName.toLowerCase().includes('credentials') ||
fieldName.toLowerCase().includes('api_key') ||
(fieldSchema && typeof fieldSchema === 'object' && fieldSchema !== null &&
('credentials' in fieldSchema || 'oauth2' in fieldSchema));
if (isCredentialField) {
// Check if this credential key might match this block's needs
const credKeyLower = credKey.toLowerCase();
// Match based on provider patterns in the key
// e.g., "linear_api_key-oauth2_credentials" contains "linear"
if (node.data.blockType.toLowerCase().includes('linear') &&
credKeyLower.includes('linear')) {
belongsToOptionalBlock = true;
break;
}
// Generic match - if the credential key contains the block type
const blockTypeWords = node.data.blockType.toLowerCase()
.replace(/([A-Z])/g, ' $1')
.split(/[\s_-]+/);
for (const word of blockTypeWords) {
if (word.length > 3 && credKeyLower.includes(word)) {
belongsToOptionalBlock = true;
break;
}
}
}
}
if (belongsToOptionalBlock) break;
}
if (!belongsToOptionalBlock) {
filteredCredentials.add(credKey);
}
}
allCredentials = filteredCredentials;
}
}
return [
availableCredentials.isSupersetOf(allCredentials),
[...allCredentials.difference(availableCredentials)],
];
}, [agentCredentialsInputFields, inputCredentials]);
}, [agentCredentialsInputFields, inputCredentials, nodes]);
const notifyMissingInputs = useCallback(
(needPresetName: boolean = true) => {
const allMissingFields = (

View File

@@ -4801,7 +4801,8 @@
"RUNNING",
"COMPLETED",
"TERMINATED",
"FAILED"
"FAILED",
"SKIPPED"
],
"title": "AgentExecutionStatus"
},