Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/agent-forge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
},
"dependencies": {
"@loushy/build-ai-agent": "file:../..",
"@xyflow/react": "^12.12.0",
"dagre": "^0.8.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"yaml": "^2.9.1"
},
"devDependencies": {
"@ai-sdk/anthropic": "^0.0.42",
"@ai-sdk/openai": "^0.0.42",
"@types/dagre": "^0.7.52",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
120 changes: 120 additions & 0 deletions apps/agent-forge/src/canvas/AgentNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { memo, useState } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { AgentGraphNode, AgentGraphNodeType } from '../graph/types';

/**
* Custom ReactFlow node renderer for every `AgentGraphNodeType`, styled to
* match `.design-ref/agent-forge-mockup.html`'s `.node` chrome: a colored
* type-swatch header, a body of key/value config rows read straight from
* the node's `AgentGraphSpec` data (nothing hardcoded per-node), and
* left/right ports drawn with ReactFlow's own `<Handle>` so pipeline
* connections attach to a real, draggable anchor rather than a picture of
* one.
*
* One component + a `type` field handles all five kinds rather than five
* near-duplicate components, since the only real differences are the
* swatch color/icon, which ports are shown, and which data rows to render.
*/

const NODE_META: Record<AgentGraphNodeType, { color: string; icon: string; hasIn: boolean; hasOut: boolean }> = {
trigger: { color: 'var(--info)', icon: '⚡', hasIn: false, hasOut: true },
llm: { color: 'var(--accent)', icon: '🧠', hasIn: true, hasOut: true },
tool: { color: 'var(--warning)', icon: '🔧', hasIn: true, hasOut: true },
approval: { color: 'var(--warning)', icon: '✓', hasIn: true, hasOut: true },
output: { color: 'var(--success)', icon: '→', hasIn: true, hasOut: false },
};

function rowsFor(node: AgentGraphNode): { k: string; v: string }[] {
switch (node.type) {
case 'trigger':
return [{ k: 'type', v: node.data.trigger.type || '(unset)' }];
case 'llm':
return [
{ k: 'provider', v: node.data.provider.type || '(unset)' },
{ k: 'model', v: node.data.provider.model || '(unset)' },
];
case 'tool':
return [{ k: 'tool', v: node.data.toolName || '(unset)' }];
case 'approval':
return [{ k: 'requiresApproval', v: node.data.policy.requiresApproval ? 'yes' : 'no' }];
case 'output':
return [];
}
}

export interface AgentNodeData extends Record<string, unknown> {
graphNode: AgentGraphNode;
onRename: (nodeId: string, label: string) => void;
}

function AgentNodeImpl({ data, selected }: NodeProps) {
const { graphNode, onRename } = data as unknown as AgentNodeData;
const meta = NODE_META[graphNode.type];
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(graphNode.label);

function commitRename() {
setEditing(false);
const trimmed = draft.trim();
if (trimmed && trimmed !== graphNode.label) onRename(graphNode.id, trimmed);
else setDraft(graphNode.label);
}

return (
<div className={`rf-node${selected ? ' selected' : ''}`} data-node-type={graphNode.type}>
{meta.hasIn && <Handle type="target" position={Position.Left} className="rf-port rf-port-in" />}
{meta.hasOut && <Handle type="source" position={Position.Right} className="rf-port rf-port-out" />}
<div className="rf-node-head" style={{ borderBottom: '1px solid var(--border)' }}>
<span className="node-swatch" style={{ background: meta.color, width: 16, height: 16, borderRadius: 5 }}>
{meta.icon}
</span>
{editing ? (
<input
className="input"
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') {
setDraft(graphNode.label);
setEditing(false);
}
}}
style={{ padding: '2px 6px', fontSize: 12 }}
/>
) : (
<span
className="rf-node-title"
onDoubleClick={() => {
setDraft(graphNode.label);
setEditing(true);
}}
title="Double-click to rename"
>
{graphNode.label}
</span>
)}
</div>
<div className="rf-node-body">
{rowsFor(graphNode).map((row) => (
<div className="node-row" key={row.k}>
<span>{row.k}</span>
<code>{row.v}</code>
</div>
))}
</div>
</div>
);
}

export const AgentNode = memo(AgentNodeImpl);

export const NODE_TYPES = {
trigger: AgentNode,
llm: AgentNode,
tool: AgentNode,
approval: AgentNode,
output: AgentNode,
};
181 changes: 181 additions & 0 deletions apps/agent-forge/src/canvas/__tests__/graphMutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import {
addNode,
connectNodes,
duplicateNode,
moveNode,
removeEdge,
removeNode,
renameNode,
updateNodeData,
} from '../graphMutations';
import type { AgentGraphSpec } from '../../graph/types';

function baseGraph(): AgentGraphSpec {
return {
version: 1,
nodes: [
{
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
label: 'Trigger',
data: { trigger: { type: 'input' } },
},
{
id: 'llm-1',
type: 'llm',
position: { x: 200, y: 0 },
label: 'agent',
data: { name: 'agent', prompt: 'hi', provider: { type: 'mock', model: 'mock-1' } },
},
{
id: 'tool-1',
type: 'tool',
position: { x: 400, y: 0 },
label: 'http',
data: { toolName: 'http' },
},
],
edges: [
{ id: 'e1', source: 'trigger-1', target: 'llm-1' },
{ id: 'e2', source: 'llm-1', target: 'tool-1' },
],
};
}

describe('addNode', () => {
it('appends a new node of the given type with default data at the given position', () => {
const g = addNode(baseGraph(), 'output', { x: 600, y: 0 });
expect(g.nodes).toHaveLength(4);
const added = g.nodes[3];
expect(added.type).toBe('output');
expect(added.position).toEqual({ x: 600, y: 0 });
expect(g.edges).toHaveLength(2); // adding a node never adds edges
});

it('does not mutate the input graph', () => {
const original = baseGraph();
const originalNodeCount = original.nodes.length;
addNode(original, 'output', { x: 0, y: 0 });
expect(original.nodes).toHaveLength(originalNodeCount);
});
});

describe('removeNode', () => {
it('removes the node and any edge touching it', () => {
const g = removeNode(baseGraph(), 'llm-1');
expect(g.nodes.map((n) => n.id)).toEqual(['trigger-1', 'tool-1']);
expect(g.edges).toEqual([]);
});

it('is a no-op for an unknown node id', () => {
const g = removeNode(baseGraph(), 'ghost');
expect(g.nodes).toHaveLength(3);
expect(g.edges).toHaveLength(2);
});
});

describe('duplicateNode', () => {
it('clones a node with a new id, offset position, no inherited edges', () => {
const g = duplicateNode(baseGraph(), 'tool-1');
expect(g.nodes).toHaveLength(4);
const clone = g.nodes[3];
expect(clone.id).not.toBe('tool-1');
expect(clone.type).toBe('tool');
expect(clone.position).toEqual({ x: 440, y: 40 });
expect(g.edges.some((e) => e.source === clone.id || e.target === clone.id)).toBe(false);
});

it('deep-clones data so editing the clone does not affect the source', () => {
const g = duplicateNode(baseGraph(), 'tool-1');
const clone = g.nodes[3];
expect(clone.type).toBe('tool');
if (clone.type === 'tool') {
expect(clone.data).not.toBe(baseGraph().nodes[2].data);
}
});

it('is a no-op for an unknown node id', () => {
const g = duplicateNode(baseGraph(), 'ghost');
expect(g.nodes).toHaveLength(3);
});
});

describe('renameNode', () => {
it('renames a node label', () => {
const g = renameNode(baseGraph(), 'tool-1', 'HTTP call');
expect(g.nodes.find((n) => n.id === 'tool-1')?.label).toBe('HTTP call');
});

it('also updates data.name for an llm node', () => {
const g = renameNode(baseGraph(), 'llm-1', 'renamed-agent');
const llm = g.nodes.find((n) => n.id === 'llm-1');
expect(llm?.label).toBe('renamed-agent');
expect(llm?.type === 'llm' && llm.data.name).toBe('renamed-agent');
});
});

describe('moveNode', () => {
it('updates a node position', () => {
const g = moveNode(baseGraph(), 'tool-1', { x: 999, y: 111 });
expect(g.nodes.find((n) => n.id === 'tool-1')?.position).toEqual({ x: 999, y: 111 });
});
});

describe('updateNodeData', () => {
it('shallow-merges a data patch', () => {
const g = updateNodeData(baseGraph(), 'tool-1', { toolName: 'day-name' });
const tool = g.nodes.find((n) => n.id === 'tool-1');
expect(tool?.type === 'tool' && tool.data.toolName).toBe('day-name');
});
});

describe('connectNodes', () => {
it('rejects a connection that runs against the pipeline direction (tool -> llm)', () => {
const result = connectNodes(baseGraph(), 'tool-1', 'llm-1');
expect(result.ok).toBe(false);
});

it('accepts a compatible connection (llm -> tool)', () => {
const g = removeNode(baseGraph(), 'tool-1'); // drop the pre-existing tool node
const withTool = addNode(g, 'tool', { x: 400, y: 0 });
const newToolId = withTool.nodes[withTool.nodes.length - 1].id;
const result = connectNodes(withTool, 'llm-1', newToolId);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.graph.edges.some((e) => e.source === 'llm-1' && e.target === newToolId)).toBe(true);
}
});

it('rejects an incompatible connection (trigger -> trigger)', () => {
const g = addNode(baseGraph(), 'trigger', { x: 0, y: 200 });
const secondTrigger = g.nodes[g.nodes.length - 1].id;
const result = connectNodes(g, 'trigger-1', secondTrigger);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/not a valid pipeline connection/);
});

it('rejects connecting a node to itself', () => {
const result = connectNodes(baseGraph(), 'llm-1', 'llm-1');
expect(result.ok).toBe(false);
});

it('rejects an already-existing connection', () => {
const result = connectNodes(baseGraph(), 'trigger-1', 'llm-1');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/already connected/);
});

it('rejects a connection referencing an unknown node', () => {
const result = connectNodes(baseGraph(), 'ghost', 'llm-1');
expect(result.ok).toBe(false);
});
});

describe('removeEdge', () => {
it('removes a single edge by id', () => {
const g = removeEdge(baseGraph(), 'e1');
expect(g.edges).toEqual([{ id: 'e2', source: 'llm-1', target: 'tool-1' }]);
});
});
46 changes: 46 additions & 0 deletions apps/agent-forge/src/canvas/__tests__/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { autoLayout } from '../layout';
import type { AgentGraphSpec } from '../../graph/types';

function graph(): AgentGraphSpec {
return {
version: 1,
nodes: [
{ id: 'trigger-1', type: 'trigger', position: { x: 0, y: 0 }, label: 'Trigger', data: { trigger: { type: 'input' } } },
{
id: 'llm-1',
type: 'llm',
position: { x: 0, y: 0 },
label: 'agent',
data: { name: 'agent', prompt: 'hi', provider: { type: 'mock', model: 'mock-1' } },
},
{ id: 'tool-1', type: 'tool', position: { x: 0, y: 0 }, label: 'http', data: { toolName: 'http' } },
],
edges: [
{ id: 'e1', source: 'trigger-1', target: 'llm-1' },
{ id: 'e2', source: 'llm-1', target: 'tool-1' },
],
};
}

describe('autoLayout', () => {
it('spreads nodes out left-to-right in pipeline order', () => {
const laidOut = autoLayout(graph());
const byId = Object.fromEntries(laidOut.nodes.map((n) => [n.id, n.position]));
expect(byId['trigger-1'].x).toBeLessThan(byId['llm-1'].x);
expect(byId['llm-1'].x).toBeLessThan(byId['tool-1'].x);
});

it('preserves node identity, type and data - only position changes', () => {
const original = graph();
const laidOut = autoLayout(original);
expect(laidOut.nodes.map((n) => n.id)).toEqual(original.nodes.map((n) => n.id));
expect(laidOut.nodes.map((n) => n.data)).toEqual(original.nodes.map((n) => n.data));
expect(laidOut.edges).toEqual(original.edges);
});

it('is a no-op on an empty graph', () => {
const empty: AgentGraphSpec = { version: 1, nodes: [], edges: [] };
expect(autoLayout(empty)).toEqual(empty);
});
});
6 changes: 6 additions & 0 deletions apps/agent-forge/src/canvas/dnd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Shared drag-and-drop MIME type for the LeftRail "Nodes" palette ->
* CanvasArea drop target (LOU-M1). Kept as one constant so the producer
* (LeftRail) and consumer (CanvasArea) can't drift out of sync.
*/
export const PALETTE_DRAG_MIME = 'application/agent-forge-node-type';
Loading
Loading