feat(platform): add optional credentials flag for agent blocks

This feature allows agent makers to mark credential fields as optional.
When credentials are not configured for an optional block, the block
will be skipped during execution rather than causing a validation error.

Use case: An agent with multiple notification channels (Discord, Twilio,
Slack) where the user only needs to configure one - unconfigured channels
are simply skipped.

Backend changes:
- Add credentials_optional property to Node model (read from metadata)
- Update _validate_node_input_credentials to return nodes_to_skip set
- Update validate_graph_with_credentials to propagate nodes_to_skip
- Add nodes_to_skip field to GraphExecutionEntry
- Skip execution of nodes with optional missing credentials in manager

Frontend changes:
- Add credentials_optional to node metadata in nodeStore
- Add getCredentialsOptional/setCredentialsOptional helpers
- Add "Optional - skip block if not configured" switch to CredentialField
- Disable auto-selection of credentials when optional flag is set
This commit is contained in:
Claude
2026-01-07 05:55:53 +00:00
parent 4a7bc006a8
commit dbce3c8c8d
7 changed files with 180 additions and 34 deletions

View File

@@ -379,6 +379,7 @@ class GraphExecutionWithNodes(GraphExecution):
self,
execution_context: ExecutionContext,
compiled_nodes_input_masks: Optional[NodesInputMasks] = None,
nodes_to_skip: Optional[set[str]] = None,
):
return GraphExecutionEntry(
user_id=self.user_id,
@@ -386,6 +387,7 @@ class GraphExecutionWithNodes(GraphExecution):
graph_version=self.graph_version or 0,
graph_exec_id=self.id,
nodes_input_masks=compiled_nodes_input_masks,
nodes_to_skip=nodes_to_skip or set(),
execution_context=execution_context,
)
@@ -1117,6 +1119,8 @@ class GraphExecutionEntry(BaseModel):
graph_id: str
graph_version: int
nodes_input_masks: Optional[NodesInputMasks] = None
nodes_to_skip: set[str] = Field(default_factory=set)
"""Node IDs that should be skipped due to optional credentials not being configured."""
execution_context: ExecutionContext = Field(default_factory=ExecutionContext)

View File

@@ -94,6 +94,15 @@ class Node(BaseDbModel):
input_links: list[Link] = []
output_links: list[Link] = []
@property
def credentials_optional(self) -> bool:
"""
Whether credentials are optional for this node.
When True and credentials are not configured, the node will be skipped
during execution rather than causing a validation error.
"""
return self.metadata.get("credentials_optional", False)
@property
def block(self) -> AnyBlockSchema | "_UnknownBlockBase":
"""Get the block for this node. Returns UnknownBlock if block is deleted/missing."""

View File

@@ -918,6 +918,21 @@ class ExecutionProcessor:
queued_node_exec = execution_queue.get()
# Check if this node should be skipped due to optional credentials
if queued_node_exec.node_id in graph_exec.nodes_to_skip:
log_metadata.info(
f"Skipping node execution {queued_node_exec.node_exec_id} "
f"for node {queued_node_exec.node_id} - optional credentials not configured"
)
# Mark the node as completed without executing
# No outputs will be produced, so downstream nodes won't trigger
update_node_execution_status(
db_client=db_client,
exec_id=queued_node_exec.node_exec_id,
status=ExecutionStatus.COMPLETED,
)
continue
log_metadata.debug(
f"Dispatching node execution {queued_node_exec.node_exec_id} "
f"for node {queued_node_exec.node_id}",

View File

@@ -239,14 +239,19 @@ async def _validate_node_input_credentials(
graph: GraphModel,
user_id: str,
nodes_input_masks: Optional[NodesInputMasks] = None,
) -> dict[str, dict[str, str]]:
) -> tuple[dict[str, dict[str, str]], set[str]]:
"""
Checks all credentials for all nodes of the graph and returns structured errors.
Checks all credentials for all nodes of the graph and returns structured errors
and a set of nodes that should be skipped due to optional missing credentials.
Returns:
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node
tuple[
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node,
set[node_id]: Nodes that should be skipped (optional credentials not configured)
]
"""
credential_errors: dict[str, dict[str, str]] = defaultdict(dict)
nodes_to_skip: set[str] = set()
for node in graph.nodes:
block = node.block
@@ -256,27 +261,41 @@ async def _validate_node_input_credentials(
if not credentials_fields:
continue
# Track if any credential field is missing for this node
has_missing_credentials = False
for field_name, credentials_meta_type in credentials_fields.items():
try:
# Check nodes_input_masks first, then input_default
field_value = None
if (
nodes_input_masks
and (node_input_mask := nodes_input_masks.get(node.id))
and field_name in node_input_mask
):
credentials_meta = credentials_meta_type.model_validate(
node_input_mask[field_name]
)
field_value = node_input_mask[field_name]
elif field_name in node.input_default:
credentials_meta = credentials_meta_type.model_validate(
node.input_default[field_name]
)
else:
# Missing credentials
credential_errors[node.id][
field_name
] = "These credentials are required"
continue
field_value = node.input_default[field_name]
# Check if credentials are missing (None, empty, or not present)
if field_value is None or (
isinstance(field_value, dict) and not field_value.get("id")
):
has_missing_credentials = True
# If node has credentials_optional flag, mark for skipping instead of error
if node.credentials_optional:
continue # Don't add error, will be marked for skip after loop
else:
credential_errors[node.id][
field_name
] = "These credentials are required"
continue
credentials_meta = credentials_meta_type.model_validate(field_value)
except ValidationError as e:
# Validation error means credentials were provided but invalid
# This should always be an error, even if optional
credential_errors[node.id][field_name] = f"Invalid credentials: {e}"
continue
@@ -287,6 +306,7 @@ async def _validate_node_input_credentials(
)
except Exception as e:
# Handle any errors fetching credentials
# If credentials were explicitly configured but unavailable, it's an error
credential_errors[node.id][
field_name
] = f"Credentials not available: {e}"
@@ -313,7 +333,19 @@ async def _validate_node_input_credentials(
] = "Invalid credentials: type/provider mismatch"
continue
return credential_errors
# If node has optional credentials and any are missing, mark for skipping
# But only if there are no other errors for this node
if (
has_missing_credentials
and node.credentials_optional
and node.id not in credential_errors
):
nodes_to_skip.add(node.id)
logger.info(
f"Node #{node.id} will be skipped: optional credentials not configured"
)
return credential_errors, nodes_to_skip
def make_node_credentials_input_map(
@@ -355,21 +387,25 @@ async def validate_graph_with_credentials(
graph: GraphModel,
user_id: str,
nodes_input_masks: Optional[NodesInputMasks] = None,
) -> Mapping[str, Mapping[str, str]]:
) -> tuple[Mapping[str, Mapping[str, str]], set[str]]:
"""
Validate graph including credentials and return structured errors per node.
Validate graph including credentials and return structured errors per node,
along with a set of nodes that should be skipped due to optional missing credentials.
Returns:
dict[node_id, dict[field_name, error_message]]: Validation errors per node
tuple[
dict[node_id, dict[field_name, error_message]]: Validation errors per node,
set[node_id]: Nodes that should be skipped (optional credentials not configured)
]
"""
# Get input validation errors
node_input_errors = GraphModel.validate_graph_get_errors(
graph, for_run=True, nodes_input_masks=nodes_input_masks
)
# Get credential input/availability/validation errors
node_credential_input_errors = await _validate_node_input_credentials(
graph, user_id, nodes_input_masks
# Get credential input/availability/validation errors and nodes to skip
node_credential_input_errors, nodes_to_skip = (
await _validate_node_input_credentials(graph, user_id, nodes_input_masks)
)
# Merge credential errors with structural errors
@@ -378,7 +414,7 @@ async def validate_graph_with_credentials(
node_input_errors[node_id] = {}
node_input_errors[node_id].update(field_errors)
return node_input_errors
return node_input_errors, nodes_to_skip
async def _construct_starting_node_execution_input(
@@ -386,7 +422,7 @@ async def _construct_starting_node_execution_input(
user_id: str,
graph_inputs: BlockInput,
nodes_input_masks: Optional[NodesInputMasks] = None,
) -> list[tuple[str, BlockInput]]:
) -> tuple[list[tuple[str, BlockInput]], set[str]]:
"""
Validates and prepares the input data for executing a graph.
This function checks the graph for starting nodes, validates the input data
@@ -400,11 +436,14 @@ async def _construct_starting_node_execution_input(
node_credentials_map: `dict[node_id, dict[input_name, CredentialsMetaInput]]`
Returns:
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and
the corresponding input data for that node.
tuple[
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID
and the corresponding input data for that node.
set[str]: Node IDs that should be skipped (optional credentials not configured)
]
"""
# Use new validation function that includes credentials
validation_errors = await validate_graph_with_credentials(
validation_errors, nodes_to_skip = await validate_graph_with_credentials(
graph, user_id, nodes_input_masks
)
n_error_nodes = len(validation_errors)
@@ -445,7 +484,7 @@ async def _construct_starting_node_execution_input(
"No starting nodes found for the graph, make sure an AgentInput or blocks with no inbound links are present as starting nodes."
)
return nodes_input
return nodes_input, nodes_to_skip
async def validate_and_construct_node_execution_input(
@@ -456,7 +495,7 @@ async def validate_and_construct_node_execution_input(
graph_credentials_inputs: Optional[Mapping[str, CredentialsMetaInput]] = None,
nodes_input_masks: Optional[NodesInputMasks] = None,
is_sub_graph: bool = False,
) -> tuple[GraphModel, list[tuple[str, BlockInput]], NodesInputMasks]:
) -> tuple[GraphModel, list[tuple[str, BlockInput]], NodesInputMasks, set[str]]:
"""
Public wrapper that handles graph fetching, credential mapping, and validation+construction.
This centralizes the logic used by both scheduler validation and actual execution.
@@ -473,6 +512,7 @@ async def validate_and_construct_node_execution_input(
GraphModel: Full graph object for the given `graph_id`.
list[tuple[node_id, BlockInput]]: Starting node IDs with corresponding inputs.
dict[str, BlockInput]: Node input masks including all passed-in credentials.
set[str]: Node IDs that should be skipped (optional credentials not configured).
Raises:
NotFoundError: If the graph is not found.
@@ -514,14 +554,14 @@ async def validate_and_construct_node_execution_input(
nodes_input_masks or {},
)
starting_nodes_input = await _construct_starting_node_execution_input(
starting_nodes_input, nodes_to_skip = await _construct_starting_node_execution_input(
graph=graph,
user_id=user_id,
graph_inputs=graph_inputs,
nodes_input_masks=nodes_input_masks,
)
return graph, starting_nodes_input, nodes_input_masks
return graph, starting_nodes_input, nodes_input_masks, nodes_to_skip
def _merge_nodes_input_masks(
@@ -779,6 +819,9 @@ async def add_graph_execution(
# Use existing execution's compiled input masks
compiled_nodes_input_masks = graph_exec.nodes_input_masks or {}
# For resumed executions, nodes_to_skip was already determined at creation time
# TODO: Consider storing nodes_to_skip in DB if we need to preserve it across resumes
nodes_to_skip: set[str] = set()
logger.info(f"Resuming graph execution #{graph_exec.id} for graph #{graph_id}")
else:
@@ -787,7 +830,7 @@ async def add_graph_execution(
)
# Create new execution
graph, starting_nodes_input, compiled_nodes_input_masks = (
graph, starting_nodes_input, compiled_nodes_input_masks, nodes_to_skip = (
await validate_and_construct_node_execution_input(
graph_id=graph_id,
user_id=user_id,
@@ -836,6 +879,7 @@ async def add_graph_execution(
try:
graph_exec_entry = graph_exec.to_graph_execution_entry(
compiled_nodes_input_masks=compiled_nodes_input_masks,
nodes_to_skip=nodes_to_skip,
execution_context=execution_context,
)
logger.info(f"Publishing execution {graph_exec.id} to execution queue")

View File

@@ -62,6 +62,10 @@ type NodeStore = {
errors: { [key: string]: string },
) => void;
clearAllNodeErrors: () => void; // Add this
// Credentials optional helpers
getCredentialsOptional: (nodeId: string) => boolean;
setCredentialsOptional: (nodeId: string, optional: boolean) => void;
};
export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -220,6 +224,9 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
...(node.data.metadata?.customized_name !== undefined && {
customized_name: node.data.metadata.customized_name,
}),
...(node.data.metadata?.credentials_optional !== undefined && {
credentials_optional: node.data.metadata.credentials_optional,
}),
},
};
},
@@ -305,4 +312,35 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
})),
}));
},
getCredentialsOptional: (nodeId: string) => {
const node = get().nodes.find((n) => n.id === nodeId);
return node?.data?.metadata?.credentials_optional ?? false;
},
setCredentialsOptional: (nodeId: string, optional: boolean) => {
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId
? {
...n,
data: {
...n.data,
metadata: {
...n.data.metadata,
credentials_optional: optional,
},
},
}
: n,
),
}));
const newState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
useHistoryStore.getState().pushState(newState);
},
}));

View File

@@ -8,6 +8,9 @@ import { APIKeyCredentialsModal } from "./models/APIKeyCredentialModal/APIKeyCre
import { OAuthCredentialModal } from "./models/OAuthCredentialModal/OAuthCredentialModal";
import { PasswordCredentialsModal } from "./models/PasswordCredentialModal/PasswordCredentialModal";
import { HostScopedCredentialsModal } from "./models/HostScopedCredentialsModal/HostScopedCredentialsModal";
import { Switch } from "@/components/atoms/Switch/Switch";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
export const CredentialsField = (props: FieldProps) => {
const {
@@ -17,6 +20,16 @@ export const CredentialsField = (props: FieldProps) => {
schema,
formContext,
} = props;
const nodeId = formContext.nodeId;
const { credentialsOptional, setCredentialsOptional } = useNodeStore(
useShallow((state) => ({
credentialsOptional: state.getCredentialsOptional(nodeId),
setCredentialsOptional: state.setCredentialsOptional,
})),
);
const {
credentials,
isCredentialListLoading,
@@ -31,8 +44,9 @@ export const CredentialsField = (props: FieldProps) => {
} = useCredentialField({
credentialSchema: schema as BlockIOCredentialsSubSchema,
formData,
nodeId: formContext.nodeId,
nodeId,
onChange,
disableAutoSelect: credentialsOptional,
});
if (isCredentialListLoading) {
@@ -57,7 +71,9 @@ export const CredentialsField = (props: FieldProps) => {
onChange={setCredential}
disabled={false}
label="Credential"
placeholder="Select credential"
placeholder={
credentialsOptional ? "Select credential (optional)" : "Select credential"
}
/>
)}
@@ -82,6 +98,21 @@ export const CredentialsField = (props: FieldProps) => {
/>
)}
</div>
{/* Optional credentials toggle */}
<div className="flex items-center gap-2 mt-1">
<Switch
id={`credentials-optional-${nodeId}`}
checked={credentialsOptional}
onCheckedChange={(checked) => setCredentialsOptional(nodeId, checked)}
/>
<label
htmlFor={`credentials-optional-${nodeId}`}
className="text-xs text-gray-500 cursor-pointer"
>
Optional - skip block if not configured
</label>
</div>
</div>
);
};

View File

@@ -15,11 +15,13 @@ export const useCredentialField = ({
formData,
nodeId,
onChange,
disableAutoSelect = false,
}: {
credentialSchema: BlockIOCredentialsSubSchema; // Here we are using manual typing, we need to fix it with automatic one
formData: Record<string, any>;
nodeId: string;
onChange: (value: Record<string, any>) => void;
disableAutoSelect?: boolean;
}) => {
const previousProviderRef = useRef<string | null>(null);
@@ -108,8 +110,10 @@ export const useCredentialField = ({
}, [credentialProvider, formData.id, credentials, onChange]);
// This side effect is used to auto-select the latest credential when none is selected [latest means last one in the list of credentials]
// Auto-selection is disabled when credentials are marked as optional
useEffect(() => {
if (
!disableAutoSelect &&
!isCredentialListLoading &&
filteredCredentials.length > 0 &&
!formData.id && // No credential currently selected
@@ -120,6 +124,7 @@ export const useCredentialField = ({
setCredential(latestCredential.id);
}
}, [
disableAutoSelect,
isCredentialListLoading,
filteredCredentials.length,
formData.id,