From fa2d968458945e14148cdf79b0c520ab361aad70 Mon Sep 17 00:00:00 2001 From: Reinier van der Leer Date: Wed, 6 Aug 2025 00:43:34 +0100 Subject: [PATCH] fix(builder): Defer graph validation to backend (#10556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../backend/backend/data/graph.py | 110 ++++++++--- .../backend/backend/executor/utils.py | 146 +++++++++----- .../backend/backend/server/routers/v1.py | 32 ++- .../backend/backend/util/exceptions.py | 14 ++ autogpt_platform/frontend/package.json | 1 - autogpt_platform/frontend/pnpm-lock.yaml | 3 - .../frontend/src/hooks/useAgentGraph.tsx | 184 ++++-------------- .../src/lib/autogpt-server-api/client.ts | 17 +- .../src/lib/autogpt-server-api/helpers.ts | 57 ++++-- .../src/lib/autogpt-server-api/index.ts | 1 + .../src/lib/autogpt-server-api/types.ts | 9 + 11 files changed, 313 insertions(+), 261 deletions(-) diff --git a/autogpt_platform/backend/backend/data/graph.py b/autogpt_platform/backend/backend/data/graph.py index df8597b218..1e423feb5e 100644 --- a/autogpt_platform/backend/backend/data/graph.py +++ b/autogpt_platform/backend/backend/data/graph.py @@ -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 diff --git a/autogpt_platform/backend/backend/executor/utils.py b/autogpt_platform/backend/backend/executor/utils.py index 65994cff0d..2fbec30b9e 100644 --- a/autogpt_platform/backend/backend/executor/utils.py +++ b/autogpt_platform/backend/backend/executor/utils.py @@ -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: diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index f874f5d0c7..e6904d7b05 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -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( diff --git a/autogpt_platform/backend/backend/util/exceptions.py b/autogpt_platform/backend/backend/util/exceptions.py index 8f2eacc4ea..03766eebe3 100644 --- a/autogpt_platform/backend/backend/util/exceptions.py +++ b/autogpt_platform/backend/backend/util/exceptions.py @@ -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 diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 647c841852..6747343bdb 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -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", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 13ffa37b4b..d3a201e761 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -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 diff --git a/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx b/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx index e8801c7b38..7b9222f493 100644 --- a/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx +++ b/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx @@ -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 = {}; - 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, diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts index 9b3c1458b1..78605bd361 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts @@ -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(); diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts index 85e7408fa1..28cbc7d11a 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/helpers.ts @@ -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 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 { + 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 { + 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 { - try { - return await response.json(); - } catch { - return { error: response.statusText }; - } -} - export async function getServerAuthToken(): Promise { const supabase = await getServerSupabase(); @@ -126,7 +139,7 @@ export function serializeRequestBody( export async function parseApiError(response: Response): Promise { 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 { 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 } diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/index.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/index.ts index d25d101d27..c9d068367e 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/index.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/index.ts @@ -4,3 +4,4 @@ export default BackendAPI; export * from "./client"; export * from "./types"; export * from "./utils"; +export { ApiError } from "./helpers"; diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 5ae3734484..6e51d818ec 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -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>; + }; +}; + /* *** LIBRARY *** */ /* Mirror of backend/server/v2/library/model.py:LibraryAgent */