mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-21 04:57:58 -05:00
Compare commits
3 Commits
make-old-w
...
spike/cond
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155b496678 | ||
|
|
f64c309fd4 | ||
|
|
03863219a3 |
@@ -14,6 +14,7 @@ from backend.data.block import (
|
|||||||
BlockType,
|
BlockType,
|
||||||
)
|
)
|
||||||
from backend.data.model import NodeExecutionStats, SchemaField
|
from backend.data.model import NodeExecutionStats, SchemaField
|
||||||
|
from backend.data.optional_block import get_optional_config
|
||||||
from backend.util import json
|
from backend.util import json
|
||||||
from backend.util.clients import get_database_manager_async_client
|
from backend.util.clients import get_database_manager_async_client
|
||||||
|
|
||||||
@@ -388,7 +389,9 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
return {"type": "function", "function": tool_function}
|
return {"type": "function", "function": tool_function}
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Creates function signatures for tools linked to a specified node within a graph.
|
||||||
|
|
||||||
@@ -398,6 +401,8 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
node_id: The node_id for which to create function signatures.
|
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:
|
Returns:
|
||||||
list[dict[str, Any]]: A list of dictionaries, each representing a function signature
|
list[dict[str, Any]]: A list of dictionaries, each representing a function signature
|
||||||
@@ -429,6 +434,41 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
if not sink_node:
|
if not sink_node:
|
||||||
raise ValueError(f"Sink node not found: {links[0].sink_id}")
|
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:
|
if sink_node.block_id == AgentExecutorBlock().id:
|
||||||
return_tool_functions.append(
|
return_tool_functions.append(
|
||||||
await SmartDecisionMakerBlock._create_agent_function_signature(
|
await SmartDecisionMakerBlock._create_agent_function_signature(
|
||||||
@@ -456,7 +496,9 @@ class SmartDecisionMakerBlock(Block):
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> BlockOutput:
|
) -> 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)
|
yield "tool_functions", json.dumps(tool_functions)
|
||||||
|
|
||||||
input_data.conversation_history = input_data.conversation_history or []
|
input_data.conversation_history = input_data.conversation_history or []
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ VALID_STATUS_TRANSITIONS = {
|
|||||||
ExecutionStatus.QUEUED,
|
ExecutionStatus.QUEUED,
|
||||||
ExecutionStatus.RUNNING,
|
ExecutionStatus.RUNNING,
|
||||||
],
|
],
|
||||||
|
ExecutionStatus.SKIPPED: [
|
||||||
|
ExecutionStatus.INCOMPLETE,
|
||||||
|
ExecutionStatus.QUEUED,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
59
autogpt_platform/backend/backend/data/optional_block.py
Normal file
59
autogpt_platform/backend/backend/data/optional_block.py
Normal 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)
|
||||||
@@ -13,6 +13,7 @@ from pika.spec import Basic, BasicProperties
|
|||||||
from redis.asyncio.lock import Lock as RedisLock
|
from redis.asyncio.lock import Lock as RedisLock
|
||||||
|
|
||||||
from backend.blocks.io import AgentOutputBlock
|
from backend.blocks.io import AgentOutputBlock
|
||||||
|
from backend.blocks.persistence import get_storage_key
|
||||||
from backend.data.model import GraphExecutionStats, NodeExecutionStats
|
from backend.data.model import GraphExecutionStats, NodeExecutionStats
|
||||||
from backend.data.notifications import (
|
from backend.data.notifications import (
|
||||||
AgentRunData,
|
AgentRunData,
|
||||||
@@ -27,6 +28,7 @@ from backend.executor.activity_status_generator import (
|
|||||||
)
|
)
|
||||||
from backend.executor.utils import LogMetadata
|
from backend.executor.utils import LogMetadata
|
||||||
from backend.notifications.notifications import queue_notification
|
from backend.notifications.notifications import queue_notification
|
||||||
|
from backend.util.clients import get_database_manager_async_client
|
||||||
from backend.util.exceptions import InsufficientBalanceError, ModerationError
|
from backend.util.exceptions import InsufficientBalanceError, ModerationError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -55,6 +57,7 @@ from backend.data.execution import (
|
|||||||
UserContext,
|
UserContext,
|
||||||
)
|
)
|
||||||
from backend.data.graph import Link, Node
|
from backend.data.graph import Link, Node
|
||||||
|
from backend.data.optional_block import get_optional_config
|
||||||
from backend.executor.utils import (
|
from backend.executor.utils import (
|
||||||
GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS,
|
GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS,
|
||||||
GRAPH_EXECUTION_CANCEL_QUEUE_NAME,
|
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 import json
|
||||||
from backend.util.clients import (
|
from backend.util.clients import (
|
||||||
get_async_execution_event_bus,
|
get_async_execution_event_bus,
|
||||||
get_database_manager_async_client,
|
|
||||||
get_database_manager_client,
|
get_database_manager_client,
|
||||||
get_execution_event_bus,
|
get_execution_event_bus,
|
||||||
get_notification_manager_client,
|
get_notification_manager_client,
|
||||||
@@ -126,6 +128,86 @@ def execute_graph(
|
|||||||
T = TypeVar("T")
|
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(
|
async def execute_node(
|
||||||
node: Node,
|
node: Node,
|
||||||
creds_manager: IntegrationCredentialsManager,
|
creds_manager: IntegrationCredentialsManager,
|
||||||
@@ -511,6 +593,28 @@ class ExecutionProcessor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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}")
|
log_metadata.info(f"Start node execution {node_exec.node_exec_id}")
|
||||||
await async_update_node_execution_status(
|
await async_update_node_execution_status(
|
||||||
db_client=db_client,
|
db_client=db_client,
|
||||||
|
|||||||
10
autogpt_platform/backend/migrations/add_skipped_status.sql
Normal file
10
autogpt_platform/backend/migrations/add_skipped_status.sql
Normal 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
|
||||||
@@ -339,6 +339,7 @@ enum AgentExecutionStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
TERMINATED
|
TERMINATED
|
||||||
FAILED
|
FAILED
|
||||||
|
SKIPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
// This model describes the execution of an AgentGraph.
|
// This model describes the execution of an AgentGraph.
|
||||||
|
|||||||
469
autogpt_platform/backend/test/executor/test_optional_blocks.py
Normal file
469
autogpt_platform/backend/test/executor/test_optional_blocks.py
Normal 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
|
||||||
@@ -128,6 +128,23 @@ export const CustomNode = React.memo(
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
let subGraphID = "";
|
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) {
|
if (data.uiType === BlockUIType.AGENT) {
|
||||||
// Display the graph's schema instead AgentExecutorBlock's schema.
|
// Display the graph's schema instead AgentExecutorBlock's schema.
|
||||||
@@ -646,7 +663,9 @@ export const CustomNode = React.memo(
|
|||||||
"dark-theme",
|
"dark-theme",
|
||||||
"rounded-xl",
|
"rounded-xl",
|
||||||
"bg-white/[.9] dark:bg-gray-800/[.9]",
|
"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 ? "w-[300px]" : "w-[500px]",
|
||||||
data.uiType === BlockUIType.NOTE
|
data.uiType === BlockUIType.NOTE
|
||||||
? "bg-yellow-100 dark:bg-yellow-900"
|
? "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";
|
return "border-purple-200 dark:border-purple-800 border-4";
|
||||||
case "queued":
|
case "queued":
|
||||||
return "border-cyan-200 dark:border-cyan-800 border-4";
|
return "border-cyan-200 dark:border-cyan-800 border-4";
|
||||||
|
case "skipped":
|
||||||
|
return "border-gray-300 dark:border-gray-600 border-4";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -736,6 +757,44 @@ export const CustomNode = React.memo(
|
|||||||
</div>
|
</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 = () => (
|
const ContextMenuContent = () => (
|
||||||
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
|
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
@@ -755,6 +814,48 @@ export const CustomNode = React.memo(
|
|||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
)}
|
)}
|
||||||
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
|
<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
|
<ContextMenu.Item
|
||||||
onSelect={deleteNode}
|
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"
|
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>
|
</span>
|
||||||
</div>
|
</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) => (
|
{data.categories.map((category) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={category.category}
|
key={category.category}
|
||||||
@@ -1034,6 +1143,8 @@ export const CustomNode = React.memo(
|
|||||||
].includes(data.status || ""),
|
].includes(data.status || ""),
|
||||||
"border-blue-600 bg-blue-600 text-white":
|
"border-blue-600 bg-blue-600 text-white":
|
||||||
data.status === "QUEUED",
|
data.status === "QUEUED",
|
||||||
|
"border-gray-400 bg-gray-400 text-white":
|
||||||
|
data.status === "SKIPPED",
|
||||||
"border-gray-600 bg-gray-600 font-black":
|
"border-gray-600 bg-gray-600 font-black":
|
||||||
data.status === "INCOMPLETE",
|
data.status === "INCOMPLETE",
|
||||||
},
|
},
|
||||||
@@ -1066,9 +1177,132 @@ export const CustomNode = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<>
|
||||||
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
|
{showOptionalConfig && (
|
||||||
</ContextMenu.Root>
|
<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) => {
|
(prevProps, nextProps) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { Node } from "@xyflow/react";
|
||||||
|
import { CustomNodeData } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||||
import type {
|
import type {
|
||||||
CredentialsMetaInput,
|
CredentialsMetaInput,
|
||||||
GraphMeta,
|
GraphMeta,
|
||||||
@@ -17,6 +18,7 @@ interface RunInputDialogProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
doClose: () => void;
|
doClose: () => void;
|
||||||
graph: GraphMeta;
|
graph: GraphMeta;
|
||||||
|
nodes?: Node<CustomNodeData>[];
|
||||||
doRun?: (
|
doRun?: (
|
||||||
inputs: Record<string, any>,
|
inputs: Record<string, any>,
|
||||||
credentialsInputs: Record<string, CredentialsMetaInput>,
|
credentialsInputs: Record<string, CredentialsMetaInput>,
|
||||||
@@ -33,6 +35,7 @@ export function RunnerInputDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
doClose,
|
doClose,
|
||||||
graph,
|
graph,
|
||||||
|
nodes,
|
||||||
doRun,
|
doRun,
|
||||||
doCreateSchedule,
|
doCreateSchedule,
|
||||||
}: RunInputDialogProps) {
|
}: RunInputDialogProps) {
|
||||||
@@ -79,6 +82,7 @@ export function RunnerInputDialog({
|
|||||||
<AgentRunDraftView
|
<AgentRunDraftView
|
||||||
className="p-0"
|
className="p-0"
|
||||||
graph={graph}
|
graph={graph}
|
||||||
|
nodes={nodes}
|
||||||
doRun={doRun ? handleRun : undefined}
|
doRun={doRun ? handleRun : undefined}
|
||||||
onRun={doRun ? undefined : doClose}
|
onRun={doRun ? undefined : doClose}
|
||||||
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
|
|||||||
isOpen={isRunInputDialogOpen}
|
isOpen={isRunInputDialogOpen}
|
||||||
doClose={() => setIsRunInputDialogOpen(false)}
|
doClose={() => setIsRunInputDialogOpen(false)}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
|
nodes={nodes}
|
||||||
doRun={saveAndRun}
|
doRun={saveAndRun}
|
||||||
doCreateSchedule={createRunSchedule}
|
doCreateSchedule={createRunSchedule}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ import {
|
|||||||
|
|
||||||
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
|
||||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||||
|
import { Node } from "@xyflow/react";
|
||||||
|
|
||||||
export function AgentRunDraftView({
|
export function AgentRunDraftView({
|
||||||
graph,
|
graph,
|
||||||
|
nodes,
|
||||||
agentPreset,
|
agentPreset,
|
||||||
doRun: _doRun,
|
doRun: _doRun,
|
||||||
onRun,
|
onRun,
|
||||||
@@ -59,6 +61,7 @@ export function AgentRunDraftView({
|
|||||||
recommendedScheduleCron,
|
recommendedScheduleCron,
|
||||||
}: {
|
}: {
|
||||||
graph: GraphMeta;
|
graph: GraphMeta;
|
||||||
|
nodes?: Node<any>[];
|
||||||
agentActions?: ButtonAction[];
|
agentActions?: ButtonAction[];
|
||||||
recommendedScheduleCron?: string | null;
|
recommendedScheduleCron?: string | null;
|
||||||
doRun?: (
|
doRun?: (
|
||||||
@@ -146,12 +149,82 @@ export function AgentRunDraftView({
|
|||||||
}, [agentInputSchema.required, inputValues]);
|
}, [agentInputSchema.required, inputValues]);
|
||||||
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
|
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
|
||||||
const availableCredentials = new Set(Object.keys(inputCredentials));
|
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 [
|
return [
|
||||||
availableCredentials.isSupersetOf(allCredentials),
|
availableCredentials.isSupersetOf(allCredentials),
|
||||||
[...allCredentials.difference(availableCredentials)],
|
[...allCredentials.difference(availableCredentials)],
|
||||||
];
|
];
|
||||||
}, [agentCredentialsInputFields, inputCredentials]);
|
}, [agentCredentialsInputFields, inputCredentials, nodes]);
|
||||||
const notifyMissingInputs = useCallback(
|
const notifyMissingInputs = useCallback(
|
||||||
(needPresetName: boolean = true) => {
|
(needPresetName: boolean = true) => {
|
||||||
const allMissingFields = (
|
const allMissingFields = (
|
||||||
|
|||||||
@@ -4801,7 +4801,8 @@
|
|||||||
"RUNNING",
|
"RUNNING",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"TERMINATED",
|
"TERMINATED",
|
||||||
"FAILED"
|
"FAILED",
|
||||||
|
"SKIPPED"
|
||||||
],
|
],
|
||||||
"title": "AgentExecutionStatus"
|
"title": "AgentExecutionStatus"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -397,7 +397,8 @@ export type NodeExecutionResult = {
|
|||||||
| "RUNNING"
|
| "RUNNING"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
| "TERMINATED"
|
| "TERMINATED"
|
||||||
| "FAILED";
|
| "FAILED"
|
||||||
|
| "SKIPPED";
|
||||||
input_data: Record<string, any>;
|
input_data: Record<string, any>;
|
||||||
output_data: Record<string, Array<any>>;
|
output_data: Record<string, Array<any>>;
|
||||||
add_time: Date;
|
add_time: Date;
|
||||||
|
|||||||
Reference in New Issue
Block a user