mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-12 08:38:09 -05:00
Compare commits
3 Commits
native-aut
...
spike/cond
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155b496678 | ||
|
|
f64c309fd4 | ||
|
|
03863219a3 |
@@ -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 []
|
||||
|
||||
@@ -115,6 +115,10 @@ VALID_STATUS_TRANSITIONS = {
|
||||
ExecutionStatus.QUEUED,
|
||||
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 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,
|
||||
|
||||
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
|
||||
TERMINATED
|
||||
FAILED
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -98,6 +98,7 @@ const RunnerUIWrapper = forwardRef<RunnerUIWrapperRef, RunnerUIWrapperProps>(
|
||||
isOpen={isRunInputDialogOpen}
|
||||
doClose={() => setIsRunInputDialogOpen(false)}
|
||||
graph={graph}
|
||||
nodes={nodes}
|
||||
doRun={saveAndRun}
|
||||
doCreateSchedule={createRunSchedule}
|
||||
/>
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -4801,7 +4801,8 @@
|
||||
"RUNNING",
|
||||
"COMPLETED",
|
||||
"TERMINATED",
|
||||
"FAILED"
|
||||
"FAILED",
|
||||
"SKIPPED"
|
||||
],
|
||||
"title": "AgentExecutionStatus"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user