mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
fix(builder): Defer graph validation to backend (#10556)
- Resolves #10553 ### Changes 🏗️ - Remove frontend graph validation in `useAgentGraph:saveAndRun(..)` - Remove now unused `ajv` dependency - Implement graph validation error propagation (backend->frontend) - Add `GraphValidationError` type in frontend and backend - Add `GraphModel.validate_graph_get_errors(..)` method - Fix error handling & propagation in frontend API request logic ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Saving & running a graph with missing required inputs gives a node-specific error - [x] Saving & running a graph with missing node credential inputs succeeds with passed-in credentials
This commit is contained in:
committed by
GitHub
parent
b935638240
commit
fa2d968458
@@ -416,6 +416,10 @@ class GraphModel(Graph):
|
||||
for_run: bool = False,
|
||||
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
|
||||
):
|
||||
"""
|
||||
Validate graph structure and raise `ValueError` on issues.
|
||||
For structured error reporting, use `validate_graph_get_errors`.
|
||||
"""
|
||||
self._validate_graph(self, for_run, nodes_input_masks)
|
||||
for sub_graph in self.sub_graphs:
|
||||
self._validate_graph(sub_graph, for_run, nodes_input_masks)
|
||||
@@ -425,15 +429,58 @@ class GraphModel(Graph):
|
||||
graph: BaseGraph,
|
||||
for_run: bool = False,
|
||||
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
|
||||
):
|
||||
def is_tool_pin(name: str) -> bool:
|
||||
return name.startswith("tools_^_")
|
||||
) -> None:
|
||||
errors = GraphModel._validate_graph_get_errors(
|
||||
graph, for_run, nodes_input_masks
|
||||
)
|
||||
if errors:
|
||||
# Just raise the first error for backward compatibility
|
||||
first_error = next(iter(errors.values()))
|
||||
first_field_error = next(iter(first_error.values()))
|
||||
raise ValueError(first_field_error)
|
||||
|
||||
def sanitize(name):
|
||||
sanitized_name = name.split("_#_")[0].split("_@_")[0].split("_$_")[0]
|
||||
if is_tool_pin(sanitized_name):
|
||||
return "tools"
|
||||
return sanitized_name
|
||||
def validate_graph_get_errors(
|
||||
self,
|
||||
for_run: bool = False,
|
||||
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Validate graph and return structured errors per node.
|
||||
|
||||
Returns: dict[node_id, dict[field_name, error_message]]
|
||||
"""
|
||||
return {
|
||||
**self._validate_graph_get_errors(self, for_run, nodes_input_masks),
|
||||
**{
|
||||
node_id: error
|
||||
for sub_graph in self.sub_graphs
|
||||
for node_id, error in self._validate_graph_get_errors(
|
||||
sub_graph, for_run, nodes_input_masks
|
||||
).items()
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_graph_get_errors(
|
||||
graph: BaseGraph,
|
||||
for_run: bool = False,
|
||||
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Validate graph and return structured errors per node.
|
||||
|
||||
Returns: dict[node_id, dict[field_name, error_message]]
|
||||
"""
|
||||
# First, check for structural issues with the graph
|
||||
try:
|
||||
GraphModel._validate_graph_structure(graph)
|
||||
except ValueError:
|
||||
# If structural validation fails, we can't provide per-node errors
|
||||
# so we re-raise as is
|
||||
raise
|
||||
|
||||
# Collect errors per node
|
||||
node_errors: dict[str, dict[str, str]] = defaultdict(dict)
|
||||
|
||||
# Validate smart decision maker nodes
|
||||
nodes_block = {
|
||||
@@ -442,7 +489,7 @@ class GraphModel(Graph):
|
||||
if (block := get_block(node.block_id)) is not None
|
||||
}
|
||||
|
||||
input_links = defaultdict(list)
|
||||
input_links: dict[str, list[Link]] = defaultdict(list)
|
||||
|
||||
for link in graph.links:
|
||||
input_links[link.sink_id].append(link)
|
||||
@@ -450,17 +497,22 @@ class GraphModel(Graph):
|
||||
# Nodes: required fields are filled or connected and dependencies are satisfied
|
||||
for node in graph.nodes:
|
||||
if (block := nodes_block.get(node.id)) is None:
|
||||
# For invalid blocks, we still raise immediately as this is a structural issue
|
||||
raise ValueError(f"Invalid block {node.block_id} for node #{node.id}")
|
||||
|
||||
node_input_mask = (
|
||||
nodes_input_masks.get(node.id, {}) if nodes_input_masks else {}
|
||||
)
|
||||
provided_inputs = set(
|
||||
[sanitize(name) for name in node.input_default]
|
||||
+ [sanitize(link.sink_name) for link in input_links.get(node.id, [])]
|
||||
[_sanitize_pin_name(name) for name in node.input_default]
|
||||
+ [
|
||||
_sanitize_pin_name(link.sink_name)
|
||||
for link in input_links.get(node.id, [])
|
||||
]
|
||||
+ ([name for name in node_input_mask] if node_input_mask else [])
|
||||
)
|
||||
InputSchema = block.input_schema
|
||||
|
||||
for name in (required_fields := InputSchema.get_required_fields()):
|
||||
if (
|
||||
name not in provided_inputs
|
||||
@@ -477,18 +529,16 @@ class GraphModel(Graph):
|
||||
]
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Node {block.name} #{node.id} required input missing: `{name}`"
|
||||
)
|
||||
node_errors[node.id][name] = "This field is required"
|
||||
|
||||
if (
|
||||
block.block_type == BlockType.INPUT
|
||||
and (input_key := node.input_default.get("name"))
|
||||
and is_credentials_field_name(input_key)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Agent input node uses reserved name '{input_key}'; "
|
||||
"'credentials' and `*_credentials` are reserved input names"
|
||||
node_errors[node.id]["name"] = (
|
||||
f"'{input_key}' is a reserved input name: "
|
||||
"'credentials' and `*_credentials` are reserved"
|
||||
)
|
||||
|
||||
# Get input schema properties and check dependencies
|
||||
@@ -538,10 +588,15 @@ class GraphModel(Graph):
|
||||
# Check for missing dependencies when dependent field is present
|
||||
missing_deps = [dep for dep in dependencies if not has_value(node, dep)]
|
||||
if missing_deps and (field_has_value or field_is_required):
|
||||
raise ValueError(
|
||||
f"Node {block.name} #{node.id}: Field `{field_name}` requires [{', '.join(missing_deps)}] to be set"
|
||||
)
|
||||
node_errors[node.id][
|
||||
field_name
|
||||
] = f"Requires {', '.join(missing_deps)} to be set"
|
||||
|
||||
return node_errors
|
||||
|
||||
@staticmethod
|
||||
def _validate_graph_structure(graph: BaseGraph):
|
||||
"""Validate graph structure (links, connections, etc.)"""
|
||||
node_map = {v.id: v for v in graph.nodes}
|
||||
|
||||
def is_static_output_block(nid: str) -> bool:
|
||||
@@ -567,7 +622,7 @@ class GraphModel(Graph):
|
||||
f"{prefix}, {node.block_id} is invalid block id, available blocks: {blocks}"
|
||||
)
|
||||
|
||||
sanitized_name = sanitize(name)
|
||||
sanitized_name = _sanitize_pin_name(name)
|
||||
vals = node.input_default
|
||||
if i == 0:
|
||||
fields = (
|
||||
@@ -581,7 +636,7 @@ class GraphModel(Graph):
|
||||
if block.block_type not in [BlockType.AGENT]
|
||||
else vals.get("input_schema", {}).get("properties", {}).keys()
|
||||
)
|
||||
if sanitized_name not in fields and not is_tool_pin(name):
|
||||
if sanitized_name not in fields and not _is_tool_pin(name):
|
||||
fields_msg = f"Allowed fields: {fields}"
|
||||
raise ValueError(f"{prefix}, `{name}` invalid, {fields_msg}")
|
||||
|
||||
@@ -618,6 +673,17 @@ class GraphModel(Graph):
|
||||
)
|
||||
|
||||
|
||||
def _is_tool_pin(name: str) -> bool:
|
||||
return name.startswith("tools_^_")
|
||||
|
||||
|
||||
def _sanitize_pin_name(name: str) -> str:
|
||||
sanitized_name = name.split("_#_")[0].split("_@_")[0].split("_$_")[0]
|
||||
if _is_tool_pin(sanitized_name):
|
||||
return "tools"
|
||||
return sanitized_name
|
||||
|
||||
|
||||
class GraphMeta(Graph):
|
||||
user_id: str
|
||||
|
||||
|
||||
@@ -4,21 +4,14 @@ import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import Future
|
||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from autogpt_libs.utils.cache import thread_cached
|
||||
from pydantic import BaseModel, JsonValue
|
||||
from pydantic import BaseModel, JsonValue, ValidationError
|
||||
|
||||
from backend.data import execution as execution_db
|
||||
from backend.data import graph as graph_db
|
||||
from backend.data.block import (
|
||||
Block,
|
||||
BlockData,
|
||||
BlockInput,
|
||||
BlockSchema,
|
||||
BlockType,
|
||||
get_block,
|
||||
)
|
||||
from backend.data.block import Block, BlockData, BlockInput, BlockType, get_block
|
||||
from backend.data.block_cost_config import BLOCK_COSTS
|
||||
from backend.data.cost import BlockCostType
|
||||
from backend.data.db import prisma
|
||||
@@ -39,7 +32,7 @@ from backend.data.rabbitmq import (
|
||||
RabbitMQConfig,
|
||||
SyncRabbitMQ,
|
||||
)
|
||||
from backend.util.exceptions import NotFoundError
|
||||
from backend.util.exceptions import GraphValidationError, NotFoundError
|
||||
from backend.util.logging import TruncatedLogger
|
||||
from backend.util.mock import MockObject
|
||||
from backend.util.service import get_service_client
|
||||
@@ -467,47 +460,65 @@ async def _validate_node_input_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
|
||||
):
|
||||
"""Checks all credentials for all nodes of the graph"""
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Checks all credentials for all nodes of the graph and returns structured errors.
|
||||
|
||||
Returns:
|
||||
dict[node_id, dict[field_name, error_message]]: Credential validation errors per node
|
||||
"""
|
||||
credential_errors: dict[str, dict[str, str]] = defaultdict(dict)
|
||||
|
||||
for node in graph.nodes:
|
||||
block = node.block
|
||||
|
||||
# Find any fields of type CredentialsMetaInput
|
||||
credentials_fields = cast(
|
||||
type[BlockSchema], block.input_schema
|
||||
).get_credentials_fields()
|
||||
credentials_fields = block.input_schema.get_credentials_fields()
|
||||
if not credentials_fields:
|
||||
continue
|
||||
|
||||
for field_name, credentials_meta_type in credentials_fields.items():
|
||||
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]
|
||||
)
|
||||
elif field_name in node.input_default:
|
||||
credentials_meta = credentials_meta_type.model_validate(
|
||||
node.input_default[field_name]
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Credentials absent for {block.name} node #{node.id} "
|
||||
f"input '{field_name}'"
|
||||
)
|
||||
try:
|
||||
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]
|
||||
)
|
||||
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
|
||||
except ValidationError as e:
|
||||
credential_errors[node.id][field_name] = f"Invalid credentials: {e}"
|
||||
continue
|
||||
|
||||
# Fetch the corresponding Credentials and perform sanity checks
|
||||
credentials = await get_integration_credentials_store().get_creds_by_id(
|
||||
user_id, credentials_meta.id
|
||||
)
|
||||
if not credentials:
|
||||
raise ValueError(
|
||||
f"Unknown credentials #{credentials_meta.id} "
|
||||
f"for node #{node.id} input '{field_name}'"
|
||||
try:
|
||||
# Fetch the corresponding Credentials and perform sanity checks
|
||||
credentials = await get_integration_credentials_store().get_creds_by_id(
|
||||
user_id, credentials_meta.id
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle any errors fetching credentials
|
||||
credential_errors[node.id][
|
||||
field_name
|
||||
] = f"Credentials not available: {e}"
|
||||
continue
|
||||
|
||||
if not credentials:
|
||||
credential_errors[node.id][
|
||||
field_name
|
||||
] = f"Unknown credentials #{credentials_meta.id}"
|
||||
continue
|
||||
|
||||
if (
|
||||
credentials.provider != credentials_meta.provider
|
||||
or credentials.type != credentials_meta.type
|
||||
@@ -518,10 +529,12 @@ async def _validate_node_input_credentials(
|
||||
f"{credentials_meta.type}<>{credentials.type};"
|
||||
f"{credentials_meta.provider}<>{credentials.provider}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Invalid credentials #{credentials.id} for node #{node.id}: "
|
||||
"type/provider mismatch"
|
||||
)
|
||||
credential_errors[node.id][
|
||||
field_name
|
||||
] = "Invalid credentials: type/provider mismatch"
|
||||
continue
|
||||
|
||||
return credential_errors
|
||||
|
||||
|
||||
def make_node_credentials_input_map(
|
||||
@@ -559,6 +572,36 @@ def make_node_credentials_input_map(
|
||||
return result
|
||||
|
||||
|
||||
async def validate_graph_with_credentials(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None,
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Validate graph including credentials and return structured errors per node.
|
||||
|
||||
Returns:
|
||||
dict[node_id, dict[field_name, error_message]]: Validation errors per node
|
||||
"""
|
||||
# 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
|
||||
)
|
||||
|
||||
# Merge credential errors with structural errors
|
||||
for node_id, field_errors in node_credential_input_errors.items():
|
||||
if node_id not in node_input_errors:
|
||||
node_input_errors[node_id] = {}
|
||||
node_input_errors[node_id].update(field_errors)
|
||||
|
||||
return node_input_errors
|
||||
|
||||
|
||||
async def construct_node_execution_input(
|
||||
graph: GraphModel,
|
||||
user_id: str,
|
||||
@@ -581,8 +624,17 @@ async def construct_node_execution_input(
|
||||
list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and
|
||||
the corresponding input data for that node.
|
||||
"""
|
||||
graph.validate_graph(for_run=True, nodes_input_masks=nodes_input_masks)
|
||||
await _validate_node_input_credentials(graph, user_id, nodes_input_masks)
|
||||
# Use new validation function that includes credentials
|
||||
validation_errors = await validate_graph_with_credentials(
|
||||
graph, user_id, nodes_input_masks
|
||||
)
|
||||
n_error_nodes = len(validation_errors)
|
||||
n_errors = sum(len(errors) for errors in validation_errors.values())
|
||||
if validation_errors:
|
||||
raise GraphValidationError(
|
||||
f"Graph validation failed: {n_errors} issues on {n_error_nodes} nodes",
|
||||
node_errors=validation_errors,
|
||||
)
|
||||
|
||||
nodes_input = []
|
||||
for node in graph.starting_nodes:
|
||||
|
||||
@@ -85,7 +85,7 @@ from backend.server.model import (
|
||||
)
|
||||
from backend.server.utils import get_user_id
|
||||
from backend.util.cloud_storage import get_cloud_storage_handler
|
||||
from backend.util.exceptions import NotFoundError
|
||||
from backend.util.exceptions import GraphValidationError, NotFoundError
|
||||
from backend.util.service import get_service_client
|
||||
from backend.util.settings import Settings
|
||||
from backend.util.virus_scanner import scan_content_safe
|
||||
@@ -753,15 +753,27 @@ async def execute_graph(
|
||||
detail="Insufficient balance to execute the agent. Please top up your account.",
|
||||
)
|
||||
|
||||
graph_exec = await execution_utils.add_graph_execution(
|
||||
graph_id=graph_id,
|
||||
user_id=user_id,
|
||||
inputs=inputs,
|
||||
preset_id=preset_id,
|
||||
graph_version=graph_version,
|
||||
graph_credentials_inputs=credentials_inputs,
|
||||
)
|
||||
return ExecuteGraphResponse(graph_exec_id=graph_exec.id)
|
||||
try:
|
||||
graph_exec = await execution_utils.add_graph_execution(
|
||||
graph_id=graph_id,
|
||||
user_id=user_id,
|
||||
inputs=inputs,
|
||||
preset_id=preset_id,
|
||||
graph_version=graph_version,
|
||||
graph_credentials_inputs=credentials_inputs,
|
||||
)
|
||||
return ExecuteGraphResponse(graph_exec_id=graph_exec.id)
|
||||
except GraphValidationError as e:
|
||||
# Return structured validation errors that the frontend can parse
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"type": "validation_error",
|
||||
"message": e.message,
|
||||
# TODO: only return node-specific errors if user has access to graph
|
||||
"node_errors": e.node_errors,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
|
||||
@@ -31,3 +31,17 @@ class InsufficientBalanceError(ValueError):
|
||||
def __str__(self):
|
||||
"""Used to display the error message in the frontend, because we str() the error when sending the execution update"""
|
||||
return self.message
|
||||
|
||||
|
||||
class GraphValidationError(ValueError):
|
||||
"""Structured validation error for graph validation failures"""
|
||||
|
||||
def __init__(
|
||||
self, message: str, node_errors: dict[str, dict[str, str]] | None = None
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.node_errors = node_errors or {}
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@types/jaro-winkler": "0.2.4",
|
||||
"@xyflow/react": "12.8.2",
|
||||
"ajv": "8.17.1",
|
||||
"boring-avatars": "1.11.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
3
autogpt_platform/frontend/pnpm-lock.yaml
generated
3
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -98,9 +98,6 @@ importers:
|
||||
'@xyflow/react':
|
||||
specifier: 12.8.2
|
||||
version: 12.8.2(@types/react@18.3.17)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
ajv:
|
||||
specifier: 8.17.1
|
||||
version: 8.17.1
|
||||
boring-avatars:
|
||||
specifier: 1.11.2
|
||||
version: 1.11.2
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CustomNode } from "@/components/CustomNode";
|
||||
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import {
|
||||
ApiError,
|
||||
Block,
|
||||
BlockIOSubSchema,
|
||||
BlockUIType,
|
||||
@@ -23,17 +24,13 @@ import {
|
||||
deepEquals,
|
||||
getTypeColor,
|
||||
removeEmptyStringsAndNulls,
|
||||
setNestedProperty,
|
||||
} from "@/lib/utils";
|
||||
import { MarkerType } from "@xyflow/react";
|
||||
import Ajv from "ajv";
|
||||
import { default as NextLink } from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
const ajv = new Ajv({ strict: false, allErrors: true });
|
||||
|
||||
export default function useAgentGraph(
|
||||
flowID?: GraphID,
|
||||
flowVersion?: number,
|
||||
@@ -610,135 +607,6 @@ export default function useAgentGraph(
|
||||
getToolFuncName,
|
||||
]);
|
||||
|
||||
const validateGraph = useCallback(
|
||||
(graph?: GraphCreatable): string | null => {
|
||||
let errorMessage = null;
|
||||
|
||||
if (!graph) {
|
||||
graph = prepareSaveableGraph();
|
||||
}
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const block = availableBlocks.find(
|
||||
(block) => block.id === node.block_id,
|
||||
);
|
||||
if (!block) {
|
||||
console.error(
|
||||
`Node ${node.id} is invalid: unknown block ID ${node.block_id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const inputSchema = block.inputSchema;
|
||||
const validate = ajv.compile(inputSchema);
|
||||
const errors: Record<string, string> = {};
|
||||
const errorPrefix = `${block.name} [${node.id.split("-")[0]}]`;
|
||||
|
||||
// Validate values against schema using AJV
|
||||
const inputData = node.input_default;
|
||||
const valid = validate(inputData);
|
||||
if (!valid) {
|
||||
// Populate errors if validation fails
|
||||
validate.errors?.forEach((error) => {
|
||||
const path =
|
||||
"dataPath" in error
|
||||
? (error.dataPath as string)
|
||||
: error.instancePath || error.params.missingProperty;
|
||||
const handle = path.split(/[\/.]/)[0];
|
||||
// Skip error if there's an edge connected
|
||||
if (
|
||||
graph.links.some(
|
||||
(link) => link.sink_id == node.id && link.sink_name == handle,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.warn(`Error in ${block.name} input: ${error}`, {
|
||||
data: inputData,
|
||||
schema: inputSchema,
|
||||
});
|
||||
errorMessage =
|
||||
`${errorPrefix}: ` + (error.message || "Invalid input");
|
||||
if (path && error.message) {
|
||||
const key = path.slice(1);
|
||||
setNestedProperty(
|
||||
errors,
|
||||
key,
|
||||
error.message[0].toUpperCase() + error.message.slice(1),
|
||||
);
|
||||
} else if (error.keyword === "required") {
|
||||
const key = error.params.missingProperty;
|
||||
setNestedProperty(errors, key, "This field is required");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.entries(inputSchema.properties).forEach(([key, schema]) => {
|
||||
if (schema.depends_on) {
|
||||
const dependencies = schema.depends_on;
|
||||
|
||||
// Check if dependent field has value
|
||||
const hasValue =
|
||||
inputData[key] != null ||
|
||||
("default" in schema && schema.default != null);
|
||||
|
||||
const mustHaveValue = inputSchema.required?.includes(key);
|
||||
|
||||
// Check for missing dependencies when dependent field is present
|
||||
const missingDependencies = dependencies.filter(
|
||||
(dep) =>
|
||||
!inputData[dep as keyof typeof inputData] ||
|
||||
String(inputData[dep as keyof typeof inputData]).trim() === "",
|
||||
);
|
||||
|
||||
if ((hasValue || mustHaveValue) && missingDependencies.length > 0) {
|
||||
setNestedProperty(
|
||||
errors,
|
||||
key,
|
||||
`Requires ${missingDependencies.join(", ")} to be set`,
|
||||
);
|
||||
errorMessage = `${errorPrefix}: field ${key} requires ${missingDependencies.join(", ")} to be set`;
|
||||
}
|
||||
|
||||
// Check if field is required when dependencies are present
|
||||
const hasAllDependencies = dependencies.every(
|
||||
(dep) =>
|
||||
inputData[dep as keyof typeof inputData] &&
|
||||
String(inputData[dep as keyof typeof inputData]).trim() !== "",
|
||||
);
|
||||
|
||||
if (hasAllDependencies && !hasValue) {
|
||||
setNestedProperty(
|
||||
errors,
|
||||
key,
|
||||
`${key} is required when ${dependencies.join(", ")} are set`,
|
||||
);
|
||||
errorMessage = `${errorPrefix}: ${key} is required when ${dependencies.join(", ")} are set`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set errors
|
||||
setXYNodes((nodes) => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === node.id) {
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
errors,
|
||||
},
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return errorMessage;
|
||||
},
|
||||
[prepareSaveableGraph, availableBlocks],
|
||||
);
|
||||
|
||||
const _saveAgent = useCallback(async () => {
|
||||
// FIXME: frontend IDs should be resolved better (e.g. returned from the server)
|
||||
// currently this relays on block_id and position
|
||||
@@ -885,14 +753,10 @@ export default function useAgentGraph(
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
const validationError = validateGraph(savedAgent);
|
||||
if (validationError) {
|
||||
toast({
|
||||
title: `Graph validation failed: ${validationError}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// NOTE: Client-side validation is skipped here because the backend now provides
|
||||
// comprehensive validation that includes credentialsInputs, which the frontend
|
||||
// validation cannot access. The backend will return structured validation errors
|
||||
// if there are any issues.
|
||||
|
||||
setIsRunning(true);
|
||||
processedUpdates.current = [];
|
||||
@@ -918,6 +782,40 @@ export default function useAgentGraph(
|
||||
completeStep("BUILDER_RUN_AGENT");
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if this is a structured validation error from the backend
|
||||
if (error instanceof ApiError && error.isGraphValidationError()) {
|
||||
const errorData = error.response.detail;
|
||||
|
||||
// 1. Apply validation errors to the corresponding nodes.
|
||||
// 2. Clear existing errors for nodes that don't have validation issues.
|
||||
setXYNodes((nodes) => {
|
||||
return nodes.map((node) => {
|
||||
const nodeErrors = node.data.backend_id
|
||||
? (errorData.node_errors[node.data.backend_id] ?? {})
|
||||
: {};
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
errors: nodeErrors,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Show a general toast about validation errors
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: errorData.message || "Graph validation failed",
|
||||
description:
|
||||
"Please fix the validation errors on the highlighted nodes and try again.",
|
||||
});
|
||||
setIsRunning(false);
|
||||
setActiveExecutionID(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic error handling for non-validation errors
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
@@ -933,10 +831,6 @@ export default function useAgentGraph(
|
||||
_saveAgent,
|
||||
toast,
|
||||
completeStep,
|
||||
savedAgent,
|
||||
prepareSaveableGraph,
|
||||
nodesSyncedWithSavedAgent,
|
||||
validateGraph,
|
||||
api,
|
||||
searchParams,
|
||||
pathname,
|
||||
|
||||
@@ -876,8 +876,7 @@ export default class BackendAPI {
|
||||
// Dynamic import is required even for client-only functions because helpers.ts
|
||||
// has server-only imports (like getServerSupabase) at the top level. Static imports
|
||||
// would bundle server-only code into the client bundle, causing runtime errors.
|
||||
const { buildClientUrl, parseErrorResponse, handleFetchError } =
|
||||
await import("./helpers");
|
||||
const { buildClientUrl, handleFetchError } = await import("./helpers");
|
||||
|
||||
const uploadUrl = buildClientUrl(path);
|
||||
|
||||
@@ -888,8 +887,7 @@ export default class BackendAPI {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await parseErrorResponse(response);
|
||||
throw handleFetchError(response, errorData);
|
||||
throw await handleFetchError(response);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
@@ -994,12 +992,8 @@ export default class BackendAPI {
|
||||
// Dynamic import is required even for client-only functions because helpers.ts
|
||||
// has server-only imports (like getServerSupabase) at the top level. Static imports
|
||||
// would bundle server-only code into the client bundle, causing runtime errors.
|
||||
const {
|
||||
buildClientUrl,
|
||||
buildUrlWithQuery,
|
||||
parseErrorResponse,
|
||||
handleFetchError,
|
||||
} = await import("./helpers");
|
||||
const { buildClientUrl, buildUrlWithQuery, handleFetchError } =
|
||||
await import("./helpers");
|
||||
|
||||
const payloadAsQuery = ["GET", "DELETE"].includes(method);
|
||||
let url = buildClientUrl(path);
|
||||
@@ -1018,8 +1012,7 @@ export default class BackendAPI {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await parseErrorResponse(response);
|
||||
throw handleFetchError(response, errorData);
|
||||
throw await handleFetchError(response);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
@@ -2,16 +2,36 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { isServerSide } from "../utils/is-server-side";
|
||||
|
||||
export class ApiError extends Error {
|
||||
public status: number;
|
||||
public response?: any;
|
||||
import { GraphValidationErrorResponse } from "./types";
|
||||
|
||||
constructor(message: string, status: number, response?: any) {
|
||||
export class ApiError<R = any> extends Error {
|
||||
public status: number;
|
||||
public response: R;
|
||||
|
||||
constructor(message: string, status: number, response: R) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if this error is a structured graph validation error
|
||||
*/
|
||||
isGraphValidationError(): this is ApiError<GraphValidationErrorResponse> {
|
||||
return (
|
||||
this.response !== undefined &&
|
||||
typeof this.response === "object" &&
|
||||
this.response !== null &&
|
||||
"detail" in this.response &&
|
||||
typeof this.response.detail === "object" &&
|
||||
this.response.detail !== null &&
|
||||
"type" in this.response.detail &&
|
||||
this.response.detail.type === "validation_error" &&
|
||||
"node_errors" in this.response.detail &&
|
||||
typeof this.response.detail.node_errors === "object"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRequestUrl(
|
||||
@@ -51,22 +71,15 @@ export function buildUrlWithQuery(
|
||||
return `${url}?${queryParams.toString()}`;
|
||||
}
|
||||
|
||||
export function handleFetchError(response: Response, errorData: any): ApiError {
|
||||
export async function handleFetchError(response: Response): Promise<ApiError> {
|
||||
const errorMessage = await parseApiError(response);
|
||||
return new ApiError(
|
||||
errorData?.error || "Request failed",
|
||||
errorMessage || "Request failed",
|
||||
response.status,
|
||||
errorData,
|
||||
await response.json(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function parseErrorResponse(response: Response): Promise<any> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerAuthToken(): Promise<string> {
|
||||
const supabase = await getServerSupabase();
|
||||
|
||||
@@ -126,7 +139,7 @@ export function serializeRequestBody(
|
||||
|
||||
export async function parseApiError(response: Response): Promise<string> {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
const errorData = await response.clone().json();
|
||||
|
||||
if (
|
||||
Array.isArray(errorData.detail) &&
|
||||
@@ -141,7 +154,12 @@ export async function parseApiError(response: Response): Promise<string> {
|
||||
return errors.join("\n");
|
||||
}
|
||||
|
||||
return errorData.detail || response.statusText;
|
||||
if (typeof errorData.detail === "object" && errorData.detail !== null) {
|
||||
if (errorData.detail.message) return errorData.detail.message;
|
||||
return response.statusText; // Fallback to status text if no message
|
||||
}
|
||||
|
||||
return errorData.detail || errorData.error || response.statusText;
|
||||
} catch {
|
||||
return response.statusText;
|
||||
}
|
||||
@@ -225,10 +243,7 @@ export async function makeAuthenticatedRequest(
|
||||
// Try to parse the full response body for better error context
|
||||
let responseData = null;
|
||||
try {
|
||||
const responseText = await response.clone().text();
|
||||
if (responseText) {
|
||||
responseData = JSON.parse(responseText);
|
||||
}
|
||||
responseData = await response.clone().json();
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export default BackendAPI;
|
||||
export * from "./client";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export { ApiError } from "./helpers";
|
||||
|
||||
@@ -372,6 +372,15 @@ export type NodeExecutionResult = {
|
||||
end_time?: Date;
|
||||
};
|
||||
|
||||
/* Structured validation error types for graph execution */
|
||||
export type GraphValidationErrorResponse = {
|
||||
detail: {
|
||||
type: "validation_error";
|
||||
message: string;
|
||||
node_errors: Record<string, Record<string, string>>;
|
||||
};
|
||||
};
|
||||
|
||||
/* *** LIBRARY *** */
|
||||
|
||||
/* Mirror of backend/server/v2/library/model.py:LibraryAgent */
|
||||
|
||||
Reference in New Issue
Block a user