From 5288814a32d6267796b8d905541dcaa0d5589e7b Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 29 Sep 2026 11:13:33 +0300 Subject: [PATCH 1/9] feat(agent-forge): agent graph data model + validation (LOU-L2) Adds AgentGraphSpec - the visual/graph-editor representation of the SDK's existing declarative AgentSpec (src/spec/schema.ts) - plus graphToSpec()/specToGraph() converters and a validateGraph() layer (cycle detection, dangling-edge detection, required-field checks per node type), returning structured ValidationError[] rather than throwing. AgentSpec is flat (one prompt/provider/tools list, no multi-step wiring), so the graph models a fixed pipeline shape (trigger -> llm -> tool -> [approval] -> output) rather than an arbitrary DAG; kept app-local since this shape is expected to change once LOU-M's real canvas and LOU-N's runtime server land. Documented lossy fields (canvas position, edge routing) directly in graph/types.ts. Round-trip and validation behavior covered by Vitest. Co-Authored-By: Claude Sonnet 5 --- .../src/graph/__tests__/roundtrip.test.ts | 71 ++++++++ .../src/graph/__tests__/validateGraph.test.ts | 157 ++++++++++++++++++ apps/agent-forge/src/graph/graphToSpec.ts | 45 +++++ apps/agent-forge/src/graph/specToGraph.ts | 103 ++++++++++++ apps/agent-forge/src/graph/types.ts | 109 ++++++++++++ apps/agent-forge/src/graph/validateGraph.ts | 134 +++++++++++++++ 6 files changed, 619 insertions(+) create mode 100644 apps/agent-forge/src/graph/__tests__/roundtrip.test.ts create mode 100644 apps/agent-forge/src/graph/__tests__/validateGraph.test.ts create mode 100644 apps/agent-forge/src/graph/graphToSpec.ts create mode 100644 apps/agent-forge/src/graph/specToGraph.ts create mode 100644 apps/agent-forge/src/graph/types.ts create mode 100644 apps/agent-forge/src/graph/validateGraph.ts diff --git a/apps/agent-forge/src/graph/__tests__/roundtrip.test.ts b/apps/agent-forge/src/graph/__tests__/roundtrip.test.ts new file mode 100644 index 000000000..d87f14309 --- /dev/null +++ b/apps/agent-forge/src/graph/__tests__/roundtrip.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +import { graphToSpec } from '../graphToSpec'; +import { specToGraph } from '../specToGraph'; +import type { AgentGraphSpec } from '../types'; + +const fullSpec: AgentSpec = { + name: 'support-triage', + prompt: 'You triage inbound support tickets.', + provider: { type: 'openai', model: 'gpt-4o-mini' }, + tools: ['http', 'current-date'], + policy: { requiresApproval: true, guardrails: ['no-pii'], customField: 'kept' }, + triggers: [{ type: 'webhook', channel: 'zendesk' }], +}; + +const minimalSpec: AgentSpec = { + name: 'minimal-agent', + prompt: 'Do the thing.', + provider: { type: 'mock', model: 'mock-1' }, +}; + +describe('spec -> graph -> spec round trip', () => { + it('is lossless for a full spec (name/prompt/provider/tools/policy/triggers)', () => { + const graph = specToGraph(fullSpec); + const roundTripped = graphToSpec(graph); + expect(roundTripped).toEqual(fullSpec); + }); + + it('is lossless for a minimal spec with no tools/policy/triggers', () => { + const graph = specToGraph(minimalSpec); + const roundTripped = graphToSpec(graph); + expect(roundTripped).toEqual(minimalSpec); + }); + + it('always produces exactly one output node with no data, regardless of spec shape', () => { + const graph = specToGraph(fullSpec); + const outputNodes = graph.nodes.filter((n) => n.type === 'output'); + expect(outputNodes).toHaveLength(1); + expect(outputNodes[0].data).toEqual({}); + }); + + it('drops canvas position data (documented lossy field)', () => { + const graph = specToGraph(fullSpec); + // Move a node - graphToSpec has no field to carry position through, so + // this has zero effect on the resulting spec. + graph.nodes[0].position = { x: 999, y: 999 }; + expect(graphToSpec(graph)).toEqual(fullSpec); + }); +}); + +describe('graph -> spec -> graph round trip', () => { + it('is lossless for node data on a graph built via specToGraph (canonical edges + fresh layout)', () => { + const graph = specToGraph(fullSpec); + const spec = graphToSpec(graph); + const rebuiltGraph = specToGraph(spec); + + // Node ids are synthetic/regenerated, positions are freshly computed, + // and edges are regenerated for the canonical pipeline shape - so we + // compare node *data* (order-independent by type) rather than the + // whole structure verbatim. + const dataByType = (g: AgentGraphSpec) => + g.nodes.reduce>((acc, n) => { + (acc[n.type] ??= []).push(n.data); + return acc; + }, {}); + + expect(dataByType(rebuiltGraph)).toEqual(dataByType(graph)); + expect(rebuiltGraph.nodes).toHaveLength(graph.nodes.length); + expect(rebuiltGraph.edges).toHaveLength(graph.edges.length); + }); +}); diff --git a/apps/agent-forge/src/graph/__tests__/validateGraph.test.ts b/apps/agent-forge/src/graph/__tests__/validateGraph.test.ts new file mode 100644 index 000000000..309160ddd --- /dev/null +++ b/apps/agent-forge/src/graph/__tests__/validateGraph.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { validateGraph } from '../validateGraph'; +import type { AgentGraphSpec } from '../types'; + +function graph(overrides: Partial): AgentGraphSpec { + return { + version: 1, + nodes: [ + { + id: 'llm-1', + type: 'llm', + position: { x: 0, y: 0 }, + label: 'agent', + data: { name: 'agent', prompt: 'hi', provider: { type: 'mock', model: 'mock-1' } }, + }, + ], + edges: [], + ...overrides, + }; +} + +describe('validateGraph', () => { + it('accepts a minimal valid graph (single llm node, no edges)', () => { + const result = validateGraph(graph({})); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('flags a dangling edge referencing a missing node', () => { + const g = graph({ edges: [{ id: 'e1', source: 'llm-1', target: 'ghost' }] }); + const result = validateGraph(g); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ edgeId: 'e1', message: expect.stringContaining("missing target node 'ghost'") }) + ); + }); + + it('flags a dangling edge with a missing source node', () => { + const g = graph({ edges: [{ id: 'e1', source: 'ghost', target: 'llm-1' }] }); + const result = validateGraph(g); + expect(result.errors).toContainEqual( + expect.objectContaining({ edgeId: 'e1', message: expect.stringContaining("missing source node 'ghost'") }) + ); + }); + + it('detects a 2-node cycle', () => { + const g = graph({ + nodes: [ + ...graph({}).nodes, + { id: 'tool-1', type: 'tool', position: { x: 0, y: 0 }, label: 't', data: { toolName: 'http' } }, + ], + edges: [ + { id: 'e1', source: 'llm-1', target: 'tool-1' }, + { id: 'e2', source: 'tool-1', target: 'llm-1' }, + ], + }); + const result = validateGraph(g); + expect(result.valid).toBe(false); + const cycleNodeIds = result.errors.filter((e) => e.message.includes('cycle')).map((e) => e.nodeId); + expect(cycleNodeIds).toEqual(expect.arrayContaining(['llm-1', 'tool-1'])); + }); + + it('detects a self-loop cycle', () => { + const g = graph({ edges: [{ id: 'e1', source: 'llm-1', target: 'llm-1' }] }); + const result = validateGraph(g); + expect(result.errors.some((e) => e.message.includes('cycle'))).toBe(true); + }); + + it('requires an llm node to have name/prompt/provider.type/provider.model', () => { + const g = graph({ + nodes: [ + { + id: 'llm-1', + type: 'llm', + position: { x: 0, y: 0 }, + label: '', + data: { name: '', prompt: '', provider: { type: '', model: '' } }, + }, + ], + }); + const result = validateGraph(g); + const messages = result.errors.map((e) => e.message); + expect(messages).toEqual( + expect.arrayContaining([ + expect.stringContaining("missing required field 'name'"), + expect.stringContaining("missing required field 'prompt'"), + expect.stringContaining("missing required field 'provider.type'"), + expect.stringContaining("missing required field 'provider.model'"), + ]) + ); + }); + + it('requires a tool node to have a toolName', () => { + const g = graph({ + nodes: [ + ...graph({}).nodes, + { id: 'tool-1', type: 'tool', position: { x: 0, y: 0 }, label: '', data: { toolName: '' } }, + ], + }); + const result = validateGraph(g); + expect(result.errors).toContainEqual( + expect.objectContaining({ nodeId: 'tool-1', message: expect.stringContaining("missing required field 'toolName'") }) + ); + }); + + it('requires a trigger node to have trigger.type', () => { + const g = graph({ + nodes: [ + ...graph({}).nodes, + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + label: '', + data: { trigger: { type: '' } }, + }, + ], + }); + const result = validateGraph(g); + expect(result.errors).toContainEqual( + expect.objectContaining({ + nodeId: 'trigger-1', + message: expect.stringContaining("missing required field 'trigger.type'"), + }) + ); + }); + + it('flags duplicate node ids', () => { + const single = graph({}).nodes[0]; + const g = graph({ nodes: [single, single] }); + const result = validateGraph(g); + expect(result.errors).toContainEqual( + expect.objectContaining({ nodeId: 'llm-1', message: expect.stringContaining('Duplicate node id') }) + ); + }); + + it('flags zero llm nodes and more than one llm node', () => { + expect(validateGraph({ version: 1, nodes: [], edges: [] }).errors).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('found none') }) + ); + + const twoLlm = graph({ + nodes: [ + ...graph({}).nodes, + { + id: 'llm-2', + type: 'llm', + position: { x: 0, y: 0 }, + label: 'agent2', + data: { name: 'agent2', prompt: 'hi', provider: { type: 'mock', model: 'mock-1' } }, + }, + ], + }); + const result = validateGraph(twoLlm); + expect(result.errors.filter((e) => e.message.includes('found 2'))).toHaveLength(2); + }); +}); diff --git a/apps/agent-forge/src/graph/graphToSpec.ts b/apps/agent-forge/src/graph/graphToSpec.ts new file mode 100644 index 000000000..3b18447b9 --- /dev/null +++ b/apps/agent-forge/src/graph/graphToSpec.ts @@ -0,0 +1,45 @@ +import type { AgentSpec, AgentSpecTrigger } from '@loushy/build-ai-agent'; +import type { AgentGraphSpec } from './types'; + +/** + * Converts a canvas graph to the SDK's declarative `AgentSpec` (see + * src/spec/schema.ts in the core SDK). Reads node `data` only - edges carry + * no information `AgentSpec` can represent (see graph/types.ts for why) - + * so wiring/order in the canvas does not affect the resulting spec beyond + * "which nodes of which type exist". + * + * Throws if the graph has no `llm` node, since `AgentSpec` requires + * `name`/`prompt`/`provider` and there is nowhere else in the graph to + * source them from. + */ +export function graphToSpec(graph: AgentGraphSpec): AgentSpec { + const llmNode = graph.nodes.find((n) => n.type === 'llm'); + if (!llmNode || llmNode.type !== 'llm') { + throw new Error('graphToSpec: graph has no llm node - AgentSpec requires name/prompt/provider'); + } + + const spec: AgentSpec = { + name: llmNode.data.name, + prompt: llmNode.data.prompt, + provider: { ...llmNode.data.provider }, + }; + + const toolNodes = graph.nodes.filter((n) => n.type === 'tool'); + if (toolNodes.length > 0) { + spec.tools = toolNodes.map((n) => (n.type === 'tool' ? n.data.toolName : '')).filter(Boolean); + } + + const approvalNode = graph.nodes.find((n) => n.type === 'approval'); + if (approvalNode && approvalNode.type === 'approval') { + spec.policy = { ...approvalNode.data.policy }; + } + + const triggerNodes = graph.nodes.filter((n) => n.type === 'trigger'); + if (triggerNodes.length > 0) { + spec.triggers = triggerNodes.map((n) => + n.type === 'trigger' ? ({ ...n.data.trigger } as AgentSpecTrigger) : ({} as AgentSpecTrigger) + ); + } + + return spec; +} diff --git a/apps/agent-forge/src/graph/specToGraph.ts b/apps/agent-forge/src/graph/specToGraph.ts new file mode 100644 index 000000000..5175fd273 --- /dev/null +++ b/apps/agent-forge/src/graph/specToGraph.ts @@ -0,0 +1,103 @@ +import type { AgentSpec } from '@loushy/build-ai-agent'; +import type { AgentGraphEdge, AgentGraphNode, AgentGraphSpec } from './types'; + +const COLUMN_WIDTH = 260; +const ROW_HEIGHT = 140; + +/** + * Converts an `AgentSpec` to a fresh canvas graph. There is no stored + * canvas layout in `AgentSpec` (see graph/types.ts), so this always + * computes a new auto-layout: one column per pipeline stage + * (trigger -> llm -> tool -> approval -> output), stacking multiple nodes + * in the same stage vertically. + */ +export function specToGraph(spec: AgentSpec): AgentGraphSpec { + const nodes: AgentGraphNode[] = []; + const edges: AgentGraphEdge[] = []; + let nextId = 0; + const id = (prefix: string) => `${prefix}-${nextId++}`; + + const triggerIds: string[] = []; + (spec.triggers ?? []).forEach((trigger, i) => { + const nodeId = id('trigger'); + triggerIds.push(nodeId); + nodes.push({ + id: nodeId, + type: 'trigger', + position: { x: 0, y: i * ROW_HEIGHT }, + label: trigger.type, + data: { trigger: { ...trigger } }, + }); + }); + + const llmId = id('llm'); + const llmRowY = (Math.max(triggerIds.length, 1) - 1) * ROW_HEIGHT * 0.5; + nodes.push({ + id: llmId, + type: 'llm', + position: { x: COLUMN_WIDTH, y: llmRowY }, + label: spec.name, + data: { + name: spec.name, + prompt: spec.prompt, + provider: { ...spec.provider }, + }, + }); + for (const triggerId of triggerIds) { + edges.push({ id: `edge-${triggerId}-${llmId}`, source: triggerId, target: llmId }); + } + + const toolIds: string[] = []; + (spec.tools ?? []).forEach((toolName, i) => { + const nodeId = id('tool'); + toolIds.push(nodeId); + nodes.push({ + id: nodeId, + type: 'tool', + position: { x: COLUMN_WIDTH * 2, y: i * ROW_HEIGHT }, + label: toolName, + data: { toolName }, + }); + edges.push({ id: `edge-${llmId}-${nodeId}`, source: llmId, target: nodeId }); + }); + + let approvalId: string | undefined; + if (spec.policy) { + approvalId = id('approval'); + nodes.push({ + id: approvalId, + type: 'approval', + position: { x: COLUMN_WIDTH * 3, y: 0 }, + label: 'Human approval', + data: { policy: { ...spec.policy } }, + }); + if (toolIds.length > 0) { + for (const toolId of toolIds) { + edges.push({ id: `edge-${toolId}-${approvalId}`, source: toolId, target: approvalId }); + } + } else { + edges.push({ id: `edge-${llmId}-${approvalId}`, source: llmId, target: approvalId }); + } + } + + const outputId = id('output'); + const outputColumn = spec.policy ? 4 : 3; + nodes.push({ + id: outputId, + type: 'output', + position: { x: COLUMN_WIDTH * outputColumn, y: 0 }, + label: 'Output', + data: {}, + }); + if (approvalId) { + edges.push({ id: `edge-${approvalId}-${outputId}`, source: approvalId, target: outputId }); + } else if (toolIds.length > 0) { + for (const toolId of toolIds) { + edges.push({ id: `edge-${toolId}-${outputId}`, source: toolId, target: outputId }); + } + } else { + edges.push({ id: `edge-${llmId}-${outputId}`, source: llmId, target: outputId }); + } + + return { version: 1, nodes, edges }; +} diff --git a/apps/agent-forge/src/graph/types.ts b/apps/agent-forge/src/graph/types.ts new file mode 100644 index 000000000..c55a75a36 --- /dev/null +++ b/apps/agent-forge/src/graph/types.ts @@ -0,0 +1,109 @@ +/** + * Agent graph data model (LOU-L2). + * + * `AgentGraphSpec` is the visual/graph-editor representation of the SDK's + * existing `AgentSpec` (src/spec/schema.ts) - it is NOT a competing agent + * format. Today's `AgentSpec` is deliberately flat: one prompt, one + * provider, a flat `tools` string list, an optional `policy` and an + * optional `triggers` list - there is no field in `AgentSpec` for + * branching/routing between multiple LLM steps. So the graph this epic + * renders is a fixed-shape pipeline that visualizes that flat spec as + * nodes: + * + * trigger(s) -> llm -> tool(s) -> [approval] -> output + * + * rather than an arbitrary user-wireable DAG of multiple LLM/tool steps. + * That's a deliberate LOU-L scope decision, not an oversight: extending + * `AgentSpec` itself to support multi-step orchestration/branching is a + * bigger, cross-cutting change that belongs to a later epic (the canvas + * work in LOU-M and/or the runtime control server in LOU-N), once we know + * what the executor actually needs to run such a graph. Kept app-local + * (apps/agent-forge/src/graph) rather than in the core SDK for the same + * reason: this shape may well change once LOU-M/N land, and it has no + * meaning outside the visual editor today. + * + * Fields that do NOT round-trip: + * - Node `position` (x/y canvas coordinates) has no equivalent in + * `AgentSpec` at all. `graphToSpec()` naturally drops it; + * `specToGraph()` always computes a fresh auto-layout position. + * - `AgentGraphEdge`s are not read by `graphToSpec()` - `AgentSpec` has no + * wiring/ordering field beyond the fixed pipeline shape above, so edges + * exist only for the canvas UI and for structural validation (cycle / + * dangling-edge checks). `specToGraph()` always regenerates a canonical + * edge set for the fixed pipeline; a user's custom edge routing (e.g. an + * edge into a *different* tool node, once branching exists) is not + * preserved across a spec round-trip today. + * - The synthetic `output` node carries no `AgentSpec` data at all (there + * is no output field in `AgentSpec`); it's always re-synthesized as a + * single fixed anchor node by `specToGraph()`. + */ + +export type AgentGraphNodeType = 'trigger' | 'llm' | 'tool' | 'approval' | 'output'; + +export interface GraphPosition { + x: number; + y: number; +} + +interface AgentGraphNodeBase { + id: string; + type: TType; + position: GraphPosition; + /** Display label shown on the node's canvas header. */ + label: string; + data: TData; +} + +/** A single raw trigger object, kept verbatim from `AgentSpecTrigger`. */ +export interface TriggerNodeData { + trigger: { type: string; [key: string]: unknown }; +} + +export interface LlmNodeData { + name: string; + prompt: string; + provider: { type: string; model: string }; +} + +export interface ToolNodeData { + toolName: string; +} + +/** Kept verbatim from `AgentSpecPolicy` so unknown/passthrough fields round-trip. */ +export interface ApprovalNodeData { + policy: { requiresApproval?: boolean; guardrails?: string[]; [key: string]: unknown }; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- output node carries no AgentSpec data (see file header) +export interface OutputNodeData {} + +export type AgentGraphNode = + | AgentGraphNodeBase<'trigger', TriggerNodeData> + | AgentGraphNodeBase<'llm', LlmNodeData> + | AgentGraphNodeBase<'tool', ToolNodeData> + | AgentGraphNodeBase<'approval', ApprovalNodeData> + | AgentGraphNodeBase<'output', OutputNodeData>; + +export interface AgentGraphEdge { + id: string; + source: string; + target: string; +} + +export interface AgentGraphSpec { + /** Schema version for this app-local format, bumped on breaking shape changes. */ + version: 1; + nodes: AgentGraphNode[]; + edges: AgentGraphEdge[]; +} + +export interface ValidationError { + message: string; + nodeId?: string; + edgeId?: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} diff --git a/apps/agent-forge/src/graph/validateGraph.ts b/apps/agent-forge/src/graph/validateGraph.ts new file mode 100644 index 000000000..69d925d9b --- /dev/null +++ b/apps/agent-forge/src/graph/validateGraph.ts @@ -0,0 +1,134 @@ +import type { AgentGraphSpec, ValidationError, ValidationResult } from './types'; + +/** + * Structural + per-node-type validation for `AgentGraphSpec`. Returns + * structured errors (node/edge id + message) rather than throwing, so the + * canvas UI (LOU-M) can surface them inline per-node instead of a single + * crash. + */ +export function validateGraph(graph: AgentGraphSpec): ValidationResult { + const errors: ValidationError[] = []; + const nodeIds = new Set(graph.nodes.map((n) => n.id)); + + // Duplicate node ids. + const seen = new Set(); + for (const node of graph.nodes) { + if (seen.has(node.id)) { + errors.push({ nodeId: node.id, message: `Duplicate node id '${node.id}'` }); + } + seen.add(node.id); + } + + // Dangling edges: source/target referencing a node id that doesn't exist. + for (const edge of graph.edges) { + if (!nodeIds.has(edge.source)) { + errors.push({ + edgeId: edge.id, + message: `Edge '${edge.id}' references missing source node '${edge.source}'`, + }); + } + if (!nodeIds.has(edge.target)) { + errors.push({ + edgeId: edge.id, + message: `Edge '${edge.id}' references missing target node '${edge.target}'`, + }); + } + } + + // Cycle detection (only over edges whose endpoints both exist). + const adjacency = new Map(); + for (const edge of graph.edges) { + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) continue; + const list = adjacency.get(edge.source) ?? []; + list.push(edge.target); + adjacency.set(edge.source, list); + } + + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + for (const node of graph.nodes) color.set(node.id, WHITE); + + const cycleNodeIds = new Set(); + function visit(nodeId: string, stack: string[]): boolean { + color.set(nodeId, GRAY); + stack.push(nodeId); + for (const next of adjacency.get(nodeId) ?? []) { + const state = color.get(next); + if (state === GRAY) { + // Found a cycle: mark every node on the stack from `next` onward. + const cycleStart = stack.indexOf(next); + for (const id of stack.slice(cycleStart)) cycleNodeIds.add(id); + return true; + } + if (state === WHITE && visit(next, stack)) { + return true; + } + } + stack.pop(); + color.set(nodeId, BLACK); + return false; + } + + for (const node of graph.nodes) { + if (color.get(node.id) === WHITE) { + visit(node.id, []); + } + } + for (const id of cycleNodeIds) { + errors.push({ nodeId: id, message: `Node '${id}' is part of a cycle` }); + } + + // Required-field checks per node type. + for (const node of graph.nodes) { + switch (node.type) { + case 'llm': { + if (!node.data.name?.trim()) { + errors.push({ nodeId: node.id, message: "LLM node is missing required field 'name'" }); + } + if (!node.data.prompt?.trim()) { + errors.push({ nodeId: node.id, message: "LLM node is missing required field 'prompt'" }); + } + if (!node.data.provider?.type?.trim()) { + errors.push({ nodeId: node.id, message: "LLM node is missing required field 'provider.type'" }); + } + if (!node.data.provider?.model?.trim()) { + errors.push({ nodeId: node.id, message: "LLM node is missing required field 'provider.model'" }); + } + break; + } + case 'tool': { + if (!node.data.toolName?.trim()) { + errors.push({ nodeId: node.id, message: "Tool node is missing required field 'toolName'" }); + } + break; + } + case 'trigger': { + if (!node.data.trigger?.type?.trim()) { + errors.push({ nodeId: node.id, message: "Trigger node is missing required field 'trigger.type'" }); + } + break; + } + case 'approval': + case 'output': + // No required fields for these node types today. + break; + } + } + + // Exactly one llm node is required for graphToSpec() to succeed. + const llmNodes = graph.nodes.filter((n) => n.type === 'llm'); + if (llmNodes.length === 0) { + errors.push({ message: 'Graph must contain exactly one llm node, found none' }); + } else if (llmNodes.length > 1) { + for (const n of llmNodes) { + errors.push({ + nodeId: n.id, + message: `Graph must contain exactly one llm node, found ${llmNodes.length}`, + }); + } + } + + return { valid: errors.length === 0, errors }; +} From 8a837a04e047ae366a96941361ee227ce2bda007 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 29 Sep 2026 11:13:49 +0300 Subject: [PATCH 2/9] feat(agent-forge): local persistence, import/export (LOU-L3) Adds the AgentStore interface (list/load/save/remove) plus two implementations: LocalStorageAgentStore (browser, backed by window.localStorage, YAML-serialized via the same `yaml` package loadSpec() uses) for this epic's dev experience, and createFsAgentStore() (Node-only, writes real .loushy/agents/.yaml files and reads them back through the core SDK's own loadSpec() - not wired into the browser app since Vite/ browser has no fs access; TODO(LOU-N) to wire it in behind the runtime control server). Also adds serializeSpec/parseSpecText/downloadSpec/importSpecFile for YAML/JSON export (Blob download) and import (validated against agentSpecSchema). All covered by Vitest, including a real filesystem round-trip test for createFsAgentStore() against a temp dir. Co-Authored-By: Claude Sonnet 5 --- .../agent-forge/src/persistence/AgentStore.ts | 30 +++++++ .../src/persistence/LocalStorageAgentStore.ts | 59 +++++++++++++ .../__tests__/LocalStorageAgentStore.test.ts | 82 +++++++++++++++++ .../__tests__/fsAgentStore.test.ts | 57 ++++++++++++ .../__tests__/importExport.test.ts | 71 +++++++++++++++ .../src/persistence/fsAgentStore.ts | 64 ++++++++++++++ .../src/persistence/importExport.ts | 88 +++++++++++++++++++ 7 files changed, 451 insertions(+) create mode 100644 apps/agent-forge/src/persistence/AgentStore.ts create mode 100644 apps/agent-forge/src/persistence/LocalStorageAgentStore.ts create mode 100644 apps/agent-forge/src/persistence/__tests__/LocalStorageAgentStore.test.ts create mode 100644 apps/agent-forge/src/persistence/__tests__/fsAgentStore.test.ts create mode 100644 apps/agent-forge/src/persistence/__tests__/importExport.test.ts create mode 100644 apps/agent-forge/src/persistence/fsAgentStore.ts create mode 100644 apps/agent-forge/src/persistence/importExport.ts diff --git a/apps/agent-forge/src/persistence/AgentStore.ts b/apps/agent-forge/src/persistence/AgentStore.ts new file mode 100644 index 000000000..21d41eb71 --- /dev/null +++ b/apps/agent-forge/src/persistence/AgentStore.ts @@ -0,0 +1,30 @@ +import type { AgentSpec } from '@loushy/build-ai-agent'; + +export interface AgentStoreEntry { + id: string; + spec: AgentSpec; + updatedAt: string; +} + +/** + * Storage boundary for `AgentSpec`s (LOU-L3). + * + * This app (Vite + browser, no backend yet - that's LOU-N's runtime control + * server) cannot touch the real filesystem, so `AgentStore` is the seam: + * today it's implemented by `LocalStorageAgentStore` (browser-only, backed + * by `window.localStorage`, good enough for a dev experience with autosave) + * and by `createFsAgentStore()` (Node-only, reads/writes real + * `.loushy/agents/*.yaml` files via the SDK's own YAML format - see + * persistence/fsAgentStore.ts) for use once a server exists. + * + * LOU-N's runtime control server should implement this same interface + * against the real filesystem and swap it in behind whatever transport it + * adds (HTTP/IPC) - the app's UI code should only ever depend on + * `AgentStore`, never on `localStorage` or `node:fs` directly. + */ +export interface AgentStore { + list(): Promise; + load(id: string): Promise; + save(id: string, spec: AgentSpec): Promise; + remove(id: string): Promise; +} diff --git a/apps/agent-forge/src/persistence/LocalStorageAgentStore.ts b/apps/agent-forge/src/persistence/LocalStorageAgentStore.ts new file mode 100644 index 000000000..e44c5b7a8 --- /dev/null +++ b/apps/agent-forge/src/persistence/LocalStorageAgentStore.ts @@ -0,0 +1,59 @@ +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +import type { AgentStore, AgentStoreEntry } from './AgentStore'; + +const KEY_PREFIX = 'agent-forge:agents:'; + +interface StoredRecord { + spec: AgentSpec; + updatedAt: string; +} + +/** + * Browser dev-experience implementation of `AgentStore`, backed by + * `window.localStorage`. Values are stored as YAML text via the same + * `yaml` package `loadSpec()` uses in the core SDK, so an entry saved here + * is byte-for-byte the same format a `.loushy/agents/.yaml` file would + * hold (see fsAgentStore.ts) - only the storage medium differs. + * + * This is explicitly a stand-in until LOU-N's runtime control server ships + * a real filesystem-backed store: it has no cross-device sync, no + * multi-tab locking, and is bounded by the browser's localStorage quota. + */ +export class LocalStorageAgentStore implements AgentStore { + constructor(private readonly storage: Storage = window.localStorage) {} + + private key(id: string): string { + return `${KEY_PREFIX}${id}`; + } + + async list(): Promise { + const entries: AgentStoreEntry[] = []; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i); + if (!key || !key.startsWith(KEY_PREFIX)) continue; + const id = key.slice(KEY_PREFIX.length); + const raw = this.storage.getItem(key); + if (!raw) continue; + const record = parseYaml(raw) as StoredRecord; + entries.push({ id, spec: record.spec, updatedAt: record.updatedAt }); + } + return entries.sort((a, b) => a.id.localeCompare(b.id)); + } + + async load(id: string): Promise { + const raw = this.storage.getItem(this.key(id)); + if (!raw) return undefined; + const record = parseYaml(raw) as StoredRecord; + return record.spec; + } + + async save(id: string, spec: AgentSpec): Promise { + const record: StoredRecord = { spec, updatedAt: new Date().toISOString() }; + this.storage.setItem(this.key(id), stringifyYaml(record)); + } + + async remove(id: string): Promise { + this.storage.removeItem(this.key(id)); + } +} diff --git a/apps/agent-forge/src/persistence/__tests__/LocalStorageAgentStore.test.ts b/apps/agent-forge/src/persistence/__tests__/LocalStorageAgentStore.test.ts new file mode 100644 index 000000000..92961524d --- /dev/null +++ b/apps/agent-forge/src/persistence/__tests__/LocalStorageAgentStore.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +import { LocalStorageAgentStore } from '../LocalStorageAgentStore'; + +/** + * A minimal in-memory `Storage`-compatible stub, used instead of jsdom's + * `window.localStorage`: at time of writing, this repo's Node/vitest + * combination (Node 26's experimental built-in `localStorage` global vs. + * jsdom's own implementation) leaves `window.localStorage` undefined in + * the test environment even with a valid origin configured. Real browsers + * are unaffected - `LocalStorageAgentStore`'s no-arg constructor still + * defaults to `window.localStorage` for actual app usage (see + * `state/AppState.tsx`) - this stub only exercises the class's logic + * against the same `Storage` interface via its constructor injection seam. + */ +class MemoryStorage implements Storage { + private map = new Map(); + get length(): number { + return this.map.size; + } + clear(): void { + this.map.clear(); + } + getItem(key: string): string | null { + return this.map.has(key) ? this.map.get(key)! : null; + } + key(index: number): string | null { + return Array.from(this.map.keys())[index] ?? null; + } + removeItem(key: string): void { + this.map.delete(key); + } + setItem(key: string, value: string): void { + this.map.set(key, value); + } +} + +const spec: AgentSpec = { + name: 'doc-qa', + prompt: 'Answer questions about the docs.', + provider: { type: 'mock', model: 'mock-1' }, + tools: ['http'], +}; + +describe('LocalStorageAgentStore', () => { + let storage: MemoryStorage; + + beforeEach(() => { + storage = new MemoryStorage(); + }); + + it('round-trips save/load for a spec', async () => { + const store = new LocalStorageAgentStore(storage); + await store.save('doc-qa', spec); + const loaded = await store.load('doc-qa'); + expect(loaded).toEqual(spec); + }); + + it('returns undefined for an id that was never saved', async () => { + const store = new LocalStorageAgentStore(storage); + expect(await store.load('nope')).toBeUndefined(); + }); + + it('lists saved entries sorted by id, ignoring unrelated localStorage keys', async () => { + const store = new LocalStorageAgentStore(storage); + storage.setItem('unrelated-key', 'noise'); + await store.save('b-agent', spec); + await store.save('a-agent', spec); + + const entries = await store.list(); + expect(entries.map((e) => e.id)).toEqual(['a-agent', 'b-agent']); + expect(entries[0].spec).toEqual(spec); + expect(typeof entries[0].updatedAt).toBe('string'); + }); + + it('removes a saved entry', async () => { + const store = new LocalStorageAgentStore(storage); + await store.save('doc-qa', spec); + await store.remove('doc-qa'); + expect(await store.load('doc-qa')).toBeUndefined(); + }); +}); diff --git a/apps/agent-forge/src/persistence/__tests__/fsAgentStore.test.ts b/apps/agent-forge/src/persistence/__tests__/fsAgentStore.test.ts new file mode 100644 index 000000000..276dd6577 --- /dev/null +++ b/apps/agent-forge/src/persistence/__tests__/fsAgentStore.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +import { createFsAgentStore } from '../fsAgentStore'; + +const spec: AgentSpec = { + name: 'ops-pipeline', + prompt: 'Coordinate the ops pipeline.', + provider: { type: 'mock', model: 'mock-1' }, +}; + +describe('createFsAgentStore', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-forge-fsstore-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes a spec to .loushy/agents/.yaml and reads it back via loadSpec()', async () => { + const store = createFsAgentStore(tmpDir); + await store.save('ops-pipeline', spec); + + const filePath = path.join(tmpDir, '.loushy', 'agents', 'ops-pipeline.yaml'); + expect(fs.existsSync(filePath)).toBe(true); + + const loaded = await store.load('ops-pipeline'); + expect(loaded).toEqual(spec); + }); + + it('returns undefined for a missing id and empty list before any save', async () => { + const store = createFsAgentStore(tmpDir); + expect(await store.load('missing')).toBeUndefined(); + expect(await store.list()).toEqual([]); + }); + + it('lists saved entries', async () => { + const store = createFsAgentStore(tmpDir); + await store.save('ops-pipeline', spec); + const entries = await store.list(); + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe('ops-pipeline'); + expect(entries[0].spec).toEqual(spec); + }); + + it('removes a saved entry', async () => { + const store = createFsAgentStore(tmpDir); + await store.save('ops-pipeline', spec); + await store.remove('ops-pipeline'); + expect(await store.load('ops-pipeline')).toBeUndefined(); + }); +}); diff --git a/apps/agent-forge/src/persistence/__tests__/importExport.test.ts b/apps/agent-forge/src/persistence/__tests__/importExport.test.ts new file mode 100644 index 000000000..ae6a61835 --- /dev/null +++ b/apps/agent-forge/src/persistence/__tests__/importExport.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +import { downloadSpec, importSpecFile, parseSpecText, serializeSpec } from '../importExport'; + +const spec: AgentSpec = { + name: 'research-assistant', + prompt: 'Research a topic and summarize findings.', + provider: { type: 'anthropic', model: 'claude-3.7-sonnet' }, + tools: ['http', 'current-date'], +}; + +describe('serializeSpec / parseSpecText', () => { + it('round-trips through YAML', () => { + const text = serializeSpec(spec, 'yaml'); + expect(parseSpecText(text, 'yaml')).toEqual(spec); + }); + + it('round-trips through JSON', () => { + const text = serializeSpec(spec, 'json'); + expect(parseSpecText(text, 'json')).toEqual(spec); + }); + + it('rejects invalid spec text with a field-naming error', () => { + expect(() => parseSpecText('name: only-a-name', 'yaml')).toThrow(/'prompt'/); + }); +}); + +describe('importSpecFile', () => { + it('parses an uploaded .yaml File', async () => { + const file = new File([serializeSpec(spec, 'yaml')], 'agent.yaml', { type: 'text/yaml' }); + expect(await importSpecFile(file)).toEqual(spec); + }); + + it('parses an uploaded .json File', async () => { + const file = new File([serializeSpec(spec, 'json')], 'agent.json', { type: 'application/json' }); + expect(await importSpecFile(file)).toEqual(spec); + }); +}); + +describe('downloadSpec', () => { + it('creates and revokes a Blob object URL to drive the download', () => { + const originalCreate = URL.createObjectURL; + const originalRevoke = URL.revokeObjectURL; + // jsdom has no real download/navigation support, so an click with + // an href it doesn't recognize logs an unimplemented-navigation error; + // stub HTMLAnchorElement.click to isolate this test from that jsdom + // limitation and just assert the Blob URL lifecycle we control. + const originalClick = HTMLAnchorElement.prototype.click; + let created = 0; + let revoked = 0; + URL.createObjectURL = () => { + created++; + return 'blob:mock'; + }; + URL.revokeObjectURL = () => { + revoked++; + }; + HTMLAnchorElement.prototype.click = () => {}; + + try { + downloadSpec(spec, 'research-assistant.yaml'); + } finally { + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + HTMLAnchorElement.prototype.click = originalClick; + } + + expect(created).toBe(1); + expect(revoked).toBe(1); + }); +}); diff --git a/apps/agent-forge/src/persistence/fsAgentStore.ts b/apps/agent-forge/src/persistence/fsAgentStore.ts new file mode 100644 index 000000000..b5ed68e32 --- /dev/null +++ b/apps/agent-forge/src/persistence/fsAgentStore.ts @@ -0,0 +1,64 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { stringify as stringifyYaml } from 'yaml'; +import { loadSpec, type AgentSpec } from '@loushy/build-ai-agent'; +import type { AgentStore, AgentStoreEntry } from './AgentStore'; + +/** + * Filesystem-backed `AgentStore`, saving/loading plain `AgentSpec` YAML + * files under `/.loushy/agents/.yaml`. Deliberately reuses the + * core SDK's `loadSpec()` for reading (so files this store writes validate + * and load exactly the way any other AgentSpec YAML file does) and the same + * `yaml` package for writing - there is only one AgentSpec file format in + * this repo, not a second bespoke one for the app. + * + * NOT wired into the Vite/browser app (`apps/agent-forge/src/App.tsx` uses + * `LocalStorageAgentStore` instead) - a browser has no `node:fs` access. + * This module is the interface point LOU-N's runtime control server is + * expected to expose over HTTP/IPC: that server can import + * `createFsAgentStore()` directly and serve it behind whatever transport it + * adds, rather than reinventing filesystem persistence. + * + * TODO(LOU-N): wire this up behind the runtime control server so the + * browser app can call it remotely instead of only using + * `LocalStorageAgentStore`. + */ +export function createFsAgentStore(baseDir: string): AgentStore { + const agentsDir = path.join(baseDir, '.loushy', 'agents'); + + function filePath(id: string): string { + return path.join(agentsDir, `${id}.yaml`); + } + + function ensureDir(): void { + fs.mkdirSync(agentsDir, { recursive: true }); + } + + return { + async list(): Promise { + if (!fs.existsSync(agentsDir)) return []; + const files = fs.readdirSync(agentsDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + return files.map((file) => { + const id = file.replace(/\.ya?ml$/, ''); + const stat = fs.statSync(path.join(agentsDir, file)); + return { id, spec: loadSpec(path.join(agentsDir, file)), updatedAt: stat.mtime.toISOString() }; + }); + }, + + async load(id: string): Promise { + const file = filePath(id); + if (!fs.existsSync(file)) return undefined; + return loadSpec(file); + }, + + async save(id: string, spec: AgentSpec): Promise { + ensureDir(); + fs.writeFileSync(filePath(id), stringifyYaml(spec), 'utf8'); + }, + + async remove(id: string): Promise { + const file = filePath(id); + if (fs.existsSync(file)) fs.unlinkSync(file); + }, + }; +} diff --git a/apps/agent-forge/src/persistence/importExport.ts b/apps/agent-forge/src/persistence/importExport.ts new file mode 100644 index 000000000..c7c6e34e1 --- /dev/null +++ b/apps/agent-forge/src/persistence/importExport.ts @@ -0,0 +1,88 @@ +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +// NOTE (deviation, documented in the epic report): `agentSpecSchema` is a +// VALUE import, not `import type`, so it ends up in this browser bundle. +// The `@loushy/build-ai-agent` package only exposes one bundled entry +// point (its "." export) which eagerly imports Node-only/native deps +// (dockerode, for the sandboxed tool executor) that break a Vite/browser +// build. There's no narrower "./spec" export subpath to import just the +// schema from. Importing the zod schema directly from the SDK's source +// tree (still inside this monorepo, not a different package) avoids +// pulling in the rest of the SDK's dependency graph. AgentSpec/ +// AgentSpecTrigger above stay `import type`-only from the package +// proper, which is erased at build time and has no such cost. +import { agentSpecSchema } from '../../../../src/spec/schema'; + +export type ExportFormat = 'yaml' | 'json'; + +/** Serializes an `AgentSpec` to text, in the same YAML/JSON shapes `loadSpec()` reads. */ +export function serializeSpec(spec: AgentSpec, format: ExportFormat): string { + return format === 'json' ? JSON.stringify(spec, null, 2) : stringifyYaml(spec); +} + +/** + * Parses spec text (YAML or JSON) and validates it against the SDK's own + * `agentSpecSchema`, mirroring `loadSpec()`'s validation for a file already + * read into memory (a browser `File` has no filesystem path for `loadSpec` + * to use `fs.readFileSync` + extension-sniff against). + */ +export function parseSpecText(text: string, format: ExportFormat): AgentSpec { + const data = format === 'json' ? JSON.parse(text) : parseYaml(text); + const result = agentSpecSchema.safeParse(data); + if (!result.success) { + const details = result.error.issues + .map((issue) => `'${issue.path.join('.') || '(root)'}': ${issue.message}`) + .join('; '); + throw new Error(`parseSpecText: import failed validation - ${details}`); + } + return result.data; +} + +function formatFromFilename(filename: string): ExportFormat { + return filename.toLowerCase().endsWith('.json') ? 'json' : 'yaml'; +} + +/** + * Triggers a browser download of `spec` as YAML or JSON, via a Blob object + * URL (no server round-trip). `document`-dependent, so only usable in a + * browser/jsdom environment. + */ +export function downloadSpec(spec: AgentSpec, filename: string): void { + const format = formatFromFilename(filename); + const text = serializeSpec(spec, format); + const blob = new Blob([text], { type: format === 'json' ? 'application/json' : 'text/yaml' }); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } finally { + URL.revokeObjectURL(url); + } +} + +/** + * Reads a `File`/`Blob`'s contents as text. Prefers the standard + * `.text()` method (real browsers, modern Node); falls back to + * `FileReader` for environments whose `File` polyfill doesn't implement it + * (e.g. jsdom, used by this app's own tests) or whose `Response` mishandles + * a `File` body. + */ +function readFileText(file: File): Promise { + if (typeof file.text === 'function') return file.text(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.onerror = () => reject(reader.error ?? new Error('readFileText: FileReader error')); + reader.readAsText(file); + }); +} + +/** Reads an uploaded `File` (from an ``) and parses/validates it as an `AgentSpec`. */ +export async function importSpecFile(file: File): Promise { + const text = await readFileText(file); + return parseSpecText(text, formatFromFilename(file.name)); +} From a384a7cb1b2bc756a9b49849538d8a95a29604c1 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 29 Sep 2026 11:14:09 +0300 Subject: [PATCH 3/9] feat(agent-forge): Vite+React+TS app shell scaffold (LOU-L1) Scaffolds apps/agent-forge (@loushy/agent-forge, private) as an npm workspace: adds "workspaces": ["apps/*"] to the root package.json so npm resolves the app's dependency on @loushy/build-ai-agent to this local package, plus the app's own package.json/tsconfig/vite.config/ eslint.config (mirroring the root repo's typescript-eslint conventions). Ports the design mockup's (.design-ref/agent-forge-mockup.html) CSS custom properties, fonts and layout structure into componentized, typed React: Topbar, LeftRail (Agents/Nodes tabs), CanvasArea (placeholder - real ReactFlow lands in LOU-M), Inspector and BottomDrawer (Chat/Logs/Trace/Output/Settings tab chrome). AppState (React context) wires a single in-memory AgentSpec through these components with a dirty-state indicator and debounced autosave to LocalStorageAgentStore (LOU-L3). Verified: `npm run dev` boots cleanly, `npm run build` produces dist/, `npx vitest run` and `npx eslint src` are clean, and the root repo's own typecheck/lint/test are unaffected. Deviations from the ticket, and why (see the epic report for full detail): - apps/agent-forge/package.json declares "@loushy/build-ai-agent": "file:../.." instead of "*". Plain "*" cannot resolve here: npm workspaces only auto-links *listed* workspace members to each other, never the monorepo root package itself back to its own workspaces, so `npm install` 404s trying to fetch it from the registry. `file:../..` is still plain npm (no workspace: protocol) and gets an equivalent local symlink. - @loushy/build-ai-agent only exposes one bundled "." export; it has no narrower "./spec" entry point, so importing anything from it at the value level pulls in its whole dependency graph, including Node-only/native deps (dockerode) that break a browser Vite build. Worked around by importing the zod `agentSpecSchema` value straight from the SDK's src/spec/schema.ts (still this monorepo, not a different package) instead of through the package barrel; type-only imports (AgentSpec, AgentSpecTrigger) are unaffected since they're erased at build time. Flagging a real "./spec" export subpath as useful follow-up whenever the SDK's own package.json/tsup config is next touched. - Added a one-line 'apps/**' entry to the root vitest.config.ts exclude list. Without it, running the root `vitest run`/`npm test` also swept up apps/agent-forge's browser-only tests and ran them under the root config's node environment, which lacks the DOM globals those tests need - failing exactly the "root test suite still passes" requirement this change is meant to satisfy. Each app workspace is tested via its own `npm run test --workspace=apps/*`. - Added @ai-sdk/openai, @ai-sdk/anthropic and ollama-ai-provider as devDependencies of apps/agent-forge (not the root package.json). @loushy/build-ai-agent's dist/index.mjs imports these as external, unconditionally, at the top of its single bundle; without them installed, Vite/vitest can't resolve that import at all for anything using the package. Co-Authored-By: Claude Sonnet 5 --- apps/agent-forge/eslint.config.mjs | 21 + apps/agent-forge/index.html | 20 + apps/agent-forge/package.json | 33 + apps/agent-forge/src/App.tsx | 23 + .../src/components/BottomDrawer.tsx | 44 + .../agent-forge/src/components/CanvasArea.tsx | 17 + apps/agent-forge/src/components/Inspector.tsx | 101 ++ apps/agent-forge/src/components/LeftRail.tsx | 115 ++ apps/agent-forge/src/components/Topbar.tsx | 76 + apps/agent-forge/src/components/layout.css | 452 ++++++ apps/agent-forge/src/main.tsx | 13 + apps/agent-forge/src/state/AppState.tsx | 99 ++ apps/agent-forge/src/theme.css | 108 ++ apps/agent-forge/src/vite-env.d.ts | 1 + apps/agent-forge/tsconfig.json | 26 + apps/agent-forge/vite.config.ts | 18 + package-lock.json | 1318 ++++++++++++++++- package.json | 3 + vitest.config.ts | 8 + 19 files changed, 2473 insertions(+), 23 deletions(-) create mode 100644 apps/agent-forge/eslint.config.mjs create mode 100644 apps/agent-forge/index.html create mode 100644 apps/agent-forge/package.json create mode 100644 apps/agent-forge/src/App.tsx create mode 100644 apps/agent-forge/src/components/BottomDrawer.tsx create mode 100644 apps/agent-forge/src/components/CanvasArea.tsx create mode 100644 apps/agent-forge/src/components/Inspector.tsx create mode 100644 apps/agent-forge/src/components/LeftRail.tsx create mode 100644 apps/agent-forge/src/components/Topbar.tsx create mode 100644 apps/agent-forge/src/components/layout.css create mode 100644 apps/agent-forge/src/main.tsx create mode 100644 apps/agent-forge/src/state/AppState.tsx create mode 100644 apps/agent-forge/src/theme.css create mode 100644 apps/agent-forge/src/vite-env.d.ts create mode 100644 apps/agent-forge/tsconfig.json create mode 100644 apps/agent-forge/vite.config.ts diff --git a/apps/agent-forge/eslint.config.mjs b/apps/agent-forge/eslint.config.mjs new file mode 100644 index 000000000..c9f01952c --- /dev/null +++ b/apps/agent-forge/eslint.config.mjs @@ -0,0 +1,21 @@ +// Mirrors the root repo's eslint.config.mjs conventions (typescript-eslint +// recommended, no-explicit-any / no-unused-vars / ban-ts-comment downgraded +// to 'warn' rather than turned off) so this app's lint output stays +// consistent with `npm run lint` at the repo root, rather than inventing a +// divergent lint setup for this one workspace. +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + ...tseslint.configs.recommended, + { + files: ['src/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/ban-ts-comment': 'warn', + }, + } +); diff --git a/apps/agent-forge/index.html b/apps/agent-forge/index.html new file mode 100644 index 000000000..f2f825d96 --- /dev/null +++ b/apps/agent-forge/index.html @@ -0,0 +1,20 @@ + + + + + + Agent Forge + + + + +
+ + + diff --git a/apps/agent-forge/package.json b/apps/agent-forge/package.json new file mode 100644 index 000000000..0e3e2f939 --- /dev/null +++ b/apps/agent-forge/package.json @@ -0,0 +1,33 @@ +{ + "name": "@loushy/agent-forge", + "version": "0.0.1", + "private": true, + "description": "Agent Forge - visual dashboard for creating, running and debugging agents built with @loushy/build-ai-agent (LOU-L: app shell & data model).", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.json && vite build", + "preview": "vite preview", + "test": "vitest", + "lint": "eslint src", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@loushy/build-ai-agent": "file:../..", + "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/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "ollama-ai-provider": "^1.2.0", + "typescript": "^5.9.3", + "vite": "^5.4.11", + "vitest": "^1.0.0" + } +} diff --git a/apps/agent-forge/src/App.tsx b/apps/agent-forge/src/App.tsx new file mode 100644 index 000000000..7f3e01ef8 --- /dev/null +++ b/apps/agent-forge/src/App.tsx @@ -0,0 +1,23 @@ +import './components/layout.css'; +import { AppStateProvider } from './state/AppState'; +import { Topbar } from './components/Topbar'; +import { LeftRail } from './components/LeftRail'; +import { CanvasArea } from './components/CanvasArea'; +import { Inspector } from './components/Inspector'; +import { BottomDrawer } from './components/BottomDrawer'; + +export function App() { + return ( + +
+ +
+ + + +
+ +
+
+ ); +} diff --git a/apps/agent-forge/src/components/BottomDrawer.tsx b/apps/agent-forge/src/components/BottomDrawer.tsx new file mode 100644 index 000000000..289500146 --- /dev/null +++ b/apps/agent-forge/src/components/BottomDrawer.tsx @@ -0,0 +1,44 @@ +import { useAppState } from '../state/AppState'; +import type { DrawerTab } from '../state/AppState'; + +const TABS: { id: DrawerTab; label: string }[] = [ + { id: 'chat', label: 'Chat' }, + { id: 'logs', label: 'Logs' }, + { id: 'trace', label: 'Trace' }, + { id: 'output', label: 'Output' }, + { id: 'settings', label: 'Settings' }, +]; + +/** + * Tab-switching chrome only - each tab's real content is a later epic: + * Chat -> LOU-P, Logs/Trace -> LOU-O, Settings -> LOU-R. "Output" shows the + * current in-memory `AgentSpec` as JSON, which is real (LOU-L2 data), not a + * placeholder. + */ +export function BottomDrawer() { + const { drawerTab, setDrawerTab, spec } = useAppState(); + + return ( +
+
+ {TABS.map((tab) => ( + + ))} +
+
+
+ {drawerTab === 'chat' &&
Chat with a running agent is wired up in LOU-P.
} + {drawerTab === 'logs' &&
Live execution logs are wired up in LOU-O (debug console).
} + {drawerTab === 'trace' &&
Execution trace spans are wired up in LOU-O (debug console).
} + {drawerTab === 'output' &&
{JSON.stringify(spec, null, 2)}
} + {drawerTab === 'settings' &&
Provider keys and deploy target settings are wired up in LOU-R.
} +
+
+ ); +} diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx new file mode 100644 index 000000000..30a22999f --- /dev/null +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -0,0 +1,17 @@ +/** + * Canvas placeholder (LOU-L1). Real ReactFlow-based node-graph editing + * lands in LOU-M - this just occupies the layout's center column with a + * styled placeholder so the app shell is complete without pretending to be + * an interactive canvas. + */ +export function CanvasArea() { + return ( +
+
+
Canvas
+ The node-graph editor (ReactFlow) is built in LOU-M. This placeholder confirms the app shell's layout, + theming and data model without a real canvas yet. +
+
+ ); +} diff --git a/apps/agent-forge/src/components/Inspector.tsx b/apps/agent-forge/src/components/Inspector.tsx new file mode 100644 index 000000000..6900f81e9 --- /dev/null +++ b/apps/agent-forge/src/components/Inspector.tsx @@ -0,0 +1,101 @@ +import { useAppState } from '../state/AppState'; + +const KNOWN_TOOLS = ['http', 'current-date', 'day-name']; +const KNOWN_PROVIDERS = ['mock', 'openai', 'anthropic', 'ollama', 'openrouter']; + +/** + * Inspector fields here are limited to what `AgentSpec` actually has a + * field for (name/prompt/provider/tools/policy.requiresApproval) - the + * mockup also shows per-node fields like temperature, max-steps and hook + * wiring that don't exist in `AgentSpec` yet (those are richer per-node + * config LOU-M's real graph editor will need to add to the spec/schema + * first). This deliberately edits the single underlying `AgentSpec` + * directly rather than a selected canvas node, since there is no real + * canvas/node-selection yet (LOU-M). + */ +export function Inspector() { + const { spec, setSpec } = useAppState(); + + function toggleTool(tool: string) { + setSpec((prev) => { + const tools = prev.tools ?? []; + const has = tools.includes(tool); + return { ...prev, tools: has ? tools.filter((t) => t !== tool) : [...tools, tool] }; + }); + } + + return ( +
+
+
Selected agent
+
{spec.name}
+
+
+
+ + setSpec((prev) => ({ ...prev, name: e.target.value }))} + /> +
+
+ +
+ + + setSpec((prev) => ({ ...prev, provider: { ...prev.provider, model: e.target.value } })) + } + /> +
+
+
+ +