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
23 changes: 21 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,29 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
# `undici` (a direct dependency, ^8.11.2 as of LOU-K3's Cloudflare
# Worker LLM provider support) declares an engines requirement of
# node >=22.19.0. Under Node 20, requiring it (indirectly, via
# anything that touches its Cache/fetch internals) throws
# `TypeError: webidl.util.markAsUncloneable is not a function`
# instead of just an EBADENGINE warning - which was crashing
# whichever vitest worker hit it first and cascading into "0 test"
# failures across the rest of that worker's queue. Node 20 was
# already below what a fresh dependency needs; bump to match.
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
- run: npm run lint
- run: npm run test:coverage
# Build the root SDK's dist *before* running tests:
# packages/create-loushy-agent/src/template.e2e.test.ts `npm pack`s the
# root SDK's own source tree to install it into a generated project,
# which needs dist/ (package.json's main/module/types) to already
# exist - it was previously built only in the step after tests, so
# this test always failed once it actually got a chance to run.
- run: npm run build
# packages/create-loushy-agent's own tests execFileSync its bin/cli.js,
# which requires ITS dist/index.js to exist too - build it before the
# test step (this is a separate package from the root SDK above).
- run: npm run build --workspace=packages/create-loushy-agent
- run: npm run test:coverage
21 changes: 21 additions & 0 deletions apps/agent-forge/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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',
},
}
);
20 changes: 20 additions & 0 deletions apps/agent-forge/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Forge</title>
<meta
name="description"
content="A flow-based builder, run console and inspector for creating, running and debugging Loushy agents."
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
33 changes: 33 additions & 0 deletions apps/agent-forge/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
23 changes: 23 additions & 0 deletions apps/agent-forge/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppStateProvider>
<div className="app">
<Topbar />
<div className="shell">
<LeftRail />
<CanvasArea />
<Inspector />
</div>
<BottomDrawer />
</div>
</AppStateProvider>
);
}
44 changes: 44 additions & 0 deletions apps/agent-forge/src/components/BottomDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="drawer">
<div className="drawer-tabs">
{TABS.map((tab) => (
<button
key={tab.id}
className={`drawer-tab${drawerTab === tab.id ? ' active' : ''}`}
onClick={() => setDrawerTab(tab.id)}
>
{tab.label}
</button>
))}
<div className="drawer-spacer" />
</div>
<div className="drawer-body">
{drawerTab === 'chat' && <div>Chat with a running agent is wired up in LOU-P.</div>}
{drawerTab === 'logs' && <div>Live execution logs are wired up in LOU-O (debug console).</div>}
{drawerTab === 'trace' && <div>Execution trace spans are wired up in LOU-O (debug console).</div>}
{drawerTab === 'output' && <pre style={{ margin: 0 }}>{JSON.stringify(spec, null, 2)}</pre>}
{drawerTab === 'settings' && <div>Provider keys and deploy target settings are wired up in LOU-R.</div>}
</div>
</div>
);
}
17 changes: 17 additions & 0 deletions apps/agent-forge/src/components/CanvasArea.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="canvas-wrap">
<div className="canvas-placeholder">
<div className="title">Canvas</div>
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.
</div>
</div>
);
}
101 changes: 101 additions & 0 deletions apps/agent-forge/src/components/Inspector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="inspector">
<div className="inspector-head">
<div className="k">Selected agent</div>
<div className="v">{spec.name}</div>
</div>
<div className="inspector-body">
<div className="field">
<label htmlFor="agent-name">Name</label>
<input
id="agent-name"
className="input"
value={spec.name}
onChange={(e) => setSpec((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="field">
<label>Provider &amp; model</label>
<div className="row2">
<select
className="select"
value={spec.provider.type}
onChange={(e) => setSpec((prev) => ({ ...prev, provider: { ...prev.provider, type: e.target.value } }))}
>
{KNOWN_PROVIDERS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<input
className="input"
value={spec.provider.model}
onChange={(e) =>
setSpec((prev) => ({ ...prev, provider: { ...prev.provider, model: e.target.value } }))
}
/>
</div>
</div>
<div className="field">
<label htmlFor="agent-prompt">System prompt</label>
<textarea
id="agent-prompt"
className="textarea"
value={spec.prompt}
onChange={(e) => setSpec((prev) => ({ ...prev, prompt: e.target.value }))}
/>
</div>
<div className="field">
<label>Tools enabled</label>
<div className="chip-list">
{KNOWN_TOOLS.map((tool) => (
<span
key={tool}
className={`chip${spec.tools?.includes(tool) ? ' on' : ''}`}
onClick={() => toggleTool(tool)}
role="button"
tabIndex={0}
>
{tool}
</span>
))}
</div>
</div>
<div className="field">
<label>Execution</label>
<div className="hint">
Requires approval: {spec.policy?.requiresApproval ? 'yes' : 'no'}. Hooks (pre/post) are wired up in
LOU-Q.
</div>
</div>
</div>
</div>
);
}
Loading
Loading