feat(platform): complete optional credentials implementation

- Backend: Populate required array in credentials schema based on
  credentials_optional metadata
- Backend: Skip input_default for optional credentials to prevent
  stale credential validation errors
- Backend: Pass nodes_to_skip through execution chain to blocks
- Backend: Filter Smart Decision Maker tools based on nodes_to_skip
  to prevent LLM from selecting unavailable tools
- Frontend: Filter incomplete credentials before sending run requests
- Frontend: Show (optional) indicator for non-required credentials
- Frontend: Only show optional toggle in builder canvas, not run dialogs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-01-08 01:22:18 -07:00
parent dbce3c8c8d
commit cdb100444f
13 changed files with 140 additions and 38 deletions

View File

@@ -603,9 +603,25 @@ class SmartDecisionMakerBlock(Block):
graph_exec_id: str,
node_exec_id: str,
user_id: str,
nodes_to_skip: set[str] | None = None,
**kwargs,
) -> BlockOutput:
tool_functions = await self._create_tool_node_signatures(node_id)
# Filter out tools for nodes that should be skipped (e.g., missing optional credentials)
if nodes_to_skip:
tool_functions = [
tf
for tf in tool_functions
if tf.get("function", {}).get("_sink_node_id") not in nodes_to_skip
]
if not tool_functions:
raise ValueError(
"No available tools to execute - all downstream nodes are unavailable "
"(possibly due to missing optional credentials)"
)
yield "tool_functions", json.dumps(tool_functions)
conversation_history = input_data.conversation_history or []

View File

@@ -335,7 +335,35 @@ class Graph(BaseGraph):
@computed_field
@property
def credentials_input_schema(self) -> dict[str, Any]:
return self._credentials_input_schema.jsonschema()
schema = self._credentials_input_schema.jsonschema()
# Determine which credential fields are required based on credentials_optional metadata
graph_credentials_inputs = self.aggregate_credentials_inputs()
required_fields = []
# Build a map of node_id -> node for quick lookup
all_nodes = {node.id: node for node in self.nodes}
for sub_graph in self.sub_graphs:
for node in sub_graph.nodes:
all_nodes[node.id] = node
for field_key, (
_field_info,
node_field_pairs,
) in graph_credentials_inputs.items():
# A field is required if ANY node using it has credentials_optional=False
is_required = False
for node_id, _field_name in node_field_pairs:
node = all_nodes.get(node_id)
if node and not node.credentials_optional:
is_required = True
break
if is_required:
required_fields.append(field_key)
schema["required"] = required_fields
return schema
@property
def _credentials_input_schema(self) -> type[BlockSchema]:

View File

@@ -147,6 +147,7 @@ async def execute_node(
data: NodeExecutionEntry,
execution_stats: NodeExecutionStats | None = None,
nodes_input_masks: Optional[NodesInputMasks] = None,
nodes_to_skip: Optional[set[str]] = None,
) -> BlockOutput:
"""
Execute a node in the graph. This will trigger a block execution on a node,
@@ -212,6 +213,7 @@ async def execute_node(
"node_exec_id": node_exec_id,
"user_id": user_id,
"execution_context": execution_context,
"nodes_to_skip": nodes_to_skip or set(),
}
# Last-minute fetch credentials + acquire a system-wide read-write lock to prevent
@@ -509,6 +511,7 @@ class ExecutionProcessor:
node_exec_progress: NodeExecutionProgress,
nodes_input_masks: Optional[NodesInputMasks],
graph_stats_pair: tuple[GraphExecutionStats, threading.Lock],
nodes_to_skip: Optional[set[str]] = None,
) -> NodeExecutionStats:
log_metadata = LogMetadata(
logger=_logger,
@@ -531,6 +534,7 @@ class ExecutionProcessor:
db_client=db_client,
log_metadata=log_metadata,
nodes_input_masks=nodes_input_masks,
nodes_to_skip=nodes_to_skip,
)
if isinstance(status, BaseException):
raise status
@@ -576,6 +580,7 @@ class ExecutionProcessor:
db_client: "DatabaseManagerAsyncClient",
log_metadata: LogMetadata,
nodes_input_masks: Optional[NodesInputMasks] = None,
nodes_to_skip: Optional[set[str]] = None,
) -> ExecutionStatus:
status = ExecutionStatus.RUNNING
@@ -612,6 +617,7 @@ class ExecutionProcessor:
data=node_exec,
execution_stats=stats,
nodes_input_masks=nodes_input_masks,
nodes_to_skip=nodes_to_skip,
):
await persist_output(output_name, output_data)
@@ -993,6 +999,7 @@ class ExecutionProcessor:
execution_stats,
execution_stats_lock,
),
nodes_to_skip=graph_exec.nodes_to_skip,
),
self.node_execution_loop,
)

View File

@@ -275,7 +275,12 @@ async def _validate_node_input_credentials(
):
field_value = node_input_mask[field_name]
elif field_name in node.input_default:
field_value = node.input_default[field_name]
# For optional credentials, don't use input_default - treat as missing
# This prevents stale credential IDs from failing validation
if node.credentials_optional:
field_value = None
else:
field_value = node.input_default[field_name]
# Check if credentials are missing (None, empty, or not present)
if field_value is None or (
@@ -554,11 +559,13 @@ async def validate_and_construct_node_execution_input(
nodes_input_masks or {},
)
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,
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, nodes_to_skip

View File

@@ -66,6 +66,7 @@ export const RunInputDialog = ({
formContext={{
showHandles: false,
size: "large",
showOptionalToggle: false,
}}
/>
</div>

View File

@@ -76,12 +76,18 @@ export const useRunInputDialog = ({
}, [credentialsSchema]);
const handleManualRun = async () => {
// Filter out incomplete credentials (those without a valid id)
// RJSF auto-populates const values (provider, type) but not id field
const validCredentials = Object.fromEntries(
Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id),
);
await executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,
data: {
inputs: inputValues,
credentials_inputs: credentialValues,
credentials_inputs: validCredentials,
source: "builder",
},
});

View File

@@ -313,9 +313,10 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
}));
},
getCredentialsOptional: (nodeId: string) => {
getCredentialsOptional: (nodeId: string): boolean => {
const node = get().nodes.find((n) => n.id === nodeId);
return node?.data?.metadata?.credentials_optional ?? false;
const value = node?.data?.metadata?.credentials_optional;
return typeof value === "boolean" ? value : false;
},
setCredentialsOptional: (nodeId: string, optional: boolean) => {

View File

@@ -33,6 +33,7 @@ type Props = {
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
isOptional?: boolean;
};
export function CredentialsInput({
@@ -43,6 +44,7 @@ export function CredentialsInput({
siblingInputs,
onLoaded,
readOnly = false,
isOptional = false,
}: Props) {
const hookData = useCredentialsInputs({
schema,
@@ -90,7 +92,14 @@ export function CredentialsInput({
return (
<div className={cn("mb-6", className)}>
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
<Text variant="large-medium">
{displayName} credentials
{isOptional && (
<span className="ml-1 text-sm font-normal text-gray-500">
(optional)
</span>
)}
</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}

View File

@@ -8,6 +8,7 @@ import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBann
export function ModalRunSection() {
const {
agent,
defaultRunType,
presetName,
setPresetName,
@@ -24,6 +25,11 @@ export function ModalRunSection() {
const inputFields = Object.entries(agentInputFields || {});
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Get the list of required credentials from the schema
const requiredCredentials = new Set(
(agent.credentials_input_schema?.required as string[]) || [],
);
return (
<div className="flex flex-col gap-4">
{defaultRunType === "automatic-trigger" ||
@@ -106,14 +112,12 @@ export function ModalRunSection() {
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={
(inputCredentials && inputCredentials[key]) ??
inputSubSchema.default
}
selectedCredentials={inputCredentials?.[key]}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
),
)}

View File

@@ -163,15 +163,21 @@ export function useAgentRunModal(
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
const availableCredentials = new Set(Object.keys(inputCredentials));
const allCredentials = new Set(
Object.keys(agentCredentialsInputFields || {}) ?? [],
);
const missing = [...allCredentials].filter(
(key) => !availableCredentials.has(key),
// Only check required credentials from schema, not all properties
// Credentials marked as optional in node metadata won't be in the required array
const requiredCredentials = new Set(
(agent.credentials_input_schema?.required as string[]) || [],
);
// Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set
const missing = [...requiredCredentials].filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
});
return [missing.length === 0, missing];
}, [agentCredentialsInputFields, inputCredentials]);
}, [agent.credentials_input_schema, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,
@@ -239,12 +245,18 @@ export function useAgentRunModal(
});
} else {
// Manual execution
// Filter out incomplete credentials (optional ones not selected)
// Only send credentials that have a valid id field
const validCredentials = Object.fromEntries(
Object.entries(inputCredentials).filter(([_, cred]) => cred && cred.id),
);
executeGraphMutation.mutate({
graphId: agent.graph_id,
graphVersion: agent.graph_version,
data: {
inputs: inputValues,
credentials_inputs: inputCredentials,
credentials_inputs: validCredentials,
source: "library",
},
});

View File

@@ -2,6 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "AutoGPT Agent Server",
"summary": "AutoGPT Agent Server",
"description": "This server is used to execute agents that are created by the AutoGPT system.",
"version": "0.1"
},
@@ -5477,6 +5478,7 @@
"APIKeyPermission": {
"type": "string",
"enum": [
"IDENTITY",
"EXECUTE_GRAPH",
"READ_GRAPH",
"EXECUTE_BLOCK",

View File

@@ -13,6 +13,7 @@ type FormContextType = {
uiType?: BlockUIType;
showHandles?: boolean;
size?: "small" | "medium" | "large";
showOptionalToggle?: boolean;
};
type FormRendererProps = {

View File

@@ -22,6 +22,8 @@ export const CredentialsField = (props: FieldProps) => {
} = props;
const nodeId = formContext.nodeId;
// Only show the optional toggle when editing blocks in the builder canvas
const showOptionalToggle = formContext.showOptionalToggle !== false && nodeId;
const { credentialsOptional, setCredentialsOptional } = useNodeStore(
useShallow((state) => ({
@@ -72,7 +74,9 @@ export const CredentialsField = (props: FieldProps) => {
disabled={false}
label="Credential"
placeholder={
credentialsOptional ? "Select credential (optional)" : "Select credential"
credentialsOptional
? "Select credential (optional)"
: "Select credential"
}
/>
)}
@@ -99,20 +103,24 @@ 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>
{/* Optional credentials toggle - only show in builder canvas, not run dialogs */}
{showOptionalToggle && (
<div className="mt-1 flex items-center gap-2">
<Switch
id={`credentials-optional-${nodeId}`}
checked={credentialsOptional}
onCheckedChange={(checked) =>
setCredentialsOptional(nodeId, checked)
}
/>
<label
htmlFor={`credentials-optional-${nodeId}`}
className="cursor-pointer text-xs text-gray-500"
>
Optional - skip block if not configured
</label>
</div>
)}
</div>
);
};