diff --git a/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx b/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx index 2d5d1757..a2ac5ba4 100644 --- a/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx +++ b/skyvern-frontend/src/components/DataSchemaInputGroup/WorkflowDataSchemaInputGroup.tsx @@ -17,7 +17,6 @@ import { AutoResizingTextarea } from "../AutoResizingTextarea/AutoResizingTextar import { Button } from "../ui/button"; import { AxiosError } from "axios"; import { toast } from "../ui/use-toast"; -import { TSON } from "@/util/tson"; import { cn } from "@/util/utils"; type Props = { @@ -37,12 +36,41 @@ function WorkflowDataSchemaInputGroup({ const [generateWithAIActive, setGenerateWithAIActive] = useState(false); const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState(""); - const tsonResult = useMemo(() => { - if (value === "null") return null; - return TSON.parse(value); - }, [value]); + function computeJsonError( + jsonText: string, + ): { message: string; line?: number; column?: number } | null { + try { + JSON.parse(jsonText); + return null; + } catch (e) { + const message = e instanceof Error ? e.message : "Invalid JSON"; + // Try to extract position and compute line/column for friendlier feedback + const match = message.match(/position\s+(\d+)/i); + if (!match) { + return { message }; + } + const pos = Number(match[1]); + if (Number.isNaN(pos)) { + return { message }; + } + let line = 1; + let col = 1; + for (let i = 0; i < Math.min(pos, jsonText.length); i++) { + if (jsonText[i] === "\n") { + line += 1; + col = 1; + } else { + col += 1; + } + } + return { message, line, column: col }; + } + } - console.log({ tsonResult }); + const jsonError = useMemo(() => { + if (value === "null") return null; + return computeJsonError(value); + }, [value]); const getDataSchemaSuggestionMutation = useMutation({ mutationFn: async () => { @@ -133,9 +161,7 @@ function WorkflowDataSchemaInputGroup({
- {tsonResult !== null && !tsonResult.success && tsonResult.error && ( -
{tsonResult.error}
+ {jsonError && ( +
+ {jsonError.line && jsonError.column + ? `Invalid JSON (${jsonError.line}:${jsonError.column}) — ${jsonError.message}` + : `Invalid JSON — ${jsonError.message}`} +
)} )} diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 38ae1a0d..d1bf316a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -2,9 +2,6 @@ import Dagre from "@dagrejs/dagre"; import type { Node } from "@xyflow/react"; import { Edge } from "@xyflow/react"; import { nanoid } from "nanoid"; - -import { TSON } from "@/util/tson"; - import { WorkflowBlockType, WorkflowBlockTypes, @@ -2191,12 +2188,10 @@ function getWorkflowErrors(nodes: Array): Array { } // Validate Task data schema JSON when enabled (value different from "null") if (node.data.dataSchema && node.data.dataSchema !== "null") { - const result = TSON.parse(node.data.dataSchema); - - if (!result.success) { - errors.push( - `${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`, - ); + try { + JSON.parse(node.data.dataSchema); + } catch { + errors.push(`${node.data.label}: Data schema is not valid JSON.`); } } }); @@ -2232,12 +2227,10 @@ function getWorkflowErrors(nodes: Array): Array { } // Validate Extraction data schema JSON when enabled (value different from "null") if (node.data.dataSchema && node.data.dataSchema !== "null") { - const result = TSON.parse(node.data.dataSchema); - - if (!result.success) { - errors.push( - `${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`, - ); + try { + JSON.parse(node.data.dataSchema); + } catch { + errors.push(`${node.data.label}: Data schema is not valid JSON.`); } } }); diff --git a/skyvern-frontend/src/util/tson.test.ts b/skyvern-frontend/src/util/tson.test.ts deleted file mode 100644 index adc92949..00000000 --- a/skyvern-frontend/src/util/tson.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// TSON.parse.test.ts -import { TSON } from "./tson"; -import { describe, test, expect } from "vitest"; - -describe("TSON.parse", () => { - test("single top-level template works", () => { - const input = "{{ hello }}"; - const result = TSON.parse(input); - - expect(result.success).toBe(true); - expect(result.data).toEqual(""); - }); - - test("preserves double braces inside quoted strings", () => { - const input = '{"a": "{{ hello }}"}'; - const result = TSON.parse(input); - - expect(result.success).toBe(true); - expect(result.data).toEqual({ a: "{{ hello }}" }); - }); - - test("replaces double braces outside strings with stub", () => { - const input = '{"a": {{ hello }} }'; - const result = TSON.parse(input); - - expect(result.success).toBe(true); - expect(result.data).toEqual({ a: "" }); - }); - - test("handles double braces in keys and values", () => { - const input = ` -{ - "hello": "world", - {{foo}}: "bar", - "baz": {{quux}} -}`; - const result = TSON.parse(input); - - expect(result.success).toBe(true); - expect(result.data).toEqual({ - hello: "world", - "": "bar", - baz: "", - }); - }); - - test("does not allow trailing commas", () => { - const input = ` -{ - "hello": "world", - {{foo}}: "bar", - "baz": {{quux}}, -}`; - const result = TSON.parse(input); - - expect(result.success).toBe(false); - expect(result.error).toContain("Expected double-quoted property name"); - }); - - test("detects unclosed double braces", () => { - const input = "{{ unclosed"; - const result = TSON.parse(input); - - expect(result.success).toBe(false); - expect(result.error).toContain("Unclosed"); - }); - - test("detects unmatched closing double braces", () => { - const input = "closed }}"; - const result = TSON.parse(input); - - expect(result.success).toBe(false); - expect(result.error).toContain("Unmatched"); - }); - - test("handles nested double braces", () => { - const input = "{{ {{ nested }} }}"; - const result = TSON.parse(input); - - expect(result.success).toBe(true); - expect(result.data).toEqual(""); - }); - - test("handles double braces in arrays", () => { - const input = '[{{ }}, {{ }}, "normal"]'; - const result = TSON.parse(input); - - expect(result.success).toBe(true); - expect(result.data).toEqual(["", "", "normal"]); - }); -}); diff --git a/skyvern-frontend/src/util/tson.ts b/skyvern-frontend/src/util/tson.ts deleted file mode 100644 index dc01254a..00000000 --- a/skyvern-frontend/src/util/tson.ts +++ /dev/null @@ -1,223 +0,0 @@ -type JSONValue = - | string - | number - | boolean - | null - | JSONValue[] - | { [key: string]: JSONValue }; - -interface ParseResult { - success: boolean; - data?: JSONValue; - error?: string; -} - -const placeholder = () => ""; - -/** - * TSON ("templated JSON") is a superset of JSON, where double curly braces {{...}} can: - * - exist anywhere outside of string literals, and - * - are treated as placeholders - */ -const TSON = { - parse(input: string): ParseResult { - try { - const balanceCheck = checkDoubleBraceBalance(input); - - if (!balanceCheck.balanced) { - return { - success: false, - error: balanceCheck.error, - }; - } - - const pipeline = [ - replaceBracesOutsideQuotes, - // removeTrailingCommas, - JSON.parse, - ]; - - const parsed = pipeline.reduce((acc, fn) => fn(acc), input) as JSONValue; - - return { - success: true, - data: parsed, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - }, -}; - -function checkDoubleBraceBalance(input: string): { - balanced: boolean; - error?: string; -} { - let inString = false; - let escapeNext = false; - let depth = 0; - - for (let i = 0; i < input.length; i++) { - const char = input[i]; - const nextChar = input[i + 1]; - - // handle escape sequences - if (escapeNext) { - escapeNext = false; - continue; - } - - if (char === "\\") { - escapeNext = true; - continue; - } - - // inside-string tracking - if (char === '"') { - inString = !inString; - continue; - } - - // double braces counts (only outside strings) - if (!inString) { - if (char === "{" && nextChar === "{") { - depth++; - i++; // skip next char - } else if (char === "}" && nextChar === "}") { - depth--; - if (depth < 0) { - return { - balanced: false, - error: `Unmatched closing }} at position ${i}`, - }; - } - i++; // skip next char - } - } - } - - if (depth > 0) { - return { - balanced: false, - error: `Unclosed {{ - missing ${depth} closing }}`, - }; - } - - return { balanced: true }; -} - -function replaceBracesOutsideQuotes(input: string): string { - let result = ""; - let inString = false; - let escapeNext = false; - let inDoubleBrace = 0; // track nesting depth of {{...}} - - for (let i = 0; i < input.length; i++) { - const char = input[i]; - const nextChar = input[i + 1]; - - // escape sequences - if (escapeNext) { - if (inDoubleBrace === 0) { - result += char; - } - escapeNext = false; - continue; - } - - if (char === "\\") { - if (inDoubleBrace === 0) { - result += char; - } - escapeNext = true; - continue; - } - - // inside-string tracking - if (char === '"') { - inString = !inString; - if (inDoubleBrace === 0) { - result += char; - } - continue; - } - - // double braces (only outside strings) - if (!inString) { - if (char === "{" && nextChar === "{") { - if (inDoubleBrace === 0) { - result += `"${placeholder()}"`; - } - inDoubleBrace++; - i++; // skip next char - continue; - } else if (char === "}" && nextChar === "}") { - inDoubleBrace--; - i++; // skip next char - continue; - } - } - - // add characters when we're not inside double braces - if (inDoubleBrace === 0) { - result += char; - } - } - - return result; -} - -export function removeTrailingCommas(input: string): string { - let result = ""; - let inString = false; - let escapeNext = false; - - for (let i = 0; i < input.length; i++) { - const char = input[i]; - - // escape sequences - if (escapeNext) { - result += char; - escapeNext = false; - continue; - } - - if (char === "\\") { - result += char; - escapeNext = true; - continue; - } - - // inside-string tracking - if (char === '"') { - inString = !inString; - result += char; - continue; - } - - // check for trailing commas (outside strings) - if (!inString && char === ",") { - // look-ahead for the next non-whitespace character - let j = i + 1; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - while (j < input.length && /\s/.test(input[j])) { - j++; - } - - // if next non-whitespace is } or ], skip the comma - if (j < input.length && (input[j] === "}" || input[j] === "]")) { - continue; // Skip this comma - } - } - - result += char; - } - - return result; -} - -export { TSON }; diff --git a/skyvern-frontend/vitest.config.ts b/skyvern-frontend/vitest.config.ts new file mode 100644 index 00000000..274f0b86 --- /dev/null +++ b/skyvern-frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react-swc' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + passWithNoTests: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) diff --git a/skyvern/core/script_generations/generate_script.py b/skyvern/core/script_generations/generate_script.py index c501e24b..7c37752f 100644 --- a/skyvern/core/script_generations/generate_script.py +++ b/skyvern/core/script_generations/generate_script.py @@ -1350,6 +1350,18 @@ def __build_base_task_statement( ) -> list[cst.Arg]: block_type = block.get("block_type") prompt = block.get("prompt") if block_type == "task_v2" else block.get("navigation_goal") + # add parameters to prompt + parameters = block.get("parameters", []) + navigation_payload = {} + # make all parameters as jinja2 template parameters in the generated code + for parameter in parameters: + parameter_key = parameter["key"] + navigation_payload[parameter_key] = "{{" + parameter_key + "}}" + + if navigation_payload: + prompt = prompt or "" + prompt = f"{prompt}\n{navigation_payload}" + args = [ cst.Arg( keyword=cst.Name("prompt"), diff --git a/skyvern/core/script_generations/skyvern_page.py b/skyvern/core/script_generations/skyvern_page.py index a796698b..9f3ebd95 100644 --- a/skyvern/core/script_generations/skyvern_page.py +++ b/skyvern/core/script_generations/skyvern_page.py @@ -509,7 +509,7 @@ class SkyvernPage: if ai_infer and intention: try: prompt = context.prompt if context else None - data = _get_context_data(data) + data = data or {} if (totp_identifier or totp_url) and context and organization_id and task_id: verification_code = await poll_verification_code( organization_id=organization_id, @@ -535,7 +535,6 @@ class SkyvernPage: script_generation_input_text_prompt = prompt_engine.load_prompt( template="script-generation-input-text-generatiion", intention=intention, - data=data, goal=prompt, ) json_response = await app.SINGLE_INPUT_AGENT_LLM_API_HANDLER( @@ -620,7 +619,8 @@ class SkyvernPage: if ai_infer and intention and task and step: try: prompt = context.prompt if context else None - data = _get_context_data(data) + # data = _get_context_data(data) + data = data or {} refreshed_page = await self.scraped_page.generate_scraped_page_without_screenshots() self.scraped_page = refreshed_page element_tree = refreshed_page.build_element_tree() diff --git a/skyvern/services/script_service.py b/skyvern/services/script_service.py index 1e0ee7af..8634ba5d 100644 --- a/skyvern/services/script_service.py +++ b/skyvern/services/script_service.py @@ -1114,6 +1114,7 @@ async def run_task( prompt: str, url: str | None = None, max_steps: int | None = None, + download_suffix: str | None = None, totp_identifier: str | None = None, totp_url: str | None = None, label: str | None = None, @@ -1133,6 +1134,7 @@ async def run_task( url=url, label=cache_key, ) + prompt = _render_template_with_label(prompt, cache_key) # set the prompt in the RunContext context = skyvern_context.ensure_context() context.prompt = prompt @@ -1190,6 +1192,7 @@ async def download( prompt: str, url: str | None = None, complete_on_download: bool = True, + download_suffix: str | None = None, max_steps: int | None = None, totp_identifier: str | None = None, totp_url: str | None = None, @@ -1198,7 +1201,6 @@ async def download( ) -> None: cache_key = cache_key or label cached_fn = script_run_context_manager.get_cached_fn(cache_key) - context: skyvern_context.SkyvernContext | None if cache_key and cached_fn: # Auto-create workflow block run and task if workflow_run_id is available @@ -1208,6 +1210,7 @@ async def download( url=url, label=cache_key, ) + prompt = _render_template_with_label(prompt, cache_key) # set the prompt in the RunContext context = skyvern_context.ensure_context() context.prompt = prompt @@ -1265,6 +1268,7 @@ async def action( prompt: str, url: str | None = None, max_steps: int | None = None, + download_suffix: str | None = None, totp_identifier: str | None = None, totp_url: str | None = None, label: str | None = None, @@ -1281,6 +1285,7 @@ async def action( url=url, label=cache_key, ) + prompt = _render_template_with_label(prompt, cache_key) # set the prompt in the RunContext context = skyvern_context.ensure_context() context.prompt = prompt @@ -1346,12 +1351,14 @@ async def login( cached_fn = script_run_context_manager.get_cached_fn(cache_key) if cache_key and cached_fn: # Auto-create workflow block run and task if workflow_run_id is available + # render template with label workflow_run_block_id, task_id, step_id = await _create_workflow_block_run_and_task( block_type=BlockType.LOGIN, prompt=prompt, url=url, label=cache_key, ) + prompt = _render_template_with_label(prompt, cache_key) # set the prompt in the RunContext context = skyvern_context.ensure_context() context.prompt = prompt @@ -1424,6 +1431,7 @@ async def extract( url=url, label=cache_key, ) + prompt = _render_template_with_label(prompt, cache_key) # set the prompt in the RunContext context = skyvern_context.ensure_context() context.prompt = prompt @@ -1546,27 +1554,27 @@ def _render_template_with_label(template: str, label: str | None = None) -> str: context = skyvern_context.current() if context and context.workflow_run_id: workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context(context.workflow_run_id) - block_reference_data: dict[str, Any] = workflow_run_context.get_block_metadata(label) template_data = workflow_run_context.values.copy() - if label in template_data: - current_value = template_data[label] - if isinstance(current_value, dict): - block_reference_data.update(current_value) - else: - LOG.warning( - f"Script service: Parameter {label} has a registered reference value, going to overwrite it by block metadata" - ) - if label: + block_reference_data = workflow_run_context.get_block_metadata(label) + if label in template_data: + current_value = template_data[label] + if isinstance(current_value, dict): + block_reference_data.update(current_value) + else: + LOG.warning( + f"Script service: Parameter {label} has a registered reference value, going to overwrite it by block metadata" + ) + template_data[label] = block_reference_data - # inject the forloop metadata as global variables - if "current_index" in block_reference_data: - template_data["current_index"] = block_reference_data["current_index"] - if "current_item" in block_reference_data: - template_data["current_item"] = block_reference_data["current_item"] - if "current_value" in block_reference_data: - template_data["current_value"] = block_reference_data["current_value"] + # inject the forloop metadata as global variables + if "current_index" in block_reference_data: + template_data["current_index"] = block_reference_data["current_index"] + if "current_item" in block_reference_data: + template_data["current_item"] = block_reference_data["current_item"] + if "current_value" in block_reference_data: + template_data["current_value"] = block_reference_data["current_value"] return render_template(template, data=template_data)