@electric-ax/agents 0.2.3 → 0.3.0

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.
Files changed (49) hide show
  1. package/dist/entrypoint.js +474 -737
  2. package/dist/index.cjs +470 -733
  3. package/dist/index.d.cts +68 -35
  4. package/dist/index.d.ts +69 -36
  5. package/dist/index.js +489 -751
  6. package/docs/entities/agents/horton.md +12 -12
  7. package/docs/entities/agents/worker.md +18 -18
  8. package/docs/entities/patterns/blackboard.md +6 -6
  9. package/docs/entities/patterns/dispatcher.md +1 -1
  10. package/docs/entities/patterns/manager-worker.md +1 -1
  11. package/docs/entities/patterns/map-reduce.md +1 -1
  12. package/docs/entities/patterns/pipeline.md +1 -1
  13. package/docs/entities/patterns/reactive-observers.md +2 -2
  14. package/docs/examples/playground.md +42 -26
  15. package/docs/index.md +25 -23
  16. package/docs/quickstart.md +12 -12
  17. package/docs/reference/agent-config.md +20 -12
  18. package/docs/reference/agent-tool.md +1 -1
  19. package/docs/reference/built-in-collections.md +21 -21
  20. package/docs/reference/cli.md +39 -30
  21. package/docs/reference/entity-definition.md +9 -9
  22. package/docs/reference/entity-handle.md +2 -2
  23. package/docs/reference/entity-registry.md +1 -1
  24. package/docs/reference/handler-context.md +34 -18
  25. package/docs/reference/mcp-registry.md +189 -0
  26. package/docs/reference/mcp-server-config.md +226 -0
  27. package/docs/reference/runtime-handler.md +25 -23
  28. package/docs/reference/shared-state-handle.md +7 -7
  29. package/docs/reference/state-collection-proxy.md +1 -1
  30. package/docs/reference/wake-event.md +23 -23
  31. package/docs/usage/app-setup.md +24 -23
  32. package/docs/usage/clients-and-react.md +40 -36
  33. package/docs/usage/configuring-the-agent.md +25 -19
  34. package/docs/usage/context-composition.md +12 -12
  35. package/docs/usage/defining-entities.md +36 -36
  36. package/docs/usage/defining-tools.md +45 -45
  37. package/docs/usage/embedded-builtins.md +54 -43
  38. package/docs/usage/managing-state.md +12 -12
  39. package/docs/usage/mcp-servers.md +354 -0
  40. package/docs/usage/overview.md +50 -45
  41. package/docs/usage/programmatic-runtime-client.md +51 -48
  42. package/docs/usage/shared-state.md +32 -32
  43. package/docs/usage/spawning-and-coordinating.md +9 -9
  44. package/docs/usage/testing.md +14 -14
  45. package/docs/usage/waking-entities.md +13 -13
  46. package/docs/usage/writing-handlers.md +52 -26
  47. package/package.json +9 -4
  48. package/scripts/sync-docs.mjs +42 -0
  49. package/docs/examples/mega-draw.md +0 -106
@@ -1,22 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { createServer } from "node:http";
3
2
  import path from "node:path";
4
- import fs, { promises, watch } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import fs from "node:fs";
5
5
  import pino from "pino";
6
6
  import { fileURLToPath } from "node:url";
7
- import { CODING_SESSION_CURSOR_COLLECTION_TYPE, CODING_SESSION_EVENT_COLLECTION_TYPE, CODING_SESSION_META_COLLECTION_TYPE, createEntityRegistry, createRuntimeHandler, db } from "@electric-ax/agents-runtime";
8
- import { spawn } from "node:child_process";
9
- import { homedir } from "node:os";
7
+ import { completeWithLowCostModel, createEntityRegistry, createRuntimeHandler, db, detectAvailableProviders, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
8
+ import { eq, not, queryOnce } from "@durable-streams/state";
10
9
  import { z } from "zod";
11
- import { deserializeCursor, discoverSessions, importLocalSession, loadSession, resolveSession, serializeCursor, tailSession } from "agent-session-protocol";
12
- import Anthropic from "@anthropic-ai/sdk";
13
10
  import { createHash } from "node:crypto";
14
11
  import fs$1 from "node:fs/promises";
15
12
  import Database from "better-sqlite3";
16
13
  import { Type } from "@sinclair/typebox";
17
14
  import { load } from "sqlite-vec";
18
15
  import { nanoid } from "nanoid";
19
- import { braveSearchTool, createBashTool, createEditTool, createReadFileTool, createWriteTool, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
16
+ import { getModels } from "@mariozechner/pi-ai";
17
+ import { braveSearchTool, createBashTool, createEditTool, createFetchUrlTool, createReadFileTool, createWriteTool, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
18
+ import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
20
19
 
21
20
  //#region src/log.ts
22
21
  const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
@@ -48,6 +47,10 @@ function formatArgs(args) {
48
47
  };
49
48
  }
50
49
  const serverLog = {
50
+ debug(...args) {
51
+ const { msg } = formatArgs(args);
52
+ logger.debug(msg);
53
+ },
51
54
  info(...args) {
52
55
  const { msg } = formatArgs(args);
53
56
  logger.info(msg);
@@ -67,516 +70,6 @@ const serverLog = {
67
70
  }
68
71
  };
69
72
 
70
- //#endregion
71
- //#region src/agents/coding-session.ts
72
- const defaultCliRunner = { async run(opts) {
73
- return new Promise((resolve, reject) => {
74
- const isClaude = opts.agent === `claude`;
75
- const bin = isClaude ? `claude` : `codex`;
76
- const args = isClaude ? opts.sessionId ? [
77
- `-r`,
78
- opts.sessionId,
79
- `--dangerously-skip-permissions`,
80
- `-p`
81
- ] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
82
- `exec`,
83
- `--skip-git-repo-check`,
84
- `resume`,
85
- opts.sessionId,
86
- opts.prompt
87
- ] : [
88
- `exec`,
89
- `--skip-git-repo-check`,
90
- opts.prompt
91
- ];
92
- const child = spawn(bin, args, {
93
- cwd: opts.cwd,
94
- stdio: [
95
- isClaude ? `pipe` : `ignore`,
96
- `pipe`,
97
- `pipe`
98
- ]
99
- });
100
- const MAX_BUF_CHARS = 4096;
101
- let stdout = ``;
102
- let stderr = ``;
103
- child.stdout?.on(`data`, (d) => {
104
- if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
105
- });
106
- child.stderr?.on(`data`, (d) => {
107
- if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
108
- });
109
- child.on(`error`, reject);
110
- child.on(`exit`, (code) => {
111
- resolve({
112
- exitCode: code ?? -1,
113
- stdout,
114
- stderr
115
- });
116
- });
117
- if (isClaude && child.stdin) {
118
- child.stdin.write(opts.prompt);
119
- child.stdin.end();
120
- }
121
- });
122
- } };
123
- async function discoverNewestSession(agent, cwd, excludeIds) {
124
- const all = await discoverSessions(agent);
125
- const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
126
- if (candidates.length === 0) return null;
127
- return candidates[0].sessionId;
128
- }
129
- /**
130
- * Compute the candidate directories where Claude Code stores per-cwd
131
- * session JSONL files. Claude resolves the cwd to its realpath when
132
- * choosing the directory name (so /tmp/foo on macOS lands under
133
- * `-private-tmp-foo`), but the entity may have been spawned with the
134
- * non-realpath form. Return both candidates so the caller can union
135
- * their contents.
136
- */
137
- async function getClaudeProjectDirs(cwd) {
138
- const home = homedir();
139
- const make = (c) => path.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
140
- const dirs = [make(cwd)];
141
- try {
142
- const real = await promises.realpath(cwd);
143
- if (real !== cwd) dirs.push(make(real));
144
- } catch {}
145
- return dirs;
146
- }
147
- async function listClaudeJsonlIdsByCwd(cwd) {
148
- const ids = new Set();
149
- for (const dir of await getClaudeProjectDirs(cwd)) try {
150
- const files = await promises.readdir(dir);
151
- for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
152
- } catch {}
153
- return ids;
154
- }
155
- /**
156
- * Deterministic-path discovery for a freshly created session. After the
157
- * Claude CLI runs in `-p` mode it writes the new JSONL straight into
158
- * `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
159
- * `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
160
- * so `discoverSessions` can miss it. Compute the expected dir directly
161
- * and diff its contents against a pre-run snapshot. Returns the newest
162
- * fresh sessionId or null. Codex falls back to discoverNewestSession.
163
- */
164
- async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
165
- if (agent === `claude`) {
166
- const dirs = await getClaudeProjectDirs(cwd);
167
- let best = null;
168
- for (const dir of dirs) try {
169
- const files = await promises.readdir(dir);
170
- for (const f of files) {
171
- if (!f.endsWith(`.jsonl`)) continue;
172
- const id = f.slice(0, -`.jsonl`.length);
173
- if (preDirectIds.has(id)) continue;
174
- const st = await promises.stat(path.join(dir, f)).catch(() => null);
175
- if (!st) continue;
176
- if (!best || st.mtimeMs > best.mtime) best = {
177
- id,
178
- mtime: st.mtimeMs
179
- };
180
- }
181
- } catch {}
182
- if (best) return best.id;
183
- }
184
- return discoverNewestSession(agent, cwd, preDiscoveredIds);
185
- }
186
- const sessionMetaRowSchema = z.object({
187
- key: z.literal(`current`),
188
- electricSessionId: z.string(),
189
- nativeSessionId: z.string().optional(),
190
- agent: z.enum([`claude`, `codex`]),
191
- cwd: z.string(),
192
- status: z.enum([
193
- `initializing`,
194
- `idle`,
195
- `running`,
196
- `error`
197
- ]),
198
- error: z.string().optional(),
199
- currentPromptInboxKey: z.string().optional()
200
- });
201
- const cursorStateRowSchema = z.object({
202
- key: z.literal(`current`),
203
- cursor: z.string(),
204
- lastProcessedInboxKey: z.string().optional()
205
- });
206
- const eventRowSchema = z.object({
207
- key: z.string(),
208
- ts: z.number(),
209
- type: z.string(),
210
- callId: z.string().optional(),
211
- payload: z.looseObject({})
212
- });
213
- const creationArgsSchema = z.object({
214
- agent: z.enum([`claude`, `codex`]),
215
- cwd: z.string().optional(),
216
- nativeSessionId: z.string().optional(),
217
- importFrom: z.object({
218
- agent: z.enum([`claude`, `codex`]),
219
- sessionId: z.string()
220
- }).optional()
221
- });
222
- const promptMessageSchema = z.object({ text: z.string() });
223
- /**
224
- * Stable key for an events-collection row, derived from the event's content.
225
- * Lets us re-insert the same event without producing duplicates — the caller
226
- * (or the collection's uniqueness guard) uses this to de-dup across retries,
227
- * replays, and crash recovery. Sorts chronologically by ts, then by type.
228
- */
229
- function eventKey(event) {
230
- const tsPart = String(event.ts).padStart(16, `0`);
231
- return `${tsPart}_${event.type}_${contentHashHex(event)}`;
232
- }
233
- function contentHashHex(event) {
234
- const json = JSON.stringify(event);
235
- let h = 5381;
236
- for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
237
- return h.toString(16).padStart(8, `0`);
238
- }
239
- function buildEventRow(event) {
240
- const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
241
- return {
242
- key: eventKey(event),
243
- ts: event.ts,
244
- type: event.type,
245
- ...callId !== void 0 ? { callId } : {},
246
- payload: event
247
- };
248
- }
249
- function appendIfNew(ctx, event) {
250
- const row = buildEventRow(event);
251
- if (ctx.events.get(row.key) !== void 0) return;
252
- ctx.actions.events_insert({ row });
253
- }
254
- /**
255
- * Mirror every event that lands in the JSONL file while `runWork` is
256
- * executing (i.e. while the CLI is running). Returns the advanced cursor
257
- * and the `runWork` result once everything has settled and every append
258
- * has been persisted to the entity's durable stream.
259
- *
260
- * If setup fails (e.g. the session file can't be resolved), `runWork`
261
- * still runs — but nothing is mirrored and `setupError` is populated so
262
- * the caller can surface the condition. If `runWork` throws, the error
263
- * propagates after the watcher has been cleaned up.
264
- */
265
- async function runWithLiveMirror(opts) {
266
- let cursor = null;
267
- let setupError = void 0;
268
- try {
269
- const session = await resolveSession(opts.nativeSessionId, opts.agent);
270
- if (opts.serializedCursor) cursor = deserializeCursor({
271
- ...opts.serializedCursor,
272
- path: session.path
273
- });
274
- else {
275
- const initial = await loadSession({
276
- sessionId: opts.nativeSessionId,
277
- agent: opts.agent
278
- });
279
- for (const ev of initial.events) appendIfNew(opts.ctx, ev);
280
- cursor = initial.cursor;
281
- }
282
- } catch (e) {
283
- setupError = e;
284
- }
285
- if (!cursor) {
286
- const result$1 = await opts.runWork();
287
- return {
288
- cursor: opts.serializedCursor,
289
- setupError,
290
- result: result$1
291
- };
292
- }
293
- let activeCursor = cursor;
294
- let busy = false;
295
- let pending = false;
296
- let stopped = false;
297
- const drainOnce = async () => {
298
- if (stopped && busy) return;
299
- if (busy) {
300
- pending = true;
301
- return;
302
- }
303
- busy = true;
304
- try {
305
- const res = await tailSession({ cursor: activeCursor });
306
- activeCursor = res.cursor;
307
- for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
308
- } catch {} finally {
309
- busy = false;
310
- if (pending && !stopped) {
311
- pending = false;
312
- drainOnce();
313
- }
314
- }
315
- };
316
- const fileWatcher = watch(activeCursor.path, () => {
317
- drainOnce();
318
- });
319
- const pollHandle = setInterval(() => {
320
- drainOnce();
321
- }, 1500);
322
- let result;
323
- try {
324
- result = await opts.runWork();
325
- } finally {
326
- stopped = true;
327
- clearInterval(pollHandle);
328
- fileWatcher.close();
329
- while (busy) await new Promise((r) => setTimeout(r, 10));
330
- try {
331
- const final = await tailSession({ cursor: activeCursor });
332
- activeCursor = final.cursor;
333
- for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
334
- } catch {}
335
- }
336
- return {
337
- cursor: serializeCursor(activeCursor),
338
- setupError,
339
- result
340
- };
341
- }
342
- function registerCodingSession(registry, options = {}) {
343
- const runner = options.cliRunner ?? defaultCliRunner;
344
- const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
345
- registry.define(`coder`, {
346
- description: `Runs a Claude Code / Codex CLI session and mirrors its normalized event stream into a durable store. Prompts arrive via message_received (type: "prompt") and are executed serially.`,
347
- creationSchema: creationArgsSchema,
348
- inboxSchemas: { prompt: promptMessageSchema },
349
- state: {
350
- sessionMeta: {
351
- schema: sessionMetaRowSchema,
352
- type: CODING_SESSION_META_COLLECTION_TYPE,
353
- primaryKey: `key`
354
- },
355
- cursorState: {
356
- schema: cursorStateRowSchema,
357
- type: CODING_SESSION_CURSOR_COLLECTION_TYPE,
358
- primaryKey: `key`
359
- },
360
- events: {
361
- schema: eventRowSchema,
362
- type: CODING_SESSION_EVENT_COLLECTION_TYPE,
363
- primaryKey: `key`
364
- }
365
- },
366
- async handler(ctx, _wake) {
367
- const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
368
- if (!existingMeta) {
369
- const args = creationArgsSchema.parse(ctx.args);
370
- const cwd = args.cwd ?? defaultCwd;
371
- const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
372
- let resolvedNativeId = args.nativeSessionId;
373
- if (args.importFrom) {
374
- const result = await importLocalSession({
375
- source: {
376
- sessionId: args.importFrom.sessionId,
377
- agent: args.importFrom.agent
378
- },
379
- target: {
380
- agent: args.agent,
381
- cwd
382
- }
383
- });
384
- resolvedNativeId = result.sessionId;
385
- }
386
- const hasNative = resolvedNativeId !== void 0;
387
- ctx.db.actions.sessionMeta_insert({ row: {
388
- key: `current`,
389
- electricSessionId,
390
- ...hasNative ? { nativeSessionId: resolvedNativeId } : {},
391
- agent: args.agent,
392
- cwd,
393
- status: hasNative ? `idle` : `initializing`
394
- } });
395
- }
396
- if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
397
- key: `current`,
398
- cursor: ``
399
- } });
400
- const metaRow = ctx.db.collections.sessionMeta.get(`current`);
401
- const cursorRow = ctx.db.collections.cursorState.get(`current`);
402
- if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
403
- if (metaRow.nativeSessionId && !cursorRow.cursor) {
404
- const mirrorCtx = {
405
- events: { get: (k) => ctx.db.collections.events.get(k) },
406
- actions: { events_insert: ctx.db.actions.events_insert }
407
- };
408
- try {
409
- const initial = await loadSession({
410
- sessionId: metaRow.nativeSessionId,
411
- agent: metaRow.agent
412
- });
413
- for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
414
- const serialized = serializeCursor(initial.cursor);
415
- ctx.db.actions.cursorState_update({
416
- key: `current`,
417
- updater: (d) => {
418
- d.cursor = JSON.stringify(serialized);
419
- }
420
- });
421
- } catch (e) {
422
- const message = e instanceof Error ? e.message : String(e);
423
- ctx.db.actions.sessionMeta_update({
424
- key: `current`,
425
- updater: (d) => {
426
- d.error = `initial mirror failed: ${message}`;
427
- }
428
- });
429
- }
430
- }
431
- const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
432
- const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
433
- const pending = inboxRows.filter((m) => m.key > lastKey);
434
- if (pending.length === 0) {
435
- if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
436
- key: `current`,
437
- updater: (d) => {
438
- d.status = `idle`;
439
- delete d.currentPromptInboxKey;
440
- delete d.error;
441
- }
442
- });
443
- return;
444
- }
445
- let runningMeta = metaRow;
446
- let runningCursor = cursorRow;
447
- for (const inboxMsg of pending) {
448
- const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
449
- if (!parsed.success) {
450
- ctx.db.actions.cursorState_update({
451
- key: `current`,
452
- updater: (d) => {
453
- d.lastProcessedInboxKey = inboxMsg.key;
454
- }
455
- });
456
- runningCursor = {
457
- ...runningCursor,
458
- lastProcessedInboxKey: inboxMsg.key
459
- };
460
- continue;
461
- }
462
- const prompt = parsed.data.text;
463
- const existingTitle = ctx.tags.title;
464
- if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
465
- ctx.db.actions.sessionMeta_update({
466
- key: `current`,
467
- updater: (d) => {
468
- d.status = `running`;
469
- d.currentPromptInboxKey = inboxMsg.key;
470
- delete d.error;
471
- }
472
- });
473
- const recordedRun = ctx.recordRun();
474
- const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
475
- try {
476
- const mirrorCtx = {
477
- events: { get: (k) => ctx.db.collections.events.get(k) },
478
- actions: { events_insert: ctx.db.actions.events_insert }
479
- };
480
- let nextCursorJson = runningCursor.cursor;
481
- if (!runningMeta.nativeSessionId) {
482
- const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
483
- const preDiscoveredIds = new Set((await discoverSessions(runningMeta.agent)).map((s) => s.sessionId));
484
- const cliResult = await runner.run({
485
- agent: runningMeta.agent,
486
- cwd: runningMeta.cwd,
487
- prompt
488
- });
489
- if (cliResult.exitCode !== 0) throw new Error(`[coding-session] ${runningMeta.agent} CLI exited ${cliResult.exitCode}. stderr=${cliResult.stderr.slice(0, 800) || `<empty>`} stdout=${cliResult.stdout.slice(0, 800) || `<empty>`}`);
490
- const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
491
- if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
492
- ctx.db.actions.sessionMeta_update({
493
- key: `current`,
494
- updater: (d) => {
495
- d.nativeSessionId = foundId;
496
- }
497
- });
498
- runningMeta = {
499
- ...runningMeta,
500
- nativeSessionId: foundId
501
- };
502
- const initial = await loadSession({
503
- sessionId: foundId,
504
- agent: runningMeta.agent
505
- });
506
- for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
507
- nextCursorJson = JSON.stringify(serializeCursor(initial.cursor));
508
- } else {
509
- const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
510
- const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
511
- agent: runningMeta.agent,
512
- nativeSessionId: runningMeta.nativeSessionId,
513
- serializedCursor,
514
- ctx: mirrorCtx,
515
- runWork: () => runner.run({
516
- agent: runningMeta.agent,
517
- sessionId: runningMeta.nativeSessionId,
518
- cwd: runningMeta.cwd,
519
- prompt
520
- })
521
- });
522
- if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
523
- if (cliResult.exitCode !== 0) throw new Error(`[coding-session] ${runningMeta.agent} CLI exited ${cliResult.exitCode}. stderr=${cliResult.stderr.slice(0, 800) || `<empty>`} stdout=${cliResult.stdout.slice(0, 800) || `<empty>`}`);
524
- const persistedCursor = nextSerialized ?? serializedCursor;
525
- nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
526
- }
527
- ctx.db.actions.cursorState_update({
528
- key: `current`,
529
- updater: (d) => {
530
- d.cursor = nextCursorJson;
531
- d.lastProcessedInboxKey = inboxMsg.key;
532
- }
533
- });
534
- runningCursor = {
535
- ...runningCursor,
536
- cursor: nextCursorJson,
537
- lastProcessedInboxKey: inboxMsg.key
538
- };
539
- for (const row of ctx.db.collections.events.toArray) {
540
- if (eventKeysBefore.has(row.key)) continue;
541
- if (row.type !== `assistant_message`) continue;
542
- const text = row.payload?.text;
543
- if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
544
- }
545
- recordedRun.end({ status: `completed` });
546
- } catch (e) {
547
- const message = e instanceof Error ? e.message : String(e);
548
- recordedRun.end({
549
- status: `failed`,
550
- finishReason: `error`
551
- });
552
- ctx.db.actions.sessionMeta_update({
553
- key: `current`,
554
- updater: (d) => {
555
- d.status = `error`;
556
- d.error = message;
557
- }
558
- });
559
- ctx.db.actions.cursorState_update({
560
- key: `current`,
561
- updater: (d) => {
562
- d.lastProcessedInboxKey = inboxMsg.key;
563
- }
564
- });
565
- throw e;
566
- }
567
- }
568
- ctx.db.actions.sessionMeta_update({
569
- key: `current`,
570
- updater: (d) => {
571
- d.status = `idle`;
572
- delete d.currentPromptInboxKey;
573
- delete d.error;
574
- }
575
- });
576
- }
577
- });
578
- }
579
-
580
73
  //#endregion
581
74
  //#region src/docs/embed.ts
582
75
  const EMBEDDING_DIMENSIONS = 128;
@@ -792,15 +285,38 @@ function findLatestQuestion(items) {
792
285
  return void 0;
793
286
  }
794
287
  function resolveDocsRoot(workingDirectory) {
288
+ const envDocsRoot = process.env.HORTON_DOCS_ROOT;
795
289
  const candidates = [
796
- process.env.HORTON_DOCS_ROOT,
797
- path.resolve(workingDirectory, `electric-agents-docs/docs`),
798
- path.resolve(process.cwd(), `electric-agents-docs/docs`),
799
- path.resolve(MODULE_DIR, `../docs`),
800
- path.resolve(MODULE_DIR, `../../docs`),
801
- path.resolve(MODULE_DIR, `../../../../../electric-agents-docs/docs`)
802
- ].filter((value) => typeof value === `string`);
803
- for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
290
+ envDocsRoot ? {
291
+ path: envDocsRoot,
292
+ requireIndex: false
293
+ } : null,
294
+ {
295
+ path: path.resolve(workingDirectory, `electric-agents-docs/docs`),
296
+ requireIndex: false
297
+ },
298
+ {
299
+ path: path.resolve(process.cwd(), `electric-agents-docs/docs`),
300
+ requireIndex: false
301
+ },
302
+ {
303
+ path: path.resolve(MODULE_DIR, `../docs`),
304
+ requireIndex: true
305
+ },
306
+ {
307
+ path: path.resolve(MODULE_DIR, `../../docs`),
308
+ requireIndex: true
309
+ },
310
+ {
311
+ path: path.resolve(MODULE_DIR, `../../../../website/docs/agents`),
312
+ requireIndex: true
313
+ },
314
+ {
315
+ path: path.resolve(MODULE_DIR, `../../../../../electric-agents-docs/docs`),
316
+ requireIndex: false
317
+ }
318
+ ].filter((value) => Boolean(value));
319
+ for (const candidate of candidates) if (fs.existsSync(candidate.path) && (!candidate.requireIndex || fs.existsSync(path.join(candidate.path, `index.md`)))) return candidate.path;
804
320
  return null;
805
321
  }
806
322
  var DocsKnowledgeBase = class {
@@ -830,7 +346,7 @@ var DocsKnowledgeBase = class {
830
346
  return db$1;
831
347
  } catch (error) {
832
348
  const message = error instanceof Error ? error.message : String(error);
833
- console.warn(`${this.logPrefix} falling back to in-memory docs index: ${message}`);
349
+ serverLog.debug(`${this.logPrefix} falling back to in-memory docs index: ${message}`);
834
350
  return null;
835
351
  }
836
352
  }
@@ -917,7 +433,7 @@ var DocsKnowledgeBase = class {
917
433
  }
918
434
  this.fallbackFingerprint = fingerprint;
919
435
  const stats$1 = this.stats();
920
- console.log(`${this.logPrefix} indexed ${stats$1.docCount} docs into ${stats$1.chunkCount} chunks (${stats$1.fingerprint.slice(0, 12)}...)`);
436
+ serverLog.debug(`${this.logPrefix} indexed ${stats$1.docCount} docs into ${stats$1.chunkCount} chunks (${stats$1.fingerprint.slice(0, 12)}...)`);
921
437
  return stats$1;
922
438
  }
923
439
  const db$1 = this.db;
@@ -954,7 +470,7 @@ var DocsKnowledgeBase = class {
954
470
  });
955
471
  reset();
956
472
  const stats = this.stats();
957
- console.log(`${this.logPrefix} indexed ${stats.docCount} docs into ${stats.chunkCount} chunks (${stats.fingerprint.slice(0, 12)}...)`);
473
+ serverLog.debug(`${this.logPrefix} indexed ${stats.docCount} docs into ${stats.chunkCount} chunks (${stats.fingerprint.slice(0, 12)}...)`);
958
474
  return stats;
959
475
  }
960
476
  hybridSearch(query, limit = DEFAULT_K) {
@@ -1094,7 +610,7 @@ function renderSearchResults(query, results, docsRoot) {
1094
610
  return lines.join(`\n`);
1095
611
  }
1096
612
  function logSearchResults(kind, query, output) {
1097
- console.log(`[horton-docs] ${kind} search for "${query}"\n${output}\n`);
613
+ serverLog.debug(`[horton-docs] ${kind} search for "${query}"\n${output}\n`);
1098
614
  }
1099
615
  function createHortonDocsSupport(workingDirectory, opts = {}) {
1100
616
  const docsRoot = opts.docsRoot ?? resolveDocsRoot(workingDirectory);
@@ -1358,11 +874,11 @@ const WORKER_TOOL_NAMES = [
1358
874
  `read`,
1359
875
  `write`,
1360
876
  `edit`,
1361
- `brave_search`,
877
+ `web_search`,
1362
878
  `fetch_url`,
1363
879
  `spawn_worker`
1364
880
  ];
1365
- function createSpawnWorkerTool(ctx) {
881
+ function createSpawnWorkerTool(ctx, modelConfig) {
1366
882
  return {
1367
883
  name: `spawn_worker`,
1368
884
  label: `Spawn Worker`,
@@ -1389,10 +905,16 @@ function createSpawnWorkerTool(ctx) {
1389
905
  details: { spawned: false }
1390
906
  };
1391
907
  const id = nanoid(10);
908
+ const workerModelArgs = modelConfig ? {
909
+ provider: modelConfig.provider,
910
+ model: modelConfig.model,
911
+ ...modelConfig.reasoningEffort && { reasoningEffort: modelConfig.reasoningEffort }
912
+ } : {};
1392
913
  try {
1393
914
  const handle = await ctx.spawn(`worker`, id, {
1394
915
  systemPrompt,
1395
- tools
916
+ tools,
917
+ ...workerModelArgs
1396
918
  }, {
1397
919
  initialMessage,
1398
920
  wake: {
@@ -1426,140 +948,137 @@ function createSpawnWorkerTool(ctx) {
1426
948
  }
1427
949
 
1428
950
  //#endregion
1429
- //#region src/tools/spawn-coder.ts
1430
- const CODER_AGENT_NAMES = [`claude`, `codex`];
1431
- function createSpawnCoderTool(ctx) {
951
+ //#region src/model-catalog.ts
952
+ const REASONING_EFFORT_VALUES = [
953
+ `auto`,
954
+ `minimal`,
955
+ `low`,
956
+ `medium`,
957
+ `high`
958
+ ];
959
+ const DEFAULT_ANTHROPIC_MODEL = `claude-sonnet-4-6`;
960
+ const DEFAULT_OPENAI_MODEL = `gpt-4.1`;
961
+ const DEFAULT_CODEX_MODEL = `gpt-5.4`;
962
+ function modelValue(provider, id) {
963
+ return `${provider}:${id}`;
964
+ }
965
+ function providerLabel(provider) {
966
+ if (provider === `anthropic`) return `Anthropic`;
967
+ if (provider === `openai-codex`) return `OpenAI Codex`;
968
+ return `OpenAI`;
969
+ }
970
+ function configuredProviders() {
971
+ return detectAvailableProviders();
972
+ }
973
+ function mockFallbackCatalog() {
974
+ const fallback = {
975
+ provider: `anthropic`,
976
+ id: DEFAULT_ANTHROPIC_MODEL,
977
+ label: `Anthropic ${DEFAULT_ANTHROPIC_MODEL}`,
978
+ value: modelValue(`anthropic`, DEFAULT_ANTHROPIC_MODEL),
979
+ reasoning: true
980
+ };
1432
981
  return {
1433
- name: `spawn_coder`,
1434
- label: `Spawn Coder`,
1435
- description: `Spawn a coding-session subagent (a coder) that drives a Claude Code or Codex CLI session in a working directory. Use when the user asks for code changes, file edits, debugging, or any task that benefits from a real coding agent with tool access. The coder is long-lived — its URL stays valid across many turns, so you can keep prompting it via prompt_coder without re-spawning. End your turn after spawning; you'll be woken when the coder finishes its first reply.`,
1436
- parameters: Type.Object({
1437
- prompt: Type.String({ description: `First user message sent to the coder. This is what kicks off the run — without it the coder will idle. Be concrete: describe the task, mention the files/paths involved, and what form of answer you want back.` }),
1438
- agent: Type.Optional(Type.Union(CODER_AGENT_NAMES.map((n) => Type.Literal(n)), { description: `Which coding agent to use. Defaults to "claude". Use "codex" only if the user explicitly asks for it.` })),
1439
- cwd: Type.Optional(Type.String({ description: `Working directory the coder runs in. Defaults to the runtime's cwd (the same directory Horton is running in). Set this when the user wants the coder to operate on a different repo.` }))
1440
- }),
1441
- execute: async (_toolCallId, params) => {
1442
- const { prompt, agent, cwd } = params;
1443
- if (typeof prompt !== `string` || prompt.length === 0) return {
1444
- content: [{
1445
- type: `text`,
1446
- text: `Error: prompt is required and must be a non-empty string.`
1447
- }],
1448
- details: { spawned: false }
1449
- };
1450
- const id = nanoid(10);
1451
- const spawnArgs = { agent: agent ?? `claude` };
1452
- if (cwd) spawnArgs.cwd = cwd;
1453
- try {
1454
- const handle = await ctx.spawn(`coder`, id, spawnArgs, {
1455
- initialMessage: { text: prompt },
1456
- wake: {
1457
- on: `runFinished`,
1458
- includeResponse: true
1459
- }
1460
- });
1461
- const coderUrl = handle.entityUrl;
1462
- return {
1463
- content: [{
1464
- type: `text`,
1465
- text: `Coder dispatched at ${coderUrl}. End your turn — when the coder finishes its current reply you'll be woken with the response. To send follow-up prompts to the same coder, call prompt_coder with this URL.`
1466
- }],
1467
- details: {
1468
- spawned: true,
1469
- coderUrl
1470
- }
1471
- };
1472
- } catch (err) {
1473
- serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1474
- return {
1475
- content: [{
1476
- type: `text`,
1477
- text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
1478
- }],
1479
- details: { spawned: false }
1480
- };
1481
- }
1482
- }
982
+ choices: [fallback],
983
+ defaultChoice: fallback
1483
984
  };
1484
985
  }
1485
- function createPromptCoderTool(ctx) {
986
+ async function fetchAvailableModelIds(provider) {
987
+ try {
988
+ const res = provider === `anthropic` ? await fetch(`https://api.anthropic.com/v1/models`, {
989
+ headers: {
990
+ "x-api-key": process.env.ANTHROPIC_API_KEY ?? ``,
991
+ "anthropic-version": `2023-06-01`
992
+ },
993
+ signal: AbortSignal.timeout(3e3)
994
+ }) : await fetch(`https://api.openai.com/v1/models`, {
995
+ headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ``}` },
996
+ signal: AbortSignal.timeout(3e3)
997
+ });
998
+ if (res.status === 401 || res.status === 403) return new Set();
999
+ if (!res.ok) return null;
1000
+ const body = await res.json();
1001
+ const ids = new Set((body.data ?? []).map((model) => model.id).filter((id) => typeof id === `string`));
1002
+ return ids.size > 0 ? ids : null;
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+ async function choicesForProvider(provider) {
1008
+ const knownModels = getModels(provider);
1009
+ if (provider === `openai-codex`) return knownModels.map((model) => ({
1010
+ provider,
1011
+ id: model.id,
1012
+ label: `${providerLabel(provider)} ${model.name}`,
1013
+ value: modelValue(provider, model.id),
1014
+ reasoning: model.reasoning
1015
+ }));
1016
+ const availableIds = await fetchAvailableModelIds(provider);
1017
+ const models = availableIds === null ? knownModels : knownModels.filter((model) => availableIds.has(model.id));
1018
+ return models.map((model) => ({
1019
+ provider,
1020
+ id: model.id,
1021
+ label: `${providerLabel(provider)} ${model.name}`,
1022
+ value: modelValue(provider, model.id),
1023
+ reasoning: model.reasoning
1024
+ }));
1025
+ }
1026
+ function withProviderPayloadDefaults(config, choice, reasoningEffort) {
1027
+ if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
1028
+ const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1029
+ const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1486
1030
  return {
1487
- name: `prompt_coder`,
1488
- label: `Prompt Coder`,
1489
- description: `Send a follow-up prompt to a coder you previously spawned. The prompt is queued on the coder's inbox and runs as the next CLI turn. End your turn after calling — you'll be woken when the coder's reply lands.`,
1490
- parameters: Type.Object({
1491
- coder_url: Type.String({ description: `Entity URL returned by spawn_coder, e.g. "/coder/abc123". Must be the URL of a coder you previously spawned in this conversation.` }),
1492
- prompt: Type.String({ description: `Follow-up message to send to the coder. Treat this like the next turn in a chat — reference earlier context the coder already saw rather than restating it.` })
1493
- }),
1494
- execute: async (_toolCallId, params) => {
1495
- const { coder_url, prompt } = params;
1496
- if (typeof coder_url !== `string` || !coder_url.startsWith(`/coder/`)) return {
1497
- content: [{
1498
- type: `text`,
1499
- text: `Error: coder_url must be a path like "/coder/<id>".`
1500
- }],
1501
- details: { sent: false }
1502
- };
1503
- if (typeof prompt !== `string` || prompt.length === 0) return {
1504
- content: [{
1505
- type: `text`,
1506
- text: `Error: prompt is required and must be a non-empty string.`
1507
- }],
1508
- details: { sent: false }
1031
+ ...config,
1032
+ onPayload: (payload) => {
1033
+ if (typeof payload !== `object` || payload === null) return void 0;
1034
+ const body = payload;
1035
+ const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1036
+ return {
1037
+ ...body,
1038
+ reasoning: {
1039
+ ...existingReasoning,
1040
+ effort
1041
+ }
1509
1042
  };
1510
- try {
1511
- ctx.send(coder_url, { text: prompt });
1512
- return {
1513
- content: [{
1514
- type: `text`,
1515
- text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
1516
- }],
1517
- details: {
1518
- sent: true,
1519
- coderUrl: coder_url
1520
- }
1521
- };
1522
- } catch (err) {
1523
- serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
1524
- return {
1525
- content: [{
1526
- type: `text`,
1527
- text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
1528
- }],
1529
- details: { sent: false }
1530
- };
1531
- }
1532
1043
  }
1533
1044
  };
1534
1045
  }
1046
+ function parseReasoningEffort(value) {
1047
+ return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
1048
+ }
1049
+ async function createBuiltinModelCatalog(options = {}) {
1050
+ const providers = configuredProviders();
1051
+ if (providers.length === 0 && options.allowMockFallback) return mockFallbackCatalog();
1052
+ const choices = (await Promise.all(providers.map((provider) => choicesForProvider(provider)))).flat();
1053
+ if (choices.length === 0) return options.allowMockFallback ? mockFallbackCatalog() : null;
1054
+ const defaultChoice = choices.find((choice) => choice.provider === `anthropic` && choice.id === DEFAULT_ANTHROPIC_MODEL) ?? choices.find((choice) => choice.provider === `openai` && choice.id === DEFAULT_OPENAI_MODEL) ?? choices.find((choice) => choice.provider === `openai-codex` && choice.id === DEFAULT_CODEX_MODEL) ?? choices[0];
1055
+ return {
1056
+ choices,
1057
+ defaultChoice
1058
+ };
1059
+ }
1060
+ function resolveBuiltinModelConfig(catalog, args) {
1061
+ const modelArg = args.model;
1062
+ const providerArg = args.provider;
1063
+ const reasoningEffort = parseReasoningEffort(args.reasoningEffort);
1064
+ const selected = typeof modelArg === `string` ? catalog.choices.find((choice$1) => choice$1.value === modelArg || choice$1.id === modelArg && choice$1.provider === providerArg) : void 0;
1065
+ const choice = selected ?? catalog.defaultChoice;
1066
+ const config = {
1067
+ provider: choice.provider,
1068
+ model: choice.id,
1069
+ ...reasoningEffort && { reasoningEffort },
1070
+ ...choice.provider === `openai-codex` && { getApiKey: () => readCodexAccessToken() }
1071
+ };
1072
+ return withProviderPayloadDefaults(config, choice, reasoningEffort);
1073
+ }
1074
+ function modelChoiceValues(catalog) {
1075
+ return catalog.choices.map((choice) => choice.value);
1076
+ }
1535
1077
 
1536
1078
  //#endregion
1537
1079
  //#region src/agents/horton.ts
1538
- const TITLE_MODEL = `claude-haiku-4-5-20251001`;
1539
- const HORTON_MODEL = `claude-sonnet-4-6`;
1540
- let anthropic = null;
1541
- function getClient() {
1542
- if (!anthropic) anthropic = new Anthropic();
1543
- return anthropic;
1544
- }
1545
- async function defaultHaikuCall(prompt) {
1546
- const client = getClient();
1547
- const res = await client.messages.create({
1548
- model: TITLE_MODEL,
1549
- max_tokens: 64,
1550
- messages: [{
1551
- role: `user`,
1552
- content: prompt
1553
- }]
1554
- });
1555
- const block = res.content[0];
1556
- return block?.type === `text` ? block.text : ``;
1557
- }
1558
- const TITLE_PROMPT = (userMessage) => `Summarize the following user request in 3-5 words for use as a chat session title.
1559
- Respond with only the title, no quotes, no punctuation, no preamble.
1560
-
1561
- User request:
1562
- ${userMessage}`;
1080
+ const TITLE_SYSTEM_PROMPT = "You generate concise chat session titles in 3-5 words. Respond with only the title, no quotes, no punctuation, no preamble.";
1081
+ const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1563
1082
  const TITLE_STOP_WORDS = new Set([
1564
1083
  `a`,
1565
1084
  `an`,
@@ -1627,19 +1146,34 @@ function buildFallbackTitle(userMessage) {
1627
1146
  const selected = informativeWords.length >= 2 ? informativeWords.slice(0, 5) : backupWords;
1628
1147
  return selected.join(` `).slice(0, 80).trim() || `Untitled Chat`;
1629
1148
  }
1630
- async function generateTitle(userMessage, llmCall = defaultHaikuCall) {
1149
+ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1150
+ return (prompt) => completeWithLowCostModel({
1151
+ catalog,
1152
+ modelConfig,
1153
+ log: (message) => serverLog.info(message),
1154
+ logPrefix,
1155
+ purpose: `title generation`,
1156
+ systemPrompt: TITLE_SYSTEM_PROMPT,
1157
+ prompt,
1158
+ maxTokens: 64
1159
+ });
1160
+ }
1161
+ async function generateTitle(userMessage, llmCall, onFallback) {
1631
1162
  try {
1632
- const raw = await llmCall(TITLE_PROMPT(userMessage));
1163
+ const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1633
1164
  const title = raw.trim();
1634
- return title.length > 0 ? title : buildFallbackTitle(userMessage);
1635
- } catch {
1165
+ if (title.length > 0) return title;
1166
+ onFallback?.(`empty LLM title response`);
1167
+ return buildFallbackTitle(userMessage);
1168
+ } catch (err) {
1169
+ onFallback?.(err instanceof Error ? err.message : String(err));
1636
1170
  return buildFallbackTitle(userMessage);
1637
1171
  }
1638
1172
  }
1639
1173
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1640
1174
  const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
1641
1175
  const skillsTools = opts.hasSkills ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` : ``;
1642
- const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use brave_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
1176
+ const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents, Durable Agents, or this framework, ALWAYS use search_durable_agents_docs FIRST. Do not use web_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
1643
1177
  const skillsGuidance = opts.hasSkills ? `\n# Skills\nYou have access to skills — specialized knowledge and guided workflows you can load on demand. Your context includes a skills catalog listing what's available. When the user's request matches a skill's description or keywords, load it with use_skill.
1644
1178
 
1645
1179
  Some skills are user-invocable — the user can trigger them with a slash command like \`/quickstart\`. When you see a message starting with \`/\` followed by a skill name, load that skill immediately with use_skill. Pass any text after the skill name as args.
@@ -1675,7 +1209,9 @@ Don't force onboarding. If someone just wants to chat or code, let them. When in
1675
1209
  - ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
1676
1210
  - The Electric Agents docs site is at ${opts.docsUrl}
1677
1211
  - The docs site covers: Usage (entity definition, handlers, tools, state, spawning, coordination, waking, shared state, client integration, app setup), Reference (handler context, entity definitions, configurations, tools, state proxies, wake events, registries), Entities (Horton, Worker), and Patterns (Manager-Worker, Pipeline, Map-Reduce, Dispatcher, Blackboard, Reactive Observers).
1678
- - For general coding questions unrelated to Electric Agents, use brave_search or your own knowledge.` : ``;
1212
+ - For general coding questions unrelated to Electric Agents, use web_search or your own knowledge.` : ``;
1213
+ const modelGuidance = opts.modelProvider && opts.modelId ? `\n# Runtime model
1214
+ You are currently running via provider "${opts.modelProvider}" with model "${opts.modelId}". If the user asks what model or provider you are using, answer with these exact runtime values. Do not infer your model identity from training data or from the name of another coding tool.` : ``;
1679
1215
  return `You are Horton, a friendly and capable assistant. You can chat, research the web, read and edit code, run shell commands, and dispatch subagents (workers) for isolated subtasks. Be warm and engaging in conversation; be precise and concrete when working with code.
1680
1216
 
1681
1217
  # Greetings
@@ -1686,18 +1222,16 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1686
1222
  - read: read a file
1687
1223
  - write: create or overwrite a file
1688
1224
  - edit: targeted string replacement in an existing file (you must read the file first)
1689
- - brave_search: search the web
1225
+ - web_search: search the web
1690
1226
  - fetch_url: fetch and convert a URL to markdown
1691
1227
  - spawn_worker: dispatch a subagent for an isolated task
1692
- - spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
1693
- - prompt_coder: send a follow-up prompt to a coder you previously spawned
1694
1228
  ${docsTools}${skillsTools}
1695
1229
 
1696
1230
  # Working with files
1697
1231
  - Prefer edit over write when modifying existing files.
1698
1232
  - You must read a file before you can edit it.
1699
1233
  - Use absolute paths or paths relative to the current working directory.
1700
- ${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
1234
+ ${modelGuidance}${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
1701
1235
 
1702
1236
  # Risky actions
1703
1237
  Pause and confirm with the user before:
@@ -1718,13 +1252,6 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
1718
1252
 
1719
1253
  After spawning, end your turn (optionally with a brief "I've dispatched a worker for X; I'll respond when it finishes"). When the worker finishes, you'll receive a message describing which worker completed and what it returned. Multiple workers may finish at different times — check the message for the worker URL to know which one you're hearing about.
1720
1254
 
1721
- # When to spawn a coder
1722
- Spawn a coder when the user asks for code changes, file edits, debugging, or any task that benefits from a real coding agent with full tool access (bash, file edits, etc.). A coder runs Claude Code or Codex CLI under the hood.
1723
-
1724
- Unlike a worker, a coder is **long-lived**: its URL stays valid across many turns. Spawn once with spawn_coder, then keep prompting it via prompt_coder for follow-ups — don't spawn a new coder for each turn. Treat the coder URL like a chat handle.
1725
-
1726
- After calling spawn_coder or prompt_coder, end your turn. When the coder's reply lands, you'll be woken with the response in the wake message — relay it (or a summary) back to the user, and call prompt_coder again if there's a follow-up.
1727
-
1728
1255
  # Reporting
1729
1256
  Report outcomes faithfully. If a command failed, say so with the relevant output. If you didn't run a verification step, say that rather than implying you did. Don't hedge confirmed results with unnecessary disclaimers.
1730
1257
 
@@ -1738,34 +1265,82 @@ function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
1738
1265
  createWriteTool(workingDirectory, readSet),
1739
1266
  createEditTool(workingDirectory, readSet),
1740
1267
  braveSearchTool,
1741
- fetchUrlTool,
1742
- createSpawnWorkerTool(ctx),
1743
- createSpawnCoderTool(ctx),
1744
- createPromptCoderTool(ctx),
1268
+ ...opts.modelCatalog && opts.modelConfig ? [createFetchUrlTool({
1269
+ catalog: opts.modelCatalog,
1270
+ modelConfig: opts.modelConfig,
1271
+ log: (message) => serverLog.info(message),
1272
+ logPrefix: opts.logPrefix ?? `[horton]`
1273
+ })] : [fetchUrlTool],
1274
+ createSpawnWorkerTool(ctx, opts.modelConfig),
1745
1275
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1746
1276
  ];
1747
1277
  }
1748
- function extractFirstUserMessage(events) {
1749
- for (const event of events) {
1750
- if (event.type !== `message_received`) continue;
1751
- const value = event.value;
1752
- if (!value || value.from === `system`) continue;
1753
- const payload = value.payload;
1754
- if (typeof payload === `string`) return payload;
1755
- if (payload != null) return JSON.stringify(payload);
1278
+ function payloadToTitleText(payload) {
1279
+ if (typeof payload === `string`) return payload;
1280
+ if (payload == null) return ``;
1281
+ if (typeof payload === `object`) {
1282
+ const text = payload.text;
1283
+ return typeof text === `string` ? text : JSON.stringify(payload);
1284
+ }
1285
+ return String(payload);
1286
+ }
1287
+ async function extractFirstUserMessage(ctx) {
1288
+ const firstMessage = await queryOnce((q) => q.from({ inbox: ctx.db.collections.inbox }).where(({ inbox }) => not(eq(inbox.from, `system`))).orderBy(({ inbox }) => inbox._seq, `asc`).findOne());
1289
+ if (!firstMessage) return null;
1290
+ const text = payloadToTitleText(firstMessage.payload);
1291
+ return text.length > 0 ? text : null;
1292
+ }
1293
+ function readAgentsMd(workingDirectory) {
1294
+ const agentsMdPath = path.join(workingDirectory, `AGENTS.md`);
1295
+ try {
1296
+ if (!fs.existsSync(agentsMdPath) || !fs.statSync(agentsMdPath).isFile()) return null;
1297
+ const content = fs.readFileSync(agentsMdPath, `utf8`);
1298
+ return [
1299
+ `<context_file kind="instructions" path="${agentsMdPath}">`,
1300
+ content,
1301
+ `</context_file>`
1302
+ ].join(`\n`);
1303
+ } catch {
1304
+ return null;
1756
1305
  }
1757
- return null;
1758
1306
  }
1759
1307
  function createAssistantHandler(options) {
1760
- const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
1308
+ const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1761
1309
  const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1762
1310
  return async function assistantHandler(ctx, wake) {
1763
1311
  const readSet = new Set();
1312
+ const effectiveCwd = typeof ctx.args.workingDirectory === `string` && ctx.args.workingDirectory.trim().length > 0 ? ctx.args.workingDirectory : workingDirectory;
1313
+ const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1314
+ const agentsMd = readAgentsMd(effectiveCwd);
1764
1315
  const tools = [
1765
1316
  ...ctx.electricTools,
1766
- ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool }),
1767
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : []
1317
+ ...createHortonTools(effectiveCwd, ctx, readSet, {
1318
+ docsSearchTool,
1319
+ modelConfig,
1320
+ modelCatalog,
1321
+ logPrefix: `[horton ${ctx.entityUrl}]`
1322
+ }),
1323
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
1324
+ ...mcp.tools()
1768
1325
  ];
1326
+ const titlePromise = ctx.firstWake && !ctx.tags.title ? (async () => {
1327
+ const firstUserMessage = await extractFirstUserMessage(ctx);
1328
+ if (!firstUserMessage) return;
1329
+ let title = null;
1330
+ try {
1331
+ const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1332
+ serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1333
+ });
1334
+ if (result.length > 0) title = result;
1335
+ } catch (err) {
1336
+ serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1337
+ }
1338
+ if (title !== null) try {
1339
+ await ctx.setTag(`title`, title);
1340
+ } catch (err) {
1341
+ serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1342
+ }
1343
+ })() : Promise.resolve();
1769
1344
  if (docsSupport) ctx.useContext({
1770
1345
  sourceBudget: 1e5,
1771
1346
  sources: {
@@ -1783,6 +1358,11 @@ function createAssistantHandler(options) {
1783
1358
  content: () => ctx.timelineMessages(),
1784
1359
  cache: `volatile`
1785
1360
  },
1361
+ ...agentsMd ? { agents_md: {
1362
+ content: () => agentsMd,
1363
+ max: 2e4,
1364
+ cache: `stable`
1365
+ } } : {},
1786
1366
  ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1787
1367
  content: () => skillsRegistry.renderCatalog(2e3),
1788
1368
  max: 2e3,
@@ -1801,41 +1381,46 @@ function createAssistantHandler(options) {
1801
1381
  conversation: {
1802
1382
  content: () => ctx.timelineMessages(),
1803
1383
  cache: `volatile`
1384
+ },
1385
+ ...agentsMd ? { agents_md: {
1386
+ content: () => agentsMd,
1387
+ max: 2e4,
1388
+ cache: `stable`
1389
+ } } : {}
1390
+ }
1391
+ });
1392
+ else if (agentsMd) ctx.useContext({
1393
+ sourceBudget: 1e5,
1394
+ sources: {
1395
+ conversation: {
1396
+ content: () => ctx.timelineMessages(),
1397
+ cache: `volatile`
1398
+ },
1399
+ agents_md: {
1400
+ content: () => agentsMd,
1401
+ max: 2e4,
1402
+ cache: `stable`
1804
1403
  }
1805
1404
  }
1806
1405
  });
1807
1406
  ctx.useAgent({
1808
- systemPrompt: buildHortonSystemPrompt(workingDirectory, {
1407
+ systemPrompt: buildHortonSystemPrompt(effectiveCwd, {
1809
1408
  hasDocsSupport: Boolean(docsSupport),
1810
1409
  hasSkills,
1811
- docsUrl
1410
+ docsUrl,
1411
+ modelProvider: modelConfig.provider,
1412
+ modelId: String(modelConfig.model)
1812
1413
  }),
1813
- model: HORTON_MODEL,
1414
+ ...modelConfig,
1814
1415
  tools,
1815
1416
  ...streamFn && { streamFn }
1816
1417
  });
1817
1418
  await ctx.agent.run();
1818
- if (ctx.firstWake && !ctx.tags.title) {
1819
- const firstUserMessage = extractFirstUserMessage(ctx.events);
1820
- if (firstUserMessage) {
1821
- let title = null;
1822
- try {
1823
- const result = await generateTitle(firstUserMessage);
1824
- if (result.length > 0) title = result;
1825
- } catch (err) {
1826
- serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1827
- }
1828
- if (title !== null) try {
1829
- await ctx.setTag(`title`, title);
1830
- } catch (err) {
1831
- serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1832
- }
1833
- }
1834
- }
1419
+ await titlePromise;
1835
1420
  };
1836
1421
  }
1837
1422
  function registerHorton(registry, options) {
1838
- const { workingDirectory, streamFn, skillsRegistry = null } = options;
1423
+ const { workingDirectory, streamFn, skillsRegistry = null, modelCatalog } = options;
1839
1424
  const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
1840
1425
  if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
1841
1426
  else serverLog.warn(`[horton] BRAVE_SEARCH_API_KEY not set — web search will fall back to Anthropic built-in search (uses your ANTHROPIC_API_KEY)`);
@@ -1850,10 +1435,17 @@ function registerHorton(registry, options) {
1850
1435
  docsSupport,
1851
1436
  docsSearchTool,
1852
1437
  skillsRegistry,
1438
+ modelCatalog,
1853
1439
  docsUrl
1854
1440
  });
1441
+ const hortonCreationSchema = z.object({
1442
+ model: z.enum(modelChoiceValues(modelCatalog)).default(modelCatalog.defaultChoice.value),
1443
+ reasoningEffort: z.enum(REASONING_EFFORT_VALUES).default(`auto`).describe(`Reasoning effort for compatible reasoning models. Auto uses a safe provider default.`),
1444
+ workingDirectory: z.string().optional().describe(`Working directory for file operations. Defaults to the server's configured cwd.`)
1445
+ });
1855
1446
  registry.define(`horton`, {
1856
1447
  description: `Friendly capable assistant — chat, code, research, dispatch`,
1448
+ creationSchema: hortonCreationSchema,
1857
1449
  handler: assistantHandler
1858
1450
  });
1859
1451
  const typeNames = [`horton`];
@@ -1898,6 +1490,9 @@ function parseWorkerArgs(value) {
1898
1490
  };
1899
1491
  }
1900
1492
  if (tools.length === 0 && !args.sharedDb) throw new Error(`[worker] must provide tools and/or sharedDb`);
1493
+ if (typeof value.model === `string`) args.model = value.model;
1494
+ if (typeof value.provider === `string`) args.provider = value.provider;
1495
+ if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
1901
1496
  return args;
1902
1497
  }
1903
1498
  function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
@@ -1915,7 +1510,7 @@ function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
1915
1510
  case `edit`:
1916
1511
  out.push(createEditTool(workingDirectory, readSet));
1917
1512
  break;
1918
- case `brave_search`:
1513
+ case `web_search`:
1919
1514
  out.push(braveSearchTool);
1920
1515
  break;
1921
1516
  case `fetch_url`:
@@ -2024,13 +1619,14 @@ function buildSharedStateTools(shared, schema, mode) {
2024
1619
  return tools;
2025
1620
  }
2026
1621
  function registerWorker(registry, options) {
2027
- const { workingDirectory, streamFn } = options;
1622
+ const { workingDirectory, streamFn, modelCatalog } = options;
2028
1623
  registry.define(`worker`, {
2029
1624
  description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
2030
1625
  async handler(ctx) {
2031
1626
  const args = parseWorkerArgs(ctx.args);
2032
1627
  const readSet = new Set();
2033
1628
  const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
1629
+ const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
2034
1630
  const sharedStateTools = [];
2035
1631
  if (args.sharedDb) {
2036
1632
  const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
@@ -2038,7 +1634,7 @@ function registerWorker(registry, options) {
2038
1634
  }
2039
1635
  ctx.useAgent({
2040
1636
  systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
2041
- model: HORTON_MODEL,
1637
+ ...modelConfig,
2042
1638
  tools: [...builtinTools, ...sharedStateTools],
2043
1639
  ...streamFn && { streamFn }
2044
1640
  });
@@ -2128,7 +1724,6 @@ function stripQuotes(value) {
2128
1724
 
2129
1725
  //#endregion
2130
1726
  //#region src/skills/extract-meta.ts
2131
- const EXTRACT_MODEL = `claude-haiku-4-5-20251001`;
2132
1727
  const DEFAULT_MAX = 1e4;
2133
1728
  async function extractSkillMeta(name, content) {
2134
1729
  const preamble = parsePreamble(content);
@@ -2141,7 +1736,7 @@ async function extractSkillMeta(name, content) {
2141
1736
  ...preamble.userInvocable && { userInvocable: true },
2142
1737
  max: preamble.max ?? DEFAULT_MAX
2143
1738
  };
2144
- if (process.env.ANTHROPIC_API_KEY) try {
1739
+ try {
2145
1740
  return await llmExtract(name, content, preamble);
2146
1741
  } catch (err) {
2147
1742
  serverLog.warn(`[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}`);
@@ -2154,7 +1749,6 @@ async function extractSkillMeta(name, content) {
2154
1749
  };
2155
1750
  }
2156
1751
  async function llmExtract(name, content, partial) {
2157
- const client = new Anthropic();
2158
1752
  const truncated = content.slice(0, 8e3);
2159
1753
  const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}".
2160
1754
 
@@ -2168,15 +1762,14 @@ Return ONLY a JSON object with these fields:
2168
1762
  - "keywords": array of 3-8 relevant keywords
2169
1763
 
2170
1764
  Return raw JSON, no markdown fences.`;
2171
- const res = await client.messages.create({
2172
- model: EXTRACT_MODEL,
2173
- max_tokens: 256,
2174
- messages: [{
2175
- role: `user`,
2176
- content: prompt
2177
- }]
1765
+ const text = await completeWithLowCostModel({
1766
+ purpose: `skill metadata extraction`,
1767
+ systemPrompt: `Extract metadata from skill documents. Return only valid JSON that matches the requested schema.`,
1768
+ prompt,
1769
+ maxTokens: 256,
1770
+ log: (message) => serverLog.info(message),
1771
+ logPrefix: `[skills]`
2178
1772
  });
2179
- const text = res.content[0]?.type === `text` ? res.content[0].text : ``;
2180
1773
  const parsed = JSON.parse(text);
2181
1774
  return {
2182
1775
  description: partial.description ?? parsed.description ?? humanize(name),
@@ -2267,6 +1860,7 @@ async function saveCache(cachePath, catalog, cacheDir) {
2267
1860
  const obj = {};
2268
1861
  for (const [name, meta] of catalog) obj[name] = meta;
2269
1862
  fs.mkdirSync(cacheDir, { recursive: true });
1863
+ await fs$1.writeFile(path.join(cacheDir, `.gitignore`), `*\n`, `utf-8`);
2270
1864
  await fs$1.writeFile(cachePath, JSON.stringify(obj, null, 2), `utf-8`);
2271
1865
  }
2272
1866
  function sha256(content) {
@@ -2306,9 +1900,10 @@ function truncate(str, max) {
2306
1900
  //#region src/bootstrap.ts
2307
1901
  const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
2308
1902
  async function createBuiltinAgentHandler(options) {
2309
- const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, workingDirectory, streamFn, createElectricTools } = options;
2310
- if (!streamFn && !process.env.ANTHROPIC_API_KEY) {
2311
- serverLog.warn(`[builtin-agents] ANTHROPIC_API_KEY not set — skipping built-in agent registration`);
1903
+ const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, workingDirectory, streamFn, createElectricTools, publicUrl, runtimeName } = options;
1904
+ const modelCatalog = await createBuiltinModelCatalog({ allowMockFallback: Boolean(streamFn) });
1905
+ if (!modelCatalog) {
1906
+ serverLog.warn(`[builtin-agents] no supported model provider API key found — set ANTHROPIC_API_KEY or OPENAI_API_KEY`);
2312
1907
  return null;
2313
1908
  }
2314
1909
  const cwd = workingDirectory ?? process.cwd();
@@ -2329,22 +1924,24 @@ async function createBuiltinAgentHandler(options) {
2329
1924
  const typeNames = registerHorton(registry, {
2330
1925
  workingDirectory: cwd,
2331
1926
  streamFn,
2332
- skillsRegistry
1927
+ skillsRegistry,
1928
+ modelCatalog
2333
1929
  });
2334
1930
  registerWorker(registry, {
2335
1931
  workingDirectory: cwd,
2336
- streamFn
1932
+ streamFn,
1933
+ modelCatalog
2337
1934
  });
2338
1935
  typeNames.push(`worker`);
2339
- registerCodingSession(registry, { defaultWorkingDirectory: cwd });
2340
- typeNames.push(`coder`);
2341
1936
  const runtime = createRuntimeHandler({
2342
1937
  baseUrl: agentServerUrl,
2343
1938
  serveEndpoint,
2344
1939
  registry,
2345
1940
  subscriptionPathForType: (name) => `/${name}/*/main`,
2346
1941
  idleTimeout: 5e3,
2347
- createElectricTools
1942
+ createElectricTools,
1943
+ publicUrl,
1944
+ name: runtimeName ?? `builtin-agents`
2348
1945
  });
2349
1946
  return {
2350
1947
  handler: runtime.onEnter,
@@ -2366,10 +1963,19 @@ var BuiltinAgentsServer = class {
2366
1963
  bootstrap = null;
2367
1964
  _url = null;
2368
1965
  publicBaseUrl = null;
1966
+ _mcpRegistry = null;
1967
+ mcpWatcherCloser = null;
1968
+ mcpToolProviderName = null;
1969
+ mcpApplyInFlight = new Set();
1970
+ mcpStopping = false;
2369
1971
  options;
2370
1972
  constructor(options) {
2371
1973
  this.options = options;
2372
1974
  }
1975
+ /** Embedded MCP registry. `null` until `start()` has run. */
1976
+ get mcpRegistry() {
1977
+ return this._mcpRegistry;
1978
+ }
2373
1979
  get url() {
2374
1980
  if (!this._url) throw new Error(`Builtin agents server not started`);
2375
1981
  return this._url;
@@ -2403,14 +2009,124 @@ var BuiltinAgentsServer = class {
2403
2009
  this.publicBaseUrl = this.options.baseUrl ?? this._url;
2404
2010
  const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
2405
2011
  const serveEndpoint = new URL(webhookPath, this.publicBaseUrl.endsWith(`/`) ? this.publicBaseUrl : `${this.publicBaseUrl}/`).toString();
2012
+ const publicUrl = this.options.mcpOAuthRedirectBase ?? this.publicBaseUrl;
2013
+ const mcpRegistry = createRegistry({
2014
+ publicUrl,
2015
+ openAuthorizeUrl: this.options.openAuthorizeUrl
2016
+ });
2017
+ this._mcpRegistry = mcpRegistry;
2018
+ const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
2019
+ const extras = this.options.extraMcpServers ?? [];
2020
+ const wirePersistence = async (cfg) => {
2021
+ const servers = [];
2022
+ for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
2023
+ const persist = await keychainPersistence({ server: s.name });
2024
+ servers.push({
2025
+ ...s,
2026
+ auth: {
2027
+ ...s.auth,
2028
+ ...persist
2029
+ }
2030
+ });
2031
+ } else servers.push(s);
2032
+ return {
2033
+ ...cfg,
2034
+ servers
2035
+ };
2036
+ };
2037
+ const merge = (jsonCfg) => {
2038
+ const jsonServers = jsonCfg?.servers ?? [];
2039
+ const jsonNames = new Set(jsonServers.map((s) => s.name));
2040
+ const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
2041
+ return {
2042
+ servers: [...filteredExtras, ...jsonServers],
2043
+ raw: jsonCfg?.raw
2044
+ };
2045
+ };
2046
+ const onConfigError = this.options.onConfigError;
2047
+ const runApply = async (jsonCfg) => {
2048
+ if (this.mcpStopping) return;
2049
+ try {
2050
+ const wired = await wirePersistence(merge(jsonCfg));
2051
+ if (this.mcpStopping) return;
2052
+ await mcpRegistry.applyConfig(wired);
2053
+ } catch (e) {
2054
+ serverLog.error(`[mcp] applyConfig:`, e);
2055
+ try {
2056
+ onConfigError?.(e);
2057
+ } catch (cbErr) {
2058
+ serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
2059
+ }
2060
+ }
2061
+ };
2062
+ const applyMerged = (jsonCfg) => {
2063
+ const p = runApply(jsonCfg);
2064
+ this.mcpApplyInFlight.add(p);
2065
+ p.finally(() => this.mcpApplyInFlight.delete(p));
2066
+ return p;
2067
+ };
2068
+ if (mcpConfigPath) {
2069
+ try {
2070
+ const cfg = await loadConfig(mcpConfigPath, process.env);
2071
+ applyMerged(cfg);
2072
+ } catch (err) {
2073
+ if (err.code !== `ENOENT`) throw err;
2074
+ if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
2075
+ else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
2076
+ applyMerged(null);
2077
+ }
2078
+ try {
2079
+ this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
2080
+ onChange: (cfg) => void applyMerged(cfg),
2081
+ onError: (e) => serverLog.error(`[mcp] config error:`, e)
2082
+ });
2083
+ } catch (e) {
2084
+ serverLog.error(`[mcp] config watcher failed to start:`, e);
2085
+ }
2086
+ } else {
2087
+ if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
2088
+ applyMerged(null);
2089
+ }
2090
+ this.mcpToolProviderName = `mcp`;
2091
+ registerToolProvider({
2092
+ name: `mcp`,
2093
+ tools: () => {
2094
+ const tools = [];
2095
+ for (const entry of mcpRegistry.list()) {
2096
+ if (entry.status !== `ready`) continue;
2097
+ const live = mcpRegistry.get(entry.name);
2098
+ if (!live?.transport) continue;
2099
+ for (const t of entry.tools) tools.push(bridgeMcpTool({
2100
+ server: entry.name,
2101
+ tool: t,
2102
+ client: live.transport.client,
2103
+ timeoutMs: live.config.timeoutMs
2104
+ }));
2105
+ const caps = live.transport.client.getServerCapabilities?.();
2106
+ if (caps?.resources) tools.push(...buildResourceTools({
2107
+ server: entry.name,
2108
+ client: live.transport.client,
2109
+ timeoutMs: live.config.timeoutMs
2110
+ }));
2111
+ if (caps?.prompts) tools.push(...buildPromptTools({
2112
+ server: entry.name,
2113
+ client: live.transport.client,
2114
+ timeoutMs: live.config.timeoutMs
2115
+ }));
2116
+ }
2117
+ return tools;
2118
+ }
2119
+ });
2406
2120
  this.bootstrap = await createBuiltinAgentHandler({
2407
2121
  agentServerUrl: this.options.agentServerUrl,
2408
2122
  serveEndpoint,
2409
2123
  workingDirectory: this.options.workingDirectory,
2410
2124
  streamFn: this.options.mockStreamFn,
2411
- createElectricTools: this.options.createElectricTools
2125
+ createElectricTools: this.options.createElectricTools,
2126
+ publicUrl,
2127
+ runtimeName: `builtin-agents`
2412
2128
  });
2413
- if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY must be set before starting builtin agents`);
2129
+ if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY or OPENAI_API_KEY must be set before starting builtin agents`);
2414
2130
  await registerBuiltinAgentTypes(this.bootstrap);
2415
2131
  serverLog.info(`[builtin-agents] webhook handler listening at ${serveEndpoint}`);
2416
2132
  resolve(this._url);
@@ -2427,6 +2143,26 @@ var BuiltinAgentsServer = class {
2427
2143
  await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2428
2144
  this.bootstrap = null;
2429
2145
  }
2146
+ this.mcpStopping = true;
2147
+ if (this.mcpWatcherCloser) {
2148
+ try {
2149
+ this.mcpWatcherCloser();
2150
+ } catch (e) {
2151
+ serverLog.error(`[mcp] watcher close failed:`, e);
2152
+ }
2153
+ this.mcpWatcherCloser = null;
2154
+ }
2155
+ if (this.mcpApplyInFlight.size > 0) await Promise.allSettled([...this.mcpApplyInFlight]);
2156
+ if (this.mcpToolProviderName) {
2157
+ unregisterToolProvider(this.mcpToolProviderName);
2158
+ this.mcpToolProviderName = null;
2159
+ }
2160
+ if (this._mcpRegistry) {
2161
+ await this._mcpRegistry.close().catch((e) => {
2162
+ serverLog.error(`[mcp] registry close failed:`, e);
2163
+ });
2164
+ this._mcpRegistry = null;
2165
+ }
2430
2166
  if (this.server) {
2431
2167
  const server = this.server;
2432
2168
  await new Promise((resolve) => {
@@ -2434,19 +2170,20 @@ var BuiltinAgentsServer = class {
2434
2170
  });
2435
2171
  this.server = null;
2436
2172
  }
2173
+ this.mcpStopping = false;
2437
2174
  this._url = null;
2438
2175
  this.publicBaseUrl = null;
2439
2176
  }
2440
2177
  async handleRequest(req, res) {
2441
2178
  const method = req.method?.toUpperCase();
2442
- const path$1 = new URL(req.url ?? `/`, `http://localhost`).pathname;
2179
+ const pathname = new URL(req.url ?? `/`, `http://localhost`).pathname;
2443
2180
  const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
2444
- if (path$1 === `/_electric/health` && method === `GET`) {
2181
+ if (pathname === `/_electric/health` && method === `GET`) {
2445
2182
  res.writeHead(200, { "content-type": `application/json` });
2446
2183
  res.end(JSON.stringify({ status: `ok` }));
2447
2184
  return;
2448
2185
  }
2449
- if (path$1 === webhookPath && method === `POST` && this.bootstrap) {
2186
+ if (pathname === webhookPath && method === `POST` && this.bootstrap) {
2450
2187
  await this.bootstrap.handler(req, res);
2451
2188
  return;
2452
2189
  }