diff --git a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx index a378f925..84112162 100644 --- a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx +++ b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx @@ -1,7 +1,7 @@ import { AxiosError } from "axios"; import { PlayIcon, ReloadIcon } from "@radix-ui/react-icons"; import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { type FieldErrors, useForm } from "react-hook-form"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; @@ -99,6 +99,34 @@ function parseValuesForWorkflowRun( ) { return [key, value.s3uri]; } + // Convert boolean values to strings for backend storage + if ( + parameter?.workflow_parameter_type === "boolean" && + typeof value === "boolean" + ) { + return [key, String(value)]; + } + if (parameter?.workflow_parameter_type === "string") { + if (value === null || value === undefined) { + return [key, ""]; + } + return [key, String(value)]; + } + + if ( + parameter?.workflow_parameter_type === "integer" || + parameter?.workflow_parameter_type === "float" + ) { + if ( + value === null || + value === undefined || + (typeof value === "number" && Number.isNaN(value)) + ) { + return [key, ""]; + } + return [key, String(value)]; + } + return [key, value]; }), ); @@ -211,6 +239,8 @@ function RunWorkflowForm({ const { data: workflow } = useWorkflowQuery({ workflowPermanentId }); const form = useForm({ + mode: "onTouched", + reValidateMode: "onChange", defaultValues: { ...initialValues, webhookCallbackUrl: initialSettings.webhookCallbackUrl, @@ -268,6 +298,7 @@ function RunWorkflowForm({ unknown > | null>(null); const [cacheKeyValue, setCacheKeyValue] = useState(""); + const [isFormReset, setIsFormReset] = useState(false); const cacheKey = workflow?.cache_key ?? "default"; useEffect(() => { @@ -297,10 +328,41 @@ function RunWorkflowForm({ setHasCode(Object.keys(blockScripts ?? {}).length > 0); }, [blockScripts]); + // Watch form changes and update run parameters without triggering validation useEffect(() => { - onChange(form.getValues()); + const subscription = form.watch((values) => { + onChange(values as RunWorkflowFormType); + }); + return () => subscription.unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, []); + + // Reset form with initial values after all fields are registered + useEffect(() => { + form.reset({ + ...initialValues, + webhookCallbackUrl: initialSettings.webhookCallbackUrl, + proxyLocation: initialSettings.proxyLocation, + browserSessionId: null, + cdpAddress: initialSettings.cdpAddress, + maxScreenshotScrolls: initialSettings.maxScreenshotScrolls, + extraHttpHeaders: initialSettings.extraHttpHeaders + ? JSON.stringify(initialSettings.extraHttpHeaders) + : null, + runWithCode: workflow?.run_with === "code", + aiFallback: workflow?.ai_fallback ?? true, + }); + setIsFormReset(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Trigger validation after form is reset and re-rendered + useEffect(() => { + if (isFormReset) { + form.trigger(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFormReset]); // if we're coming from debugger, block scripts may already be cached; let's ensure we bust it // on mount @@ -360,6 +422,22 @@ function RunWorkflowForm({ setRunParameters(parsedParameters); } + const handleInvalid = (errors: FieldErrors) => { + const hasBlockingErrors = workflowParameters.some( + (param) => + (param.workflow_parameter_type === "boolean" || + param.workflow_parameter_type === "integer" || + param.workflow_parameter_type === "float" || + param.workflow_parameter_type === "file_url" || + param.workflow_parameter_type === "json") && + errors[param.key], + ); + + if (!hasBlockingErrors) { + onSubmit(form.getValues()); + } + }; + if (!workflowPermanentId || !workflow) { return
Invalid workflow
; } @@ -367,8 +445,7 @@ function RunWorkflowForm({ return (
@@ -383,19 +460,74 @@ function RunWorkflowForm({ name={parameter.key} rules={{ validate: (value) => { - if ( - parameter.workflow_parameter_type === "json" && - typeof value === "string" - ) { - try { - JSON.parse(value); - return true; - } catch (e) { - return "Invalid JSON"; + if (parameter.workflow_parameter_type === "json") { + if (value === null || value === undefined) { + return "This field is required"; } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") { + return "This field is required"; + } + try { + JSON.parse(trimmed); + return true; + } catch (e) { + return "Invalid JSON"; + } + } + return; } - if (value === null) { - return "This field is required"; + + // Boolean parameters are required - show error and block submission + if (parameter.workflow_parameter_type === "boolean") { + if (value === null || value === undefined) { + return "This field is required"; + } + return; + } + + // Numeric parameters are required - show error and block submission + if ( + parameter.workflow_parameter_type === "integer" || + parameter.workflow_parameter_type === "float" + ) { + if ( + value === null || + value === undefined || + Number.isNaN(value) + ) { + return "This field is required"; + } + return; + } + + if (parameter.workflow_parameter_type === "file_url") { + if ( + value === null || + value === undefined || + (typeof value === "string" && value.trim() === "") || + (typeof value === "object" && + value !== null && + "s3uri" in value && + !value.s3uri) + ) { + return "This field is required"; + } + return; + } + + // For string parameters, show warning but don't block + if ( + parameter.workflow_parameter_type === "string" && + (value === null || value === "") + ) { + return "Warning: you left this field empty"; + } + + // For all other non-boolean types, show warning but don't block + if (value === null || value === undefined) { + return "Warning: you left this field empty"; } }, }} @@ -403,7 +535,7 @@ function RunWorkflowForm({ return (
- +
{parameter.key} @@ -423,11 +555,27 @@ function RunWorkflowForm({ { + field.onChange(value); + form.trigger(parameter.key); + }} /> {form.formState.errors[parameter.key] && ( -
+
{form.formState.errors[parameter.key]?.message}
)} diff --git a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx index e9a81c47..cbb21923 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowParameterInput.tsx @@ -1,9 +1,14 @@ import { FileInputValue, FileUpload } from "@/components/FileUpload"; -import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { CodeEditor } from "./components/CodeEditor"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; -import { Label } from "@/components/ui/label"; import { WorkflowParameterValueType } from "./types/workflowTypes"; import { CredentialSelector } from "./components/CredentialSelector"; @@ -60,16 +65,19 @@ function WorkflowParameterInput({ type, value, onChange }: Props) { } if (type === "boolean") { - const checked = typeof value === "boolean" ? value : Boolean(value); return ( -
- onChange(checked)} - className="block" - /> - -
+ ); } diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index cd1744f9..4957f654 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -116,6 +116,26 @@ function convertToParametersYAML( | CredentialParameterYAML | undefined => { if (parameter.parameterType === WorkflowEditorParameterTypes.Workflow) { + // Convert boolean default values to strings for backend + let defaultValue = parameter.defaultValue; + if ( + parameter.dataType === "boolean" && + typeof parameter.defaultValue === "boolean" + ) { + defaultValue = String(parameter.defaultValue); + } + if ( + (parameter.dataType === "integer" || + parameter.dataType === "float") && + (typeof parameter.defaultValue === "number" || + typeof parameter.defaultValue === "string") + ) { + defaultValue = + parameter.defaultValue === null + ? parameter.defaultValue + : String(parameter.defaultValue); + } + return { parameter_type: WorkflowParameterTypes.Workflow, key: parameter.key, @@ -123,7 +143,7 @@ function convertToParametersYAML( workflow_parameter_type: parameter.dataType, ...(parameter.defaultValue === null ? {} - : { default_value: parameter.defaultValue }), + : { default_value: defaultValue }), }; } else if ( parameter.parameterType === WorkflowEditorParameterTypes.Context diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx index 909eb7df..055047bb 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowParameterEditPanel.tsx @@ -538,11 +538,32 @@ function WorkflowParameterEditPanel({ return; } } - const defaultValue = + let defaultValue = defaultValueState.defaultValue; + + // Handle JSON parsing + if ( parameterType === "json" && typeof defaultValueState.defaultValue === "string" - ? JSON.parse(defaultValueState.defaultValue) - : defaultValueState.defaultValue; + ) { + defaultValue = JSON.parse(defaultValueState.defaultValue); + } + // Convert boolean to string for backend storage + else if ( + parameterType === "boolean" && + typeof defaultValueState.defaultValue === "boolean" + ) { + defaultValue = String(defaultValueState.defaultValue); + } + // Convert numeric defaults to strings for backend storage + else if ( + (parameterType === "integer" || + parameterType === "float") && + (typeof defaultValueState.defaultValue === "number" || + typeof defaultValueState.defaultValue === "string") + ) { + defaultValue = String(defaultValueState.defaultValue); + } + onSave({ key, parameterType: "workflow", diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 3fba6db6..2739b253 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -1852,11 +1852,30 @@ function convertParametersToParameterYAML( }; } case WorkflowParameterTypes.Workflow: { + // Convert default values to strings for backend when needed + let defaultValue = parameter.default_value; + if ( + parameter.workflow_parameter_type === "boolean" && + typeof parameter.default_value === "boolean" + ) { + defaultValue = String(parameter.default_value); + } else if ( + (parameter.workflow_parameter_type === "integer" || + parameter.workflow_parameter_type === "float") && + (typeof parameter.default_value === "number" || + typeof parameter.default_value === "string") + ) { + defaultValue = + parameter.default_value === null + ? parameter.default_value + : String(parameter.default_value); + } + return { ...base, parameter_type: WorkflowParameterTypes.Workflow, workflow_parameter_type: parameter.workflow_parameter_type, - default_value: parameter.default_value, + default_value: defaultValue, }; } case WorkflowParameterTypes.Credential: { diff --git a/skyvern-frontend/src/routes/workflows/utils.ts b/skyvern-frontend/src/routes/workflows/utils.ts index c916a9b4..eb7e807f 100644 --- a/skyvern-frontend/src/routes/workflows/utils.ts +++ b/skyvern-frontend/src/routes/workflows/utils.ts @@ -12,31 +12,24 @@ export const getInitialValues = ( ? location.state.data : workflowParameters?.reduce( (acc, curr) => { - if (curr.workflow_parameter_type === "json") { - if (typeof curr.default_value === "string") { - acc[curr.key] = curr.default_value; + const hasDefaultValue = + curr.default_value !== null && curr.default_value !== undefined; + if (hasDefaultValue) { + // Handle JSON parameters + if (curr.workflow_parameter_type === "json") { + if (typeof curr.default_value === "string") { + acc[curr.key] = curr.default_value; + } else { + acc[curr.key] = JSON.stringify(curr.default_value, null, 2); + } return acc; } - if (curr.default_value) { - acc[curr.key] = JSON.stringify(curr.default_value, null, 2); + if (curr.workflow_parameter_type === "boolean") { + // Backend stores as strings, convert to boolean for frontend + acc[curr.key] = + curr.default_value === "true" || curr.default_value === true; return acc; } - } - if ( - curr.default_value && - curr.workflow_parameter_type === "boolean" - ) { - acc[curr.key] = Boolean(curr.default_value); - return acc; - } - if ( - curr.default_value === null && - curr.workflow_parameter_type === "string" - ) { - acc[curr.key] = ""; - return acc; - } - if (curr.default_value) { acc[curr.key] = curr.default_value; return acc; } diff --git a/skyvern/forge/sdk/api/llm/api_handler_factory.py b/skyvern/forge/sdk/api/llm/api_handler_factory.py index 54c0329d..f16dbe3a 100644 --- a/skyvern/forge/sdk/api/llm/api_handler_factory.py +++ b/skyvern/forge/sdk/api/llm/api_handler_factory.py @@ -673,6 +673,12 @@ class LLMAPIHandlerFactory: # Add Vertex AI cache reference only for the intended cached prompt vertex_cache_attached = False cache_resource_name = getattr(context, "vertex_cache_name", None) + LOG.info( + "Vertex cache attachment check", + cache_resource_name=cache_resource_name, + prompt_name=prompt_name, + use_prompt_caching=getattr(context, "use_prompt_caching", None) if context else None, + ) if ( cache_resource_name and prompt_name == EXTRACT_ACTION_PROMPT_NAME diff --git a/skyvern/forge/sdk/api/llm/vertex_cache_manager.py b/skyvern/forge/sdk/api/llm/vertex_cache_manager.py index a8501949..5f97c3b1 100644 --- a/skyvern/forge/sdk/api/llm/vertex_cache_manager.py +++ b/skyvern/forge/sdk/api/llm/vertex_cache_manager.py @@ -8,16 +8,13 @@ Unlike the Anthropic-style cache_control markers, Vertex AI requires: 3. Referencing that cache name in subsequent requests """ -import json from datetime import datetime from typing import Any import google.auth import requests import structlog -from google.auth.credentials import Credentials from google.auth.transport.requests import Request -from google.oauth2 import service_account from skyvern.config import settings @@ -32,7 +29,7 @@ class VertexCacheManager: unlike implicit caching which requires exact prompt matches. """ - def __init__(self, project_id: str, location: str = "global", credentials_json: str | None = None): + def __init__(self, project_id: str, location: str = "global"): self.project_id = project_id self.location = location # Use regional endpoint for non-global locations, global endpoint for global @@ -41,39 +38,13 @@ class VertexCacheManager: else: self.api_endpoint = f"{location}-aiplatform.googleapis.com" self._cache_registry: dict[str, dict[str, Any]] = {} # Maps cache_key -> cache_data - self._scopes = ["https://www.googleapis.com/auth/cloud-platform"] - self._default_credentials = None - self._service_account_credentials = None - self._service_account_info: dict[str, Any] | None = None - - if credentials_json: - try: - self._service_account_info = json.loads(credentials_json) - except Exception as exc: # noqa: BLE001 - LOG.warning("Failed to parse Vertex credentials JSON, falling back to ADC", error=str(exc)) def _get_access_token(self) -> str: """Get Google Cloud access token for API calls.""" try: - credentials: Credentials | None = None - if self._service_account_info: - if not self._service_account_credentials: - self._service_account_credentials = service_account.Credentials.from_service_account_info( - self._service_account_info, - scopes=self._scopes, - ) - credentials = self._service_account_credentials - else: - if not self._default_credentials: - self._default_credentials, _ = google.auth.default(scopes=self._scopes) - credentials = self._default_credentials - - if credentials is None: - raise RuntimeError("Unable to initialize Google credentials for Vertex cache manager") - - if not credentials.valid or credentials.expired: - credentials.refresh(Request()) - + # Try to use default credentials + credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) + credentials.refresh(Request()) return credentials.token except Exception as e: LOG.error("Failed to get access token", error=str(e)) @@ -226,11 +197,7 @@ def get_cache_manager() -> VertexCacheManager: # Default to "global" to match the model configs in cloud/__init__.py # Can be overridden with VERTEX_LOCATION (e.g., "us-central1" for better caching) location = settings.VERTEX_LOCATION or "global" - _global_cache_manager = VertexCacheManager( - project_id=project_id, - location=location, - credentials_json=settings.VERTEX_CREDENTIALS, - ) + _global_cache_manager = VertexCacheManager(project_id, location) LOG.info("Created global cache manager", project_id=project_id, location=location) return _global_cache_manager