integrate parameters to support legacy tasks with parameters (#3567)

This commit is contained in:
Shuchang Zheng
2025-09-30 17:28:31 -07:00
committed by GitHub
parent 33b3c126ea
commit bb1e7316a3
8 changed files with 105 additions and 361 deletions

View File

@@ -17,7 +17,6 @@ import { AutoResizingTextarea } from "../AutoResizingTextarea/AutoResizingTextar
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { toast } from "../ui/use-toast"; import { toast } from "../ui/use-toast";
import { TSON } from "@/util/tson";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
type Props = { type Props = {
@@ -37,12 +36,41 @@ function WorkflowDataSchemaInputGroup({
const [generateWithAIActive, setGenerateWithAIActive] = useState(false); const [generateWithAIActive, setGenerateWithAIActive] = useState(false);
const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState(""); const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState("");
const tsonResult = useMemo(() => { function computeJsonError(
if (value === "null") return null; jsonText: string,
return TSON.parse(value); ): { message: string; line?: number; column?: number } | null {
}, [value]); 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({ const getDataSchemaSuggestionMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -133,9 +161,7 @@ function WorkflowDataSchemaInputGroup({
<div <div
className={cn( className={cn(
"rounded-md", "rounded-md",
tsonResult && !tsonResult.success jsonError ? "ring-1 ring-red-500" : undefined,
? "ring-1 ring-red-500"
: undefined,
)} )}
> >
<CodeEditor <CodeEditor
@@ -146,8 +172,12 @@ function WorkflowDataSchemaInputGroup({
fontSize={8} fontSize={8}
/> />
</div> </div>
{tsonResult !== null && !tsonResult.success && tsonResult.error && ( {jsonError && (
<div className="text-xs text-red-400">{tsonResult.error}</div> <div className="text-xs text-red-400">
{jsonError.line && jsonError.column
? `Invalid JSON (${jsonError.line}:${jsonError.column}) — ${jsonError.message}`
: `Invalid JSON — ${jsonError.message}`}
</div>
)} )}
</div> </div>
)} )}

View File

@@ -2,9 +2,6 @@ import Dagre from "@dagrejs/dagre";
import type { Node } from "@xyflow/react"; import type { Node } from "@xyflow/react";
import { Edge } from "@xyflow/react"; import { Edge } from "@xyflow/react";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { TSON } from "@/util/tson";
import { import {
WorkflowBlockType, WorkflowBlockType,
WorkflowBlockTypes, WorkflowBlockTypes,
@@ -2191,12 +2188,10 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
} }
// Validate Task data schema JSON when enabled (value different from "null") // Validate Task data schema JSON when enabled (value different from "null")
if (node.data.dataSchema && node.data.dataSchema !== "null") { if (node.data.dataSchema && node.data.dataSchema !== "null") {
const result = TSON.parse(node.data.dataSchema); try {
JSON.parse(node.data.dataSchema);
if (!result.success) { } catch {
errors.push( errors.push(`${node.data.label}: Data schema is not valid JSON.`);
`${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
);
} }
} }
}); });
@@ -2232,12 +2227,10 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
} }
// Validate Extraction data schema JSON when enabled (value different from "null") // Validate Extraction data schema JSON when enabled (value different from "null")
if (node.data.dataSchema && node.data.dataSchema !== "null") { if (node.data.dataSchema && node.data.dataSchema !== "null") {
const result = TSON.parse(node.data.dataSchema); try {
JSON.parse(node.data.dataSchema);
if (!result.success) { } catch {
errors.push( errors.push(`${node.data.label}: Data schema is not valid JSON.`);
`${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
);
} }
} }
}); });

View File

@@ -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("<STUB>");
});
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: "<STUB>" });
});
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",
"<STUB>": "bar",
baz: "<STUB>",
});
});
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("<STUB>");
});
test("handles double braces in arrays", () => {
const input = '[{{ }}, {{ }}, "normal"]';
const result = TSON.parse(input);
expect(result.success).toBe(true);
expect(result.data).toEqual(["<STUB>", "<STUB>", "normal"]);
});
});

View File

@@ -1,223 +0,0 @@
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
interface ParseResult {
success: boolean;
data?: JSONValue;
error?: string;
}
const placeholder = () => "<STUB>";
/**
* 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 };

View File

@@ -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'),
},
},
})

View File

@@ -1350,6 +1350,18 @@ def __build_base_task_statement(
) -> list[cst.Arg]: ) -> list[cst.Arg]:
block_type = block.get("block_type") block_type = block.get("block_type")
prompt = block.get("prompt") if block_type == "task_v2" else block.get("navigation_goal") 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 = [ args = [
cst.Arg( cst.Arg(
keyword=cst.Name("prompt"), keyword=cst.Name("prompt"),

View File

@@ -509,7 +509,7 @@ class SkyvernPage:
if ai_infer and intention: if ai_infer and intention:
try: try:
prompt = context.prompt if context else None 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: if (totp_identifier or totp_url) and context and organization_id and task_id:
verification_code = await poll_verification_code( verification_code = await poll_verification_code(
organization_id=organization_id, organization_id=organization_id,
@@ -535,7 +535,6 @@ class SkyvernPage:
script_generation_input_text_prompt = prompt_engine.load_prompt( script_generation_input_text_prompt = prompt_engine.load_prompt(
template="script-generation-input-text-generatiion", template="script-generation-input-text-generatiion",
intention=intention, intention=intention,
data=data,
goal=prompt, goal=prompt,
) )
json_response = await app.SINGLE_INPUT_AGENT_LLM_API_HANDLER( 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: if ai_infer and intention and task and step:
try: try:
prompt = context.prompt if context else None 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() refreshed_page = await self.scraped_page.generate_scraped_page_without_screenshots()
self.scraped_page = refreshed_page self.scraped_page = refreshed_page
element_tree = refreshed_page.build_element_tree() element_tree = refreshed_page.build_element_tree()

View File

@@ -1114,6 +1114,7 @@ async def run_task(
prompt: str, prompt: str,
url: str | None = None, url: str | None = None,
max_steps: int | None = None, max_steps: int | None = None,
download_suffix: str | None = None,
totp_identifier: str | None = None, totp_identifier: str | None = None,
totp_url: str | None = None, totp_url: str | None = None,
label: str | None = None, label: str | None = None,
@@ -1133,6 +1134,7 @@ async def run_task(
url=url, url=url,
label=cache_key, label=cache_key,
) )
prompt = _render_template_with_label(prompt, cache_key)
# set the prompt in the RunContext # set the prompt in the RunContext
context = skyvern_context.ensure_context() context = skyvern_context.ensure_context()
context.prompt = prompt context.prompt = prompt
@@ -1190,6 +1192,7 @@ async def download(
prompt: str, prompt: str,
url: str | None = None, url: str | None = None,
complete_on_download: bool = True, complete_on_download: bool = True,
download_suffix: str | None = None,
max_steps: int | None = None, max_steps: int | None = None,
totp_identifier: str | None = None, totp_identifier: str | None = None,
totp_url: str | None = None, totp_url: str | None = None,
@@ -1198,7 +1201,6 @@ async def download(
) -> None: ) -> None:
cache_key = cache_key or label cache_key = cache_key or label
cached_fn = script_run_context_manager.get_cached_fn(cache_key) cached_fn = script_run_context_manager.get_cached_fn(cache_key)
context: skyvern_context.SkyvernContext | None context: skyvern_context.SkyvernContext | None
if cache_key and cached_fn: if cache_key and cached_fn:
# Auto-create workflow block run and task if workflow_run_id is available # Auto-create workflow block run and task if workflow_run_id is available
@@ -1208,6 +1210,7 @@ async def download(
url=url, url=url,
label=cache_key, label=cache_key,
) )
prompt = _render_template_with_label(prompt, cache_key)
# set the prompt in the RunContext # set the prompt in the RunContext
context = skyvern_context.ensure_context() context = skyvern_context.ensure_context()
context.prompt = prompt context.prompt = prompt
@@ -1265,6 +1268,7 @@ async def action(
prompt: str, prompt: str,
url: str | None = None, url: str | None = None,
max_steps: int | None = None, max_steps: int | None = None,
download_suffix: str | None = None,
totp_identifier: str | None = None, totp_identifier: str | None = None,
totp_url: str | None = None, totp_url: str | None = None,
label: str | None = None, label: str | None = None,
@@ -1281,6 +1285,7 @@ async def action(
url=url, url=url,
label=cache_key, label=cache_key,
) )
prompt = _render_template_with_label(prompt, cache_key)
# set the prompt in the RunContext # set the prompt in the RunContext
context = skyvern_context.ensure_context() context = skyvern_context.ensure_context()
context.prompt = prompt context.prompt = prompt
@@ -1346,12 +1351,14 @@ async def login(
cached_fn = script_run_context_manager.get_cached_fn(cache_key) cached_fn = script_run_context_manager.get_cached_fn(cache_key)
if cache_key and cached_fn: if cache_key and cached_fn:
# Auto-create workflow block run and task if workflow_run_id is available # 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( workflow_run_block_id, task_id, step_id = await _create_workflow_block_run_and_task(
block_type=BlockType.LOGIN, block_type=BlockType.LOGIN,
prompt=prompt, prompt=prompt,
url=url, url=url,
label=cache_key, label=cache_key,
) )
prompt = _render_template_with_label(prompt, cache_key)
# set the prompt in the RunContext # set the prompt in the RunContext
context = skyvern_context.ensure_context() context = skyvern_context.ensure_context()
context.prompt = prompt context.prompt = prompt
@@ -1424,6 +1431,7 @@ async def extract(
url=url, url=url,
label=cache_key, label=cache_key,
) )
prompt = _render_template_with_label(prompt, cache_key)
# set the prompt in the RunContext # set the prompt in the RunContext
context = skyvern_context.ensure_context() context = skyvern_context.ensure_context()
context.prompt = prompt context.prompt = prompt
@@ -1546,27 +1554,27 @@ def _render_template_with_label(template: str, label: str | None = None) -> str:
context = skyvern_context.current() context = skyvern_context.current()
if context and context.workflow_run_id: if context and context.workflow_run_id:
workflow_run_context = app.WORKFLOW_CONTEXT_MANAGER.get_workflow_run_context(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() 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: 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 template_data[label] = block_reference_data
# inject the forloop metadata as global variables # inject the forloop metadata as global variables
if "current_index" in block_reference_data: if "current_index" in block_reference_data:
template_data["current_index"] = block_reference_data["current_index"] template_data["current_index"] = block_reference_data["current_index"]
if "current_item" in block_reference_data: if "current_item" in block_reference_data:
template_data["current_item"] = block_reference_data["current_item"] template_data["current_item"] = block_reference_data["current_item"]
if "current_value" in block_reference_data: if "current_value" in block_reference_data:
template_data["current_value"] = block_reference_data["current_value"] template_data["current_value"] = block_reference_data["current_value"]
return render_template(template, data=template_data) return render_template(template, data=template_data)