Compare commits

...

3 Commits

Author SHA1 Message Date
Nicholas Tindle
155b496678 wip 2025-10-03 13:25:39 -05:00
Nicholas Tindle
f64c309fd4 feat: baseline impl 2025-09-25 18:24:43 -05:00
Nicholas Tindle
03863219a3 feat: baseline backend for optional 2025-09-25 16:24:19 -05:00
13 changed files with 1015 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ from backend.data.block import (
BlockType,
)
from backend.data.model import NodeExecutionStats, SchemaField
from backend.data.optional_block import get_optional_config
from backend.util import json
from backend.util.clients import get_database_manager_async_client
@@ -388,7 +389,9 @@ class SmartDecisionMakerBlock(Block):
return {"type": "function", "function": tool_function}
@staticmethod
async def _create_function_signature(node_id: str) -> list[dict[str, Any]]:
async def _create_function_signature(
node_id: str, user_id: str | None = None, check_optional: bool = True
) -> list[dict[str, Any]]:
"""
Creates function signatures for tools linked to a specified node within a graph.
@@ -398,6 +401,8 @@ class SmartDecisionMakerBlock(Block):
Args:
node_id: The node_id for which to create function signatures.
user_id: The ID of the user, used for checking credential-based optional blocks.
check_optional: Whether to check and skip optional blocks based on their conditions.
Returns:
list[dict[str, Any]]: A list of dictionaries, each representing a function signature
@@ -429,6 +434,41 @@ class SmartDecisionMakerBlock(Block):
if not sink_node:
raise ValueError(f"Sink node not found: {links[0].sink_id}")
# todo: use the renamed value of metadata when available
# Check if this node is marked as optional and should be skipped
optional_config = get_optional_config(sink_node.metadata)
if optional_config and optional_config.enabled and check_optional:
# Check conditions to determine if block should be skipped
skip_block = False
# Check credential availability if configured
if optional_config.conditions.on_missing_credentials and user_id:
# Get credential fields from the block
sink_block = sink_node.block
if hasattr(sink_block, "input_schema"):
creds_fields = sink_block.input_schema.get_credentials_fields()
if creds_fields:
# For Smart Decision Maker, we simplify by assuming
# credentials might be missing if optional is enabled
# Full check happens at execution time
logger.debug(
f"Optional block {sink_node.id} may be skipped based on credentials"
)
# Continue to exclude from available tools
continue
# If other conditions exist but can't be checked now, be conservative
if (
optional_config.conditions.input_flag
or optional_config.conditions.kv_flag
):
# These runtime flags can't be checked here, so exclude the block
logger.debug(
f"Optional block {sink_node.id} excluded due to runtime conditions"
)
continue
if sink_node.block_id == AgentExecutorBlock().id:
return_tool_functions.append(
await SmartDecisionMakerBlock._create_agent_function_signature(
@@ -456,7 +496,9 @@ class SmartDecisionMakerBlock(Block):
user_id: str,
**kwargs,
) -> BlockOutput:
tool_functions = await self._create_function_signature(node_id)
tool_functions = await self._create_function_signature(
node_id, user_id=user_id, check_optional=True
)
yield "tool_functions", json.dumps(tool_functions)
input_data.conversation_history = input_data.conversation_history or []

View File

@@ -115,6 +115,10 @@ VALID_STATUS_TRANSITIONS = {
ExecutionStatus.QUEUED,
ExecutionStatus.RUNNING,
],
ExecutionStatus.SKIPPED: [
ExecutionStatus.INCOMPLETE,
ExecutionStatus.QUEUED,
],
}

View File

@@ -0,0 +1,59 @@
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class ConditionOperator(str, Enum):
AND = "and"
OR = "or"
class OptionalBlockConditions(BaseModel):
"""Conditions that determine when a block should be skipped"""
on_missing_credentials: bool = Field(
default=False,
description="Skip block if any required credentials are missing",
)
check_skip_input: bool = Field(
default=True,
description="Check the standard 'skip' input to control skip behavior",
)
kv_flag: Optional[str] = Field(
default=None,
description="Key-value store flag name that controls skip behavior",
)
operator: ConditionOperator = Field(
default=ConditionOperator.OR,
description="Logical operator for combining conditions (AND/OR)",
)
class OptionalBlockConfig(BaseModel):
"""Configuration for making a block optional/skippable"""
enabled: bool = Field(
default=False,
description="Whether this block can be optionally skipped",
)
conditions: OptionalBlockConditions = Field(
default_factory=OptionalBlockConditions,
description="Conditions that trigger skipping",
)
skip_message: Optional[str] = Field(
default=None,
description="Custom message to log when block is skipped",
)
def get_optional_config(node_metadata: dict) -> Optional[OptionalBlockConfig]:
"""Extract optional block configuration from node metadata"""
if "optional" not in node_metadata:
return None
optional_data = node_metadata.get("optional", {})
if not optional_data:
return None
return OptionalBlockConfig(**optional_data)

View File

@@ -13,6 +13,7 @@ from pika.spec import Basic, BasicProperties
from redis.asyncio.lock import Lock as RedisLock
from backend.blocks.io import AgentOutputBlock
from backend.blocks.persistence import get_storage_key
from backend.data.model import GraphExecutionStats, NodeExecutionStats
from backend.data.notifications import (
AgentRunData,
@@ -27,6 +28,7 @@ from backend.executor.activity_status_generator import (
)
from backend.executor.utils import LogMetadata
from backend.notifications.notifications import queue_notification
from backend.util.clients import get_database_manager_async_client
from backend.util.exceptions import InsufficientBalanceError, ModerationError
if TYPE_CHECKING:
@@ -55,6 +57,7 @@ from backend.data.execution import (
UserContext,
)
from backend.data.graph import Link, Node
from backend.data.optional_block import get_optional_config
from backend.executor.utils import (
GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS,
GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
@@ -73,7 +76,6 @@ from backend.server.v2.AutoMod.manager import automod_manager
from backend.util import json
from backend.util.clients import (
get_async_execution_event_bus,
get_database_manager_async_client,
get_database_manager_client,
get_execution_event_bus,
get_notification_manager_client,
@@ -126,6 +128,86 @@ def execute_graph(
T = TypeVar("T")
async def should_skip_node(
node: Node,
creds_manager: IntegrationCredentialsManager,
user_id: str,
user_context: UserContext,
input_data: BlockInput,
graph_id: str,
) -> tuple[bool, str]:
"""
Check if a node should be skipped based on optional configuration.
Returns:
Tuple of (should_skip, skip_reason)
"""
optional_config = get_optional_config(node.metadata)
if not optional_config or not optional_config.enabled:
return False, ""
conditions = optional_config.conditions
skip_reasons = []
conditions_met = []
# Check credential availability
if conditions.on_missing_credentials:
node_block = node.block
input_model = cast(type[BlockSchema], node_block.input_schema)
for field_name, input_type in input_model.get_credentials_fields().items():
if field_name in input_data:
credentials_meta = input_type(**input_data[field_name])
# Check if credentials exist without acquiring lock
if not await creds_manager.exists(user_id, credentials_meta.id):
skip_reasons.append(f"Missing credentials: {field_name}")
conditions_met.append(True)
break
else:
# All credentials exist
if conditions.on_missing_credentials:
conditions_met.append(False)
# 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)
# Check key-value flag
if conditions.kv_flag:
# Determine storage key (assume within_agent scope for now)
storage_key = get_storage_key(conditions.kv_flag, "within_agent", graph_id)
# Retrieve the value from KV store
kv_value = await get_database_manager_async_client().get_execution_kv_data(
user_id=user_id,
key=storage_key,
)
# Skip if flag is True (treat missing/None as False)
if kv_value is True:
skip_reasons.append(f"KV flag '{conditions.kv_flag}' is true")
conditions_met.append(True)
else:
conditions_met.append(False)
# Apply logical operator
if not conditions_met:
return False, ""
if conditions.operator == "and":
should_skip = all(conditions_met)
else: # OR
should_skip = any(conditions_met)
skip_message = optional_config.skip_message or "; ".join(skip_reasons)
return should_skip, skip_message
async def execute_node(
node: Node,
creds_manager: IntegrationCredentialsManager,
@@ -511,6 +593,28 @@ class ExecutionProcessor:
)
try:
# Check if node should be skipped
should_skip, skip_reason = await should_skip_node(
node=node,
creds_manager=self.creds_manager,
user_id=node_exec.user_id,
user_context=node_exec.user_context,
input_data=node_exec.inputs,
graph_id=node_exec.graph_id,
)
if should_skip:
log_metadata.info(
f"Skipping node execution {node_exec.node_exec_id}: {skip_reason}"
)
await async_update_node_execution_status(
db_client=db_client,
exec_id=node_exec.node_exec_id,
status=ExecutionStatus.SKIPPED,
stats={"skip_reason": skip_reason},
)
return ExecutionStatus.SKIPPED
log_metadata.info(f"Start node execution {node_exec.node_exec_id}")
await async_update_node_execution_status(
db_client=db_client,

View File

@@ -0,0 +1,10 @@
-- Migration: Add SKIPPED status to AgentExecutionStatus enum
-- This migration adds support for conditional/optional block execution
-- Add SKIPPED value to the AgentExecutionStatus enum
ALTER TYPE "AgentExecutionStatus" ADD VALUE 'SKIPPED';
-- Note: This migration is irreversible in PostgreSQL.
-- Enum values cannot be removed once added.
-- To run this migration, execute:
-- cd autogpt_platform/backend && poetry run prisma migrate dev --name add-skipped-execution-status

View File

@@ -339,6 +339,7 @@ enum AgentExecutionStatus {
COMPLETED
TERMINATED
FAILED
SKIPPED
}
// This model describes the execution of an AgentGraph.

View File

@@ -0,0 +1,469 @@
"""Tests for optional/conditional block execution."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.data.execution import ExecutionStatus
from backend.data.graph import Node
from backend.data.optional_block import (
ConditionOperator,
OptionalBlockConditions,
OptionalBlockConfig,
get_optional_config,
)
from backend.executor.manager import should_skip_node
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.util.user import UserContext
@pytest.fixture
def mock_node():
"""Create a mock node for testing."""
node = MagicMock(spec=Node)
node.metadata = {}
node.block = MagicMock()
node.block.input_schema = MagicMock()
node.block.input_schema.get_credentials_fields.return_value = {}
return node
@pytest.fixture
def mock_creds_manager():
"""Create a mock credentials manager."""
manager = AsyncMock(spec=IntegrationCredentialsManager)
manager.exists = AsyncMock(return_value=True)
return manager
@pytest.fixture
def user_context():
"""Create a mock user context."""
return UserContext(user_id="test_user", scopes=[])
class TestOptionalBlockConfig:
"""Test OptionalBlockConfig model."""
def test_optional_config_defaults(self):
"""Test default values for OptionalBlockConfig."""
config = OptionalBlockConfig()
assert config.enabled is False
assert config.conditions.on_missing_credentials is False
assert config.conditions.input_flag is None
assert config.conditions.kv_flag is None
assert config.conditions.operator == ConditionOperator.OR
assert config.skip_message is None
def test_optional_config_with_values(self):
"""Test OptionalBlockConfig with custom values."""
config = OptionalBlockConfig(
enabled=True,
conditions=OptionalBlockConditions(
on_missing_credentials=True,
input_flag="skip_linear",
kv_flag="enable_linear",
operator=ConditionOperator.AND,
),
skip_message="Skipping Linear block due to missing credentials",
)
assert config.enabled is True
assert config.conditions.on_missing_credentials is True
assert config.conditions.input_flag == "skip_linear"
assert config.conditions.kv_flag == "enable_linear"
assert config.conditions.operator == ConditionOperator.AND
assert config.skip_message == "Skipping Linear block due to missing credentials"
def test_get_optional_config_from_metadata(self):
"""Test extracting optional config from node metadata."""
# No optional config
metadata = {}
config = get_optional_config(metadata)
assert config is None
# Empty optional config
metadata = {"optional": {}}
config = get_optional_config(metadata)
assert config is None
# Valid optional config
metadata = {
"optional": {
"enabled": True,
"conditions": {
"on_missing_credentials": True,
},
}
}
config = get_optional_config(metadata)
assert config is not None
assert config.enabled is True
assert config.conditions.on_missing_credentials is True
@pytest.mark.asyncio
class TestShouldSkipNode:
"""Test should_skip_node function."""
async def test_skip_when_not_optional(
self, mock_node, mock_creds_manager, user_context
):
"""Test that non-optional nodes are not skipped."""
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={},
graph_id="test_graph_id",
)
assert should_skip is False
assert reason == ""
async def test_skip_when_optional_disabled(
self, mock_node, mock_creds_manager, user_context
):
"""Test that optional but disabled nodes are not skipped."""
mock_node.metadata = {
"optional": {
"enabled": False,
"conditions": {"on_missing_credentials": True},
}
}
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={},
graph_id="test_graph_id",
)
assert should_skip is False
assert reason == ""
async def test_skip_on_missing_credentials(
self, mock_node, mock_creds_manager, user_context
):
"""Test skipping when credentials are missing."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"on_missing_credentials": True},
}
}
mock_node.block.input_schema.get_credentials_fields.return_value = {
"credentials": MagicMock()
}
mock_creds_manager.exists.return_value = False
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={"credentials": {"id": "cred_123"}},
graph_id="test_graph_id",
)
assert should_skip is True
assert "Missing credentials" in reason
async def test_no_skip_when_credentials_exist(
self, mock_node, mock_creds_manager, user_context
):
"""Test no skip when credentials exist."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"on_missing_credentials": True},
}
}
mock_node.block.input_schema.get_credentials_fields.return_value = {
"credentials": MagicMock()
}
mock_creds_manager.exists.return_value = True
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={"credentials": {"id": "cred_123"}},
graph_id="test_graph_id",
)
assert should_skip is False
assert reason == ""
async def test_skip_on_skip_input_true(
self, mock_node, mock_creds_manager, user_context
):
"""Test skipping when skip_run_block input is true."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"check_skip_input": True},
}
}
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={"skip_run_block": True},
graph_id="test_graph_id",
)
assert should_skip is True
assert "Skip input is true" in reason
async def test_no_skip_on_skip_input_false(
self, mock_node, mock_creds_manager, user_context
):
"""Test no skip when skip_run_block input is false."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"check_skip_input": True},
}
}
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={"skip_run_block": False},
graph_id="test_graph_id",
)
assert should_skip is False
assert reason == ""
async def test_skip_with_or_operator(
self, mock_node, mock_creds_manager, user_context
):
"""Test OR logic - skip if any condition is met."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {
"on_missing_credentials": True,
"check_skip_input": True,
"operator": "or",
},
}
}
mock_node.block.input_schema.get_credentials_fields.return_value = {
"credentials": MagicMock()
}
# Credentials exist but input flag is true
mock_creds_manager.exists.return_value = True
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={
"credentials": {"id": "cred_123"},
"skip_run_block": True,
},
graph_id="test_graph_id",
)
assert should_skip is True # OR: at least one condition met
assert "Skip input is true" in reason
async def test_skip_with_and_operator(
self, mock_node, mock_creds_manager, user_context
):
"""Test AND logic - skip only if all conditions are met."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {
"on_missing_credentials": True,
"check_skip_input": True,
"operator": "and",
},
}
}
mock_node.block.input_schema.get_credentials_fields.return_value = {
"credentials": MagicMock()
}
# Credentials missing but input flag is false
mock_creds_manager.exists.return_value = False
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={
"credentials": {"id": "cred_123"},
"skip_run_block": False,
},
graph_id="test_graph_id",
)
assert should_skip is False # AND: not all conditions met
assert reason == ""
async def test_custom_skip_message(
self, mock_node, mock_creds_manager, user_context
):
"""Test custom skip message."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"check_skip_input": True},
"skip_message": "Custom skip message for testing",
}
}
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={"skip_run_block": True},
graph_id="test_graph_id",
)
assert should_skip is True
assert reason == "Custom skip message for testing"
async def test_skip_on_kv_flag_true(
self, mock_node, mock_creds_manager, user_context
):
"""Test skipping when KV flag is true."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"kv_flag": "skip_linear"},
}
}
# Mock the database client to return True for the KV flag
with patch(
"backend.executor.manager.get_database_manager_async_client"
) as mock_db_client:
mock_db_client.return_value.get_execution_kv_data = AsyncMock(
return_value=True
)
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={},
graph_id="test_graph_id",
)
assert should_skip is True
assert "KV flag 'skip_linear' is true" in reason
# Verify the correct key was used
mock_db_client.return_value.get_execution_kv_data.assert_called_once_with(
user_id="test_user",
key="agent#test_graph_id#skip_linear",
)
async def test_no_skip_on_kv_flag_false(
self, mock_node, mock_creds_manager, user_context
):
"""Test no skip when KV flag is false."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {"kv_flag": "skip_linear"},
}
}
# Mock the database client to return False for the KV flag
with patch(
"backend.executor.manager.get_database_manager_async_client"
) as mock_db_client:
mock_db_client.return_value.get_execution_kv_data = AsyncMock(
return_value=False
)
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={},
graph_id="test_graph_id",
)
assert should_skip is False
assert reason == ""
async def test_kv_flag_with_combined_conditions(
self, mock_node, mock_creds_manager, user_context
):
"""Test KV flag combined with other conditions using OR operator."""
mock_node.metadata = {
"optional": {
"enabled": True,
"conditions": {
"kv_flag": "enable_integration",
"check_skip_input": True,
"operator": "or",
},
}
}
# Mock the database client to return False for the KV flag
with patch(
"backend.executor.manager.get_database_manager_async_client"
) as mock_db_client:
mock_db_client.return_value.get_execution_kv_data = AsyncMock(
return_value=False
)
# 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={"skip_run_block": True},
graph_id="test_graph_id",
)
assert should_skip is True
assert "Skip input is true" in reason
@pytest.mark.asyncio
class TestExecutionFlow:
"""Test execution flow with optional blocks."""
async def test_skipped_status_transition(self):
"""Test that SKIPPED is a valid status transition."""
from backend.data.execution import VALID_STATUS_TRANSITIONS
assert ExecutionStatus.SKIPPED in VALID_STATUS_TRANSITIONS
assert (
ExecutionStatus.INCOMPLETE
in VALID_STATUS_TRANSITIONS[ExecutionStatus.SKIPPED]
)
assert (
ExecutionStatus.QUEUED in VALID_STATUS_TRANSITIONS[ExecutionStatus.SKIPPED]
)
async def test_smart_decision_maker_filters_optional(self):
"""Test that Smart Decision Maker filters out optional blocks."""
from backend.data.optional_block import get_optional_config
# Create a mock node with optional config
node = MagicMock()
node.metadata = {
"optional": {
"enabled": True,
"conditions": {"on_missing_credentials": True},
}
}
# Verify optional config is detected
config = get_optional_config(node.metadata)
assert config is not None
assert config.enabled is True
# The Smart Decision Maker should skip this node when building function signatures
# This is tested in the actual implementation where optional nodes are filtered

View File

@@ -128,6 +128,23 @@ export const CustomNode = React.memo(
const [isLoading, setIsLoading] = useState(false);
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.
@@ -646,7 +663,9 @@ export const CustomNode = React.memo(
"dark-theme",
"rounded-xl",
"bg-white/[.9] dark:bg-gray-800/[.9]",
"border border-gray-300 dark:border-gray-600",
isOptional
? "border-2 border-dashed border-blue-400 dark:border-blue-500"
: "border border-gray-300 dark:border-gray-600",
data.uiType === BlockUIType.NOTE ? "w-[300px]" : "w-[500px]",
data.uiType === BlockUIType.NOTE
? "bg-yellow-100 dark:bg-yellow-900"
@@ -675,6 +694,8 @@ export const CustomNode = React.memo(
return "border-purple-200 dark:border-purple-800 border-4";
case "queued":
return "border-cyan-200 dark:border-cyan-800 border-4";
case "skipped":
return "border-gray-300 dark:border-gray-600 border-4";
default:
return "";
}
@@ -736,6 +757,44 @@ export const CustomNode = React.memo(
</div>
);
const toggleOptional = () => {
const currentOptional = data.metadata?.optional || {};
updateNodeData(id, {
metadata: {
...data.metadata,
optional: {
...currentOptional,
enabled: !currentOptional.enabled,
// Default conditions when enabling
conditions: currentOptional.conditions || {
on_missing_credentials: true,
operator: "or",
},
},
},
});
};
const [showOptionalConfig, setShowOptionalConfig] = useState(false);
const configureOptionalConditions = () => {
setShowOptionalConfig(true);
};
const saveOptionalConditions = (conditions: any) => {
const currentOptional = data.metadata?.optional || {};
updateNodeData(id, {
metadata: {
...data.metadata,
optional: {
...currentOptional,
conditions,
},
},
});
setShowOptionalConfig(false);
};
const ContextMenuContent = () => (
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item
@@ -755,6 +814,48 @@ export const CustomNode = React.memo(
</ContextMenu.Item>
)}
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
onSelect={toggleOptional}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Switch
checked={isOptional}
className="mr-2 h-4 w-4 pointer-events-none"
/>
<span className="dark:text-gray-100">Make Optional</span>
</ContextMenu.Item>
{isOptional && (
<>
<ContextMenu.Item
onSelect={configureOptionalConditions}
className="flex cursor-pointer items-center rounded-md px-3 py-2 pl-8 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span className="dark:text-gray-100">
Configure conditions...
</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?.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?.check_skip_input === false &&
!data.metadata?.optional?.conditions?.on_missing_credentials &&
!data.metadata?.optional?.conditions?.kv_flag && (
<div> No conditions set</div>
)}
</div>
</>
)}
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
onSelect={deleteNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -882,6 +983,14 @@ export const CustomNode = React.memo(
</span>
</div>
)}
{isOptional && (
<Badge
variant="outline"
className="h-6 whitespace-nowrap rounded-full border border-blue-400 bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"
>
Optional
</Badge>
)}
{data.categories.map((category) => (
<Badge
key={category.category}
@@ -1034,6 +1143,8 @@ export const CustomNode = React.memo(
].includes(data.status || ""),
"border-blue-600 bg-blue-600 text-white":
data.status === "QUEUED",
"border-gray-400 bg-gray-400 text-white":
data.status === "SKIPPED",
"border-gray-600 bg-gray-600 font-black":
data.status === "INCOMPLETE",
},
@@ -1066,9 +1177,132 @@ export const CustomNode = React.memo(
);
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
</ContextMenu.Root>
<>
{showOptionalConfig && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-[500px] max-w-[90vw]">
<h2 className="text-xl font-bold mb-4 dark:text-white">
Configure Optional Conditions
</h2>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="on_missing_credentials"
checked={data.metadata?.optional?.conditions?.on_missing_credentials || false}
onChange={(e) => {
const conditions = data.metadata?.optional?.conditions || {};
saveOptionalConditions({
...conditions,
on_missing_credentials: e.target.checked,
});
}}
className="h-4 w-4"
/>
<label htmlFor="on_missing_credentials" className="dark:text-gray-100">
Skip on missing credentials
</label>
</div>
<div className="flex items-center space-x-2">
<input
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,
check_skip_input: e.target.checked,
});
}}
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">
<label className="block text-sm font-medium dark:text-gray-100">
Key-Value Flag (from persistence blocks)
</label>
<input
type="text"
value={data.metadata?.optional?.conditions?.kv_flag || ''}
onChange={(e) => {
const conditions = data.metadata?.optional?.conditions || {};
saveOptionalConditions({
...conditions,
kv_flag: e.target.value || undefined,
});
}}
placeholder="e.g., enable_integration"
className="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium dark:text-gray-100">
Condition Operator
</label>
<select
value={data.metadata?.optional?.conditions?.operator || 'or'}
onChange={(e) => {
const conditions = data.metadata?.optional?.conditions || {};
saveOptionalConditions({
...conditions,
operator: e.target.value,
});
}}
className="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
>
<option value="or">OR (skip if ANY condition is met)</option>
<option value="and">AND (skip if ALL conditions are met)</option>
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium dark:text-gray-100">
Skip Message (optional)
</label>
<input
type="text"
value={data.metadata?.optional?.skip_message || ''}
onChange={(e) => {
const currentOptional = data.metadata?.optional || {};
updateNodeData(id, {
metadata: {
...data.metadata,
optional: {
...currentOptional,
skip_message: e.target.value || undefined,
},
},
});
}}
placeholder="Custom message when block is skipped"
className="w-full p-2 border rounded dark:bg-gray-700 dark:text-white dark:border-gray-600"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowOptionalConfig(false)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 dark:text-white"
>
Close
</button>
</div>
</div>
</div>
)}
<ContextMenu.Root>
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
</ContextMenu.Root>
</>
);
},
(prevProps, nextProps) => {

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

View File

@@ -397,7 +397,8 @@ export type NodeExecutionResult = {
| "RUNNING"
| "COMPLETED"
| "TERMINATED"
| "FAILED";
| "FAILED"
| "SKIPPED";
input_data: Record<string, any>;
output_data: Record<string, Array<any>>;
add_time: Date;