-
Notifications
You must be signed in to change notification settings - Fork 0
Add Array Loop Iterator & Data Transform Nodes #46
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 |
|---|---|---|
|
|
@@ -432,6 +432,39 @@ export function generatePython( | |
| `# ${c.content ? c.content.replace(/\n/g, "\n# ") : "(empty note)"}`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
| case "loop": | ||
| return [ | ||
| `arr = state.get(${pyStr((c.array_key || "items").replace(/^state\./, ""))}, [])`, | ||
| `items = arr if isinstance(arr, list) else []`, | ||
| `mapped = []`, | ||
| `max_iter = ${parseInt(c.max_iterations || "100", 10)}`, | ||
|
|
||
| `for idx, item in enumerate(items[:max_iter]):`, | ||
| ` # apply item transform`, | ||
| ` state.set(${pyStr((c.item_var || "item").trim())}, item)`, | ||
| ` mapped.append(item)`, | ||
|
Comment on lines
+443
to
+444
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 | 🏗️ Heavy lift Generate the configured The runtime in Also applies to: 667-668 🤖 Prompt for AI Agents |
||
| `state.set(${pyStr((c.target_key || "processed_items").replace(/^state\./, ""))}, mapped)`, | ||
| `state.last = {"count": len(mapped), "items": mapped}`, | ||
| `return "empty" if len(items) == 0 else "next"`, | ||
| ].join("\n"); | ||
| case "transform": | ||
| return [ | ||
| `op = ${pyStr((c.operation || "template_string").toLowerCase())}`, | ||
| `source_key = ${pyStr((c.source_key || "data").replace(/^state\./, ""))}`, | ||
| `target_key = ${pyStr((c.target_key || "transformed").replace(/^state\./, ""))}`, | ||
| `expr = interpolate(${pyStr(c.expression || "")}, state)`, | ||
| `if op == "pick_fields":`, | ||
| ` source_obj = state.get(source_key, {})`, | ||
| ` fields = [f.strip() for f in ${pyStr(c.fields || "")}.split(",") if f.strip()]`, | ||
| ` res = {f: source_obj.get(f) for f in fields if isinstance(source_obj, dict) and f in source_obj}`, | ||
| `else:`, | ||
| ` try:`, | ||
| ` res = json.loads(expr)`, | ||
| ` except Exception:`, | ||
| ` res = expr`, | ||
|
Comment on lines
+455
to
+463
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 | 🏗️ Heavy lift Implement all transform operations in both generated targets. The runtime supports Also applies to: 681-688 🤖 Prompt for AI Agents |
||
| `state.set(target_key, res)`, | ||
| `state.last = {"operation": op, "target_key": target_key, "result": res}`, | ||
| `return "next"`, | ||
| ].join("\n"); | ||
| default: { | ||
| const _exhaustive: never = d.kind as never; | ||
| return `return "next" # unknown kind ${_exhaustive}`; | ||
|
|
@@ -626,6 +659,37 @@ export function generateJavaScript( | |
| `// ${c.content ? c.content.replace(/\n/g, "\n// ") : "(empty note)"}`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| case "loop": | ||
| return [ | ||
| `const items = Array.isArray(state.get("${(c.array_key || "items").replace(/^state\./, "")}")) ? state.get("${(c.array_key || "items").replace(/^state\./, "")}") : [];`, | ||
|
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. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target ---'
sed -n '640,700p' frontend/src/flow/codegen.ts
printf '%s\n' '--- related generator definitions/usages ---'
rg -n -C 3 'array_key|item_transform|operation|fields|JSON.stringify|parseInt' frontend/src/flow/codegen.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 11608 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- changed hunk ---'
git diff -- frontend/src/flow/codegen.ts | sed -n '1,220p'
printf '%s\n' '--- generator context ---'
sed -n '640,700p' frontend/src/flow/codegen.ts
printf '%s\n' '--- direct symbols ---'
rg -n -C 3 'array_key|item_transform|operation|fields|JSON.stringify|parseInt' frontend/src/flow/codegen.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 11618 🏁 Script executed: printf '%s\n' '--- target ---'
sed -n '650,695p' frontend/src/flow/codegen.ts
printf '%s\n' '--- diff ---'
git diff -- frontend/src/flow/codegen.ts | sed -n '1,180p'Repository: Jacobcdsmith/agent-flow-canvas Length of output: 2364 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- file context ---'
sed -n '655,690p' frontend/src/flow/codegen.ts
printf '%s\n' '--- configuration declarations and validation ---'
rg -n -C 4 'array_key|item_transform|operation|fields' frontend/src --glob '*.ts' --glob '*.tsx' | head -240Repository: Jacobcdsmith/agent-flow-canvas Length of output: 19143 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- generated JavaScript consumers ---'
rg -n -C 4 'generateJavaScript|new Function|Function\(|eval\(|js\.code|generated.*code|\.code' frontend/src --glob '*.ts' --glob '*.tsx' | head -260
printf '%s\n' '--- generator declarations ---'
sed -n '500,630p' frontend/src/flow/codegen.tsRepository: Jacobcdsmith/agent-flow-canvas Length of output: 25587 Serialize configuration values before embedding them in generated JavaScript. The generated module runs these node bodies through 🤖 Prompt for AI Agents |
||
| `const maxIter = ${parseInt(c.max_iterations || "100", 10)};`, | ||
| `const mapped = items.slice(0, maxIter).map((item, idx) => {`, | ||
| ` state.set("${(c.item_var || "item").trim()}", item);`, | ||
| ` return item;`, | ||
| `});`, | ||
| `state.set("${(c.target_key || "processed_items").replace(/^state\./, "")}", mapped);`, | ||
| `state.last = { count: mapped.length, items: mapped };`, | ||
| `return items.length === 0 ? "empty" : "next";`, | ||
| ].join("\n"); | ||
|
Comment on lines
+662
to
+673
|
||
| case "transform": | ||
| return [ | ||
| `const op = "${(c.operation || "template_string").toLowerCase()}";`, | ||
| `const sourceKey = "${(c.source_key || "data").replace(/^state\./, "")}";`, | ||
| `const targetKey = "${(c.target_key || "transformed").replace(/^state\./, "")}";`, | ||
| `const expr = interpolate(${JSON.stringify(c.expression || "")}, state);`, | ||
| `let res;`, | ||
| `if (op === "pick_fields") {`, | ||
| ` const sourceObj = state.get(sourceKey) ?? {};`, | ||
| ` const fields = "${c.fields || ""}".split(",").map(f => f.trim()).filter(Boolean);`, | ||
| ` res = {};`, | ||
| ` fields.forEach(f => { if (sourceObj && f in sourceObj) res[f] = sourceObj[f]; });`, | ||
| `} else {`, | ||
| ` try { res = JSON.parse(expr); } catch { res = expr; }`, | ||
| `}`, | ||
| `state.set(targetKey, res);`, | ||
| `state.last = { operation: op, target_key: targetKey, result: res };`, | ||
| `return "next";`, | ||
| ].join("\n"); | ||
| default: | ||
| return `return "next";`; | ||
| } | ||
|
|
@@ -677,4 +741,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","note","loop","transform"]; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -478,6 +478,148 @@ export async function runNode( | |
| annotationOnly: true, | ||
| }; | ||
| } | ||
| case "loop": { | ||
| const rawArrayKey = (cfg.array_key || "items").replace(/^state\./, ""); | ||
| const arrayVal = getPath(state, rawArrayKey); | ||
| const items = Array.isArray(arrayVal) ? arrayVal : []; | ||
| const itemVarName = (cfg.item_var || "item").trim(); | ||
| const targetKey = (cfg.target_key || "processed_items").replace(/^state\./, ""); | ||
| const maxIter = parseIntOr(cfg.max_iterations, 100); | ||
| const transformTemplate = cfg.item_transform || ""; | ||
|
|
||
| const mapped: unknown[] = []; | ||
| const limit = Math.min(items.length, maxIter); | ||
|
|
||
| for (let i = 0; i < limit; i++) { | ||
| const itemVal = items[i]; | ||
| if (transformTemplate.trim()) { | ||
| // Create iteration context including loop item | ||
| const loopState = { | ||
| ...state, | ||
| [itemVarName]: itemVal, | ||
| index: i, | ||
| }; | ||
|
|
||
| // Interpolate template with loopState context | ||
| let interpolated = interpolate(transformTemplate, loopState, globalsList, secretsList); | ||
| // Replace item-specific placeholders | ||
| interpolated = interpolated.replace( | ||
| new RegExp(`\\{\\{\\s*${itemVarName}\\.([\\w.]+)\\s*\\}\\}`, "g"), | ||
|
Comment on lines
+506
to
+507
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 | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target context ---'
sed -n '450,535p' frontend/src/flow/runFlow.ts
printf '%s\n' '--- itemVarName bindings and regex construction ---'
rg -n -C 4 'itemVarName|item_var|new RegExp' frontend/src/flow/runFlow.ts frontend/srcRepository: Jacobcdsmith/agent-flow-canvas Length of output: 14462 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- interpolation implementation ---'
sed -n '1,90p' frontend/src/flow/runFlow.ts
printf '%s\n' '--- loop configuration contract ---'
sed -n '145,175p' frontend/src/flow/types.ts
printf '%s\n' '--- item_var editor/validation references ---'
rg -n -C 3 'configFields|item_var|validate.*config|config.*valid|identifier' frontend/src --glob '!flow/runFlow.ts' --glob '!flow/types.ts'Repository: Jacobcdsmith/agent-flow-canvas Length of output: 19286 Escape
🧰 Tools🪛 ast-grep (0.45.2)[warning] 506-506: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns. (regexp-from-variable) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| (_m, k) => { | ||
| if (itemVal && typeof itemVal === "object") { | ||
| const p = getPath(itemVal as Record<string, unknown>, String(k)); | ||
| return p === undefined ? "" : typeof p === "string" ? p : JSON.stringify(p); | ||
| } | ||
| return ""; | ||
| } | ||
| ).replace( | ||
| new RegExp(`\\{\\{\\s*${itemVarName}\\s*\\}\\}`, "g"), | ||
| () => (typeof itemVal === "string" ? itemVal : JSON.stringify(itemVal)) | ||
| ); | ||
|
Comment on lines
+503
to
+518
|
||
|
|
||
| try { | ||
| mapped.push(JSON.parse(interpolated)); | ||
| } catch { | ||
| mapped.push(interpolated); | ||
| } | ||
| } else { | ||
| mapped.push(itemVal); | ||
| } | ||
| } | ||
|
|
||
| state[targetKey] = mapped; | ||
| if (items.length === 0) { | ||
| state.__router_branch = "empty"; | ||
| } | ||
|
|
||
| return { | ||
| source_key: rawArrayKey, | ||
| target_key: targetKey, | ||
| count: mapped.length, | ||
| items: mapped, | ||
| }; | ||
| } | ||
| case "transform": { | ||
| const op = (cfg.operation || "template_string").toLowerCase(); | ||
| const sourceKey = (cfg.source_key || "data").replace(/^state\./, ""); | ||
| const targetKey = (cfg.target_key || "transformed").replace(/^state\./, ""); | ||
| const expr = cfg.expression || ""; | ||
| const fieldsStr = cfg.fields || ""; | ||
|
|
||
| let result: unknown = null; | ||
|
|
||
| if (op === "template_string") { | ||
| const interpolated = interpolate(expr, state, globalsList, secretsList); | ||
| try { | ||
| result = JSON.parse(interpolated); | ||
| } catch { | ||
| result = interpolated; | ||
| } | ||
| } else if (op === "json_map") { | ||
| const sourceData = getPath(state, sourceKey); | ||
| const sourceObj = (sourceData && typeof sourceData === "object") ? sourceData : { raw: sourceData }; | ||
| const interpolatedExpr = interpolate(expr, { ...state, source: sourceObj }, globalsList, secretsList); | ||
| try { | ||
| result = JSON.parse(interpolatedExpr); | ||
| } catch { | ||
| result = interpolatedExpr; | ||
| } | ||
| } else if (op === "pick_fields") { | ||
| const sourceData = getPath(state, sourceKey); | ||
| if (sourceData && typeof sourceData === "object" && !Array.isArray(sourceData)) { | ||
| const picked: Record<string, unknown> = {}; | ||
| const fieldList = fieldsStr.split(",").map((f) => f.trim()).filter(Boolean); | ||
| fieldList.forEach((f) => { | ||
| if (f in (sourceData as Record<string, unknown>)) { | ||
| picked[f] = (sourceData as Record<string, unknown>)[f]; | ||
| } | ||
|
Comment on lines
+573
to
+575
|
||
| }); | ||
| result = picked; | ||
| } else { | ||
| result = sourceData ?? null; | ||
| } | ||
| } else if (op === "set_keys") { | ||
| const currentTarget = (getPath(state, targetKey) ?? {}) as Record<string, unknown>; | ||
| const merged = { ...currentTarget }; | ||
|
Comment on lines
+582
to
+583
|
||
| if (expr.trim()) { | ||
| const interpolated = interpolate(expr, state, globalsList, secretsList); | ||
| try { | ||
| const parsed = JSON.parse(interpolated); | ||
| if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { | ||
| Object.assign(merged, parsed); | ||
| } | ||
| } catch { | ||
| // ignore JSON parse error for set_keys | ||
| } | ||
| } | ||
| result = merged; | ||
| } else if (op === "flatten_object") { | ||
| const sourceData = getPath(state, sourceKey); | ||
| 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 (typeof obj[k] === "object" && obj[k] !== null && !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 (sourceData && typeof sourceData === "object" && !Array.isArray(sourceData)) { | ||
| result = flatten(sourceData as Record<string, unknown>); | ||
| } else { | ||
| result = sourceData ?? {}; | ||
| } | ||
| } | ||
|
|
||
|
Comment on lines
+614
to
+615
|
||
| state[targetKey] = result; | ||
| return { | ||
| operation: op, | ||
| target_key: targetKey, | ||
| result, | ||
| }; | ||
| } | ||
| default: | ||
| return { kind: node.data.kind, note: "no executor" }; | ||
| } | ||
|
|
||
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: 8073
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 7964
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 15621
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 3865
Apply
parseIntOrsemantics to generated loop limits.For an invalid
c.max_iterations, the generator emitsNaN. Python then raisesNameErroratmax_iter = NaN. JavaScript passesNaNtoslice, which returns an empty result. Use the same fallback behavior asparseIntOr(cfg.max_iterations, 100)before emitting either target.🤖 Prompt for AI Agents