Refactored decision node

This commit is contained in:
Laurie Voss
2025-07-20 13:02:53 -07:00
parent 3af0abacc7
commit a5762220d0
11 changed files with 517 additions and 291 deletions
+43
View File
@@ -0,0 +1,43 @@
---
description:
globs:
alwaysApply: true
---
# Llama Agent Creator Architecture
This document outlines the core architecture of the Llama Agent Creator application, focusing on how the visual workflow graph is translated into executable logic.
## Core Components and Data Flow
The application has a clear separation between the visual graph representation, an intermediate JSON format, and two distinct execution engines.
`Visual Graph (React Flow)` --> `workflow-compiler.ts` --> `WorkflowJson (Intermediate State)` --> `[RunView.tsx OR typescript-compiler.ts]`
1. **Visual Graph Editor (`src/components/AgentFlow.tsx`)**:
* This is where the user builds the agent workflow using a drag-and-drop interface powered by `@xyflow/react`.
* The state of the graph (nodes and edges) is saved to `localStorage`.
2. **Workflow Compiler (`src/lib/workflow-compiler.ts`)**:
* This is a critical module that acts as a bridge between the UI and the execution logic.
* It takes the raw nodes and edges from the React Flow state.
* It processes this graph structure and compiles it into a standardized intermediate representation, `WorkflowJson`. This JSON object describes the nodes, their connections (via an event-based system), and their configurations.
3. **Execution Engines**:
The `WorkflowJson` can be consumed by two different parts of the application to execute the workflow:
* **Interactive Runner (`src/components/RunView.tsx`)**:
* Provides an in-browser, step-by-step execution of the workflow.
* It loads the graph from `localStorage`, uses `workflow-compiler.ts` to get the `WorkflowJson`, and then walks through the nodes.
* It implements the logic for each node type (e.g., 'promptLLM', 'userInput', 'decision') directly, making API calls to the backend (`/api/...`) as needed.
* It visually highlights the currently active node on the graph and provides a chat interface for user interaction.
* This provides a way to debug and test the workflow interactively.
* **TypeScript Code Generator (`src/lib/typescript-compiler.ts`)**:
* Takes the same `WorkflowJson` and generates a standalone, executable TypeScript file.
* This generated script uses the `@llamaindex/workflow-core` library to define the workflow programmatically.
* The logic within this compiler *mirrors* the logic in `RunView.tsx`, but instead of executing nodes directly, it generates code that will execute them in a Node.js environment. For example, it generates LLM calls, tool definitions, and the event handling logic that connects the nodes.
* This allows the user to export their visually-designed agent as a portable script.
## Key Takeaway
When modifying or debugging, it's essential to understand this dual-execution model. Changes to the logic of a node type (e.g., how a 'decision' node works) often need to be implemented in **both** `RunView.tsx` (for interactive execution) and `typescript-compiler.ts` (for code generation) to ensure consistent behavior across both modes. The `workflow-compiler.ts` is central to ensuring both execution engines receive the same structured information from the UI.
+38 -43
View File
@@ -1,43 +1,38 @@
---
description:
globs:
alwaysApply: true
---
# Llama Agent Creator Architecture
This document outlines the core architecture of the Llama Agent Creator application, focusing on how the visual workflow graph is translated into executable logic.
## Core Components and Data Flow
The application has a clear separation between the visual graph representation, an intermediate JSON format, and two distinct execution engines.
`Visual Graph (React Flow)` --> `workflow-compiler.ts` --> `WorkflowJson (Intermediate State)` --> `[RunView.tsx OR typescript-compiler.ts]`
1. **Visual Graph Editor (`src/components/AgentFlow.tsx`)**:
* This is where the user builds the agent workflow using a drag-and-drop interface powered by `@xyflow/react`.
* The state of the graph (nodes and edges) is saved to `localStorage`.
2. **Workflow Compiler (`src/lib/workflow-compiler.ts`)**:
* This is a critical module that acts as a bridge between the UI and the execution logic.
* It takes the raw nodes and edges from the React Flow state.
* It processes this graph structure and compiles it into a standardized intermediate representation, `WorkflowJson`. This JSON object describes the nodes, their connections (via an event-based system), and their configurations.
3. **Execution Engines**:
The `WorkflowJson` can be consumed by two different parts of the application to execute the workflow:
* **Interactive Runner (`src/components/RunView.tsx`)**:
* Provides an in-browser, step-by-step execution of the workflow.
* It loads the graph from `localStorage`, uses `workflow-compiler.ts` to get the `WorkflowJson`, and then walks through the nodes.
* It implements the logic for each node type (e.g., 'promptLLM', 'userInput', 'decision') directly, making API calls to the backend (`/api/...`) as needed.
* It visually highlights the currently active node on the graph and provides a chat interface for user interaction.
* This provides a way to debug and test the workflow interactively.
* **TypeScript Code Generator (`src/lib/typescript-compiler.ts`)**:
* Takes the same `WorkflowJson` and generates a standalone, executable TypeScript file.
* This generated script uses the `@llamaindex/workflow-core` library to define the workflow programmatically.
* The logic within this compiler *mirrors* the logic in `RunView.tsx`, but instead of executing nodes directly, it generates code that will execute them in a Node.js environment. For example, it generates LLM calls, tool definitions, and the event handling logic that connects the nodes.
* This allows the user to export their visually-designed agent as a portable script.
## Key Takeaway
When modifying or debugging, it's essential to understand this dual-execution model. Changes to the logic of a node type (e.g., how a 'decision' node works) often need to be implemented in **both** `RunView.tsx` (for interactive execution) and `typescript-compiler.ts` (for code generation) to ensure consistent behavior across both modes. The `workflow-compiler.ts` is central to ensuring both execution engines receive the same structured information from the UI.
---
description:
globs:
alwaysApply: true
---
# Llama Agent Creator: Technical Details Supplement
This document supplements the main `architecture.mdc` file by providing specific details about the technologies and implementation of the Llama Agent Creator.
## Application Stack
- **Framework**: [Next.js](mdc:https:/nextjs.org)
- **Language**: [TypeScript](mdc:https:/www.typescriptlang.org)
- **UI Library**: [React](mdc:https:/react.dev) with [shadcn/ui](mdc:https:/ui.shadcn.com) components.
- **Graph Visualization**: [`@xyflow/react`](mdc:https:/reactflow.dev) is used to build the interactive workflow editor in `AgentFlow.tsx`.
- **State & Storage**:
- The visual graph's state (nodes and edges) is persisted to `localStorage`.
- Global chat state is managed via React Context in `src/components/ChatProvider.tsx`.
- **Core Workflow Engine**: [`@llamaindex/workflow-core`](mdc:https:/www.npmjs.com/package/@llamaindex/workflow-core) provides the foundational classes and logic for creating and running workflows in the generated TypeScript code.
- **LLM Integrations**: The application uses `@llamaindex/openai`, `@llamaindex/anthropic`, and `@llamaindex/google` to connect to various language models.
- **Testing**: The project is set up with [Vitest](mdc:https:/vitest.dev) for testing.
## File-Specific Details
This section clarifies the roles of key files and directories not fully detailed in the high-level architecture diagram.
- `app/api/`: This directory contains the backend serverless functions that the interactive runner (`RunView.tsx`) calls. For instance, when a `promptLLM` node is executed in the UI, it makes a POST request to `app/api/llm/call/route.ts` to get the model's response.
- `src/components/nodes/`: This directory contains the React components for each type of node that can be dragged onto the canvas (e.g., `PromptLLMNode.tsx`, `DecisionNode.tsx`, `UserInputNode.tsx`). These components define the appearance and configuration options of each node in the editor.
- `src/components/CompileView.tsx`: This component provides the user interface for triggering the TypeScript compilation and viewing/downloading the generated code.
- `src/lib/llm-utils.ts`: Contains utility functions for configuring and preparing the LLM providers, abstracting some of the setup required for making LLM calls.
- `src/lib/initial-graph.ts`: Defines the default set of nodes and edges that are loaded when a user first visits the application or clears the canvas.
+94
View File
@@ -0,0 +1,94 @@
---
description:
globs:
alwaysApply: true
---
# Understanding @llamaindex/workflow-core (TypeScript)
This document explains the core concepts of the `@llamaindex/workflow-core` library for TypeScript, which is used by `typescript-compiler.ts` to generate executable agent scripts. The library uses a functional, event-driven paradigm.
## Core Concepts
The workflow is built by defining events and then creating handlers that listen for those events. The execution flow is determined by which events are emitted from handlers.
1. **Event-Driven Flow**: The entire workflow is orchestrated by events. A handler function executes when it receives an event of a type it's registered to handle. After executing its logic, the handler can emit a new event, which in turn triggers another handler.
2. **Functional Approach**: Instead of class-based inheritance (like in Python), the TypeScript version uses factory functions to build the workflow.
- `workflowEvent<T>()`: Creates a new type of event.
- `createWorkflow()`: Creates a new workflow instance.
- `workflow.handle([...events], handlerFn)`: Registers a function `handlerFn` to be called when any of the specified `events` are detected.
3. **Asynchronous by Design**: All handlers can be `async` and are expected to return `Promise`s, making them suitable for I/O-bound tasks like LLM calls and tool interactions.
## Key Components
### 1. `workflowEvent`
This function is used to define the different types of events that will flow through your system. They are the data carriers.
```typescript
import { workflowEvent } from "@llamaindex/workflow-core";
// Define an event that carries a string payload
const startEvent = workflowEvent<string>();
// Define an event that carries a structured object
interface ToolCall {
id: string;
name: string;
args: any;
}
const toolCallEvent = workflowEvent<ToolCall>();
```
### 2. `createWorkflow` and `.handle`
`createWorkflow()` creates the main workflow object. The `.handle()` method is then used to register listeners for specific events. The return value of a handler function becomes a new event that is dispatched into the workflow.
```typescript
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
const workflow = createWorkflow();
const startEvent = workflowEvent<string>();
const stopEvent = workflowEvent<string>();
// Register a handler for 'startEvent'
workflow.handle([startEvent], async (event) => {
console.log(`Received start event with: ${event.data}`);
// The return value is automatically dispatched as a new event
return stopEvent("Workflow finished!");
});
// Register a handler for 'stopEvent'
workflow.handle([stopEvent], (event) => {
console.log(event.data); // "Workflow finished!"
});
```
### 3. `Context`
For more complex workflows, you can access the workflow's context within a handler using `getContext()`. This provides more advanced capabilities.
```typescript
import { getContext } from "@llamaindex/workflow-core";
workflow.handle([someEvent], (event) => {
const { sendEvent, stream, signal } = getContext();
// ...
});
```
- **`sendEvent(event)`**: Manually dispatches an event. This is the key to creating "fan-out" patterns where one handler can trigger multiple downstream handlers in parallel.
- **`stream`**: An async iterable representing the stream of all events in the workflow. It has powerful utility methods like `.filter()`, `.until()`, and `.toArray()` to manage complex flows, like waiting for the results of parallel tasks.
- **`signal`**: An `AbortSignal` that can be used to manage cancellation of long-running async tasks if the workflow is aborted.
## Control Flow Patterns
The event-driven architecture enables flexible control flow.
- **Branching**: A handler can implement conditional logic and return different event types. The workflow will then proceed down the path corresponding to the event that was returned.
- **Parallel Execution (Fan-out)**: A handler can call `sendEvent()` multiple times to emit several events simultaneously, triggering multiple downstream handlers to run in parallel.
- **Joining/Aggregation**: The `stream` object in the context is used to collect results from parallel tasks. You can use `stream.filter(...).until(...).toArray()` to wait for a specific number of events to complete before proceeding.
- **Middleware**: The library supports middleware to add functionality like state management (`createStatefulMiddleware`) or validation (`withValidation`) to your workflows.
This functional and event-driven approach provides a powerful and flexible way to define complex agentic logic that can be compiled into a standalone, executable script. For more details, you can refer to the official documentation at [https://ts.llamaindex.ai/docs/workflows](mdc:https:/ts.llamaindex.ai/docs/workflows).
+35 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getLlm } from "@/lib/llm-utils";
import { MessageContent } from "@llamaindex/core/llms";
export async function POST(req: NextRequest) {
try {
@@ -12,8 +13,41 @@ export async function POST(req: NextRequest) {
const llm = getLlm(settings, node);
let finalInput = typeof input === 'string' ? input : JSON.stringify(input);
if (node.type === 'decision' && node.data.question) {
finalInput = `Given the following information, answer the question with only "true" or "false".
Information:
${finalInput}
Question: ${node.data.question}`;
const response = await llm.chat({
messages: [{ role: "user", content: finalInput }]
});
const content: MessageContent = response.message.content;
let textContent: string | null = null;
if (typeof content === 'string') {
textContent = content;
} else if (Array.isArray(content)) {
const textBlock = content.find(c => c.type === 'text');
if (textBlock && 'text' in textBlock) {
textContent = textBlock.text;
}
}
const result = textContent?.toLowerCase().trim();
return NextResponse.json({ output: result === 'true' });
}
if (node.data.promptPrefix) {
finalInput = `${node.data.promptPrefix}\n\n${finalInput}`;
}
const response = await llm.chat({
messages: [{ role: "user", content: typeof input === 'string' ? input : JSON.stringify(input) }]
messages: [{ role: "user", content: finalInput }]
});
return NextResponse.json({ output: response.message.content });
+26 -8
View File
@@ -230,17 +230,35 @@ All colors MUST be HSL.
}
.node-decision {
border-color: hsl(var(--node-decision));
background: hsl(var(--card));
transform: rotate(45deg);
width: 100px !important;
height: 100px !important;
min-width: 100px !important;
min-height: 100px !important;
background: transparent;
border: none;
box-shadow: none;
width: 200px;
height: 200px;
}
.glow-path {
filter: drop-shadow(0 0 5px hsl(var(--primary-glow)));
}
.node-decision .node-content {
transform: rotate(-45deg);
padding: 20px;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.node-decision .node-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.node-decision .textarea {
min-height: 60px;
}
/* Handle Styles */
+14 -2
View File
@@ -1,5 +1,5 @@
import '@xyflow/react/dist/style.css';
import { useCallback, useRef, useState, useEffect, Dispatch, SetStateAction } from 'react';
import { useCallback, useRef, useState, useEffect, Dispatch, SetStateAction, useMemo } from 'react';
import {
ReactFlow,
Background,
@@ -16,6 +16,7 @@ import {
ReactFlowProvider,
OnNodesChange,
OnEdgesChange,
NodeProps,
} from '@xyflow/react';
// Import custom nodes
@@ -106,11 +107,22 @@ const AgentFlowInner = ({ nodes, edges, onNodesChange, onEdgesChange, setNodes,
const onConnect = useCallback(
(params: Connection) => {
const sourceNode = nodes.find(node => node.id === params.source);
let label;
if (sourceNode?.type === 'decision') {
if (params.sourceHandle === 'true') {
label = 'True';
} else if (params.sourceHandle === 'false') {
label = 'False';
}
}
const newEdge: Edge = {
...params,
id: `edge-${params.source}-${params.target}`,
type: 'default',
animated: false,
label,
style: {
strokeWidth: 2,
stroke: 'hsl(var(--muted-foreground))'
@@ -121,7 +133,7 @@ const AgentFlowInner = ({ nodes, edges, onNodesChange, onEdgesChange, setNodes,
};
setEdges((eds) => addEdge(newEdge, eds));
},
[setEdges]
[setEdges, nodes]
);
const onDragOver = useCallback((event: React.DragEvent) => {
+14 -29
View File
@@ -387,13 +387,16 @@ const RunViewInner = () => {
return;
}
// Pass the input along to the next node
setWorkflowState((prevState) => ({ ...prevState, [nodeId]: input }));
const thinkingMessageId = Math.random().toString();
setMessages((prev) => [
...prev,
{
id: thinkingMessageId,
role: 'assistant',
content: `Thinking with ${node.data.label}...`,
content: `Evaluating: ${node.data.question}...`,
},
]);
@@ -401,19 +404,7 @@ const RunViewInner = () => {
method: 'POST',
body: JSON.stringify({
input: input,
node: {
...node,
data: {
...node.data,
prompt: `Given the input, which of the following paths should be taken? The choices are: ${Object.keys(
node.data.choices,
).join(', ')}. Please respond with only the name of the path.
Input:
${input}
`,
},
},
node: node,
settings: loadSavedSettings(),
}),
headers: {
@@ -427,8 +418,7 @@ ${input}
);
}
const { output: llmOutput } = await llmResponse.json();
const decision = getMessageContent(llmOutput).trim();
const { output: decision } = await llmResponse.json();
setMessages((prev) =>
prev.map((msg) =>
@@ -439,20 +429,15 @@ ${input}
);
// Find the output handle that matches the decision
const choiceHandleId = (node.data.choices as any)[decision];
if (choiceHandleId) {
const nextDecisionNode = workflow.nodes.find(
(n) => n.accepts === choiceHandleId,
);
if (nextDecisionNode) {
nextNodeId = nextDecisionNode.id.replace('node-', '');
} else {
setExecutionStatus('finished');
}
const handleId = decision ? 'true' : 'false';
const nextEdge = workflow.nodes.find(
(n) => n.accepts === (node.emits as any)[handleId],
);
if (nextEdge) {
nextNodeId = nextEdge.id.replace('node-', '');
} else {
setError(`No path found for decision: ${decision}`);
setExecutionStatus('error');
return;
setExecutionStatus('finished');
}
break;
}
+73 -22
View File
@@ -1,32 +1,93 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { memo, useCallback } from 'react';
import { Handle, Position, useReactFlow } from '@xyflow/react';
import { GitBranch } from 'lucide-react';
import { Textarea } from '../ui/textarea';
interface DecisionNodeProps {
id: string;
data: {
label?: string;
condition?: string;
question?: string;
};
selected?: boolean;
}
const DecisionNode = memo(({ data, selected }: DecisionNodeProps) => {
const DecisionNode = memo(({ id, data, selected }: DecisionNodeProps) => {
const { setNodes } = useReactFlow();
const size = 240;
const halfSize = size / 2;
const strokeWidth = 2;
const path = `M ${halfSize},${strokeWidth} L ${size - strokeWidth},${halfSize} L ${halfSize},${size - strokeWidth} L ${strokeWidth},${halfSize} Z`;
const onChange = useCallback(
(evt: React.ChangeEvent<HTMLTextAreaElement>) => {
const newQuestion = evt.target.value;
setNodes((nds) =>
nds.map((node) => {
if (node.id === id) {
return {
...node,
data: {
...node.data,
question: newQuestion,
},
};
}
return node;
}),
);
},
[id, setNodes],
);
return (
<div className={`agent-node node-decision ${selected ? 'selected' : ''}`}>
<div className="node-content flex flex-col items-center justify-center text-foreground text-center">
<GitBranch className="w-5 h-5 mb-2" />
<span className="text-sm font-medium">{data.label || 'Decision'}</span>
<div
className={`relative ${selected ? 'selected' : ''}`}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="absolute"
>
<path
d={path}
fill="hsl(var(--card))"
stroke="hsl(var(--node-decision))"
strokeWidth={strokeWidth}
/>
{selected && (
<path
d={path}
fill="none"
stroke="hsl(var(--primary))"
strokeWidth={strokeWidth * 2}
className="glow-path"
/>
)}
</svg>
<div className="relative flex flex-col items-center justify-center h-full text-foreground p-5">
<div className="flex items-center mb-2">
<GitBranch className="w-5 h-5 mr-2" />
<span className="text-sm font-medium">{data.label || 'Decision'}</span>
</div>
<Textarea
className="nodrag nowheel text-sm text-center bg-transparent border-none focus:outline-none"
value={data.question || ''}
onChange={onChange}
placeholder="Condition..."
data-id="question"
style={{ resize: 'none' }}
/>
</div>
<Handle
type="target"
position={Position.Left}
position={Position.Top}
id="input"
style={{
width: '16px',
height: '16px',
left: '0%',
top: '50%',
transform: 'translate(-50%, -50%)',
border: '3px solid hsl(var(--primary))',
backgroundColor: 'hsl(var(--card))',
}}
@@ -38,11 +99,6 @@ const DecisionNode = memo(({ data, selected }: DecisionNodeProps) => {
style={{
width: '12px',
height: '12px',
right: '0%',
top: '50%',
transform: 'translate(50%, -50%)',
border: '2px solid hsl(var(--primary))',
backgroundColor: 'hsl(var(--primary))',
}}
/>
<Handle
@@ -52,11 +108,6 @@ const DecisionNode = memo(({ data, selected }: DecisionNodeProps) => {
style={{
width: '12px',
height: '12px',
left: '50%',
bottom: '0%',
transform: 'translate(-50%, 50%)',
border: '2px solid hsl(var(--primary))',
backgroundColor: 'hsl(var(--primary))',
}}
/>
</div>
+32
View File
@@ -6,16 +6,48 @@ interface PromptLLMNodeProps {
id: string;
data: {
label?: string;
promptPrefix?: string;
};
selected?: boolean;
}
const PromptLLMNode = memo(({ id, data, selected }: PromptLLMNodeProps) => {
const { setNodes } = useReactFlow();
const [prefix, setPrefix] = useState(data.promptPrefix || '');
const onPrefixChange = useCallback(
(evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrefix(evt.target.value);
},
[],
);
useEffect(() => {
setNodes(nds =>
nds.map(node => {
if (node.id === id) {
node.data = {
...node.data,
promptPrefix: prefix,
};
}
return node;
}),
);
}, [id, prefix, setNodes]);
return (
<div className={`agent-node node-llm ${selected ? 'selected' : ''}`}>
<div className="node-content flex flex-col items-center justify-center text-foreground p-4 min-w-[180px]">
<Brain className="w-5 h-5 mb-2" />
<span className="text-sm font-medium mb-2">{data.label || 'Prompt LLM'}</span>
<label className="text-xs font-medium mt-2">Prompt Prefix</label>
<textarea
value={prefix}
onChange={onPrefixChange}
className="nodrag nowheel w-full text-xs p-1 mt-1 rounded-md bg-background border border-border"
rows={3}
/>
</div>
<Handle
type="target"
+113 -53
View File
@@ -1,5 +1,7 @@
import { getLlmModelName } from "./llm-utils";
import { WorkflowJson, WorkflowNodeJson } from "./workflow-compiler";
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
import { MessageContent } from "@llamaindex/core/llms";
const toCamelCase = (str: string): string => {
if (!str) return '';
@@ -312,79 +314,137 @@ workflow.handle([${startEventName}], async (ctx) => {
});
`);
} else if (node.type === 'userInput') {
const incomingEvent = getEventName(node.accepts as string);
const incomingEvents = Array.isArray(node.accepts) ? node.accepts.map(getEventName) : [getEventName(node.accepts as string)];
const outgoingEvent = getEventName(node.emits as string);
const promptText = node.data.prompt || 'Please provide input:';
handlerLines.push(`
workflow.handle([${incomingEvent}], async () => {
for (const event of incomingEvents) {
handlerLines.push(`
workflow.handle([${event}], async () => {
return need_input_for_${outgoingEvent}.with("${promptText}");
});
`);
} else if (node.type === 'promptAgent') {
const incomingEvent = getEventName(node.accepts as string);
const outgoingEvent = getEventName(node.emits as string);
const toolNames = (node.data.tools || []).map((tool: any) => toCamelCase(tool.name));
const modelName = getLlmModelName(json.settings, { data: node.data });
const llmVarName = toCamelCase(modelName);
const systemPrompt = node.data.prompt;
}
} else if (node.type === "splitter") {
const outgoingEvent = getEventName(node.emits as string);
handlerLines.push(
`// Splitter node ${node.id} - fans out to multiple branches`,
);
} else if (node.type === "collector") {
// Collector nodes are implicitly handled by having multiple events
// trigger the same handler. The handler will receive an array of results.
handlerLines.push(`// Collector node ${node.id} - logic is in the handler`);
} else if (node.type === "promptAgent") {
const llmIdentifier = toCamelCase(getLlmModelName(json.settings, node));
const toolVars = (node.data.tools || []).map((tool: any) =>
toCamelCase(
tool.name || `search_index_${tool.id.replace(/-/g, "_")}`,
),
);
const systemPrompt = node.data.prompt
? `\`${node.data.prompt}\``
: "undefined";
const outgoingEvent = getEventName(node.emits as string);
const agentVar = toCamelCase(`${node.id}Agent`);
const agentProperties = [
`llm: ${llmVarName}`,
`tools: [${toolNames.join(", ")}]`,
];
handlerLines.push(`
const ${agentVar} = agent({
llm: ${llmIdentifier},
tools: [${toolVars.join(", ")}],
systemPrompt: ${systemPrompt},
});`);
if (systemPrompt) {
agentProperties.push(`systemPrompt: ${JSON.stringify(systemPrompt)}`);
}
handlerLines.push(`
workflow.handle([${incomingEvent}], async (ctx) => {
const configuredAgent = agent({
${agentProperties.join(",\n ")}
});
const result = await configuredAgent.run(ctx.data);
const incomingEvents = Array.isArray(node.accepts) ? node.accepts.map(getEventName) : [getEventName(node.accepts as string)];
for (const event of incomingEvents) {
handlerLines.push(`
workflow.handle([${event}], async (ctx) => {
console.log("Executing node ${node.id}");
const result = await ${agentVar}.run(ctx.data);
return ${outgoingEvent}.with(result.data.result);
});
`);
} else if (node.type === 'stop') {
const incomingEvent = getEventName(node.accepts as string);
handlerLines.push(`
workflow.handle([${incomingEvent}], async (ctx) => {
return stopEvent.with(ctx.data);
});
`);
});`);
}
} else if (node.type === "promptLLM") {
const llmIdentifier = toCamelCase(getLlmModelName(json.settings, node));
const inputVar = toCamelCase(node.id);
const promptPrefix = node.data.promptPrefix
? `\`${node.data.promptPrefix}\\n\\n\${JSON.stringify(${inputVar})}\``
: `JSON.stringify(${inputVar})`;
const outgoingEvent = getEventName(node.emits as string);
const incomingEvents = Array.isArray(node.accepts) ? node.accepts.map(getEventName) : [getEventName(node.accepts as string)];
for (const event of incomingEvents) {
handlerLines.push(`
workflow.handle([${event}], async ( ${inputVar} ) => {
console.log("Executing node ${node.id}");
const response = await ${llmIdentifier}.chat({
messages: [{ role: "user", content: ${promptPrefix} }]
});
const content: MessageContent = response.message.content;
let textContent: string | null = null;
if (typeof content === 'string') {
textContent = content;
} else if (Array.isArray(content)) {
const textBlock = content.find(c => c.type === 'text');
if (textBlock && 'text' in textBlock) {
textContent = textBlock.text;
}
}
return ${outgoingEvent}.with(textContent);
});`);
}
} else if (node.type === "stop") {
const incomingEvents = Array.isArray(node.accepts) ? node.accepts.map(getEventName) : [getEventName(node.accepts as string)];
for (const event of incomingEvents) {
handlerLines.push(
`workflow.handle([${event}], async (result) => {
console.log("Workflow finished with result:", result);
return stopEvent.with(result.data);
});`,
);
}
} else if (node.type === 'decision') {
const trueEvent = (node.emits as { [key: string]: string })?.true;
const falseEvent = (node.emits as { [key: string]: string })?.false;
const trueEventName = getEventName((node.emits as { [key: string]: string })?.true);
const falseEventName = getEventName((node.emits as { [key: string]: string })?.false);
const question = node.data.question;
const llmVar = toCamelCase(getLlmModelName(json.settings, { data: node.data }));
const incomingEvents = Array.isArray(node.accepts) ? node.accepts.map(getEventName) : [getEventName(node.accepts as string)];
for (const event of incomingEvents) {
handlerLines.push(`
workflow.handle([${event}], async (ctx) => {
console.log("Evaluating question: ${question}");
const llm = ${llmVar};
const response = await llm.chat({
messages: [{ role: "user", content: \`Given the following information, answer the question with only "true" or "false".
let trueTarget, falseTarget;
Information:
\${JSON.stringify(ctx.data)}
if (trueEvent) {
trueTarget = json.nodes.find(n => n.accepts === trueEvent);
}
if (falseEvent) {
falseTarget = json.nodes.find(n => n.accepts === falseEvent);
}
handlerLines.push(`
workflow.handle([${getEventName(node.accepts as string)}], async (ctx) => {
console.log("Executing condition:", \`${node.data.condition}\`);
const llm = ${toCamelCase(getLlmModelName(json.settings, node))};
const response = await llm.complete({
prompt: \`You are a decision-making AI. Based on the following context, is the condition true or false? Condition: ${node.data.condition}. Context: \${JSON.stringify(ctx.data)}\`,
Question: ${question}\`}]
});
const conditionResult = response.text.toLowerCase().includes('true');
const content: MessageContent = response.message.content;
let textContent: string | null = null;
if (typeof content === 'string') {
textContent = content;
} else if (Array.isArray(content)) {
const textBlock = content.find(c => c.type === 'text');
if (textBlock && 'text' in textBlock) {
textContent = textBlock.text;
}
}
const result = textContent?.toLowerCase().trim();
const conditionResult = result === 'true';
console.log("Condition result:", conditionResult);
if (conditionResult) {
${trueTarget ? `await workflow.emit("${trueEvent}", { ...ctx.data });` : ''}
return ${trueEventName}.with(ctx.data);
} else {
${falseTarget ? `await workflow.emit("${falseEvent}", { ...ctx.data });` : ''}
return ${falseEventName}.with(ctx.data);
}
});
`);
}
}
}
return handlerLines.join("\n");
+35 -133
View File
@@ -14,72 +14,6 @@ export interface WorkflowJson {
settings: any;
}
function buildNodeJson(node: Node, allNodes: Node[], allEdges: Edge[], processedNodeIds: Set<string>): any {
if (!node || processedNodeIds.has(node.id)) {
return null;
}
processedNodeIds.add(node.id);
const outgoingEdges = allEdges.filter(edge => edge.source === node.id);
const nodeJson: any = {
id: `node-${node.id}`,
type: node.type,
};
if (node.type === 'promptAgent') {
nodeJson.tools = [];
const toolEdges = outgoingEdges.filter(edge => {
const targetNode = allNodes.find(n => n.id === edge.target);
return targetNode && targetNode.type === 'agentTool';
});
for (const edge of toolEdges) {
const toolNode = allNodes.find(n => n.id === edge.target);
if (toolNode) {
// Recursively build the tool node JSON, but don't add it to the main processed list yet
const toolJson = buildNodeJson(toolNode, allNodes, allEdges, new Set());
if (toolJson) {
nodeJson.tools.push(toolJson);
// Mark the tool node as processed so it's not added to the top level
processedNodeIds.add(toolNode.id);
}
}
}
}
// Find the primary outgoing edge that is NOT a tool connection for promptAgent
let primaryOutgoingEdge;
if (node.type === 'promptAgent') {
primaryOutgoingEdge = outgoingEdges.find(edge => {
const targetNode = allNodes.find(n => n.id === edge.target);
return targetNode && targetNode.type !== 'agentTool';
});
} else {
primaryOutgoingEdge = outgoingEdges[0];
}
if (primaryOutgoingEdge) {
const nextNode = allNodes.find(node => node.id === primaryOutgoingEdge.target);
if (nextNode) {
const nextNodeJson = buildNodeJson(nextNode, allNodes, allEdges, processedNodeIds);
if (nextNodeJson) {
nodeJson.next = nextNodeJson;
// Simplified event-based connection for top-level nodes
nodeJson.emits = `event-${primaryOutgoingEdge.id}`;
nextNodeJson.accepts = `event-${primaryOutgoingEdge.id}`;
}
}
}
// Remove label from the final JSON
if (node.data && 'label' in node.data) {
// keeping other data properties if they exist
}
return nodeJson;
}
export function compileWorkflow(nodes: Node[], edges: Edge[]): any {
let settings = {};
if (typeof window !== "undefined") {
@@ -98,43 +32,28 @@ export function compileWorkflow(nodes: Node[], edges: Edge[]): any {
return { error: "No start node found" };
}
const processedNodeIds = new Set<string>();
const allNodeJsons: any[] = [];
const allReachableNodes = new Set<Node>();
const queue: Node[] = [startNode];
const visitedNodeIds = new Set<string>([startNode.id]);
// First pass: handle promptAgent and its tools
nodes.forEach(node => {
if (node.type === 'promptAgent') {
const nodeJson = buildNodeJson(node, nodes, edges, processedNodeIds);
if (nodeJson) {
allNodeJsons.push(nodeJson);
while (queue.length > 0) {
const currentNode = queue.shift()!;
allReachableNodes.add(currentNode);
const outgoingEdges = edges.filter(edge => edge.source === currentNode.id);
for (const edge of outgoingEdges) {
const targetNode = nodes.find(n => n.id === edge.target);
if (targetNode && !visitedNodeIds.has(targetNode.id)) {
visitedNodeIds.add(targetNode.id);
queue.push(targetNode);
}
}
}
});
}
const allProcessedNodes = Array.from(allReachableNodes);
// Second pass: handle all other nodes that haven't been processed
nodes.forEach(node => {
if (!processedNodeIds.has(node.id)) {
const nodeJson = buildNodeJson(node, nodes, edges, processedNodeIds);
if (nodeJson) {
allNodeJsons.push(nodeJson);
}
}
});
// The final JSON should be a flat list of nodes, where tools are nested.
// We need to rebuild the structure from the startNode to get the correct order and nesting.
processedNodeIds.clear();
const finalJson = buildNodeJson(startNode, nodes, edges, processedNodeIds);
// After building the main path, we need to collect all nodes that were processed.
const allProcessedNodes = nodes.filter(n => processedNodeIds.has(n.id));
// Now, we create the flat list for the output, but the nesting logic is handled inside buildNodeJson
// Now, we create the flat list for the output
const resultNodes = allProcessedNodes.map(node => {
// Re-running buildNodeJson without recursion, just to get the final structure for each node.
// This is not efficient and needs a better approach.
const tempProcessedIds = new Set<string>();
// We can't just call buildNodeJson again. We need a new function to format the output.
const nodeJson: any = {
id: `node-${node.id}`,
@@ -186,7 +105,14 @@ export function compileWorkflow(nodes: Node[], edges: Edge[]): any {
nodeJson.data.prompt = node.data.prompt;
}
if (node.type === 'decision') {
if (node.type === 'promptLLM' && node.data && node.data.promptPrefix) {
nodeJson.data.promptPrefix = node.data.promptPrefix;
}
if (node.type === 'decision' && node.data) {
if (node.data.question) {
nodeJson.data.question = node.data.question;
}
const trueEdge = outgoingEdges.find(e => e.sourceHandle === 'true');
const falseEdge = outgoingEdges.find(e => e.sourceHandle === 'false');
const emits: { [key: string]: string } = {};
@@ -209,12 +135,17 @@ export function compileWorkflow(nodes: Node[], edges: Edge[]): any {
const incomingEdges = edges.filter(e => e.target === node.id);
if (incomingEdges.length > 0) {
const edge = incomingEdges.find(e => {
// This logic is getting complicated. Let's simplify.
// A node accepts an event from its incoming connection.
const relevantEdges = incomingEdges.filter(e => {
const source = nodes.find(n => n.id === e.source);
return !(source?.type === 'promptAgent' && nodeJson.type === 'agentTool');
});
if (edge) {
nodeJson.accepts = `event-${edge.id}`;
})
if (relevantEdges.length > 1) {
nodeJson.accepts = relevantEdges.map(e => `event-${e.id}`);
} else if (relevantEdges.length === 1) {
nodeJson.accepts = `event-${relevantEdges[0].id}`;
}
}
@@ -237,35 +168,6 @@ export function compileWorkflow(nodes: Node[], edges: Edge[]): any {
});
const finalNodes = resultNodes.filter(n => !toolNodeIds.has(n.id.replace('node-','')));
// Find the primary incoming edge for each node to set the 'accepts' property
finalNodes.forEach(nodeJson => {
const nodeId = nodeJson.id.replace('node-','');
const incomingEdge = edges.find(e => e.target === nodeId && nodes.find(n => n.id === e.source)?.type !== 'promptAgent' && nodes.find(n => n.id === e.target)?.type === 'agentTool');
const primaryIncomingEdge = edges.find(e => {
const sourceNode = nodes.find(n => n.id === e.source);
if (!sourceNode) return false;
// if the target is me, and the source is not a prompt agent sending me a tool connection
return e.target === nodeId && (sourceNode.type !== 'promptAgent' || nodes.find(n => n.id === e.target)?.type !== 'agentTool');
});
const incomingEdges = edges.filter(e => e.target === nodeId);
if (incomingEdges.length > 0) {
// This logic is getting complicated. Let's simplify.
// A node accepts an event from its incoming connection.
const edge = incomingEdges.find(e => {
const source = nodes.find(n => n.id === e.source);
return !(source?.type === 'promptAgent' && nodeJson.type === 'agentTool');
})
if (edge) {
nodeJson.accepts = `event-${edge.id}`;
}
}
if (nodeJson.type === 'start') {
delete nodeJson.accepts;
}
});
return { nodes: finalNodes, settings };
}