@electric-ax/agents 0.2.1 → 0.2.2
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 +5 -3
- package/dist/index.cjs +5 -3
- package/dist/index.js +5 -3
- package/docs/entities/agents/horton.md +89 -0
- package/docs/entities/agents/worker.md +102 -0
- package/docs/entities/patterns/blackboard.md +111 -0
- package/docs/entities/patterns/dispatcher.md +77 -0
- package/docs/entities/patterns/manager-worker.md +127 -0
- package/docs/entities/patterns/map-reduce.md +81 -0
- package/docs/entities/patterns/pipeline.md +101 -0
- package/docs/entities/patterns/reactive-observers.md +125 -0
- package/docs/examples/mega-draw.md +106 -0
- package/docs/examples/playground.md +46 -0
- package/docs/index.md +208 -0
- package/docs/quickstart.md +201 -0
- package/docs/reference/agent-config.md +82 -0
- package/docs/reference/agent-tool.md +58 -0
- package/docs/reference/built-in-collections.md +334 -0
- package/docs/reference/cli.md +238 -0
- package/docs/reference/entity-definition.md +57 -0
- package/docs/reference/entity-handle.md +63 -0
- package/docs/reference/entity-registry.md +73 -0
- package/docs/reference/handler-context.md +108 -0
- package/docs/reference/runtime-handler.md +136 -0
- package/docs/reference/shared-state-handle.md +74 -0
- package/docs/reference/state-collection-proxy.md +41 -0
- package/docs/reference/wake-event.md +132 -0
- package/docs/usage/app-setup.md +165 -0
- package/docs/usage/clients-and-react.md +191 -0
- package/docs/usage/configuring-the-agent.md +136 -0
- package/docs/usage/context-composition.md +204 -0
- package/docs/usage/defining-entities.md +181 -0
- package/docs/usage/defining-tools.md +229 -0
- package/docs/usage/embedded-builtins.md +180 -0
- package/docs/usage/managing-state.md +93 -0
- package/docs/usage/overview.md +284 -0
- package/docs/usage/programmatic-runtime-client.md +216 -0
- package/docs/usage/shared-state.md +169 -0
- package/docs/usage/spawning-and-coordinating.md +165 -0
- package/docs/usage/testing.md +76 -0
- package/docs/usage/waking-entities.md +148 -0
- package/docs/usage/writing-handlers.md +267 -0
- package/package.json +2 -1
- package/skills/quickstart/scaffold/package.json +16 -3
- package/skills/quickstart/scaffold/tsconfig.json +8 -3
- package/skills/quickstart/scaffold/vite.config.ts +21 -0
- package/skills/quickstart/scaffold-ui/index.html +12 -0
- package/skills/quickstart/scaffold-ui/main.tsx +235 -0
- package/skills/quickstart.md +244 -334
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Spawning & coordinating
|
|
3
|
+
titleTemplate: '... - Electric Agents'
|
|
4
|
+
description: >-
|
|
5
|
+
Spawn child entities, observe existing ones, send messages, and use EntityHandle for coordination.
|
|
6
|
+
outline: [2, 3]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Spawning & coordinating
|
|
10
|
+
|
|
11
|
+
Entities coordinate by spawning children, observing other entities, and sending messages.
|
|
12
|
+
|
|
13
|
+
## spawn
|
|
14
|
+
|
|
15
|
+
Create a child entity:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const child = await ctx.spawn(type, id, args?, opts?)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Parameter | Type | Description |
|
|
22
|
+
| --------------------- | ------------------------- | -------------------------------------- |
|
|
23
|
+
| `type` | `string` | Entity type name (must be registered) |
|
|
24
|
+
| `id` | `string` | Unique child ID |
|
|
25
|
+
| `args` | `Record<string, unknown>` | Passed to child handler as `ctx.args` |
|
|
26
|
+
| `opts.initialMessage` | `unknown` | First message delivered to child |
|
|
27
|
+
| `opts.wake` | `Wake` | When to wake the parent (see below) |
|
|
28
|
+
| `opts.tags` | `Record<string, string>` | Key-value tags applied to the child |
|
|
29
|
+
| `opts.observe` | `boolean` | Also observe the child (default: true) |
|
|
30
|
+
|
|
31
|
+
`spawn` is a creation-only operation. Calling it with a `(type, id)` pair that already exists in the entity's manifest throws an error. Use `observe(entity(url))` to get a handle to an existing child.
|
|
32
|
+
|
|
33
|
+
The `wake` option controls when the parent's handler is re-invoked:
|
|
34
|
+
|
|
35
|
+
- `'runFinished'` — wake when the child's agent run completes. The child's text response is included in the wake event by default.
|
|
36
|
+
- `{ on: 'runFinished', includeResponse?: boolean }` — same as above, but set `includeResponse: false` to omit the child's text response from the wake event.
|
|
37
|
+
- `{ on: 'change', collections?: string[], debounceMs?: number, timeoutMs?: number }` — wake when specified collections change.
|
|
38
|
+
|
|
39
|
+
Returns an [`EntityHandle`](#entityhandle).
|
|
40
|
+
|
|
41
|
+
## EntityHandle
|
|
42
|
+
|
|
43
|
+
Returned by `spawn` and `observe`:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
interface EntityHandle {
|
|
47
|
+
entityUrl: string
|
|
48
|
+
type?: string
|
|
49
|
+
db: EntityStreamDB // Read-only TanStack DB
|
|
50
|
+
events: ChangeEvent[]
|
|
51
|
+
run: Promise<void> // Resolves when child's run completes
|
|
52
|
+
text(): Promise<string[]> // Get completed text outputs
|
|
53
|
+
send(msg: unknown): void // Send follow-up message
|
|
54
|
+
status(): ChildStatus | undefined
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`status()` returns a `ChildStatus` object (or `undefined` if no status is known yet) with `.status`, `.entity_url`, `.entity_type`, and `.key`.
|
|
59
|
+
|
|
60
|
+
## Waiting for children
|
|
61
|
+
|
|
62
|
+
Wait for a single child:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
await child.run
|
|
66
|
+
const output = (await child.text()).join('\n\n')
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Wait for multiple children in parallel:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const results = await Promise.all(
|
|
73
|
+
children.map(async ({ handle }) => ({
|
|
74
|
+
text: (await handle.text()).join('\n\n'),
|
|
75
|
+
}))
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## observe
|
|
80
|
+
|
|
81
|
+
Subscribe to an existing entity without spawning it:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const handle = await ctx.observe(entity(entityUrl), {
|
|
85
|
+
wake: { on: 'change', collections: ['runs', 'childStatus'] },
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Returns an `EntityHandle`. Use `wake` to re-invoke the parent handler when the observed entity changes.
|
|
90
|
+
|
|
91
|
+
## send
|
|
92
|
+
|
|
93
|
+
Fire-and-forget message to another entity:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
ctx.send('/assistant/target-id', { text: 'Hello' })
|
|
97
|
+
ctx.send('/assistant/target-id', payload, { type: 'custom_type' })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Messages appear in the target entity's `inbox` collection.
|
|
101
|
+
|
|
102
|
+
## sleep
|
|
103
|
+
|
|
104
|
+
Return the entity to idle state, ending the current handler invocation:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
ctx.sleep()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The entity remains alive and can be woken again by incoming messages or observed changes.
|
|
111
|
+
|
|
112
|
+
## Working with existing children
|
|
113
|
+
|
|
114
|
+
After spawning children in `firstWake`, use `observe` on subsequent wakes to get handles:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
async handler(ctx) {
|
|
118
|
+
if (ctx.firstWake) {
|
|
119
|
+
await ctx.spawn(
|
|
120
|
+
"worker",
|
|
121
|
+
"analyst",
|
|
122
|
+
{ systemPrompt: "...", tools: ["read"] },
|
|
123
|
+
{
|
|
124
|
+
initialMessage: "Initial task.",
|
|
125
|
+
wake: "runFinished",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const analyst = await ctx.observe(entity("/worker/analyst"))
|
|
131
|
+
|
|
132
|
+
if (wake.type === "message_received") {
|
|
133
|
+
analyst.send(wake.payload)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`spawn` creates the child once. `observe` returns a handle on every wake — it's how you interact with children after creation.
|
|
139
|
+
|
|
140
|
+
## Workers and authenticated APIs
|
|
141
|
+
|
|
142
|
+
Workers are least-privilege sandboxes. The built-in `worker` receives a `systemPrompt`, a selected `tools` subset, an optional `sharedDb` config, and the `initialMessage` delivered at spawn time. Never interpolate secrets (`process.env.API_KEY`, auth tokens) into a worker's prompt or message — they are persisted in the entity's durable stream.
|
|
143
|
+
|
|
144
|
+
**Manager-side prefetch** is the recommended pattern: the manager does the authenticated fetch and passes the raw data to the worker.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// In the manager's tool:
|
|
148
|
+
const response = await fetch(apiUrl, {
|
|
149
|
+
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
|
|
150
|
+
})
|
|
151
|
+
const data = await response.json()
|
|
152
|
+
|
|
153
|
+
// Pass data, not credentials, to the worker
|
|
154
|
+
await ctx.spawn(
|
|
155
|
+
'worker',
|
|
156
|
+
id,
|
|
157
|
+
{ systemPrompt: 'Summarise this data.', tools: ['read'] },
|
|
158
|
+
{
|
|
159
|
+
initialMessage: JSON.stringify(data),
|
|
160
|
+
wake: 'runFinished',
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
When the worker needs to make follow-up authenticated calls (pagination, conditional fetches), register a custom worker entity type in your app that closes over the credential at registration time — don't use the built-in `worker` type for this.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing
|
|
3
|
+
titleTemplate: '... - Electric Agents'
|
|
4
|
+
description: >-
|
|
5
|
+
Test entity handlers with testResponses for LLM mocking, plus unit and integration testing patterns.
|
|
6
|
+
outline: [2, 3]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Testing
|
|
10
|
+
|
|
11
|
+
## testResponses
|
|
12
|
+
|
|
13
|
+
Test agent handlers without calling the LLM by providing canned responses:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
ctx.useAgent({
|
|
17
|
+
systemPrompt: '...',
|
|
18
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
19
|
+
tools: [...ctx.electricTools],
|
|
20
|
+
testResponses: ['Hello! How can I help?'],
|
|
21
|
+
})
|
|
22
|
+
await ctx.agent.run()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For array responses, the runtime picks a response based on the number of prior runs for the entity, which makes repeated wakes deterministic without calling an LLM. The selected string becomes the agent's text output for that run.
|
|
26
|
+
|
|
27
|
+
## TestResponseFn
|
|
28
|
+
|
|
29
|
+
For dynamic test responses, provide a function instead of an array:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
testResponses: async (message, bridge) => {
|
|
33
|
+
bridge.onTextStart()
|
|
34
|
+
bridge.onTextDelta('Test response')
|
|
35
|
+
bridge.onTextEnd()
|
|
36
|
+
return 'Test response'
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The `bridge` parameter gives control over text-level events, letting you simulate tool calls, reasoning steps, and multi-turn interactions. Returning a string emits it as a text block automatically; returning `undefined` emits no automatic text response.
|
|
41
|
+
|
|
42
|
+
::: info Runtime-managed lifecycle
|
|
43
|
+
The runtime wraps your `TestResponseFn` with `bridge.onRunStart()` / `bridge.onRunEnd()` and step start/end calls automatically. Do not call these yourself — only use text-level bridge methods (e.g. `onTextStart`, `onTextDelta`, `onTextEnd`) or tool-level methods inside the function.
|
|
44
|
+
:::
|
|
45
|
+
|
|
46
|
+
## Unit testing entity registration
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createEntityRegistry } from '@electric-ax/agents-runtime'
|
|
50
|
+
|
|
51
|
+
const registry = createEntityRegistry()
|
|
52
|
+
registerAssistant(registry)
|
|
53
|
+
|
|
54
|
+
test('registers assistant', () => {
|
|
55
|
+
const entry = registry.get('assistant')
|
|
56
|
+
expect(entry).toBeDefined()
|
|
57
|
+
expect(entry!.definition.handler).toBeTypeOf('function')
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Unit testing runtime creation
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
test('creates runtime with types', () => {
|
|
65
|
+
const runtime = createRuntimeHandler({
|
|
66
|
+
baseUrl: 'http://localhost:4437',
|
|
67
|
+
serveEndpoint: 'http://localhost:3000/webhook',
|
|
68
|
+
registry,
|
|
69
|
+
})
|
|
70
|
+
expect(runtime.typeNames).toContain('assistant')
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Integration testing
|
|
75
|
+
|
|
76
|
+
Integration testing with the full Electric Agents server is possible using the `@electric-ax/agents-server-conformance-tests` package, which provides test server utilities for running against a live server instance.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Waking entities
|
|
3
|
+
titleTemplate: '... - Electric Agents'
|
|
4
|
+
description: >-
|
|
5
|
+
How entity handlers get invoked - the triggers that produce wakes, how wake config threads through spawn/observe/observe(db(...)), and how to read a WakeEvent in a handler.
|
|
6
|
+
outline: [2, 3]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Waking entities
|
|
10
|
+
|
|
11
|
+
Entities in Electric Agents are driven by **wakes**. A wake is a single handler invocation triggered by something outside the handler: a new message, a child finishing, a change in an observed stream, or a schedule. Between wakes the entity is idle — no process, no memory, no running handler.
|
|
12
|
+
|
|
13
|
+
Everything you do to make an entity respond to something — `ctx.spawn(..., { wake })`, `ctx.observe(..., { wake })`, `ctx.send()`, `upsertCronSchedule()` — is ultimately a way to produce a wake.
|
|
14
|
+
|
|
15
|
+
## The mental model
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
external event ─► wake entry (persisted) ─► handler invocation ─► WakeEvent passed to handler
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
1. **External event.** A message arrives, a child transitions, a watched collection changes, a cron fires.
|
|
22
|
+
2. **Wake entry is persisted** to the entity's stream. This is the durability guarantee — wakes survive process restarts, network blips, and crashes. A wake that was written will eventually be delivered to a handler.
|
|
23
|
+
3. **Handler is invoked.** The runtime picks up the wake, loads the entity's state, and calls your handler with a `WakeEvent` describing what triggered this invocation.
|
|
24
|
+
4. **Handler runs.** You read `ctx.events`, inspect `wake`, configure the agent, emit new events. When the handler returns (or calls `ctx.sleep()`), the entity goes idle until the next wake.
|
|
25
|
+
|
|
26
|
+
This means handlers are re-entrant: the same handler function is called fresh on every wake. Use `ctx.firstWake` for one-time initialization, and `ctx.db.actions` / `ctx.db.collections` to carry state across wakes.
|
|
27
|
+
|
|
28
|
+
## What produces a wake
|
|
29
|
+
|
|
30
|
+
There are five things that can wake an entity:
|
|
31
|
+
|
|
32
|
+
### 1. An incoming message
|
|
33
|
+
|
|
34
|
+
Any external `/send` (via the CLI, HTTP, or another entity's `ctx.send()`) appends a `message_received` event to the entity's stream, which wakes the handler:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
ctx.send('/assistant/peer', { text: 'hello' })
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The receiving handler sees `wake.type === "message_received"` and finds the payload on `wake.payload`.
|
|
41
|
+
|
|
42
|
+
### 2. A spawned child
|
|
43
|
+
|
|
44
|
+
Pass `wake` when spawning a child to control when the parent wakes:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const child = await ctx.spawn(
|
|
48
|
+
'worker',
|
|
49
|
+
'analysis-1',
|
|
50
|
+
{ systemPrompt: 'Analyse this input.', tools: ['read'] },
|
|
51
|
+
{
|
|
52
|
+
initialMessage: 'begin',
|
|
53
|
+
wake: { on: 'runFinished', includeResponse: true },
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See the full catalog of `Wake` values in [WakeEvent](../reference/wake-event#wake).
|
|
59
|
+
|
|
60
|
+
### 3. An observed entity
|
|
61
|
+
|
|
62
|
+
`ctx.observe()` subscribes to another entity's stream without spawning it. Pair it with a `wake` option to re-invoke this handler when the observed stream changes:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { entity } from '@electric-ax/agents-runtime'
|
|
66
|
+
|
|
67
|
+
await ctx.observe(entity(someEntityUrl), {
|
|
68
|
+
wake: { on: 'change', collections: ['status'], debounceMs: 250 },
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The `entity()` helper wraps a raw URL string into the correct observe target type.
|
|
73
|
+
|
|
74
|
+
### 4. Shared state
|
|
75
|
+
|
|
76
|
+
`observe(db(...))` connects to a shared-state stream and, with `wake`, re-wakes the connecting entity when its collections change:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
await ctx.observe(db('board-1', schema), {
|
|
80
|
+
wake: { on: 'change', collections: ['findings'] },
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 5. A schedule
|
|
85
|
+
|
|
86
|
+
Runtime hosts can expose schedule-management tools through `ctx.electricTools`. The current schedule tool set is `list_schedules`, `upsert_cron_schedule`, `upsert_future_send`, and `delete_schedule`. Schedule entries live on the entity's manifest, so they survive restarts and can be updated or cancelled idempotently.
|
|
87
|
+
|
|
88
|
+
## Reading a WakeEvent
|
|
89
|
+
|
|
90
|
+
Your handler signature is:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
handler(ctx: HandlerContext, wake: WakeEvent) => void | Promise<void>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The minimum useful pattern is to branch on `wake.type`:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
async handler(ctx, wake) {
|
|
100
|
+
if (wake.type === "message_received") {
|
|
101
|
+
// external input - reply, dispatch, etc.
|
|
102
|
+
ctx.useAgent({ ... })
|
|
103
|
+
await ctx.agent.run()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// everything else (child finished, change, cron, timeout) arrives as type "wake".
|
|
108
|
+
// Inspect wake.payload for the specific sub-kind.
|
|
109
|
+
ctx.sleep()
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Two wake types reach handlers directly:
|
|
114
|
+
|
|
115
|
+
- `"message_received"` — an external message was delivered to this entity's inbox.
|
|
116
|
+
- `"wake"` — a synthesised wake for anything else (child finished, collection change, cron, timeout). The specifics are on `wake.payload`. A future-send schedule delivers a message, so it arrives as `"message_received"`.
|
|
117
|
+
|
|
118
|
+
For the full payload shape (`changes[]`, `finished_child`, `other_children`, `timeout`), see the [wake-type catalog](../reference/wake-event#wake-type-catalog) in the reference.
|
|
119
|
+
|
|
120
|
+
## Coalescing and idempotency
|
|
121
|
+
|
|
122
|
+
Multiple external events that arrive while an entity is busy (or between acks) are coalesced into a single wake. The runtime guarantees that:
|
|
123
|
+
|
|
124
|
+
- A wake covers a contiguous range of offsets in the source stream (`wake.fromOffset`..`wake.toOffset`).
|
|
125
|
+
- `wake.eventCount` tells you how many new events this wake represents.
|
|
126
|
+
- Handlers must be safe to re-run with the same input — at-least-once delivery. Use `ctx.firstWake` and idempotent writes to collections rather than side effects on each wake.
|
|
127
|
+
|
|
128
|
+
If you need to deduplicate explicitly, key your writes by something stable (the child's entity URL, the message's producer/epoch/seq headers, etc.) and let the collection's primary key do the dedup.
|
|
129
|
+
|
|
130
|
+
## Debounce and timeouts on `change` wakes
|
|
131
|
+
|
|
132
|
+
`{ on: 'change' }` has two knobs worth understanding:
|
|
133
|
+
|
|
134
|
+
- `debounceMs` — if set, rapid-fire changes are batched; the wake fires `debounceMs` after the last change.
|
|
135
|
+
- `timeoutMs` — if set, the wake fires after this interval **even if nothing changed**. Useful for heartbeat-style handlers that need to periodically check state without requiring external events.
|
|
136
|
+
|
|
137
|
+
Both are optional. If neither is set, every change produces a wake.
|
|
138
|
+
|
|
139
|
+
## Sleeping between wakes
|
|
140
|
+
|
|
141
|
+
When the handler finishes (or calls `ctx.sleep()`), the entity returns to idle. The runtime persists the ack offset so the next wake starts from the right place. You don't have to — and shouldn't — hold resources across wakes.
|
|
142
|
+
|
|
143
|
+
## See also
|
|
144
|
+
|
|
145
|
+
- [WakeEvent](../reference/wake-event) — full type reference and wake-type catalog.
|
|
146
|
+
- [Spawning & coordinating](./spawning-and-coordinating) — using `wake` with `spawn` and `observe`.
|
|
147
|
+
- [Shared state](./shared-state) — using `wake` with `observe(db(...))`.
|
|
148
|
+
- [Writing handlers](./writing-handlers) — `HandlerContext` and `firstWake` patterns.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Writing handlers
|
|
3
|
+
titleTemplate: '... - Electric Agents'
|
|
4
|
+
description: >-
|
|
5
|
+
Implement entity handlers using HandlerContext and WakeEvent, with patterns for first wake, messaging, and tool use.
|
|
6
|
+
outline: [2, 3]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Writing handlers
|
|
10
|
+
|
|
11
|
+
The handler is the function that runs each time an entity wakes. It receives a `HandlerContext` and a `WakeEvent` describing what triggered the invocation.
|
|
12
|
+
|
|
13
|
+
## Signature
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
handler(ctx: HandlerContext, wake: WakeEvent) => void | Promise<void>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## HandlerContext
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
interface HandlerContext<TState extends StateProxy = StateProxy> {
|
|
23
|
+
firstWake: boolean
|
|
24
|
+
tags: Readonly<EntityTags>
|
|
25
|
+
entityUrl: string
|
|
26
|
+
entityType: string
|
|
27
|
+
args: Readonly<Record<string, unknown>>
|
|
28
|
+
db: EntityStreamDBWithActions
|
|
29
|
+
state: TState
|
|
30
|
+
events: Array<ChangeEvent>
|
|
31
|
+
actions: Record<string, (...args: unknown[]) => unknown>
|
|
32
|
+
electricTools: AgentTool[]
|
|
33
|
+
useAgent: (config: AgentConfig) => AgentHandle
|
|
34
|
+
useContext: (config: UseContextConfig) => void
|
|
35
|
+
timelineMessages: (opts?: TimelineProjectionOpts) => Array<TimestampedMessage>
|
|
36
|
+
insertContext: (id: string, entry: ContextEntryInput) => void
|
|
37
|
+
removeContext: (id: string) => void
|
|
38
|
+
getContext: (id: string) => ContextEntry | undefined
|
|
39
|
+
listContext: () => Array<ContextEntry>
|
|
40
|
+
agent: AgentHandle
|
|
41
|
+
spawn: (
|
|
42
|
+
type: string,
|
|
43
|
+
id: string,
|
|
44
|
+
args?: Record<string, unknown>,
|
|
45
|
+
opts?: {
|
|
46
|
+
initialMessage?: unknown
|
|
47
|
+
wake?: Wake
|
|
48
|
+
tags?: Record<string, string>
|
|
49
|
+
observe?: boolean
|
|
50
|
+
}
|
|
51
|
+
) => Promise<EntityHandle>
|
|
52
|
+
observe: (
|
|
53
|
+
source: ObservationSource,
|
|
54
|
+
opts?: { wake?: Wake }
|
|
55
|
+
) => Promise<EntityHandle | SharedStateHandle | ObservationHandle>
|
|
56
|
+
mkdb: <T extends SharedStateSchemaMap>(
|
|
57
|
+
id: string,
|
|
58
|
+
schema: T
|
|
59
|
+
) => SharedStateHandle<T>
|
|
60
|
+
send: (
|
|
61
|
+
entityUrl: string,
|
|
62
|
+
payload: unknown,
|
|
63
|
+
opts?: { type?: string; afterMs?: number }
|
|
64
|
+
) => void
|
|
65
|
+
setTag: (key: string, value: string) => Promise<void>
|
|
66
|
+
removeTag: (key: string) => Promise<void>
|
|
67
|
+
sleep: () => void
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Property reference
|
|
72
|
+
|
|
73
|
+
| Property | Description |
|
|
74
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `firstWake` | `true` on the entity's first activation ever. Use for initialization. |
|
|
76
|
+
| `tags` | Entity tags -- key/value metadata associated with this entity. |
|
|
77
|
+
| `entityUrl` | The entity's URL path, e.g. `"/assistant/my-chat"`. |
|
|
78
|
+
| `entityType` | The registered type name, e.g. `"assistant"`. |
|
|
79
|
+
| `args` | Arguments passed when the entity was spawned. Immutable. |
|
|
80
|
+
| `db` | The entity's stream database. Use `db.actions` for writes and `db.collections` for reads. |
|
|
81
|
+
| `state` | Proxy object keyed by collection name. Each property is a [`StateCollectionProxy`](../reference/state-collection-proxy). |
|
|
82
|
+
| `events` | Change events that triggered this wake. |
|
|
83
|
+
| `actions` | Custom non-CRUD action functions from the entity definition's `actions` factory. |
|
|
84
|
+
| `electricTools` | Host-provided runtime-level tools to pass to `useAgent` when needed. May be empty. |
|
|
85
|
+
| `useAgent` | Configures the LLM agent. Returns an `AgentHandle`. See [Configuring the agent](./configuring-the-agent). |
|
|
86
|
+
| `useContext` | Declares context sources with token budgets and cache tiers. See [Context composition](./context-composition). |
|
|
87
|
+
| `timelineMessages` | Projects the entity timeline into LLM messages. See [Context composition](./context-composition#timelinemessages). |
|
|
88
|
+
| `insertContext` | Inserts a durable context entry. See [Context composition](./context-composition#context-entries). |
|
|
89
|
+
| `removeContext` | Removes a context entry by id. |
|
|
90
|
+
| `getContext` | Gets a context entry by id, or `undefined` if not found. |
|
|
91
|
+
| `listContext` | Lists all context entries. |
|
|
92
|
+
| `agent` | The configured agent handle. Call `agent.run()` to start the agent loop. |
|
|
93
|
+
| `spawn` | Creates a child entity. See [Spawning and coordinating](./spawning-and-coordinating). |
|
|
94
|
+
| `observe` | Connects to another entity's stream or shared db. See [Reactive observers](../entities/patterns/reactive-observers) and [Shared state](./shared-state). |
|
|
95
|
+
| `mkdb` | Creates a new shared state stream. See [Shared state](./shared-state). |
|
|
96
|
+
| `send` | Sends a message to another entity's inbox. Supports delayed delivery via `afterMs`. |
|
|
97
|
+
| `setTag` | Sets a tag on this entity. |
|
|
98
|
+
| `removeTag` | Removes a tag from this entity. |
|
|
99
|
+
| `sleep` | Returns the entity to idle without re-waking. |
|
|
100
|
+
|
|
101
|
+
## WakeEvent
|
|
102
|
+
|
|
103
|
+
Describes what triggered this handler invocation.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
type WakeEvent = {
|
|
107
|
+
source: string
|
|
108
|
+
type: string
|
|
109
|
+
fromOffset: number
|
|
110
|
+
toOffset: number
|
|
111
|
+
eventCount: number
|
|
112
|
+
payload?: unknown
|
|
113
|
+
summary?: string
|
|
114
|
+
fullRef?: string
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Field | Description |
|
|
119
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
120
|
+
| `source` | The stream or entity that caused the wake. |
|
|
121
|
+
| `type` | The wake type: `"message_received"` for inbox messages or `"wake"` for child completion, observed changes, cron, and timeouts. |
|
|
122
|
+
| `fromOffset` | Start offset of the events that triggered this wake. |
|
|
123
|
+
| `toOffset` | End offset of the events that triggered this wake. |
|
|
124
|
+
| `eventCount` | Number of new events since last wake. |
|
|
125
|
+
| `payload` | Optional payload from the trigger event. |
|
|
126
|
+
| `summary` | Optional human-readable summary. |
|
|
127
|
+
| `fullRef` | Optional full reference string for the trigger. |
|
|
128
|
+
|
|
129
|
+
## Typical handler pattern
|
|
130
|
+
|
|
131
|
+
Most handlers follow the same structure: initialize state on first wake, configure the agent, run the agent.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
registry.define('assistant', {
|
|
135
|
+
description: 'A general-purpose assistant',
|
|
136
|
+
state: {
|
|
137
|
+
status: { primaryKey: 'key' },
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async handler(ctx) {
|
|
141
|
+
if (ctx.firstWake) {
|
|
142
|
+
ctx.db.actions.status_insert({ row: { key: 'current', value: 'idle' } })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ctx.useAgent({
|
|
146
|
+
systemPrompt: 'You are a helpful assistant.',
|
|
147
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
148
|
+
tools: [...ctx.electricTools],
|
|
149
|
+
})
|
|
150
|
+
await ctx.agent.run()
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## AgentConfig
|
|
156
|
+
|
|
157
|
+
Passed to `ctx.useAgent()`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
interface AgentConfig {
|
|
161
|
+
systemPrompt: string
|
|
162
|
+
model: string | Model<any>
|
|
163
|
+
provider?: KnownProvider
|
|
164
|
+
tools: AgentTool[]
|
|
165
|
+
streamFn?: StreamFn
|
|
166
|
+
testResponses?: string[] | TestResponseFn
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## firstWake
|
|
171
|
+
|
|
172
|
+
`ctx.firstWake` is `true` only on the entity's very first activation. Use it for one-time initialization:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
async handler(ctx) {
|
|
176
|
+
if (ctx.firstWake) {
|
|
177
|
+
ctx.db.actions.status_insert({ row: { key: 'current', value: 'idle' } })
|
|
178
|
+
ctx.db.actions.counters_insert({ row: { key: 'runs', value: 0 } })
|
|
179
|
+
}
|
|
180
|
+
// ...
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
On subsequent wakes (new messages, child completion, etc.), `firstWake` is `false`.
|
|
185
|
+
|
|
186
|
+
## sleep
|
|
187
|
+
|
|
188
|
+
Call `ctx.sleep()` to return the entity to idle without triggering a re-wake. The handler exits and the entity waits for the next external event.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
async handler(ctx, wake) {
|
|
192
|
+
if (wake.type === 'some-condition') {
|
|
193
|
+
// Nothing to do right now
|
|
194
|
+
ctx.sleep()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
// Otherwise, run the agent
|
|
198
|
+
ctx.useAgent({ ... })
|
|
199
|
+
await ctx.agent.run()
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Using spawn args
|
|
204
|
+
|
|
205
|
+
Arguments passed at spawn time are available as `ctx.args`. This is how you parameterize entity behavior:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// Spawning side
|
|
209
|
+
const child = await ctx.spawn('worker', 'analysis-1', {
|
|
210
|
+
systemPrompt: 'You are an analyst.',
|
|
211
|
+
tools: ['read'],
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Worker handler
|
|
215
|
+
async handler(ctx) {
|
|
216
|
+
const { systemPrompt } = ctx.args as { systemPrompt: string }
|
|
217
|
+
ctx.useAgent({
|
|
218
|
+
systemPrompt,
|
|
219
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
220
|
+
tools: [...ctx.electricTools],
|
|
221
|
+
})
|
|
222
|
+
await ctx.agent.run()
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Adding custom tools
|
|
227
|
+
|
|
228
|
+
Combine `ctx.electricTools` with custom tools:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
async handler(ctx) {
|
|
232
|
+
const myTool: AgentTool = {
|
|
233
|
+
name: 'lookup',
|
|
234
|
+
label: 'Lookup',
|
|
235
|
+
description: 'Looks up a value by key',
|
|
236
|
+
parameters: Type.Object({
|
|
237
|
+
key: Type.String({ description: 'The key to look up' }),
|
|
238
|
+
}),
|
|
239
|
+
execute: async (_toolCallId, params) => {
|
|
240
|
+
const { key } = params as { key: string }
|
|
241
|
+
const row = ctx.db.collections.kv?.get(key)
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: 'text', text: row ? JSON.stringify(row) : 'Not found' }],
|
|
244
|
+
details: {},
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
ctx.useAgent({
|
|
250
|
+
systemPrompt: 'You are an assistant with lookup capabilities.',
|
|
251
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
252
|
+
tools: [...ctx.electricTools, myTool],
|
|
253
|
+
})
|
|
254
|
+
await ctx.agent.run()
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Sending messages
|
|
259
|
+
|
|
260
|
+
Use `ctx.send()` to deliver a message to another entity's inbox:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
ctx.send('/worker/task-1', { action: 'process', data: payload })
|
|
264
|
+
ctx.send('/worker/task-1', payload, { type: 'custom_type' })
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The target entity will be woken to process the message.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@electric-ax/agents",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Built-in Electric Agents runtimes such as Horton and worker",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
54
|
"dist",
|
|
55
|
+
"docs",
|
|
55
56
|
"skills"
|
|
56
57
|
],
|
|
57
58
|
"sideEffects": false,
|