@electric-ax/agents 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +40 -12
- package/dist/index.cjs +40 -12
- package/dist/index.js +40 -12
- package/docs/entities/agents/coder.md +99 -0
- package/docs/entities/agents/horton.md +16 -13
- package/docs/entities/agents/worker.md +18 -18
- package/docs/entities/patterns/blackboard.md +6 -6
- package/docs/entities/patterns/dispatcher.md +1 -1
- package/docs/entities/patterns/manager-worker.md +1 -1
- package/docs/entities/patterns/map-reduce.md +1 -1
- package/docs/entities/patterns/pipeline.md +1 -1
- package/docs/entities/patterns/reactive-observers.md +2 -2
- package/docs/examples/playground.md +42 -26
- package/docs/index.md +23 -23
- package/docs/quickstart.md +13 -13
- package/docs/reference/agent-config.md +20 -12
- package/docs/reference/agent-tool.md +1 -1
- package/docs/reference/built-in-collections.md +21 -21
- package/docs/reference/cli.md +39 -30
- package/docs/reference/entity-definition.md +9 -9
- package/docs/reference/entity-handle.md +2 -2
- package/docs/reference/entity-registry.md +1 -1
- package/docs/reference/handler-context.md +69 -18
- package/docs/reference/runtime-handler.md +25 -23
- package/docs/reference/shared-state-handle.md +7 -7
- package/docs/reference/state-collection-proxy.md +1 -1
- package/docs/reference/wake-event.md +23 -23
- package/docs/usage/app-setup.md +24 -23
- package/docs/usage/clients-and-react.md +44 -36
- package/docs/usage/configuring-the-agent.md +25 -19
- package/docs/usage/context-composition.md +12 -12
- package/docs/usage/defining-entities.md +36 -36
- package/docs/usage/defining-tools.md +45 -45
- package/docs/usage/embedded-builtins.md +48 -47
- package/docs/usage/managing-state.md +12 -12
- package/docs/usage/overview.md +52 -45
- package/docs/usage/programmatic-runtime-client.md +50 -47
- package/docs/usage/shared-state.md +32 -32
- package/docs/usage/spawning-and-coordinating.md +9 -9
- package/docs/usage/testing.md +14 -14
- package/docs/usage/waking-entities.md +13 -13
- package/docs/usage/writing-handlers.md +57 -26
- package/package.json +5 -2
- package/scripts/sync-docs.mjs +42 -0
- package/skills/quickstart/scaffold/package.json +1 -0
- package/skills/quickstart/scaffold-ui/index.html +1 -1
- package/skills/quickstart/scaffold-ui/main.tsx +221 -16
- package/skills/quickstart.md +49 -94
- package/docs/examples/mega-draw.md +0 -106
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
2
10
|
import { createRoot } from 'react-dom/client'
|
|
3
11
|
import { createAgentsClient, entity } from '@electric-ax/agents-runtime'
|
|
4
12
|
import { useChat } from '@electric-ax/agents-runtime/react'
|
|
@@ -11,12 +19,132 @@ import {
|
|
|
11
19
|
TextField,
|
|
12
20
|
Button,
|
|
13
21
|
Card,
|
|
22
|
+
IconButton,
|
|
14
23
|
} from '@radix-ui/themes'
|
|
24
|
+
import { SunIcon, MoonIcon, DesktopIcon } from '@radix-ui/react-icons'
|
|
15
25
|
import { Streamdown } from 'streamdown'
|
|
16
26
|
import '@radix-ui/themes/styles.css'
|
|
17
27
|
import 'streamdown/styles.css'
|
|
18
28
|
import type { EntityStreamDB } from '@electric-ax/agents-runtime'
|
|
19
29
|
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Dark-mode hook (inlined – single-file scaffold)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const DARK_MODE_STORAGE_KEY = `electric-perspectives.dark-mode`
|
|
35
|
+
|
|
36
|
+
type ThemePreference = `light` | `dark` | `system`
|
|
37
|
+
|
|
38
|
+
type DarkModeContextValue = {
|
|
39
|
+
darkMode: boolean
|
|
40
|
+
preference: ThemePreference
|
|
41
|
+
cyclePreference: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DarkModeContext = createContext<DarkModeContextValue | null>(null)
|
|
45
|
+
|
|
46
|
+
function readInitialPreference(): ThemePreference {
|
|
47
|
+
if (typeof window === `undefined`) return `system`
|
|
48
|
+
const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY)
|
|
49
|
+
if (stored === `true` || stored === `dark`) return `dark`
|
|
50
|
+
if (stored === `false` || stored === `light`) return `light`
|
|
51
|
+
return `system`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function systemPrefersDark(): boolean {
|
|
55
|
+
if (typeof window === `undefined`) return false
|
|
56
|
+
return window.matchMedia?.(`(prefers-color-scheme: dark)`).matches ?? false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cycleOrder: Record<ThemePreference, ThemePreference> = {
|
|
60
|
+
light: `dark`,
|
|
61
|
+
dark: `system`,
|
|
62
|
+
system: `light`,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function DarkModeProvider({
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
children: React.ReactNode
|
|
69
|
+
}): React.ReactElement {
|
|
70
|
+
const [preference, setPreference] = useState<ThemePreference>(
|
|
71
|
+
readInitialPreference
|
|
72
|
+
)
|
|
73
|
+
const [systemDark, setSystemDark] = useState<boolean>(systemPrefersDark)
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (typeof window === `undefined`) return
|
|
77
|
+
const mql = window.matchMedia(`(prefers-color-scheme: dark)`)
|
|
78
|
+
const onChange = (e: MediaQueryListEvent): void => setSystemDark(e.matches)
|
|
79
|
+
mql.addEventListener(`change`, onChange)
|
|
80
|
+
return () => mql.removeEventListener(`change`, onChange)
|
|
81
|
+
}, [])
|
|
82
|
+
|
|
83
|
+
const darkMode = preference === `system` ? systemDark : preference === `dark`
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
document.documentElement.classList.toggle(`dark`, darkMode)
|
|
87
|
+
}, [darkMode])
|
|
88
|
+
|
|
89
|
+
const cyclePreference = useCallback(() => {
|
|
90
|
+
setPreference((current) => {
|
|
91
|
+
const next = cycleOrder[current]
|
|
92
|
+
window.localStorage.setItem(DARK_MODE_STORAGE_KEY, next)
|
|
93
|
+
return next
|
|
94
|
+
})
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
const value = useMemo(
|
|
98
|
+
() => ({ darkMode, preference, cyclePreference }),
|
|
99
|
+
[darkMode, preference, cyclePreference]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<DarkModeContext.Provider value={value}>
|
|
104
|
+
{children}
|
|
105
|
+
</DarkModeContext.Provider>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function useDarkModeContext(): DarkModeContextValue {
|
|
110
|
+
const value = useContext(DarkModeContext)
|
|
111
|
+
if (!value) {
|
|
112
|
+
throw new Error(`useDarkModeContext must be used inside DarkModeProvider`)
|
|
113
|
+
}
|
|
114
|
+
return value
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Theme toggle button
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function themeButtonIcon(preference: ThemePreference) {
|
|
122
|
+
if (preference === `light`) return <SunIcon />
|
|
123
|
+
if (preference === `dark`) return <MoonIcon />
|
|
124
|
+
return <DesktopIcon />
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function themeButtonAriaLabel(preference: ThemePreference): string {
|
|
128
|
+
if (preference === `light`) return `Switch to dark mode`
|
|
129
|
+
if (preference === `dark`) return `Switch to system theme`
|
|
130
|
+
return `Switch to light mode`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ThemeToggle() {
|
|
134
|
+
const { preference, cyclePreference } = useDarkModeContext()
|
|
135
|
+
return (
|
|
136
|
+
<IconButton
|
|
137
|
+
variant="ghost"
|
|
138
|
+
size="2"
|
|
139
|
+
color="gray"
|
|
140
|
+
onClick={cyclePreference}
|
|
141
|
+
aria-label={themeButtonAriaLabel(preference)}
|
|
142
|
+
>
|
|
143
|
+
{themeButtonIcon(preference)}
|
|
144
|
+
</IconButton>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
20
148
|
const AGENTS_URL = `http://localhost:4437`
|
|
21
149
|
|
|
22
150
|
const AGENT_COLORS: Record<string, { bg: string; border: string }> = {
|
|
@@ -121,13 +249,29 @@ function MessageBubble({ msg }: { msg: AgentMessage }) {
|
|
|
121
249
|
)
|
|
122
250
|
}
|
|
123
251
|
|
|
252
|
+
function urlsFromId(id: string) {
|
|
253
|
+
return {
|
|
254
|
+
entityUrl: `/perspectives/${id}`,
|
|
255
|
+
optimistUrl: `/worker/${id}-optimist`,
|
|
256
|
+
criticUrl: `/worker/${id}-critic`,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function getIdFromHash(): string | null {
|
|
261
|
+
const hash = window.location.hash.slice(1)
|
|
262
|
+
return hash || null
|
|
263
|
+
}
|
|
264
|
+
|
|
124
265
|
function App() {
|
|
125
266
|
const [question, setQuestion] = useState(``)
|
|
126
267
|
const [urls, setUrls] = useState<{
|
|
127
268
|
entityUrl: string
|
|
128
269
|
optimistUrl: string
|
|
129
270
|
criticUrl: string
|
|
130
|
-
} | null>(
|
|
271
|
+
} | null>(() => {
|
|
272
|
+
const id = getIdFromHash()
|
|
273
|
+
return id ? urlsFromId(id) : null
|
|
274
|
+
})
|
|
131
275
|
const [loading, setLoading] = useState(false)
|
|
132
276
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
133
277
|
|
|
@@ -143,11 +287,15 @@ function App() {
|
|
|
143
287
|
2000
|
|
144
288
|
)
|
|
145
289
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
290
|
+
const hasOptimist = optimistMessages.length > 0
|
|
291
|
+
const hasCritic = criticMessages.length > 0
|
|
292
|
+
const hasWorkerMessages = hasOptimist || hasCritic
|
|
293
|
+
const hasBothWorkers = hasOptimist && hasCritic
|
|
294
|
+
// First analyser message is the intro; the rest is synthesis after workers
|
|
295
|
+
const analyserIntro = analyserMessages.slice(0, 1)
|
|
296
|
+
const analyserSynthesis = hasWorkerMessages
|
|
297
|
+
? analyserMessages.slice(1).filter((m) => m.text.trim() !== `waiting`)
|
|
298
|
+
: []
|
|
151
299
|
|
|
152
300
|
useEffect(() => {
|
|
153
301
|
const el = bottomRef.current?.parentElement
|
|
@@ -177,6 +325,8 @@ function App() {
|
|
|
177
325
|
optimistUrl: string
|
|
178
326
|
criticUrl: string
|
|
179
327
|
}
|
|
328
|
+
const id = data.entityUrl.split(`/`).pop()!
|
|
329
|
+
window.location.hash = id
|
|
180
330
|
setUrls(data)
|
|
181
331
|
} catch (err) {
|
|
182
332
|
console.error(`Analyze failed:`, err)
|
|
@@ -186,12 +336,13 @@ function App() {
|
|
|
186
336
|
}
|
|
187
337
|
|
|
188
338
|
return (
|
|
189
|
-
|
|
339
|
+
<>
|
|
190
340
|
<style>{`@keyframes blink { 50% { opacity: 0; } }`}</style>
|
|
191
|
-
<Box maxWidth="
|
|
192
|
-
<
|
|
193
|
-
Perspectives Analyzer
|
|
194
|
-
|
|
341
|
+
<Box maxWidth="900px" mx="auto" p="5">
|
|
342
|
+
<Flex justify="between" align="center" mb="1">
|
|
343
|
+
<Heading size="6">Perspectives Analyzer</Heading>
|
|
344
|
+
<ThemeToggle />
|
|
345
|
+
</Flex>
|
|
195
346
|
<Text size="2" color="gray" mb="5" as="p">
|
|
196
347
|
Ask a question and get two perspectives — an optimist and a critic —
|
|
197
348
|
then a balanced analysis.
|
|
@@ -217,19 +368,73 @@ function App() {
|
|
|
217
368
|
</Flex>
|
|
218
369
|
|
|
219
370
|
<Flex direction="column" gap="3">
|
|
220
|
-
{urls &&
|
|
371
|
+
{urls && analyserIntro.length === 0 && (
|
|
221
372
|
<Text color="gray" size="2">
|
|
222
373
|
Waiting for agents to respond...
|
|
223
374
|
</Text>
|
|
224
375
|
)}
|
|
225
|
-
|
|
226
|
-
|
|
376
|
+
|
|
377
|
+
{/* Analyser intro — full width */}
|
|
378
|
+
{analyserIntro.map((msg, i) => (
|
|
379
|
+
<MessageBubble
|
|
380
|
+
key={`analyser-intro-${i}`}
|
|
381
|
+
msg={hasWorkerMessages ? { ...msg, isStreaming: false } : msg}
|
|
382
|
+
/>
|
|
227
383
|
))}
|
|
384
|
+
|
|
385
|
+
{/* Workers — single column until both respond, then side-by-side */}
|
|
386
|
+
{hasWorkerMessages && !hasBothWorkers && (
|
|
387
|
+
<Flex direction="column" gap="3">
|
|
388
|
+
{optimistMessages.map((msg, i) => (
|
|
389
|
+
<MessageBubble key={`optimist-${i}`} msg={msg} />
|
|
390
|
+
))}
|
|
391
|
+
{criticMessages.map((msg, i) => (
|
|
392
|
+
<MessageBubble key={`critic-${i}`} msg={msg} />
|
|
393
|
+
))}
|
|
394
|
+
</Flex>
|
|
395
|
+
)}
|
|
396
|
+
{hasBothWorkers && (
|
|
397
|
+
<Flex gap="3">
|
|
398
|
+
<Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
|
|
399
|
+
{optimistMessages.map((msg, i) => (
|
|
400
|
+
<MessageBubble key={`optimist-${i}`} msg={msg} />
|
|
401
|
+
))}
|
|
402
|
+
</Flex>
|
|
403
|
+
<Flex direction="column" gap="3" style={{ flex: 1, minWidth: 0 }}>
|
|
404
|
+
{criticMessages.map((msg, i) => (
|
|
405
|
+
<MessageBubble key={`critic-${i}`} msg={msg} />
|
|
406
|
+
))}
|
|
407
|
+
</Flex>
|
|
408
|
+
</Flex>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
{/* Analyser synthesis — full width */}
|
|
412
|
+
{analyserSynthesis.map((msg, i) => (
|
|
413
|
+
<MessageBubble key={`analyser-synth-${i}`} msg={msg} />
|
|
414
|
+
))}
|
|
415
|
+
|
|
228
416
|
<div ref={bottomRef} />
|
|
229
417
|
</Flex>
|
|
230
418
|
</Box>
|
|
419
|
+
</>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function ThemedApp() {
|
|
424
|
+
const { darkMode } = useDarkModeContext()
|
|
425
|
+
return (
|
|
426
|
+
<Theme
|
|
427
|
+
appearance={darkMode ? `dark` : `light`}
|
|
428
|
+
accentColor="blue"
|
|
429
|
+
radius="medium"
|
|
430
|
+
>
|
|
431
|
+
<App />
|
|
231
432
|
</Theme>
|
|
232
433
|
)
|
|
233
434
|
}
|
|
234
435
|
|
|
235
|
-
createRoot(document.getElementById(`root`)!).render(
|
|
436
|
+
createRoot(document.getElementById(`root`)!).render(
|
|
437
|
+
<DarkModeProvider>
|
|
438
|
+
<ThemedApp />
|
|
439
|
+
</DarkModeProvider>
|
|
440
|
+
)
|
package/skills/quickstart.md
CHANGED
|
@@ -152,7 +152,7 @@ import { registerPerspectives } from './entities/perspectives'
|
|
|
152
152
|
registerPerspectives(registry)
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
Test: `
|
|
155
|
+
Test: `npx electric-ax agents spawn /perspectives/test-1 && npx electric-ax agents send /perspectives/test-1 "Is remote work better than office work?" && npx electric-ax agents observe /perspectives/test-1`
|
|
156
156
|
|
|
157
157
|
## Step 2: One worker
|
|
158
158
|
|
|
@@ -214,7 +214,7 @@ export function registerPerspectives(registry: EntityRegistry) {
|
|
|
214
214
|
}
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
-
Test: `
|
|
217
|
+
Test: `npx electric-ax agents spawn /perspectives/test-2 && npx electric-ax agents send /perspectives/test-2 "Is remote work better than office work?" && npx electric-ax agents observe /perspectives/test-2`
|
|
218
218
|
|
|
219
219
|
## Step 3: Two workers + state
|
|
220
220
|
|
|
@@ -283,7 +283,7 @@ export function registerPerspectives(registry: EntityRegistry) {
|
|
|
283
283
|
state: { children: { primaryKey: 'key' } },
|
|
284
284
|
async handler(ctx) {
|
|
285
285
|
ctx.useAgent({
|
|
286
|
-
systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. End your turn
|
|
286
|
+
systemPrompt: `You are a balanced analyst.\n\n1. Call analyze_question with the question.\n2. Tell the user you are spawning an optimist and a critic to analyze their question and that you will synthesize their perspectives once they finish.\n3. End your turn immediately — say nothing else.\n\nYou will be woken once per worker that finishes. Each wake includes finished_child and other_children (with status "running" or "finished").\n\nCRITICAL: When woken, check other_children. If ANY child still has status "running", you MUST respond with ONLY the exact text "waiting" and nothing else — no commentary, no status updates, no emoji. Just the single word "waiting".\n\nOnly when ALL children show status "finished" should you synthesize a balanced response using both perspectives.`,
|
|
287
287
|
model: 'claude-sonnet-4-6',
|
|
288
288
|
tools: [...ctx.electricTools, createAnalyzeTool(ctx)],
|
|
289
289
|
})
|
|
@@ -293,7 +293,7 @@ export function registerPerspectives(registry: EntityRegistry) {
|
|
|
293
293
|
}
|
|
294
294
|
```
|
|
295
295
|
|
|
296
|
-
Test: `
|
|
296
|
+
Test: `npx electric-ax agents spawn /perspectives/test-3 && npx electric-ax agents send /perspectives/test-3 "Is remote work better than office work?" && npx electric-ax agents observe /perspectives/test-3`
|
|
297
297
|
|
|
298
298
|
## Step 4: Server routes
|
|
299
299
|
|
|
@@ -303,104 +303,60 @@ Next, we'll turn this into a real app. The server.ts you've been running is a pl
|
|
|
303
303
|
|
|
304
304
|
`createRuntimeServerClient()` gives you a programmatic client for spawning entities and sending messages from your server code — the same operations you've been doing with the CLI.
|
|
305
305
|
|
|
306
|
-
Update `server.ts` — add the runtime server client and an `/api/analyze` route:
|
|
306
|
+
Update `server.ts` — add the runtime server client import and an `/api/analyze` route:
|
|
307
|
+
|
|
308
|
+
Add `createRuntimeServerClient` to your imports from `@electric-ax/agents-runtime`, then create a client instance:
|
|
307
309
|
|
|
308
310
|
```typescript
|
|
309
|
-
import http from 'node:http'
|
|
310
|
-
import path from 'node:path'
|
|
311
|
-
import { fileURLToPath } from 'node:url'
|
|
312
311
|
import {
|
|
313
312
|
createEntityRegistry,
|
|
314
313
|
createRuntimeHandler,
|
|
315
|
-
createRuntimeServerClient,
|
|
314
|
+
createRuntimeServerClient, // ← add this
|
|
316
315
|
} from '@electric-ax/agents-runtime'
|
|
317
|
-
import { createElectricTools } from './lib/electric-tools'
|
|
318
|
-
import { registerPerspectives } from './entities/perspectives'
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
322
|
-
process.loadEnvFile(path.resolve(here, '.env'))
|
|
323
|
-
} catch {}
|
|
324
|
-
|
|
325
|
-
if (!process.env.ANTHROPIC_API_KEY) {
|
|
326
|
-
console.warn(
|
|
327
|
-
'[app] ANTHROPIC_API_KEY is not set — agent.run() will throw on the first wake.'
|
|
328
|
-
)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const ELECTRIC_AGENTS_URL =
|
|
332
|
-
process.env.ELECTRIC_AGENTS_URL ?? 'http://localhost:4437'
|
|
333
|
-
const PORT = Number(process.env.PORT ?? 3000)
|
|
334
|
-
const SERVE_URL = process.env.SERVE_URL ?? `http://localhost:${PORT}`
|
|
335
316
|
|
|
336
|
-
|
|
337
|
-
registerPerspectives(registry)
|
|
338
|
-
|
|
339
|
-
const runtime = createRuntimeHandler({
|
|
340
|
-
baseUrl: ELECTRIC_AGENTS_URL,
|
|
341
|
-
serveEndpoint: `${SERVE_URL}/webhook`,
|
|
342
|
-
registry,
|
|
343
|
-
createElectricTools,
|
|
344
|
-
})
|
|
317
|
+
// ... existing setup ...
|
|
345
318
|
|
|
346
319
|
const client = createRuntimeServerClient({ baseUrl: ELECTRIC_AGENTS_URL })
|
|
320
|
+
```
|
|
347
321
|
|
|
348
|
-
|
|
349
|
-
const chunks: Array<Buffer> = []
|
|
350
|
-
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
351
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
352
|
-
}
|
|
322
|
+
Then add this route handler inside `http.createServer`, after the existing `/webhook` handler:
|
|
353
323
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
324
|
+
```typescript
|
|
325
|
+
if (req.url === '/api/analyze' && req.method === 'POST') {
|
|
326
|
+
try {
|
|
327
|
+
const body = (await readJson(req)) as { question?: string }
|
|
328
|
+
if (!body.question) {
|
|
329
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
330
|
+
res.end(JSON.stringify({ error: 'Missing "question" field' }))
|
|
331
|
+
return
|
|
332
|
+
}
|
|
359
333
|
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
const body = (await readJson(req)) as { question?: string }
|
|
363
|
-
if (!body.question) {
|
|
364
|
-
res.writeHead(400, { 'content-type': 'application/json' })
|
|
365
|
-
res.end(JSON.stringify({ error: 'Missing "question" field' }))
|
|
366
|
-
return
|
|
367
|
-
}
|
|
334
|
+
const id = `analysis-${crypto.randomUUID().slice(0, 8)}`
|
|
368
335
|
|
|
369
|
-
|
|
336
|
+
await client.spawnEntity({
|
|
337
|
+
type: 'perspectives',
|
|
338
|
+
id,
|
|
339
|
+
initialMessage: body.question,
|
|
340
|
+
})
|
|
370
341
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
342
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
343
|
+
res.end(
|
|
344
|
+
JSON.stringify({
|
|
345
|
+
entityUrl: `/perspectives/${id}`,
|
|
346
|
+
optimistUrl: `/worker/${id}-optimist`,
|
|
347
|
+
criticUrl: `/worker/${id}-critic`,
|
|
375
348
|
})
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
)
|
|
385
|
-
} catch (err) {
|
|
386
|
-
res.writeHead(500, { 'content-type': 'application/json' })
|
|
387
|
-
res.end(
|
|
388
|
-
JSON.stringify({
|
|
389
|
-
error: err instanceof Error ? err.message : String(err),
|
|
390
|
-
})
|
|
391
|
-
)
|
|
392
|
-
}
|
|
393
|
-
return
|
|
349
|
+
)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
352
|
+
res.end(
|
|
353
|
+
JSON.stringify({
|
|
354
|
+
error: err instanceof Error ? err.message : String(err),
|
|
355
|
+
})
|
|
356
|
+
)
|
|
394
357
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
res.end()
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
server.listen(PORT, async () => {
|
|
401
|
-
await runtime.registerTypes()
|
|
402
|
-
console.log(`App server ready on port ${PORT}`)
|
|
403
|
-
})
|
|
358
|
+
return
|
|
359
|
+
}
|
|
404
360
|
```
|
|
405
361
|
|
|
406
362
|
Test with curl:
|
|
@@ -414,7 +370,7 @@ curl -X POST http://localhost:3000/api/analyze \
|
|
|
414
370
|
Then observe the spawned entity:
|
|
415
371
|
|
|
416
372
|
```bash
|
|
417
|
-
|
|
373
|
+
npx electric-ax agents observe /perspectives/analysis-<id>
|
|
418
374
|
```
|
|
419
375
|
|
|
420
376
|
**Key concepts:**
|
|
@@ -536,7 +492,7 @@ Blue for the analyser, green for the optimist, red for the critic. `Streamdown`
|
|
|
536
492
|
|
|
537
493
|
### Wiring it together
|
|
538
494
|
|
|
539
|
-
The `App` component subscribes to all three entities and renders
|
|
495
|
+
The `App` component subscribes to all three entities and renders them in sections — the analyser's intro at full width, then the optimist and critic side-by-side in two columns, then the analyser's synthesis at full width:
|
|
540
496
|
|
|
541
497
|
```tsx
|
|
542
498
|
const analyserMessages = useAgentMessages(urls?.entityUrl ?? null, 'analyser')
|
|
@@ -547,14 +503,13 @@ const optimistMessages = useAgentMessages(
|
|
|
547
503
|
)
|
|
548
504
|
const criticMessages = useAgentMessages(urls?.criticUrl ?? null, 'critic', 2000)
|
|
549
505
|
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
]
|
|
506
|
+
const hasWorkerMessages =
|
|
507
|
+
optimistMessages.length > 0 || criticMessages.length > 0
|
|
508
|
+
const analyserIntro = analyserMessages.slice(0, 1)
|
|
509
|
+
const analyserSynthesis = hasWorkerMessages ? analyserMessages.slice(1) : []
|
|
555
510
|
```
|
|
556
511
|
|
|
557
|
-
|
|
512
|
+
The layout transitions naturally: first the analyser's intro appears full-width, then two columns open up as workers start streaming, and finally the synthesis appears full-width below. Workers get `retryMs=2000` because they're spawned asynchronously.
|
|
558
513
|
|
|
559
514
|
After explaining, tell the user to restart with `npm run dev:all` (starts both server and Vite). Open `http://localhost:5175`.
|
|
560
515
|
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Mega Draw
|
|
3
|
-
titleTemplate: '... - Electric Agents'
|
|
4
|
-
description: >-
|
|
5
|
-
Multi-agent collaborative drawing example with coordinator-worker patterns and 100 tile agents.
|
|
6
|
-
outline: [2, 3]
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
# Mega Draw
|
|
10
|
-
|
|
11
|
-
A collaborative multi-agent drawing app where 100 AI agents each own a tile of a shared 1000x1000 pixel canvas and work together to produce a drawing from a single text prompt. Located at `examples/mega-draw/` in the repository.
|
|
12
|
-
|
|
13
|
-
## What it demonstrates
|
|
14
|
-
|
|
15
|
-
- **Coordinator + worker pattern** at scale (1 coordinator spawning 100 tile agents)
|
|
16
|
-
- **Custom drawing tools** --- `fill_rect`, `draw_line`, `draw_circle`, `fill_gradient`, `set_pixels`
|
|
17
|
-
- **Shared canvas** --- in-memory pixel buffer flushed to PNG, served via a live viewer
|
|
18
|
-
- **Follow-up instructions** --- send a new prompt and only affected tiles get re-instructed
|
|
19
|
-
- **Two-pass workflow** --- coordinator does a quick first pass for backgrounds, then a detail pass
|
|
20
|
-
|
|
21
|
-
## Architecture
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
User
|
|
25
|
-
│
|
|
26
|
-
│ spawn /coordinator/my-drawing
|
|
27
|
-
│ send "Draw a sunset over mountains"
|
|
28
|
-
▼
|
|
29
|
-
┌────────────────────────────────┐
|
|
30
|
-
│ Coordinator Agent │
|
|
31
|
-
│ - Receives prompt │
|
|
32
|
-
│ - Plans composition + palette │
|
|
33
|
-
│ - Spawns 100 tile agents │
|
|
34
|
-
│ - Can re-instruct tiles │
|
|
35
|
-
└──────────┬─────────────────────┘
|
|
36
|
-
│ spawn tile-agent (10×10 grid)
|
|
37
|
-
▼
|
|
38
|
-
┌────────┐ ┌────────┐
|
|
39
|
-
│Tile 0,0│ │Tile 1,0│ ... (10 columns)
|
|
40
|
-
└────────┘ └────────┘
|
|
41
|
-
┌────────┐ ┌────────┐
|
|
42
|
-
│Tile 0,1│ │Tile 1,1│ ...
|
|
43
|
-
└────────┘ └────────┘
|
|
44
|
-
... ... (10 rows = 100 tiles)
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Each tile agent:
|
|
48
|
-
|
|
49
|
-
- Owns a 100x100 pixel region
|
|
50
|
-
- Can **see** 50px beyond its borders (200x200 viewport) for edge coordination
|
|
51
|
-
- Can only **draw** within its own tile
|
|
52
|
-
- Receives drawing instructions from the coordinator
|
|
53
|
-
|
|
54
|
-
## Key files
|
|
55
|
-
|
|
56
|
-
### `src/server.ts`
|
|
57
|
-
|
|
58
|
-
Entry point. Creates the registry, runtime handler, and two HTTP servers (one for the Electric Agents webhook, one for the canvas viewer).
|
|
59
|
-
|
|
60
|
-
```ts
|
|
61
|
-
const registry = createEntityRegistry()
|
|
62
|
-
registerCoordinator(registry, WEB_PORT)
|
|
63
|
-
registerTileAgent(registry)
|
|
64
|
-
|
|
65
|
-
const runtime = createRuntimeHandler({
|
|
66
|
-
baseUrl: ELECTRIC_AGENTS_URL,
|
|
67
|
-
serveEndpoint: `${SERVE_URL}/webhook`,
|
|
68
|
-
registry,
|
|
69
|
-
})
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### `src/coordinator.ts`
|
|
73
|
-
|
|
74
|
-
The coordinator entity. Defines two custom tools:
|
|
75
|
-
|
|
76
|
-
- `set_drawing_plan` --- sets the composition description and color palette
|
|
77
|
-
- `instruct_tile` --- spawns or re-instructs a tile agent with drawing directions
|
|
78
|
-
|
|
79
|
-
### `src/tile-agent.ts`
|
|
80
|
-
|
|
81
|
-
The tile agent entity. Each instance gets drawing tools scoped to its tile:
|
|
82
|
-
|
|
83
|
-
- `read_viewport` --- see current pixel state (own tile + neighbors)
|
|
84
|
-
- `fill_rect`, `draw_line`, `draw_circle`, `fill_gradient`, `set_pixels`
|
|
85
|
-
|
|
86
|
-
All coordinates are tile-relative (0--99) and automatically clipped to tile bounds.
|
|
87
|
-
|
|
88
|
-
## Running it
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
cd examples/mega-draw
|
|
92
|
-
pnpm install
|
|
93
|
-
cp ../../.env.template .env # Set ANTHROPIC_API_KEY
|
|
94
|
-
pnpm dev
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Requires a running Electric Agents runtime server at `http://localhost:4437`.
|
|
98
|
-
|
|
99
|
-
Then in another terminal:
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
npx electric-ax agents spawn /coordinator/my-drawing
|
|
103
|
-
npx electric-ax agents send /coordinator/my-drawing 'Draw a sunset over mountains'
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
View the canvas live at `http://localhost:3000/my-drawing` --- it auto-refreshes as tiles draw.
|