From d573bab4a0f2f72d8af2bae92d5c8b7683f2ec31 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 15:47:49 +0100 Subject: [PATCH] Add the code node to the workflow builder Expose the workflow code node in the builder UI: a draggable palette entry, a canvas node, and a config panel for the code, inputs, output variable, timeout, and an optional JSON schema (validated before save). The panel serializes to the same config the engine reads, so a code node built in the UI runs and produces an artifact reference; workflows without code nodes load unchanged. --- frontend/src/agents/types/workflow.ts | 12 +- .../src/agents/workflow/WorkflowBuilder.tsx | 299 +++++++++++++++++- .../src/agents/workflow/WorkflowPreview.tsx | 3 + .../agents/workflow/codeNodeConfig.test.ts | 173 ++++++++++ .../src/agents/workflow/codeNodeConfig.ts | 133 ++++++++ .../src/agents/workflow/nodes/BaseNode.tsx | 5 +- .../src/agents/workflow/nodes/CodeNode.tsx | 56 ++++ frontend/src/agents/workflow/nodes/index.tsx | 2 + 8 files changed, 673 insertions(+), 10 deletions(-) create mode 100644 frontend/src/agents/workflow/codeNodeConfig.test.ts create mode 100644 frontend/src/agents/workflow/codeNodeConfig.ts create mode 100644 frontend/src/agents/workflow/nodes/CodeNode.tsx diff --git a/frontend/src/agents/types/workflow.ts b/frontend/src/agents/types/workflow.ts index 0557bfb6..d2f5c27b 100644 --- a/frontend/src/agents/types/workflow.ts +++ b/frontend/src/agents/types/workflow.ts @@ -4,7 +4,17 @@ export type NodeType = | 'agent' | 'note' | 'state' - | 'condition'; + | 'condition' + | 'code'; + +export interface CodeNodeConfig { + code: string; + language: string; + inputs: string[]; + output_variable?: string; + timeout?: number; + json_schema?: Record; +} export interface ConditionCase { name?: string; diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index e58566ce..3c59deb3 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -3,6 +3,7 @@ import 'reactflow/dist/style.css'; import { AlertCircle, Bot, + Code2, Database, Flag, GitBranch, @@ -64,10 +65,20 @@ import { getToolDisplayName } from '../../utils/toolUtils'; import AgentPageHeader from '../AgentPageHeader'; import { Agent } from '../types'; import { ConditionCase, WorkflowNode } from '../types/workflow'; +import { + createDefaultCodeConfig, + normalizeCodeConfig, + parseCodeInputs, + parseCodeJsonSchemaDraft, + serializeCodeConfig, + stringifyCodeInputs, + validateCodeJsonSchema, +} from './codeNodeConfig'; import MobileBlocker from './components/MobileBlocker'; import PromptTextArea from './components/PromptTextArea'; import { AgentNode, + CodeNode, ConditionNode, EndNode, NoteNode, @@ -283,15 +294,18 @@ function createWorkflowPayload( | 'agent' | 'note' | 'state' - | 'condition', + | 'condition' + | 'code', title: node.data.title || node.data.label || node.type, position: node.position, data: - node.type === 'agent' || - node.type === 'condition' || - node.type === 'state' - ? node.data.config - : node.data, + node.type === 'code' + ? serializeCodeConfig(node.data.config) + : node.type === 'agent' || + node.type === 'condition' || + node.type === 'state' + ? node.data.config + : node.data, })), edges: workflowEdges.map((edge) => ({ id: edge.id, @@ -310,6 +324,7 @@ const NODE_TYPES: NodeTypes = { note: NoteNode, state: SetStateNode, condition: ConditionNode, + code: CodeNode, }; function WorkflowBuilderInner() { @@ -515,6 +530,10 @@ function WorkflowBuilderInner() { mode: 'simple', cases: [{ name: '', expression: '', sourceHandle: 'case_0' }], }; + } else if (type === 'code') { + baseNode.data.title = 'Code'; + baseNode.data.label = 'Code'; + baseNode.data.config = createDefaultCodeConfig(); } else if (type === 'note') { baseNode.data.title = 'Note'; baseNode.data.label = 'Note'; @@ -615,6 +634,27 @@ function WorkflowBuilderInner() { [handleUpdateNodeData, selectedNode], ); + const handleCodeJsonSchemaChange = useCallback( + (text: string) => { + if (!selectedNode || selectedNode.type !== 'code') return; + + const nodeId = selectedNode.id; + setAgentJsonSchemaDrafts((prev) => ({ ...prev, [nodeId]: text })); + + const { schema, error } = parseCodeJsonSchemaDraft(text); + setAgentJsonSchemaErrors((prev) => ({ ...prev, [nodeId]: error })); + if (!error) { + handleUpdateNodeData({ + config: { + ...(selectedNode.data.config || {}), + json_schema: schema, + }, + }); + } + }, + [handleUpdateNodeData, selectedNode], + ); + const handleUpload = useCallback((files: File[]) => { if (files && files.length > 0) { setImageFile(files[0]); @@ -781,6 +821,29 @@ function WorkflowBuilderInner() { }); }, [selectedNode]); + useEffect(() => { + if (!selectedNode || selectedNode.type !== 'code') return; + const nodeId = selectedNode.id; + const rawSchema = selectedNode.data.config?.json_schema; + + setAgentJsonSchemaDrafts((prev) => { + if (prev[nodeId] !== undefined) return prev; + if (rawSchema === undefined || rawSchema === null) { + return { ...prev, [nodeId]: '' }; + } + try { + return { ...prev, [nodeId]: JSON.stringify(rawSchema, null, 2) }; + } catch { + return { ...prev, [nodeId]: String(rawSchema) }; + } + }); + + setAgentJsonSchemaErrors((prev) => { + if (prev[nodeId] !== undefined) return prev; + return { ...prev, [nodeId]: validateCodeJsonSchema(rawSchema) }; + }); + }, [selectedNode]); + useEffect(() => { const loadAgentDetails = async () => { if (!agentId) return; @@ -834,6 +897,10 @@ function WorkflowBuilderInner() { }; } else if (n.type === 'state' && n.data) { nodeData.config = n.data; + } else if (n.type === 'code') { + nodeData.config = normalizeCodeConfig( + n.data as Record | undefined, + ); } else if (n.data) { Object.assign(nodeData, n.data); } @@ -1089,6 +1156,27 @@ function WorkflowBuilderInner() { }); }); + const codeNodes = nodes.filter((n) => n.type === 'code'); + codeNodes.forEach((node) => { + const codeTitle = node.data?.title || node.id; + const config = node.data?.config; + if (!(config?.code || '').trim()) { + errors.push(`Code node "${codeTitle}" must have code to run`); + } + + const schemaValidationError = validateCodeJsonSchema(config?.json_schema); + const draftSchemaError = agentJsonSchemaErrors[node.id]; + const effectiveSchemaError = + draftSchemaError !== undefined + ? draftSchemaError + : schemaValidationError; + if (effectiveSchemaError) { + errors.push( + `Code node "${codeTitle}" JSON schema ${effectiveSchemaError}`, + ); + } + }); + return errors; }, [workflowName, nodes, edges, agentJsonSchemaErrors, availableModels]); @@ -1367,6 +1455,31 @@ function WorkflowBuilderInner() { return selectedModel.supports_structured_output; }, [selectedNode, availableModels]); + const selectedCodeJsonSchemaText = useMemo(() => { + if (!selectedNode || selectedNode.type !== 'code') return ''; + + const draft = agentJsonSchemaDrafts[selectedNode.id]; + if (draft !== undefined) return draft; + + const schema = selectedNode.data.config?.json_schema; + if (schema === undefined || schema === null) return ''; + + try { + return JSON.stringify(schema, null, 2); + } catch { + return String(schema); + } + }, [selectedNode, agentJsonSchemaDrafts]); + + const selectedCodeJsonSchemaError = useMemo(() => { + if (!selectedNode || selectedNode.type !== 'code') return null; + + const cachedError = agentJsonSchemaErrors[selectedNode.id]; + if (cachedError !== undefined) return cachedError; + + return validateCodeJsonSchema(selectedNode.data.config?.json_schema); + }, [selectedNode, agentJsonSchemaErrors]); + return ( <> @@ -1724,6 +1837,23 @@ function WorkflowBuilderInner() { +
handleNodeDragStart(e, 'code')} + > +
+ +
+
+ + Code + + + Run code in a sandbox + +
+
@@ -1765,6 +1895,7 @@ function WorkflowBuilderInner() { {selectedNode.type === 'note' && 'Note'} {selectedNode.type === 'state' && 'Set global variables'} {selectedNode.type === 'condition' && 'If / Else'} + {selectedNode.type === 'code' && 'Code'}