-
Notifications
You must be signed in to change notification settings - Fork 0
Add Data Transform and Array Loop Iterator Nodes #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -426,6 +426,47 @@ export function generatePython( | |
| `state.last = result`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
| case "transform": | ||
| return [ | ||
| `op = ${pyStr(c.operation || "json_map")}`, | ||
| `input_path = ${pyStr(c.input_path || "state.last_output")}`, | ||
| `output_key = ${pyStr(c.output_key || "transformed_data")}`, | ||
| `raw_params = ${pyStr(c.params || "")}`, | ||
| `input_val = state.get(input_path.replace("state.", ""), state.last)`, | ||
| `# transform operation: ${c.operation || "json_map"}`, | ||
| `if op == "template_string":`, | ||
| ` res = interpolate(raw_params or str(input_val or ""), state)`, | ||
| `elif op == "pick_fields":`, | ||
| ` fields = [f.strip() for f in raw_params.split(",") if f.strip()]`, | ||
| ` res = {k: v for k, v in input_val.items() if k in fields} if isinstance(input_val, dict) else input_val`, | ||
| `elif op == "set_keys":`, | ||
| ` keys = json.loads(interpolate(raw_params, state)) if raw_params.strip() else {}`, | ||
| ` for k, v in keys.items(): state.set(k, v)`, | ||
| ` res = keys`, | ||
| `else:`, | ||
| ` res = input_val`, | ||
|
Comment on lines
+446
to
+447
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
fd -t f 'codegen\.ts|runFlow\.ts' frontend
printf '%s\n' '--- codegen symbols and target branches ---'
rg -n -C 8 'json_map|flatten_object|input_val|inputVal|generate|transforms|transform' frontend/src/flow/codegen.ts
printf '%s\n' '--- runtime transform definitions and callers ---'
rg -n -C 8 'json_map|flatten_object' frontend/src/flow frontend/src
printf '%s\n' '--- runFlow output contract ---'
sed -n '540,650p' frontend/src/flow/runFlow.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 41639 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- runtime transform implementation ---'
sed -n '477,575p' frontend/src/flow/runFlow.ts
printf '%s\n' '--- generated Python transform block ---'
sed -n '429,451p' frontend/src/flow/codegen.ts
printf '%s\n' '--- generated JavaScript transform block ---'
sed -n '664,685p' frontend/src/flow/codegen.ts
printf '%s\n' '--- transform test expectations ---'
sed -n '8,145p' frontend/src/test/transformAndLoop.test.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 10695 Implement 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| `state.set(output_key, res)`, | ||
| `state.last = res`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
|
Comment on lines
+429
to
+451
|
||
| case "loop": | ||
| return [ | ||
| `arr_path = ${pyStr(c.array_path || "state.items")}`, | ||
| `item_var = ${pyStr(c.item_var || "item")}`, | ||
| `tmpl = ${pyStr(c.transform_template || "")}`, | ||
| `max_iter = ${parseInt(c.max_iterations || "100", 10)}`, | ||
| `out_key = ${pyStr(c.output_key || "loop_results")}`, | ||
| `arr = state.get(arr_path.replace("state.", ""), [])`, | ||
| `arr = arr if isinstance(arr, list) else [arr] if arr is not None else []`, | ||
| `results = []`, | ||
| `for item in arr[:max_iter]:`, | ||
| ` state.set(item_var, item)`, | ||
| ` mapped = interpolate(tmpl, state) if tmpl else item`, | ||
| ` results.append(mapped)`, | ||
|
Comment on lines
+464
to
+465
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- codegen structure ---'
ast-grep outline frontend/src/flow/codegen.ts --match 'interpolate' --view expanded 2>/dev/null || true
printf '%s\n' '--- Python generator context ---'
sed -n '400,475p' frontend/src/flow/codegen.ts
printf '%s\n' '--- JavaScript generator context ---'
sed -n '640,705p' frontend/src/flow/codegen.ts
printf '%s\n' '--- interpolation references and definitions ---'
rg -n -C 3 'interpolate|itemVar|tmpl' frontend/src/flow frontend/src/test/transformAndLoop.test.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 31800 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- generated Python interpolation helper ---'
sed -n '185,225p' frontend/src/flow/codegen.ts
printf '%s\n' '--- generated JavaScript interpolation helper ---'
sed -n '535,575p' frontend/src/flow/codegen.ts
printf '%s\n' '--- runtime interpolation and path helper ---'
sed -n '35,85p' frontend/src/flow/runFlow.ts
rg -n -C 8 'transformTemplate|item\.name|prod\.title|loop' frontend/src/test/transformAndLoop.test.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 10291 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- generated State contract ---'
rg -n -C 8 'class State|set\(|data:' frontend/src/flow/codegen.ts
printf '%s\n' '--- complete runtime path resolution ---'
sed -n '70,115p' frontend/src/flow/runFlow.ts
printf '%s\n' '--- generated loop fixture assertions ---'
sed -n '174,230p' frontend/src/test/transformAndLoop.test.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 17955 Make generated interpolation resolve loop-item and nested paths. Both generated helpers replace only direct 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| `state.set(out_key, results)`, | ||
| `state.last = results`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
|
Comment on lines
+452
to
+469
|
||
| case "note": | ||
| return [ | ||
| `# Sticky Note Annotation:`, | ||
|
|
@@ -620,6 +661,45 @@ export function generateJavaScript( | |
| `state.last = await runJsScript(${JSON.stringify(c.code || "")}, state);`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| case "transform": | ||
| return [ | ||
| `const op = ${JSON.stringify(c.operation || "json_map")};`, | ||
| `const inputPath = ${JSON.stringify(c.input_path || "state.last_output")};`, | ||
| `const outputKey = ${JSON.stringify(c.output_key || "transformed_data")};`, | ||
| `const rawParams = ${JSON.stringify(c.params || "")};`, | ||
| `const inputVal = state.get(inputPath.replace("state.", "")) ?? state.last;`, | ||
| `let res = inputVal;`, | ||
| `if (op === "template_string") res = interpolate(rawParams || String(inputVal ?? ""), state);`, | ||
| `else if (op === "pick_fields") {`, | ||
| ` const fields = rawParams.split(",").map(s => s.trim());`, | ||
| ` if (inputVal && typeof inputVal === "object") { res = {}; fields.forEach(f => { if (f in inputVal) res[f] = inputVal[f]; }); }`, | ||
| `} else if (op === "set_keys") {`, | ||
| ` const keys = JSON.parse(interpolate(rawParams, state) || "{}");`, | ||
| ` Object.entries(keys).forEach(([k, v]) => state.set(k, v));`, | ||
| ` res = keys;`, | ||
| `}`, | ||
| `state.set(outputKey, res);`, | ||
| `state.last = res;`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| case "loop": | ||
| return [ | ||
| `const arrPath = ${JSON.stringify(c.array_path || "state.items")};`, | ||
| `const itemVar = ${JSON.stringify(c.item_var || "item")};`, | ||
| `const tmpl = ${JSON.stringify(c.transform_template || "")};`, | ||
| `const maxIter = ${parseInt(c.max_iterations || "100", 10)};`, | ||
| `const outKey = ${JSON.stringify(c.output_key || "loop_results")};`, | ||
| `const rawArr = state.get(arrPath.replace("state.", "")) ?? [];`, | ||
| `const arr = Array.isArray(rawArr) ? rawArr : (rawArr != null ? [rawArr] : []);`, | ||
| `const results = [];`, | ||
| `for (let i = 0; i < Math.min(arr.length, maxIter); i++) {`, | ||
| ` state.set(itemVar, arr[i]);`, | ||
| ` results.push(tmpl ? interpolate(tmpl, state) : arr[i]);`, | ||
| `}`, | ||
| `state.set(outKey, results);`, | ||
| `state.last = results;`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| case "note": | ||
| return [ | ||
| `// Sticky Note Annotation:`, | ||
|
|
@@ -677,4 +757,4 @@ export function generateCode( | |
| } | ||
|
|
||
| // also export the kind set for sanity | ||
| export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script","note"]; | ||
| export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script","transform","loop","note"]; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,7 +77,16 @@ export function interpolate( | |
| const v = getPath(state, String(k)); | ||
| return v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v); | ||
| }) | ||
| .replace(/\{\{?\s*query\s*\}?\}/g, () => String(state.query ?? "")); | ||
| .replace(/\{\{?\s*query\s*\}?\}/g, () => String(state.query ?? "")) | ||
| .replace(/\{\{?\s*([\w.]+)\s*\}?\}/g, (_m, k) => { | ||
| const keyStr = String(k); | ||
| const rootKey = keyStr.split(".")[0]; | ||
| if (rootKey in state) { | ||
| const v = getPath(state, keyStr); | ||
| return v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v); | ||
| } | ||
| return _m; | ||
| }); | ||
|
Comment on lines
+81
to
+89
|
||
| } | ||
|
|
||
| /** | ||
|
|
@@ -465,6 +474,157 @@ export async function runNode( | |
| throw new Error(`Script execution failed: ${e instanceof Error ? e.message : String(e)}`); | ||
| } | ||
| } | ||
| case "transform": { | ||
| const op = cfg.operation || "json_map"; | ||
| const inputPath = cfg.input_path || "state.last_output"; | ||
| const outputKey = cfg.output_key || "transformed_data"; | ||
| const rawParams = cfg.params || ""; | ||
|
|
||
| let inputVal: unknown; | ||
| if (inputPath.startsWith("state.")) { | ||
| inputVal = getPath(state, inputPath.slice(6)); | ||
| } else { | ||
| inputVal = getPath(state, inputPath) ?? state.last_output ?? inputPath; | ||
| } | ||
|
|
||
| let transformed: unknown; | ||
|
|
||
| if (op === "json_map") { | ||
| let parsedInput = inputVal; | ||
| if (typeof inputVal === "string") { | ||
| try { | ||
| parsedInput = JSON.parse(inputVal); | ||
| } catch { | ||
| parsedInput = inputVal; | ||
| } | ||
| } | ||
| let mapping: Record<string, string> = {}; | ||
| if (rawParams.trim()) { | ||
| try { | ||
| mapping = JSON.parse(interpolate(rawParams, state, globalsList, secretsList)); | ||
| } catch { | ||
| mapping = {}; | ||
| } | ||
| } | ||
| if (parsedInput && typeof parsedInput === "object" && !Array.isArray(parsedInput)) { | ||
| const mappedObj: Record<string, unknown> = {}; | ||
| for (const [k, v] of Object.entries(parsedInput as Record<string, unknown>)) { | ||
| const targetKey = mapping[k] ?? k; | ||
| mappedObj[targetKey] = v; | ||
| } | ||
| transformed = mappedObj; | ||
| } else { | ||
| transformed = parsedInput; | ||
| } | ||
| } else if (op === "pick_fields") { | ||
| let fieldsToPick: string[] = []; | ||
| if (rawParams.trim()) { | ||
| try { | ||
| const parsed = JSON.parse(rawParams); | ||
| fieldsToPick = Array.isArray(parsed) ? parsed : String(parsed).split(",").map((s) => s.trim()); | ||
| } catch { | ||
| fieldsToPick = rawParams.split(",").map((s) => s.trim()); | ||
| } | ||
| } | ||
|
Comment on lines
+520
to
+528
|
||
| if (inputVal && typeof inputVal === "object" && !Array.isArray(inputVal)) { | ||
| const pickedObj: Record<string, unknown> = {}; | ||
| for (const f of fieldsToPick) { | ||
| if (f in (inputVal as Record<string, unknown>)) { | ||
| pickedObj[f] = (inputVal as Record<string, unknown>)[f]; | ||
| } | ||
| } | ||
| transformed = pickedObj; | ||
| } else { | ||
| transformed = inputVal; | ||
| } | ||
| } else if (op === "template_string") { | ||
| const template = rawParams || String(inputVal ?? ""); | ||
| transformed = interpolate(template, state, globalsList, secretsList); | ||
| } else if (op === "set_keys") { | ||
| let keysToSet: Record<string, unknown> = {}; | ||
| if (rawParams.trim()) { | ||
| try { | ||
| const interpolatedParams = interpolate(rawParams, state, globalsList, secretsList); | ||
| keysToSet = JSON.parse(interpolatedParams); | ||
| } catch { | ||
| keysToSet = {}; | ||
| } | ||
| } | ||
| for (const [k, v] of Object.entries(keysToSet)) { | ||
| state[k] = v; | ||
| } | ||
| transformed = keysToSet; | ||
| } else if (op === "flatten_object") { | ||
| const flatten = (obj: Record<string, unknown>, prefix = ""): Record<string, unknown> => { | ||
| return Object.keys(obj).reduce((acc: Record<string, unknown>, k: string) => { | ||
| const pre = prefix.length ? prefix + "." : ""; | ||
| if (obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k])) { | ||
| Object.assign(acc, flatten(obj[k] as Record<string, unknown>, pre + k)); | ||
| } else { | ||
| acc[pre + k] = obj[k]; | ||
| } | ||
| return acc; | ||
| }, {}); | ||
| }; | ||
| if (inputVal && typeof inputVal === "object" && !Array.isArray(inputVal)) { | ||
| transformed = flatten(inputVal as Record<string, unknown>); | ||
| } else { | ||
| transformed = inputVal; | ||
| } | ||
| } else { | ||
| transformed = inputVal; | ||
| } | ||
|
|
||
| state[outputKey] = transformed; | ||
| return { operation: op, output_key: outputKey, result: transformed }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Return the actual node result to preserve
Add a chained transform and loop regression test through 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| } | ||
| case "loop": { | ||
| const arrayPath = cfg.array_path || "state.items"; | ||
| const itemVar = cfg.item_var || "item"; | ||
| const transformTemplate = cfg.transform_template || ""; | ||
| const maxIterations = parseIntOr(cfg.max_iterations, 100); | ||
| const outputKey = cfg.output_key || "loop_results"; | ||
|
|
||
| let rawArray: unknown; | ||
| if (arrayPath.startsWith("state.")) { | ||
| rawArray = getPath(state, arrayPath.slice(6)); | ||
| } else { | ||
| rawArray = getPath(state, arrayPath) ?? state.last_output; | ||
| } | ||
|
|
||
| let arrayToIterate: unknown[] = []; | ||
| if (Array.isArray(rawArray)) { | ||
| arrayToIterate = rawArray; | ||
| } else if (rawArray !== undefined && rawArray !== null) { | ||
| arrayToIterate = [rawArray]; | ||
| } | ||
|
|
||
| const results: unknown[] = []; | ||
| const iterateCount = Math.min(arrayToIterate.length, maxIterations); | ||
|
|
||
| for (let i = 0; i < iterateCount; i++) { | ||
| const currentItem = arrayToIterate[i]; | ||
| state[itemVar] = currentItem; | ||
|
|
||
| if (!transformTemplate.trim()) { | ||
| results.push(currentItem); | ||
| } else if (transformTemplate.includes("{{")) { | ||
| results.push(interpolate(transformTemplate, state, globalsList, secretsList)); | ||
| } else if (transformTemplate.startsWith(`${itemVar}.`)) { | ||
| const subProp = transformTemplate.slice(itemVar.length + 1); | ||
| if (currentItem && typeof currentItem === "object") { | ||
| results.push(getPath(currentItem as Record<string, unknown>, subProp)); | ||
| } else { | ||
| results.push(undefined); | ||
| } | ||
| } else { | ||
| results.push(interpolate(transformTemplate, state, globalsList, secretsList)); | ||
| } | ||
| } | ||
|
Comment on lines
+602
to
+623
|
||
|
|
||
| state[outputKey] = results; | ||
| return { count: results.length, output_key: outputKey, results }; | ||
| } | ||
| case "sink": { | ||
| return { | ||
| target: cfg.target || "response", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 23599
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 19127
Resolve dotted paths in both generated targets. When
input_pathorarray_pathisstate.payload.items, generated Python and JavaScript strip onlystate.and passpayload.itemstoState.get. The generatedState.getmethods perform flat lookups, so transforms can use the fallback value and loops can receive an empty array instead of the nested value. Use a nested-path resolver at all four sites, with the same semantics asrunFlow.ts#getPath, and test both generated targets.📍 Affects 1 file
frontend/src/flow/codegen.ts#L435-L435(this comment)frontend/src/flow/codegen.ts#L459-L460frontend/src/flow/codegen.ts#L670-L670frontend/src/flow/codegen.ts#L692-L693🤖 Prompt for AI Agents