Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/flow/AgentNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const KIND_COLOR: Record<string, string> = {
memory: "hsl(var(--node-memory))",
human: "hsl(var(--node-human))",
sink: "hsl(var(--node-sink))",
http: "hsl(var(--node-http))",
script: "hsl(var(--node-script))",
};

interface ExtraData extends AgentNodeData {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/flow/Palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const KIND_COLOR: Record<string, string> = {
memory: "hsl(var(--node-memory))",
human: "hsl(var(--node-human))",
sink: "hsl(var(--node-sink))",
http: "hsl(var(--node-http))",
script: "hsl(var(--node-script))",
};

export function Palette({ onAdd }: Props) {
Expand Down
75 changes: 73 additions & 2 deletions frontend/src/flow/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,37 @@ export function generatePython(
lines.push(` print(json.dumps(payload, default=str, indent=2))`);
lines.push(` return payload`);
lines.push(``);
lines.push(`async def make_http_request(method: str, url: str, headers_json: str, body_str: str, state: State) -> Any:`);
lines.push(` import urllib.request`);
lines.push(` import urllib.error`);
lines.push(` log.info("HTTP %s %s", method, url)`);
lines.push(` headers = {}`);
lines.push(` if headers_json:`);
lines.push(` try:`);
lines.push(` headers = json.loads(headers_json)`);
lines.push(` except Exception:`);
lines.push(` pass`);
Comment on lines +215 to +219

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Inconsistent header-JSON error handling across generated helpers. The Python helper silently ignores invalid header JSON (except Exception: pass) while the JS helper throws a raw SyntaxError from JSON.parse; the runtime throws a descriptive "HTTP headers must be valid JSON" error. Both generated helpers should match the runtime's fail-fast behavior.

  • frontend/src/flow/codegen.ts#L215-L219: Replace pass with raise RuntimeError(f"HTTP headers must be valid JSON: {exc}").
  • frontend/src/flow/codegen.ts#L397: Wrap JSON.parse(headersJson) in try/catch and throw new Error("HTTP headers must be valid JSON") on failure.
📍 Affects 1 file
  • frontend/src/flow/codegen.ts#L215-L219 (this comment)
  • frontend/src/flow/codegen.ts#L397-L397
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/codegen.ts` around lines 215 - 219, Update the generated
Python helper in frontend/src/flow/codegen.ts lines 215-219 to raise
RuntimeError with the invalid-JSON exception details instead of silently
passing, and update the generated JavaScript helper at
frontend/src/flow/codegen.ts line 397 to catch JSON.parse failures and throw the
descriptive HTTP headers error. Ensure both helpers fail fast consistently with
runtime behavior.

lines.push(` req_data = None`);
lines.push(` if body_str:`);
lines.push(` req_data = body_str.encode("utf-8")`);
lines.push(` if "Content-Type" not in headers and "content-type" not in headers:`);
lines.push(` headers["Content-Type"] = "application/json"`);
lines.push(` req = urllib.request.Request(url, data=req_data, headers=headers, method=method)`);
lines.push(` try:`);
lines.push(` with urllib.request.urlopen(req) as response:`);
lines.push(` status = response.status`);
lines.push(` resp_body = response.read().decode("utf-8")`);
lines.push(` try:`);
lines.push(` resp_body = json.loads(resp_body)`);
lines.push(` except Exception:`);
lines.push(` pass`);
lines.push(` return {"status": status, "body": resp_body}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Generated HTTP helpers omit response headers (Python and JS). Both make_http_request (Python) and makeHttpRequest (JS) return {status, body} while the runtime in runFlow.ts returns {status, headers, body}, causing a state-shape contract mismatch between the visual runner and generated code.

  • frontend/src/flow/codegen.ts#L234: Add resp_headers = dict(response.headers) and include "headers": resp_headers in the return dict.
  • frontend/src/flow/codegen.ts#L410: Add const respHeaders = Object.fromEntries(res.headers.entries()) and include headers: respHeaders in the return object.
📍 Affects 1 file
  • frontend/src/flow/codegen.ts#L234-L234 (this comment)
  • frontend/src/flow/codegen.ts#L410-L410
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/codegen.ts` at line 234, Update generated helpers in
frontend/src/flow/codegen.ts at lines 234 and 410: make Python make_http_request
collect response headers into resp_headers and return them with status and body,
and make JavaScript makeHttpRequest build respHeaders from res.headers and
include it in the returned object. Ensure both generated response shapes match
runFlow.ts with status, headers, and body.

lines.push(` except urllib.error.HTTPError as err:`);
lines.push(` err_body = err.read().decode("utf-8")`);
lines.push(` raise RuntimeError(f"HTTP {err.code}: {err_body}")`);
lines.push(` except Exception as exc:`);
lines.push(` raise RuntimeError(f"HTTP request failed: {exc}")`);
lines.push(``);
lines.push(``);
lines.push(`# ---------------------------------------------------------------`);
lines.push(`# Graph definition (generated)`);
Expand Down Expand Up @@ -309,6 +340,25 @@ export function generatePython(
`await emit_output(${pyStr(c.target || "response")}, state)`,
`return "next"`,
].join("\n");
case "http":
return [
`# HTTP Request`,
`method = ${pyStr(c.method || "GET")}`,
`url = ${pyStr(c.url || "")}`,
`headers_json = ${pyStr(c.headers || "")}`,
`body = ${pyStr(c.body || "")}`,
`result = await make_http_request(method, url, headers_json, body, state)`,
`state.last = result`,
`return "on_success"`,
].join("\n");
case "script":
return [
`# JS Script Node (Simulated execution block)`,
`# Original Code:`,
...((c.code || "").split("\n").map((line) => `# ${line}`)),
`# Setting execution stub outputs`,
`return "next"`,
].join("\n");
default: {
const _exhaustive: never = d.kind as never;
return `return "next" # unknown kind ${_exhaustive}`;
Expand Down Expand Up @@ -342,6 +392,24 @@ export function generateJavaScript(
lines.push(` get(k, d = null) { return this.data[k] ?? d; }`);
lines.push(`}`);
lines.push(``);
lines.push(`// adapters — swap with real SDKs`);
lines.push(`const makeHttpRequest = async (method, url, headersJson, body, state) => {`);
lines.push(` const headers = headersJson ? JSON.parse(headersJson) : {};`);
lines.push(` if (body && !headers["Content-Type"] && !headers["content-type"]) {`);
lines.push(` headers["Content-Type"] = "application/json";`);
lines.push(` }`);
lines.push(` const opts = { method, headers };`);
lines.push(` if (method !== "GET" && method !== "HEAD" && body) {`);
lines.push(` opts.body = body;`);
lines.push(` }`);
lines.push(` const res = await fetch(url, opts);`);
lines.push(` const text = await res.text();`);
lines.push(` let respBody;`);
lines.push(` try { respBody = JSON.parse(text); } catch { respBody = text; }`);
lines.push(` if (!res.ok) throw new Error(\`HTTP \${res.status}: \${text.slice(0, 100)}\`);`);
lines.push(` return { status: res.status, body: respBody };`);
lines.push(`};`);
lines.push(``);
lines.push(`class Graph {`);
lines.push(` constructor() { this.nodes = new Map(); this.edges = new Map(); this.entry = null; }`);
lines.push(` node(name, fn) { this.nodes.set(name, fn); if (!this.edges.has(name)) this.edges.set(name, []); }`);
Expand Down Expand Up @@ -370,7 +438,6 @@ export function generateJavaScript(
lines.push(` }`);
lines.push(`}`);
lines.push(``);
lines.push(`// adapters — swap with real SDKs`);
lines.push(`const callLlm = async (model, prompt) => \`[\${model}] \${prompt.slice(0, 40)}…\`;`);
lines.push(`const callTool = async (tool, args) => ({ tool, ok: true, args });`);
lines.push(`const memoryRead = async (key, state) => state.get(\`mem::\${key}\`);`);
Expand Down Expand Up @@ -423,6 +490,10 @@ export function generateJavaScript(
return `state.last = await awaitHuman(${JSON.stringify(c.channel || "ui")}, ${JSON.stringify(c.prompt || "")});\nreturn "next";`;
case "sink":
return `await emitOutput(${JSON.stringify(c.target || "response")}, state);\nreturn "next";`;
case "http":
return `const method = ${JSON.stringify(c.method || "GET")};\nconst url = ${JSON.stringify(c.url || "")};\nconst headers = ${JSON.stringify(c.headers || "")};\nconst body = ${JSON.stringify(c.body || "")};\nstate.last = await makeHttpRequest(method, url, headers, body, state);\nreturn "on_success";`;
case "script":
return `// JS Script Node Execution\n${c.code || ""}\nreturn "next";`;
default:
return `return "next";`;
}
Expand Down Expand Up @@ -466,4 +537,4 @@ export function generateCode(lang: CodeLanguage, nodes: Node<AgentNodeData>[], e
}

// also export the kind set for sanity
export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink"];
export const ALL_KINDS: AgentNodeKind[] = ["trigger","llm","tool","router","subagent","memory","human","sink","http","script"];
88 changes: 86 additions & 2 deletions frontend/src/flow/runFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface RunLog {
output?: unknown;
error?: string;
ms: number;
/** Snapshot of the execution state after this node runs */
stateSnapshot?: Record<string, unknown>;
}

export interface RunOptions {
Expand Down Expand Up @@ -78,8 +80,8 @@ function pickNextEdge(
const onError = outgoing.find((e) => String(e.label) === "on_error");
if (onError) return onError;
}
// router branch
const branch = state.__router_branch as "true" | "false" | undefined;
// router or script branch override
const branch = state.__router_branch as string | undefined;
if (branch) {
const m = outgoing.find((e) => String(e.label) === branch);
if (m) return m;
Expand Down Expand Up @@ -153,6 +155,7 @@ export async function runFlow(opts: RunOptions): Promise<RunLog[]> {
output,
error,
ms,
stateSnapshot: JSON.parse(JSON.stringify(state)),
};
logs.push(log);
onLog?.(log);
Expand Down Expand Up @@ -280,6 +283,87 @@ async function runNode(
result: state.last_output ?? null,
};
}
case "http": {
const method = (cfg.method || "GET").trim().toUpperCase();
let urlStr = interpolate(cfg.url || "", state);
if (!urlStr.startsWith("http://") && !urlStr.startsWith("https://") && urlStr) {
urlStr = "https://" + urlStr;
}
if (!urlStr) {
throw new Error("HTTP request URL is required");
}

let parsedHeaders: Record<string, string> = {};
if (cfg.headers?.trim()) {
try {
const rawHeaders = interpolate(cfg.headers, state);
parsedHeaders = JSON.parse(rawHeaders);
} catch {
throw new Error("HTTP headers must be valid JSON");
}
}

let rawBody = interpolate(cfg.body || "", state);
// Ensure content-type is set if body is JSON
if (rawBody && !parsedHeaders["Content-Type"] && !parsedHeaders["content-type"]) {
try {
JSON.parse(rawBody);
parsedHeaders["Content-Type"] = "application/json";
} catch {
// No-op, send raw
}
}

const fetchOptions: RequestInit = {
method,
headers: parsedHeaders,
};

if (method !== "GET" && method !== "HEAD" && rawBody) {
fetchOptions.body = rawBody;
}

const res = await fetch(urlStr, fetchOptions);
Comment on lines +317 to +326

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

fetch has no timeout — a hanging request stalls the entire run indefinitely.

There's no AbortController/timeout around this fetch. A slow or non-responding endpoint blocks the whole flow forever. Downstream in Index.tsx, there is also no way to cancel an in-flight run (the drawer's "close" button only hides the panel, it doesn't abort runFlow), so running stays stuck and the flow keeps executing in the background with no user-visible recovery path.

🛡️ Proposed fix
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 15000);
+      let res: Response;
+      try {
+        res = await fetch(urlStr, { ...fetchOptions, signal: controller.signal });
+      } finally {
+        clearTimeout(timeoutId);
+      }
-      const res = await fetch(urlStr, fetchOptions);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fetchOptions: RequestInit = {
method,
headers: parsedHeaders,
};
if (method !== "GET" && method !== "HEAD" && rawBody) {
fetchOptions.body = rawBody;
}
const res = await fetch(urlStr, fetchOptions);
const fetchOptions: RequestInit = {
method,
headers: parsedHeaders,
};
if (method !== "GET" && method !== "HEAD" && rawBody) {
fetchOptions.body = rawBody;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
let res: Response;
try {
res = await fetch(urlStr, { ...fetchOptions, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/runFlow.ts` around lines 317 - 326, Update the fetch flow
in runFlow to use an AbortController with a finite timeout, aborting requests
that exceed the configured limit and handling the resulting cancellation so the
run terminates cleanly. Ensure the controller and timer are properly cleaned up
after fetch completion or failure, and propagate cancellation rather than
leaving running state stuck.

const text = await res.text();
let responseBody: unknown;
try {
responseBody = JSON.parse(text);
} catch {
responseBody = text;
}

if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}

return {
status: res.status,
headers: Object.fromEntries(res.headers.entries()),
body: responseBody,
};
}
case "script": {
const userCode = cfg.code || "";
// Create a sandboxed execution where the user can read/write properties on `state`
// We will allow returning a value which gets mapped as custom edge label.
try {
const fn = new Function("state", `
with (state) {
${userCode}
}
`);
// We evaluate using a Proxy or just normal state object. To make standard assignments work,
// we can let standard state mutation happen directly on the state reference.
const returnedLabel = fn(state);
if (typeof returnedLabel === "string") {
state.__router_branch = returnedLabel; // Re-use the branch selector matching logic
return { returned: returnedLabel, success: true };
}
return { success: true };
} catch (e) {
throw new Error(`Script evaluation error: ${e instanceof Error ? e.message : String(e)}`);
}
}
Comment on lines +345 to +366

Copy link
Copy Markdown
Contributor

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file range and nearby helpers.
FILE="frontend/src/flow/runFlow.ts"
wc -l "$FILE"
sed -n '300,410p' "$FILE"

# Search for related script execution / state handling.
rg -n "new Function|with \\(state\\)|__router_branch|script" frontend/src/flow/runFlow.ts frontend/src/flow -S

# Probe JavaScript semantics relevant to the comment.
node - <<'JS'
function run(userCode, state) {
  const fn = new Function("state", `
    with (state) {
      ${userCode}
    }
  `);
  return fn(state);
}

const state = {};
try {
  run("newVar = 5; return typeof newVar + ':' + state.newVar;", state);
  console.log("state after assignment:", JSON.stringify(state));
  console.log("global newVar:", globalThis.newVar);
  delete globalThis.newVar;
} catch (e) {
  console.error("error:", e && e.message);
}

const state2 = {};
try {
  const out = run("state.x = 1; return x + ':' + state.x;", state2);
  console.log("output for state.x assignment:", out);
} catch (e) {
  console.error("error2:", e && e.message);
}

const state3 = { existing: 0 };
try {
  const out = run("existing = 7; return existing + ':' + state.existing;", state3);
  console.log("output for existing property assignment:", out);
  console.log("state3 after:", JSON.stringify(state3));
} catch (e) {
  console.error("error3:", e && e.message);
}
JS

Repository: Jacobcdsmith/agent-flow-canvas

Length of output: 2059


with (state) leaks new assignments out of state. Bare writes like newVar = 5 create globals in this sloppy new Function body instead of updating state, so scripts can appear to work while downstream nodes never see the value. Since state is already passed in, drop the with wrapper and require explicit state.foo writes.

🧰 Tools
🪛 OpenGrep (1.25.0)

[ERROR] 350-354: new Function() with dynamic input can execute arbitrary code. Avoid dynamic code evaluation entirely, or use a safe alternative.

(coderabbit.code-injection.new-function-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/flow/runFlow.ts` around lines 345 - 366, Update the script
execution block in the “script” case to remove the `with (state)` wrapper and
execute userCode directly with the existing `state` parameter. Require scripts
to use explicit `state.foo` reads and writes, while preserving returned-label
handling and the existing Script evaluation error behavior.

default:
return { kind: node.data.kind, note: "no executor" };
}
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/flow/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ export type AgentNodeKind =
| "subagent"
| "memory"
| "human"
| "sink";
| "sink"
| "http"
| "script";

export interface AgentNodeData {
kind: AgentNodeKind;
Expand Down Expand Up @@ -111,6 +113,27 @@ export const NODE_TYPES: NodeTypeMeta[] = [
],
isTerminal: true,
},
{
kind: "http",
label: "HTTP Request",
description: "Execute a client-side HTTP/HTTPS web request with custom config.",
defaultName: "web_request",
configFields: [
{ key: "method", label: "method", placeholder: "GET | POST | PUT | DELETE" },
{ key: "url", label: "url", placeholder: "https://api.example.com/v1/data" },
{ key: "headers", label: "headers (json)", placeholder: '{"Authorization": "Bearer token"}' },
{ key: "body", label: "body", placeholder: '{"query": "{{state.query}}"}' },
],
},
{
kind: "script",
label: "JS Script",
description: "Execute custom sandboxed JavaScript to modify state or compute labels.",
defaultName: "js_code",
configFields: [
{ key: "code", label: "code", placeholder: "state.processed = true;\nreturn 'on_success';" },
],
},
];

export const EDGE_LABELS = ["next", "on_success", "on_error", "tool_result", "true", "false"] as const;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ All colors MUST be HSL.
--node-memory: 340 50% 50%;
--node-human: 50 80% 45%;
--node-sink: 0 0% 25%;
--node-http: 185 80% 40%;
--node-script: 40 85% 45%;

--edge-selected: 200 95% 45%;
--issue: 0 75% 55%;
Expand Down
66 changes: 44 additions & 22 deletions frontend/src/pages/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,49 @@ const nodeTypes = { agent: AgentNode };
let idCounter = 100;
const nextId = () => `n${++idCounter}`;

function LogItem({ l }: { l: RunLog }) {
const [isStateOpen, setIsStateOpen] = useState(false);
return (
<div
className="border border-dashed border-[hsl(var(--grid-line))] p-2 font-mono text-[10px]"
style={l.error ? { borderColor: "hsl(var(--issue))" } : undefined}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-[hsl(var(--ink-faint))]">#{l.step}</span>
<span className="font-semibold text-[hsl(var(--ink))]">{l.name}</span>
<span className="uppercase tracking-[0.15em] text-[9px] text-[hsl(var(--ink-soft))]">{l.kind}</span>
<span className="ml-auto text-[hsl(var(--ink-faint))]">{l.ms}ms</span>
</div>
<div className="text-[hsl(var(--ink-soft))]">
→ <span className="uppercase tracking-wider">{l.label}</span>
</div>
{l.error ? (
<pre className="mt-1 whitespace-pre-wrap text-[hsl(var(--issue))]">{l.error}</pre>
) : (
<pre className="mt-1 whitespace-pre-wrap text-[hsl(var(--ink))] max-h-40 overflow-auto">
{typeof l.output === "string" ? l.output : JSON.stringify(l.output, null, 2)}
</pre>
)}
{l.stateSnapshot && (
<div className="mt-2 pt-2 border-t border-dashed border-[hsl(var(--grid-line))]">
<button
type="button"
onClick={() => setIsStateOpen(!isStateOpen)}
className="text-[9px] uppercase tracking-wider text-[hsl(var(--ink-soft))] hover:text-[hsl(var(--ink))] font-semibold"
>
{isStateOpen ? "▼ hide cumulative state" : "▶ show cumulative state"}
</button>
{isStateOpen && (
<pre className="mt-1.5 p-1.5 bg-[hsl(var(--ink)/0.02)] border border-dashed border-[hsl(var(--grid-line))] overflow-auto max-h-32 text-[9px] text-[hsl(var(--ink-soft))]">
{JSON.stringify(l.stateSnapshot, null, 2)}
</pre>
)}
</div>
)}
</div>
);
}

function Canvas() {
const rf = useReactFlow();
const isMobile = useIsMobile();
Expand Down Expand Up @@ -1097,28 +1140,7 @@ function Canvas() {
</div>
)}
{runLogs?.map((l) => (
<div
key={`${l.step}-${l.nodeId}`}
className="border border-dashed border-[hsl(var(--grid-line))] p-2 font-mono text-[10px]"
style={l.error ? { borderColor: "hsl(var(--issue))" } : undefined}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-[hsl(var(--ink-faint))]">#{l.step}</span>
<span className="font-semibold text-[hsl(var(--ink))]">{l.name}</span>
<span className="uppercase tracking-[0.15em] text-[9px] text-[hsl(var(--ink-soft))]">{l.kind}</span>
<span className="ml-auto text-[hsl(var(--ink-faint))]">{l.ms}ms</span>
</div>
<div className="text-[hsl(var(--ink-soft))]">
→ <span className="uppercase tracking-wider">{l.label}</span>
</div>
{l.error ? (
<pre className="mt-1 whitespace-pre-wrap text-[hsl(var(--issue))]">{l.error}</pre>
) : (
<pre className="mt-1 whitespace-pre-wrap text-[hsl(var(--ink))] max-h-40 overflow-auto">
{typeof l.output === "string" ? l.output : JSON.stringify(l.output, null, 2)}
</pre>
)}
</div>
<LogItem key={`${l.step}-${l.nodeId}`} l={l} />
))}
</div>
</div>
Expand Down
Loading