@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.
- package/dist/entrypoint.js +474 -737
- package/dist/index.cjs +470 -733
- package/dist/index.d.cts +68 -35
- package/dist/index.d.ts +69 -36
- package/dist/index.js +489 -751
- package/docs/entities/agents/horton.md +12 -12
- package/docs/entities/agents/worker.md +18 -18
- package/docs/entities/patterns/blackboard.md +6 -6
- package/docs/entities/patterns/dispatcher.md +1 -1
- package/docs/entities/patterns/manager-worker.md +1 -1
- package/docs/entities/patterns/map-reduce.md +1 -1
- package/docs/entities/patterns/pipeline.md +1 -1
- package/docs/entities/patterns/reactive-observers.md +2 -2
- package/docs/examples/playground.md +42 -26
- package/docs/index.md +25 -23
- package/docs/quickstart.md +12 -12
- package/docs/reference/agent-config.md +20 -12
- package/docs/reference/agent-tool.md +1 -1
- package/docs/reference/built-in-collections.md +21 -21
- package/docs/reference/cli.md +39 -30
- package/docs/reference/entity-definition.md +9 -9
- package/docs/reference/entity-handle.md +2 -2
- package/docs/reference/entity-registry.md +1 -1
- package/docs/reference/handler-context.md +34 -18
- package/docs/reference/mcp-registry.md +189 -0
- package/docs/reference/mcp-server-config.md +226 -0
- package/docs/reference/runtime-handler.md +25 -23
- package/docs/reference/shared-state-handle.md +7 -7
- package/docs/reference/state-collection-proxy.md +1 -1
- package/docs/reference/wake-event.md +23 -23
- package/docs/usage/app-setup.md +24 -23
- package/docs/usage/clients-and-react.md +40 -36
- package/docs/usage/configuring-the-agent.md +25 -19
- package/docs/usage/context-composition.md +12 -12
- package/docs/usage/defining-entities.md +36 -36
- package/docs/usage/defining-tools.md +45 -45
- package/docs/usage/embedded-builtins.md +54 -43
- package/docs/usage/managing-state.md +12 -12
- package/docs/usage/mcp-servers.md +354 -0
- package/docs/usage/overview.md +50 -45
- package/docs/usage/programmatic-runtime-client.md +51 -48
- package/docs/usage/shared-state.md +32 -32
- package/docs/usage/spawning-and-coordinating.md +9 -9
- package/docs/usage/testing.md +14 -14
- package/docs/usage/waking-entities.md +13 -13
- package/docs/usage/writing-handlers.md +52 -26
- package/package.json +9 -4
- package/scripts/sync-docs.mjs +42 -0
- package/docs/examples/mega-draw.md +0 -106
package/dist/index.js
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { completeWithLowCostModel, createEntityRegistry, createRuntimeHandler, db, detectAvailableProviders, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
|
|
4
|
+
import fs from "node:fs";
|
|
5
5
|
import pino from "pino";
|
|
6
|
-
import {
|
|
7
|
-
import { homedir } from "node:os";
|
|
6
|
+
import { eq, not, queryOnce } from "@durable-streams/state";
|
|
8
7
|
import { z } from "zod";
|
|
9
|
-
import { deserializeCursor, discoverSessions, importLocalSession, loadSession, resolveSession, serializeCursor, tailSession } from "agent-session-protocol";
|
|
10
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
11
8
|
import { createHash } from "node:crypto";
|
|
12
|
-
import fs from "node:fs/promises";
|
|
9
|
+
import fs$1 from "node:fs/promises";
|
|
13
10
|
import Database from "better-sqlite3";
|
|
14
11
|
import { Type } from "@sinclair/typebox";
|
|
15
12
|
import { load } from "sqlite-vec";
|
|
16
13
|
import { nanoid } from "nanoid";
|
|
17
|
-
import {
|
|
14
|
+
import { getModels } from "@mariozechner/pi-ai";
|
|
15
|
+
import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createFetchUrlTool, createReadFileTool, createWriteTool, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
|
|
16
|
+
import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
|
|
18
17
|
import { createServer } from "node:http";
|
|
19
18
|
|
|
20
19
|
//#region src/log.ts
|
|
21
20
|
const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
22
|
-
|
|
21
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
23
22
|
const LOG_FILE = path.join(LOG_DIR, `builtin-agents-${Date.now()}.jsonl`);
|
|
24
23
|
const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
25
24
|
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST;
|
|
@@ -47,6 +46,10 @@ function formatArgs(args) {
|
|
|
47
46
|
};
|
|
48
47
|
}
|
|
49
48
|
const serverLog = {
|
|
49
|
+
debug(...args) {
|
|
50
|
+
const { msg } = formatArgs(args);
|
|
51
|
+
logger.debug(msg);
|
|
52
|
+
},
|
|
50
53
|
info(...args) {
|
|
51
54
|
const { msg } = formatArgs(args);
|
|
52
55
|
logger.info(msg);
|
|
@@ -66,516 +69,6 @@ const serverLog = {
|
|
|
66
69
|
}
|
|
67
70
|
};
|
|
68
71
|
|
|
69
|
-
//#endregion
|
|
70
|
-
//#region src/agents/coding-session.ts
|
|
71
|
-
const defaultCliRunner = { async run(opts) {
|
|
72
|
-
return new Promise((resolve, reject) => {
|
|
73
|
-
const isClaude = opts.agent === `claude`;
|
|
74
|
-
const bin = isClaude ? `claude` : `codex`;
|
|
75
|
-
const args = isClaude ? opts.sessionId ? [
|
|
76
|
-
`-r`,
|
|
77
|
-
opts.sessionId,
|
|
78
|
-
`--dangerously-skip-permissions`,
|
|
79
|
-
`-p`
|
|
80
|
-
] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
|
|
81
|
-
`exec`,
|
|
82
|
-
`--skip-git-repo-check`,
|
|
83
|
-
`resume`,
|
|
84
|
-
opts.sessionId,
|
|
85
|
-
opts.prompt
|
|
86
|
-
] : [
|
|
87
|
-
`exec`,
|
|
88
|
-
`--skip-git-repo-check`,
|
|
89
|
-
opts.prompt
|
|
90
|
-
];
|
|
91
|
-
const child = spawn(bin, args, {
|
|
92
|
-
cwd: opts.cwd,
|
|
93
|
-
stdio: [
|
|
94
|
-
isClaude ? `pipe` : `ignore`,
|
|
95
|
-
`pipe`,
|
|
96
|
-
`pipe`
|
|
97
|
-
]
|
|
98
|
-
});
|
|
99
|
-
const MAX_BUF_CHARS = 4096;
|
|
100
|
-
let stdout = ``;
|
|
101
|
-
let stderr = ``;
|
|
102
|
-
child.stdout?.on(`data`, (d) => {
|
|
103
|
-
if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
|
|
104
|
-
});
|
|
105
|
-
child.stderr?.on(`data`, (d) => {
|
|
106
|
-
if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
|
|
107
|
-
});
|
|
108
|
-
child.on(`error`, reject);
|
|
109
|
-
child.on(`exit`, (code) => {
|
|
110
|
-
resolve({
|
|
111
|
-
exitCode: code ?? -1,
|
|
112
|
-
stdout,
|
|
113
|
-
stderr
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
if (isClaude && child.stdin) {
|
|
117
|
-
child.stdin.write(opts.prompt);
|
|
118
|
-
child.stdin.end();
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
} };
|
|
122
|
-
async function discoverNewestSession(agent, cwd, excludeIds) {
|
|
123
|
-
const all = await discoverSessions(agent);
|
|
124
|
-
const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
|
|
125
|
-
if (candidates.length === 0) return null;
|
|
126
|
-
return candidates[0].sessionId;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Compute the candidate directories where Claude Code stores per-cwd
|
|
130
|
-
* session JSONL files. Claude resolves the cwd to its realpath when
|
|
131
|
-
* choosing the directory name (so /tmp/foo on macOS lands under
|
|
132
|
-
* `-private-tmp-foo`), but the entity may have been spawned with the
|
|
133
|
-
* non-realpath form. Return both candidates so the caller can union
|
|
134
|
-
* their contents.
|
|
135
|
-
*/
|
|
136
|
-
async function getClaudeProjectDirs(cwd) {
|
|
137
|
-
const home = homedir();
|
|
138
|
-
const make = (c) => path.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
|
|
139
|
-
const dirs = [make(cwd)];
|
|
140
|
-
try {
|
|
141
|
-
const real = await promises.realpath(cwd);
|
|
142
|
-
if (real !== cwd) dirs.push(make(real));
|
|
143
|
-
} catch {}
|
|
144
|
-
return dirs;
|
|
145
|
-
}
|
|
146
|
-
async function listClaudeJsonlIdsByCwd(cwd) {
|
|
147
|
-
const ids = new Set();
|
|
148
|
-
for (const dir of await getClaudeProjectDirs(cwd)) try {
|
|
149
|
-
const files = await promises.readdir(dir);
|
|
150
|
-
for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
|
|
151
|
-
} catch {}
|
|
152
|
-
return ids;
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Deterministic-path discovery for a freshly created session. After the
|
|
156
|
-
* Claude CLI runs in `-p` mode it writes the new JSONL straight into
|
|
157
|
-
* `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
|
|
158
|
-
* `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
|
|
159
|
-
* so `discoverSessions` can miss it. Compute the expected dir directly
|
|
160
|
-
* and diff its contents against a pre-run snapshot. Returns the newest
|
|
161
|
-
* fresh sessionId or null. Codex falls back to discoverNewestSession.
|
|
162
|
-
*/
|
|
163
|
-
async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
|
|
164
|
-
if (agent === `claude`) {
|
|
165
|
-
const dirs = await getClaudeProjectDirs(cwd);
|
|
166
|
-
let best = null;
|
|
167
|
-
for (const dir of dirs) try {
|
|
168
|
-
const files = await promises.readdir(dir);
|
|
169
|
-
for (const f of files) {
|
|
170
|
-
if (!f.endsWith(`.jsonl`)) continue;
|
|
171
|
-
const id = f.slice(0, -`.jsonl`.length);
|
|
172
|
-
if (preDirectIds.has(id)) continue;
|
|
173
|
-
const st = await promises.stat(path.join(dir, f)).catch(() => null);
|
|
174
|
-
if (!st) continue;
|
|
175
|
-
if (!best || st.mtimeMs > best.mtime) best = {
|
|
176
|
-
id,
|
|
177
|
-
mtime: st.mtimeMs
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
} catch {}
|
|
181
|
-
if (best) return best.id;
|
|
182
|
-
}
|
|
183
|
-
return discoverNewestSession(agent, cwd, preDiscoveredIds);
|
|
184
|
-
}
|
|
185
|
-
const sessionMetaRowSchema = z.object({
|
|
186
|
-
key: z.literal(`current`),
|
|
187
|
-
electricSessionId: z.string(),
|
|
188
|
-
nativeSessionId: z.string().optional(),
|
|
189
|
-
agent: z.enum([`claude`, `codex`]),
|
|
190
|
-
cwd: z.string(),
|
|
191
|
-
status: z.enum([
|
|
192
|
-
`initializing`,
|
|
193
|
-
`idle`,
|
|
194
|
-
`running`,
|
|
195
|
-
`error`
|
|
196
|
-
]),
|
|
197
|
-
error: z.string().optional(),
|
|
198
|
-
currentPromptInboxKey: z.string().optional()
|
|
199
|
-
});
|
|
200
|
-
const cursorStateRowSchema = z.object({
|
|
201
|
-
key: z.literal(`current`),
|
|
202
|
-
cursor: z.string(),
|
|
203
|
-
lastProcessedInboxKey: z.string().optional()
|
|
204
|
-
});
|
|
205
|
-
const eventRowSchema = z.object({
|
|
206
|
-
key: z.string(),
|
|
207
|
-
ts: z.number(),
|
|
208
|
-
type: z.string(),
|
|
209
|
-
callId: z.string().optional(),
|
|
210
|
-
payload: z.looseObject({})
|
|
211
|
-
});
|
|
212
|
-
const creationArgsSchema = z.object({
|
|
213
|
-
agent: z.enum([`claude`, `codex`]),
|
|
214
|
-
cwd: z.string().optional(),
|
|
215
|
-
nativeSessionId: z.string().optional(),
|
|
216
|
-
importFrom: z.object({
|
|
217
|
-
agent: z.enum([`claude`, `codex`]),
|
|
218
|
-
sessionId: z.string()
|
|
219
|
-
}).optional()
|
|
220
|
-
});
|
|
221
|
-
const promptMessageSchema = z.object({ text: z.string() });
|
|
222
|
-
/**
|
|
223
|
-
* Stable key for an events-collection row, derived from the event's content.
|
|
224
|
-
* Lets us re-insert the same event without producing duplicates — the caller
|
|
225
|
-
* (or the collection's uniqueness guard) uses this to de-dup across retries,
|
|
226
|
-
* replays, and crash recovery. Sorts chronologically by ts, then by type.
|
|
227
|
-
*/
|
|
228
|
-
function eventKey(event) {
|
|
229
|
-
const tsPart = String(event.ts).padStart(16, `0`);
|
|
230
|
-
return `${tsPart}_${event.type}_${contentHashHex(event)}`;
|
|
231
|
-
}
|
|
232
|
-
function contentHashHex(event) {
|
|
233
|
-
const json = JSON.stringify(event);
|
|
234
|
-
let h = 5381;
|
|
235
|
-
for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
|
|
236
|
-
return h.toString(16).padStart(8, `0`);
|
|
237
|
-
}
|
|
238
|
-
function buildEventRow(event) {
|
|
239
|
-
const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
|
|
240
|
-
return {
|
|
241
|
-
key: eventKey(event),
|
|
242
|
-
ts: event.ts,
|
|
243
|
-
type: event.type,
|
|
244
|
-
...callId !== void 0 ? { callId } : {},
|
|
245
|
-
payload: event
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
function appendIfNew(ctx, event) {
|
|
249
|
-
const row = buildEventRow(event);
|
|
250
|
-
if (ctx.events.get(row.key) !== void 0) return;
|
|
251
|
-
ctx.actions.events_insert({ row });
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Mirror every event that lands in the JSONL file while `runWork` is
|
|
255
|
-
* executing (i.e. while the CLI is running). Returns the advanced cursor
|
|
256
|
-
* and the `runWork` result once everything has settled and every append
|
|
257
|
-
* has been persisted to the entity's durable stream.
|
|
258
|
-
*
|
|
259
|
-
* If setup fails (e.g. the session file can't be resolved), `runWork`
|
|
260
|
-
* still runs — but nothing is mirrored and `setupError` is populated so
|
|
261
|
-
* the caller can surface the condition. If `runWork` throws, the error
|
|
262
|
-
* propagates after the watcher has been cleaned up.
|
|
263
|
-
*/
|
|
264
|
-
async function runWithLiveMirror(opts) {
|
|
265
|
-
let cursor = null;
|
|
266
|
-
let setupError = void 0;
|
|
267
|
-
try {
|
|
268
|
-
const session = await resolveSession(opts.nativeSessionId, opts.agent);
|
|
269
|
-
if (opts.serializedCursor) cursor = deserializeCursor({
|
|
270
|
-
...opts.serializedCursor,
|
|
271
|
-
path: session.path
|
|
272
|
-
});
|
|
273
|
-
else {
|
|
274
|
-
const initial = await loadSession({
|
|
275
|
-
sessionId: opts.nativeSessionId,
|
|
276
|
-
agent: opts.agent
|
|
277
|
-
});
|
|
278
|
-
for (const ev of initial.events) appendIfNew(opts.ctx, ev);
|
|
279
|
-
cursor = initial.cursor;
|
|
280
|
-
}
|
|
281
|
-
} catch (e) {
|
|
282
|
-
setupError = e;
|
|
283
|
-
}
|
|
284
|
-
if (!cursor) {
|
|
285
|
-
const result$1 = await opts.runWork();
|
|
286
|
-
return {
|
|
287
|
-
cursor: opts.serializedCursor,
|
|
288
|
-
setupError,
|
|
289
|
-
result: result$1
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
let activeCursor = cursor;
|
|
293
|
-
let busy = false;
|
|
294
|
-
let pending = false;
|
|
295
|
-
let stopped = false;
|
|
296
|
-
const drainOnce = async () => {
|
|
297
|
-
if (stopped && busy) return;
|
|
298
|
-
if (busy) {
|
|
299
|
-
pending = true;
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
busy = true;
|
|
303
|
-
try {
|
|
304
|
-
const res = await tailSession({ cursor: activeCursor });
|
|
305
|
-
activeCursor = res.cursor;
|
|
306
|
-
for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
|
|
307
|
-
} catch {} finally {
|
|
308
|
-
busy = false;
|
|
309
|
-
if (pending && !stopped) {
|
|
310
|
-
pending = false;
|
|
311
|
-
drainOnce();
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
const fileWatcher = watch(activeCursor.path, () => {
|
|
316
|
-
drainOnce();
|
|
317
|
-
});
|
|
318
|
-
const pollHandle = setInterval(() => {
|
|
319
|
-
drainOnce();
|
|
320
|
-
}, 1500);
|
|
321
|
-
let result;
|
|
322
|
-
try {
|
|
323
|
-
result = await opts.runWork();
|
|
324
|
-
} finally {
|
|
325
|
-
stopped = true;
|
|
326
|
-
clearInterval(pollHandle);
|
|
327
|
-
fileWatcher.close();
|
|
328
|
-
while (busy) await new Promise((r) => setTimeout(r, 10));
|
|
329
|
-
try {
|
|
330
|
-
const final = await tailSession({ cursor: activeCursor });
|
|
331
|
-
activeCursor = final.cursor;
|
|
332
|
-
for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
|
|
333
|
-
} catch {}
|
|
334
|
-
}
|
|
335
|
-
return {
|
|
336
|
-
cursor: serializeCursor(activeCursor),
|
|
337
|
-
setupError,
|
|
338
|
-
result
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
function registerCodingSession(registry, options = {}) {
|
|
342
|
-
const runner = options.cliRunner ?? defaultCliRunner;
|
|
343
|
-
const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
|
|
344
|
-
registry.define(`coder`, {
|
|
345
|
-
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.`,
|
|
346
|
-
creationSchema: creationArgsSchema,
|
|
347
|
-
inboxSchemas: { prompt: promptMessageSchema },
|
|
348
|
-
state: {
|
|
349
|
-
sessionMeta: {
|
|
350
|
-
schema: sessionMetaRowSchema,
|
|
351
|
-
type: CODING_SESSION_META_COLLECTION_TYPE,
|
|
352
|
-
primaryKey: `key`
|
|
353
|
-
},
|
|
354
|
-
cursorState: {
|
|
355
|
-
schema: cursorStateRowSchema,
|
|
356
|
-
type: CODING_SESSION_CURSOR_COLLECTION_TYPE,
|
|
357
|
-
primaryKey: `key`
|
|
358
|
-
},
|
|
359
|
-
events: {
|
|
360
|
-
schema: eventRowSchema,
|
|
361
|
-
type: CODING_SESSION_EVENT_COLLECTION_TYPE,
|
|
362
|
-
primaryKey: `key`
|
|
363
|
-
}
|
|
364
|
-
},
|
|
365
|
-
async handler(ctx, _wake) {
|
|
366
|
-
const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
|
|
367
|
-
if (!existingMeta) {
|
|
368
|
-
const args = creationArgsSchema.parse(ctx.args);
|
|
369
|
-
const cwd = args.cwd ?? defaultCwd;
|
|
370
|
-
const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
|
|
371
|
-
let resolvedNativeId = args.nativeSessionId;
|
|
372
|
-
if (args.importFrom) {
|
|
373
|
-
const result = await importLocalSession({
|
|
374
|
-
source: {
|
|
375
|
-
sessionId: args.importFrom.sessionId,
|
|
376
|
-
agent: args.importFrom.agent
|
|
377
|
-
},
|
|
378
|
-
target: {
|
|
379
|
-
agent: args.agent,
|
|
380
|
-
cwd
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
resolvedNativeId = result.sessionId;
|
|
384
|
-
}
|
|
385
|
-
const hasNative = resolvedNativeId !== void 0;
|
|
386
|
-
ctx.db.actions.sessionMeta_insert({ row: {
|
|
387
|
-
key: `current`,
|
|
388
|
-
electricSessionId,
|
|
389
|
-
...hasNative ? { nativeSessionId: resolvedNativeId } : {},
|
|
390
|
-
agent: args.agent,
|
|
391
|
-
cwd,
|
|
392
|
-
status: hasNative ? `idle` : `initializing`
|
|
393
|
-
} });
|
|
394
|
-
}
|
|
395
|
-
if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
|
|
396
|
-
key: `current`,
|
|
397
|
-
cursor: ``
|
|
398
|
-
} });
|
|
399
|
-
const metaRow = ctx.db.collections.sessionMeta.get(`current`);
|
|
400
|
-
const cursorRow = ctx.db.collections.cursorState.get(`current`);
|
|
401
|
-
if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
|
|
402
|
-
if (metaRow.nativeSessionId && !cursorRow.cursor) {
|
|
403
|
-
const mirrorCtx = {
|
|
404
|
-
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
405
|
-
actions: { events_insert: ctx.db.actions.events_insert }
|
|
406
|
-
};
|
|
407
|
-
try {
|
|
408
|
-
const initial = await loadSession({
|
|
409
|
-
sessionId: metaRow.nativeSessionId,
|
|
410
|
-
agent: metaRow.agent
|
|
411
|
-
});
|
|
412
|
-
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
413
|
-
const serialized = serializeCursor(initial.cursor);
|
|
414
|
-
ctx.db.actions.cursorState_update({
|
|
415
|
-
key: `current`,
|
|
416
|
-
updater: (d) => {
|
|
417
|
-
d.cursor = JSON.stringify(serialized);
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
} catch (e) {
|
|
421
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
422
|
-
ctx.db.actions.sessionMeta_update({
|
|
423
|
-
key: `current`,
|
|
424
|
-
updater: (d) => {
|
|
425
|
-
d.error = `initial mirror failed: ${message}`;
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
431
|
-
const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
|
|
432
|
-
const pending = inboxRows.filter((m) => m.key > lastKey);
|
|
433
|
-
if (pending.length === 0) {
|
|
434
|
-
if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
|
|
435
|
-
key: `current`,
|
|
436
|
-
updater: (d) => {
|
|
437
|
-
d.status = `idle`;
|
|
438
|
-
delete d.currentPromptInboxKey;
|
|
439
|
-
delete d.error;
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
let runningMeta = metaRow;
|
|
445
|
-
let runningCursor = cursorRow;
|
|
446
|
-
for (const inboxMsg of pending) {
|
|
447
|
-
const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
|
|
448
|
-
if (!parsed.success) {
|
|
449
|
-
ctx.db.actions.cursorState_update({
|
|
450
|
-
key: `current`,
|
|
451
|
-
updater: (d) => {
|
|
452
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
|
-
runningCursor = {
|
|
456
|
-
...runningCursor,
|
|
457
|
-
lastProcessedInboxKey: inboxMsg.key
|
|
458
|
-
};
|
|
459
|
-
continue;
|
|
460
|
-
}
|
|
461
|
-
const prompt = parsed.data.text;
|
|
462
|
-
const existingTitle = ctx.tags.title;
|
|
463
|
-
if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
|
|
464
|
-
ctx.db.actions.sessionMeta_update({
|
|
465
|
-
key: `current`,
|
|
466
|
-
updater: (d) => {
|
|
467
|
-
d.status = `running`;
|
|
468
|
-
d.currentPromptInboxKey = inboxMsg.key;
|
|
469
|
-
delete d.error;
|
|
470
|
-
}
|
|
471
|
-
});
|
|
472
|
-
const recordedRun = ctx.recordRun();
|
|
473
|
-
const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
|
|
474
|
-
try {
|
|
475
|
-
const mirrorCtx = {
|
|
476
|
-
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
477
|
-
actions: { events_insert: ctx.db.actions.events_insert }
|
|
478
|
-
};
|
|
479
|
-
let nextCursorJson = runningCursor.cursor;
|
|
480
|
-
if (!runningMeta.nativeSessionId) {
|
|
481
|
-
const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
|
|
482
|
-
const preDiscoveredIds = new Set((await discoverSessions(runningMeta.agent)).map((s) => s.sessionId));
|
|
483
|
-
const cliResult = await runner.run({
|
|
484
|
-
agent: runningMeta.agent,
|
|
485
|
-
cwd: runningMeta.cwd,
|
|
486
|
-
prompt
|
|
487
|
-
});
|
|
488
|
-
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>`}`);
|
|
489
|
-
const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
|
|
490
|
-
if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
|
|
491
|
-
ctx.db.actions.sessionMeta_update({
|
|
492
|
-
key: `current`,
|
|
493
|
-
updater: (d) => {
|
|
494
|
-
d.nativeSessionId = foundId;
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
runningMeta = {
|
|
498
|
-
...runningMeta,
|
|
499
|
-
nativeSessionId: foundId
|
|
500
|
-
};
|
|
501
|
-
const initial = await loadSession({
|
|
502
|
-
sessionId: foundId,
|
|
503
|
-
agent: runningMeta.agent
|
|
504
|
-
});
|
|
505
|
-
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
506
|
-
nextCursorJson = JSON.stringify(serializeCursor(initial.cursor));
|
|
507
|
-
} else {
|
|
508
|
-
const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
|
|
509
|
-
const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
|
|
510
|
-
agent: runningMeta.agent,
|
|
511
|
-
nativeSessionId: runningMeta.nativeSessionId,
|
|
512
|
-
serializedCursor,
|
|
513
|
-
ctx: mirrorCtx,
|
|
514
|
-
runWork: () => runner.run({
|
|
515
|
-
agent: runningMeta.agent,
|
|
516
|
-
sessionId: runningMeta.nativeSessionId,
|
|
517
|
-
cwd: runningMeta.cwd,
|
|
518
|
-
prompt
|
|
519
|
-
})
|
|
520
|
-
});
|
|
521
|
-
if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
|
|
522
|
-
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>`}`);
|
|
523
|
-
const persistedCursor = nextSerialized ?? serializedCursor;
|
|
524
|
-
nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
|
|
525
|
-
}
|
|
526
|
-
ctx.db.actions.cursorState_update({
|
|
527
|
-
key: `current`,
|
|
528
|
-
updater: (d) => {
|
|
529
|
-
d.cursor = nextCursorJson;
|
|
530
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
runningCursor = {
|
|
534
|
-
...runningCursor,
|
|
535
|
-
cursor: nextCursorJson,
|
|
536
|
-
lastProcessedInboxKey: inboxMsg.key
|
|
537
|
-
};
|
|
538
|
-
for (const row of ctx.db.collections.events.toArray) {
|
|
539
|
-
if (eventKeysBefore.has(row.key)) continue;
|
|
540
|
-
if (row.type !== `assistant_message`) continue;
|
|
541
|
-
const text = row.payload?.text;
|
|
542
|
-
if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
|
|
543
|
-
}
|
|
544
|
-
recordedRun.end({ status: `completed` });
|
|
545
|
-
} catch (e) {
|
|
546
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
547
|
-
recordedRun.end({
|
|
548
|
-
status: `failed`,
|
|
549
|
-
finishReason: `error`
|
|
550
|
-
});
|
|
551
|
-
ctx.db.actions.sessionMeta_update({
|
|
552
|
-
key: `current`,
|
|
553
|
-
updater: (d) => {
|
|
554
|
-
d.status = `error`;
|
|
555
|
-
d.error = message;
|
|
556
|
-
}
|
|
557
|
-
});
|
|
558
|
-
ctx.db.actions.cursorState_update({
|
|
559
|
-
key: `current`,
|
|
560
|
-
updater: (d) => {
|
|
561
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
throw e;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
ctx.db.actions.sessionMeta_update({
|
|
568
|
-
key: `current`,
|
|
569
|
-
updater: (d) => {
|
|
570
|
-
d.status = `idle`;
|
|
571
|
-
delete d.currentPromptInboxKey;
|
|
572
|
-
delete d.error;
|
|
573
|
-
}
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
|
|
579
72
|
//#endregion
|
|
580
73
|
//#region src/docs/embed.ts
|
|
581
74
|
const EMBEDDING_DIMENSIONS = 128;
|
|
@@ -646,7 +139,7 @@ function normalizeWhitespace(value) {
|
|
|
646
139
|
}
|
|
647
140
|
async function collectMarkdownFiles(root) {
|
|
648
141
|
async function walk(dir) {
|
|
649
|
-
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
142
|
+
const entries = await fs$1.readdir(dir, { withFileTypes: true });
|
|
650
143
|
const files = [];
|
|
651
144
|
for (const entry of entries) {
|
|
652
145
|
const fullPath = path.join(dir, entry.name);
|
|
@@ -791,15 +284,38 @@ function findLatestQuestion(items) {
|
|
|
791
284
|
return void 0;
|
|
792
285
|
}
|
|
793
286
|
function resolveDocsRoot(workingDirectory) {
|
|
287
|
+
const envDocsRoot = process.env.HORTON_DOCS_ROOT;
|
|
794
288
|
const candidates = [
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
289
|
+
envDocsRoot ? {
|
|
290
|
+
path: envDocsRoot,
|
|
291
|
+
requireIndex: false
|
|
292
|
+
} : null,
|
|
293
|
+
{
|
|
294
|
+
path: path.resolve(workingDirectory, `electric-agents-docs/docs`),
|
|
295
|
+
requireIndex: false
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
path: path.resolve(process.cwd(), `electric-agents-docs/docs`),
|
|
299
|
+
requireIndex: false
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
path: path.resolve(MODULE_DIR, `../docs`),
|
|
303
|
+
requireIndex: true
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
path: path.resolve(MODULE_DIR, `../../docs`),
|
|
307
|
+
requireIndex: true
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
path: path.resolve(MODULE_DIR, `../../../../website/docs/agents`),
|
|
311
|
+
requireIndex: true
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
path: path.resolve(MODULE_DIR, `../../../../../electric-agents-docs/docs`),
|
|
315
|
+
requireIndex: false
|
|
316
|
+
}
|
|
317
|
+
].filter((value) => Boolean(value));
|
|
318
|
+
for (const candidate of candidates) if (fs.existsSync(candidate.path) && (!candidate.requireIndex || fs.existsSync(path.join(candidate.path, `index.md`)))) return candidate.path;
|
|
803
319
|
return null;
|
|
804
320
|
}
|
|
805
321
|
var DocsKnowledgeBase = class {
|
|
@@ -820,7 +336,7 @@ var DocsKnowledgeBase = class {
|
|
|
820
336
|
this.readyPromise = this.ensureIngested();
|
|
821
337
|
}
|
|
822
338
|
openDatabase() {
|
|
823
|
-
|
|
339
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
824
340
|
try {
|
|
825
341
|
const db$1 = new Database(this.dbPath);
|
|
826
342
|
load(db$1);
|
|
@@ -829,7 +345,7 @@ var DocsKnowledgeBase = class {
|
|
|
829
345
|
return db$1;
|
|
830
346
|
} catch (error) {
|
|
831
347
|
const message = error instanceof Error ? error.message : String(error);
|
|
832
|
-
|
|
348
|
+
serverLog.debug(`${this.logPrefix} falling back to in-memory docs index: ${message}`);
|
|
833
349
|
return null;
|
|
834
350
|
}
|
|
835
351
|
}
|
|
@@ -887,11 +403,11 @@ var DocsKnowledgeBase = class {
|
|
|
887
403
|
};
|
|
888
404
|
}
|
|
889
405
|
async ensureIngested() {
|
|
890
|
-
await fs.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
406
|
+
await fs$1.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
891
407
|
const files = (await collectMarkdownFiles(this.docsRoot)).sort();
|
|
892
408
|
const docs = await Promise.all(files.map(async (filePath) => ({
|
|
893
409
|
path: path.relative(this.docsRoot, filePath),
|
|
894
|
-
content: await fs.readFile(filePath, `utf8`)
|
|
410
|
+
content: await fs$1.readFile(filePath, `utf8`)
|
|
895
411
|
})));
|
|
896
412
|
const fingerprint = createFingerprint(docs);
|
|
897
413
|
if (!this.db) {
|
|
@@ -916,7 +432,7 @@ var DocsKnowledgeBase = class {
|
|
|
916
432
|
}
|
|
917
433
|
this.fallbackFingerprint = fingerprint;
|
|
918
434
|
const stats$1 = this.stats();
|
|
919
|
-
|
|
435
|
+
serverLog.debug(`${this.logPrefix} indexed ${stats$1.docCount} docs into ${stats$1.chunkCount} chunks (${stats$1.fingerprint.slice(0, 12)}...)`);
|
|
920
436
|
return stats$1;
|
|
921
437
|
}
|
|
922
438
|
const db$1 = this.db;
|
|
@@ -953,7 +469,7 @@ var DocsKnowledgeBase = class {
|
|
|
953
469
|
});
|
|
954
470
|
reset();
|
|
955
471
|
const stats = this.stats();
|
|
956
|
-
|
|
472
|
+
serverLog.debug(`${this.logPrefix} indexed ${stats.docCount} docs into ${stats.chunkCount} chunks (${stats.fingerprint.slice(0, 12)}...)`);
|
|
957
473
|
return stats;
|
|
958
474
|
}
|
|
959
475
|
hybridSearch(query, limit = DEFAULT_K) {
|
|
@@ -1093,7 +609,7 @@ function renderSearchResults(query, results, docsRoot) {
|
|
|
1093
609
|
return lines.join(`\n`);
|
|
1094
610
|
}
|
|
1095
611
|
function logSearchResults(kind, query, output) {
|
|
1096
|
-
|
|
612
|
+
serverLog.debug(`[horton-docs] ${kind} search for "${query}"\n${output}\n`);
|
|
1097
613
|
}
|
|
1098
614
|
function createHortonDocsSupport(workingDirectory, opts = {}) {
|
|
1099
615
|
const docsRoot = opts.docsRoot ?? resolveDocsRoot(workingDirectory);
|
|
@@ -1238,7 +754,7 @@ function createSkillTools(registry, ctx) {
|
|
|
1238
754
|
const mdFiles = allRefFiles.filter((f) => f.endsWith(`.md`));
|
|
1239
755
|
const refContents = [];
|
|
1240
756
|
for (const f of mdFiles) try {
|
|
1241
|
-
const refContent = await fs.readFile(path.join(skillDir, f), `utf-8`);
|
|
757
|
+
const refContent = await fs$1.readFile(path.join(skillDir, f), `utf-8`);
|
|
1242
758
|
const refId = `${skillContextId(name)}:${f}`;
|
|
1243
759
|
ctx.insertContext(refId, {
|
|
1244
760
|
name: `skill_reference`,
|
|
@@ -1338,10 +854,10 @@ function substituteArgs(content, rawArgs, argNames) {
|
|
|
1338
854
|
function listRefFiles(dir, prefix = ``) {
|
|
1339
855
|
try {
|
|
1340
856
|
const results = [];
|
|
1341
|
-
for (const entry of
|
|
857
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1342
858
|
const full = path.join(dir, entry);
|
|
1343
859
|
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
1344
|
-
if (
|
|
860
|
+
if (fs.statSync(full).isDirectory()) results.push(...listRefFiles(full, rel));
|
|
1345
861
|
else results.push(rel);
|
|
1346
862
|
}
|
|
1347
863
|
return results;
|
|
@@ -1357,11 +873,11 @@ const WORKER_TOOL_NAMES = [
|
|
|
1357
873
|
`read`,
|
|
1358
874
|
`write`,
|
|
1359
875
|
`edit`,
|
|
1360
|
-
`
|
|
876
|
+
`web_search`,
|
|
1361
877
|
`fetch_url`,
|
|
1362
878
|
`spawn_worker`
|
|
1363
879
|
];
|
|
1364
|
-
function createSpawnWorkerTool(ctx) {
|
|
880
|
+
function createSpawnWorkerTool(ctx, modelConfig) {
|
|
1365
881
|
return {
|
|
1366
882
|
name: `spawn_worker`,
|
|
1367
883
|
label: `Spawn Worker`,
|
|
@@ -1388,10 +904,16 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1388
904
|
details: { spawned: false }
|
|
1389
905
|
};
|
|
1390
906
|
const id = nanoid(10);
|
|
907
|
+
const workerModelArgs = modelConfig ? {
|
|
908
|
+
provider: modelConfig.provider,
|
|
909
|
+
model: modelConfig.model,
|
|
910
|
+
...modelConfig.reasoningEffort && { reasoningEffort: modelConfig.reasoningEffort }
|
|
911
|
+
} : {};
|
|
1391
912
|
try {
|
|
1392
913
|
const handle = await ctx.spawn(`worker`, id, {
|
|
1393
914
|
systemPrompt,
|
|
1394
|
-
tools
|
|
915
|
+
tools,
|
|
916
|
+
...workerModelArgs
|
|
1395
917
|
}, {
|
|
1396
918
|
initialMessage,
|
|
1397
919
|
wake: {
|
|
@@ -1425,140 +947,138 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1425
947
|
}
|
|
1426
948
|
|
|
1427
949
|
//#endregion
|
|
1428
|
-
//#region src/
|
|
1429
|
-
const
|
|
1430
|
-
|
|
950
|
+
//#region src/model-catalog.ts
|
|
951
|
+
const REASONING_EFFORT_VALUES = [
|
|
952
|
+
`auto`,
|
|
953
|
+
`minimal`,
|
|
954
|
+
`low`,
|
|
955
|
+
`medium`,
|
|
956
|
+
`high`
|
|
957
|
+
];
|
|
958
|
+
const DEFAULT_ANTHROPIC_MODEL = `claude-sonnet-4-6`;
|
|
959
|
+
const DEFAULT_OPENAI_MODEL = `gpt-4.1`;
|
|
960
|
+
const DEFAULT_CODEX_MODEL = `gpt-5.4`;
|
|
961
|
+
function modelValue(provider, id) {
|
|
962
|
+
return `${provider}:${id}`;
|
|
963
|
+
}
|
|
964
|
+
function providerLabel(provider) {
|
|
965
|
+
if (provider === `anthropic`) return `Anthropic`;
|
|
966
|
+
if (provider === `openai-codex`) return `OpenAI Codex`;
|
|
967
|
+
return `OpenAI`;
|
|
968
|
+
}
|
|
969
|
+
function configuredProviders() {
|
|
970
|
+
return detectAvailableProviders();
|
|
971
|
+
}
|
|
972
|
+
function mockFallbackCatalog() {
|
|
973
|
+
const fallback = {
|
|
974
|
+
provider: `anthropic`,
|
|
975
|
+
id: DEFAULT_ANTHROPIC_MODEL,
|
|
976
|
+
label: `Anthropic ${DEFAULT_ANTHROPIC_MODEL}`,
|
|
977
|
+
value: modelValue(`anthropic`, DEFAULT_ANTHROPIC_MODEL),
|
|
978
|
+
reasoning: true
|
|
979
|
+
};
|
|
1431
980
|
return {
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
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.`,
|
|
1435
|
-
parameters: Type.Object({
|
|
1436
|
-
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.` }),
|
|
1437
|
-
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.` })),
|
|
1438
|
-
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.` }))
|
|
1439
|
-
}),
|
|
1440
|
-
execute: async (_toolCallId, params) => {
|
|
1441
|
-
const { prompt, agent, cwd } = params;
|
|
1442
|
-
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1443
|
-
content: [{
|
|
1444
|
-
type: `text`,
|
|
1445
|
-
text: `Error: prompt is required and must be a non-empty string.`
|
|
1446
|
-
}],
|
|
1447
|
-
details: { spawned: false }
|
|
1448
|
-
};
|
|
1449
|
-
const id = nanoid(10);
|
|
1450
|
-
const spawnArgs = { agent: agent ?? `claude` };
|
|
1451
|
-
if (cwd) spawnArgs.cwd = cwd;
|
|
1452
|
-
try {
|
|
1453
|
-
const handle = await ctx.spawn(`coder`, id, spawnArgs, {
|
|
1454
|
-
initialMessage: { text: prompt },
|
|
1455
|
-
wake: {
|
|
1456
|
-
on: `runFinished`,
|
|
1457
|
-
includeResponse: true
|
|
1458
|
-
}
|
|
1459
|
-
});
|
|
1460
|
-
const coderUrl = handle.entityUrl;
|
|
1461
|
-
return {
|
|
1462
|
-
content: [{
|
|
1463
|
-
type: `text`,
|
|
1464
|
-
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.`
|
|
1465
|
-
}],
|
|
1466
|
-
details: {
|
|
1467
|
-
spawned: true,
|
|
1468
|
-
coderUrl
|
|
1469
|
-
}
|
|
1470
|
-
};
|
|
1471
|
-
} catch (err) {
|
|
1472
|
-
serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1473
|
-
return {
|
|
1474
|
-
content: [{
|
|
1475
|
-
type: `text`,
|
|
1476
|
-
text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1477
|
-
}],
|
|
1478
|
-
details: { spawned: false }
|
|
1479
|
-
};
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
981
|
+
choices: [fallback],
|
|
982
|
+
defaultChoice: fallback
|
|
1482
983
|
};
|
|
1483
984
|
}
|
|
1484
|
-
function
|
|
985
|
+
async function fetchAvailableModelIds(provider) {
|
|
986
|
+
try {
|
|
987
|
+
const res = provider === `anthropic` ? await fetch(`https://api.anthropic.com/v1/models`, {
|
|
988
|
+
headers: {
|
|
989
|
+
"x-api-key": process.env.ANTHROPIC_API_KEY ?? ``,
|
|
990
|
+
"anthropic-version": `2023-06-01`
|
|
991
|
+
},
|
|
992
|
+
signal: AbortSignal.timeout(3e3)
|
|
993
|
+
}) : await fetch(`https://api.openai.com/v1/models`, {
|
|
994
|
+
headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ``}` },
|
|
995
|
+
signal: AbortSignal.timeout(3e3)
|
|
996
|
+
});
|
|
997
|
+
if (res.status === 401 || res.status === 403) return new Set();
|
|
998
|
+
if (!res.ok) return null;
|
|
999
|
+
const body = await res.json();
|
|
1000
|
+
const ids = new Set((body.data ?? []).map((model) => model.id).filter((id) => typeof id === `string`));
|
|
1001
|
+
return ids.size > 0 ? ids : null;
|
|
1002
|
+
} catch {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
async function choicesForProvider(provider) {
|
|
1007
|
+
const knownModels = getModels(provider);
|
|
1008
|
+
if (provider === `openai-codex`) return knownModels.map((model) => ({
|
|
1009
|
+
provider,
|
|
1010
|
+
id: model.id,
|
|
1011
|
+
label: `${providerLabel(provider)} ${model.name}`,
|
|
1012
|
+
value: modelValue(provider, model.id),
|
|
1013
|
+
reasoning: model.reasoning
|
|
1014
|
+
}));
|
|
1015
|
+
const availableIds = await fetchAvailableModelIds(provider);
|
|
1016
|
+
const models = availableIds === null ? knownModels : knownModels.filter((model) => availableIds.has(model.id));
|
|
1017
|
+
return models.map((model) => ({
|
|
1018
|
+
provider,
|
|
1019
|
+
id: model.id,
|
|
1020
|
+
label: `${providerLabel(provider)} ${model.name}`,
|
|
1021
|
+
value: modelValue(provider, model.id),
|
|
1022
|
+
reasoning: model.reasoning
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
function withProviderPayloadDefaults(config, choice, reasoningEffort) {
|
|
1026
|
+
if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
|
|
1027
|
+
const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
|
|
1028
|
+
const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
|
|
1485
1029
|
return {
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
type: `text`,
|
|
1498
|
-
text: `Error: coder_url must be a path like "/coder/<id>".`
|
|
1499
|
-
}],
|
|
1500
|
-
details: { sent: false }
|
|
1501
|
-
};
|
|
1502
|
-
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1503
|
-
content: [{
|
|
1504
|
-
type: `text`,
|
|
1505
|
-
text: `Error: prompt is required and must be a non-empty string.`
|
|
1506
|
-
}],
|
|
1507
|
-
details: { sent: false }
|
|
1030
|
+
...config,
|
|
1031
|
+
onPayload: (payload) => {
|
|
1032
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1033
|
+
const body = payload;
|
|
1034
|
+
const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
|
|
1035
|
+
return {
|
|
1036
|
+
...body,
|
|
1037
|
+
reasoning: {
|
|
1038
|
+
...existingReasoning,
|
|
1039
|
+
effort
|
|
1040
|
+
}
|
|
1508
1041
|
};
|
|
1509
|
-
try {
|
|
1510
|
-
ctx.send(coder_url, { text: prompt });
|
|
1511
|
-
return {
|
|
1512
|
-
content: [{
|
|
1513
|
-
type: `text`,
|
|
1514
|
-
text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
|
|
1515
|
-
}],
|
|
1516
|
-
details: {
|
|
1517
|
-
sent: true,
|
|
1518
|
-
coderUrl: coder_url
|
|
1519
|
-
}
|
|
1520
|
-
};
|
|
1521
|
-
} catch (err) {
|
|
1522
|
-
serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1523
|
-
return {
|
|
1524
|
-
content: [{
|
|
1525
|
-
type: `text`,
|
|
1526
|
-
text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1527
|
-
}],
|
|
1528
|
-
details: { sent: false }
|
|
1529
|
-
};
|
|
1530
|
-
}
|
|
1531
1042
|
}
|
|
1532
1043
|
};
|
|
1533
1044
|
}
|
|
1045
|
+
function parseReasoningEffort(value) {
|
|
1046
|
+
return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
|
|
1047
|
+
}
|
|
1048
|
+
async function createBuiltinModelCatalog(options = {}) {
|
|
1049
|
+
const providers = configuredProviders();
|
|
1050
|
+
if (providers.length === 0 && options.allowMockFallback) return mockFallbackCatalog();
|
|
1051
|
+
const choices = (await Promise.all(providers.map((provider) => choicesForProvider(provider)))).flat();
|
|
1052
|
+
if (choices.length === 0) return options.allowMockFallback ? mockFallbackCatalog() : null;
|
|
1053
|
+
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];
|
|
1054
|
+
return {
|
|
1055
|
+
choices,
|
|
1056
|
+
defaultChoice
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function resolveBuiltinModelConfig(catalog, args) {
|
|
1060
|
+
const modelArg = args.model;
|
|
1061
|
+
const providerArg = args.provider;
|
|
1062
|
+
const reasoningEffort = parseReasoningEffort(args.reasoningEffort);
|
|
1063
|
+
const selected = typeof modelArg === `string` ? catalog.choices.find((choice$1) => choice$1.value === modelArg || choice$1.id === modelArg && choice$1.provider === providerArg) : void 0;
|
|
1064
|
+
const choice = selected ?? catalog.defaultChoice;
|
|
1065
|
+
const config = {
|
|
1066
|
+
provider: choice.provider,
|
|
1067
|
+
model: choice.id,
|
|
1068
|
+
...reasoningEffort && { reasoningEffort },
|
|
1069
|
+
...choice.provider === `openai-codex` && { getApiKey: () => readCodexAccessToken() }
|
|
1070
|
+
};
|
|
1071
|
+
return withProviderPayloadDefaults(config, choice, reasoningEffort);
|
|
1072
|
+
}
|
|
1073
|
+
function modelChoiceValues(catalog) {
|
|
1074
|
+
return catalog.choices.map((choice) => choice.value);
|
|
1075
|
+
}
|
|
1534
1076
|
|
|
1535
1077
|
//#endregion
|
|
1536
1078
|
//#region src/agents/horton.ts
|
|
1537
|
-
const TITLE_MODEL = `claude-haiku-4-5-20251001`;
|
|
1538
1079
|
const HORTON_MODEL = `claude-sonnet-4-6`;
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
if (!anthropic) anthropic = new Anthropic();
|
|
1542
|
-
return anthropic;
|
|
1543
|
-
}
|
|
1544
|
-
async function defaultHaikuCall(prompt) {
|
|
1545
|
-
const client = getClient();
|
|
1546
|
-
const res = await client.messages.create({
|
|
1547
|
-
model: TITLE_MODEL,
|
|
1548
|
-
max_tokens: 64,
|
|
1549
|
-
messages: [{
|
|
1550
|
-
role: `user`,
|
|
1551
|
-
content: prompt
|
|
1552
|
-
}]
|
|
1553
|
-
});
|
|
1554
|
-
const block = res.content[0];
|
|
1555
|
-
return block?.type === `text` ? block.text : ``;
|
|
1556
|
-
}
|
|
1557
|
-
const TITLE_PROMPT = (userMessage) => `Summarize the following user request in 3-5 words for use as a chat session title.
|
|
1558
|
-
Respond with only the title, no quotes, no punctuation, no preamble.
|
|
1559
|
-
|
|
1560
|
-
User request:
|
|
1561
|
-
${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}`;
|
|
1562
1082
|
const TITLE_STOP_WORDS = new Set([
|
|
1563
1083
|
`a`,
|
|
1564
1084
|
`an`,
|
|
@@ -1626,19 +1146,34 @@ function buildFallbackTitle(userMessage) {
|
|
|
1626
1146
|
const selected = informativeWords.length >= 2 ? informativeWords.slice(0, 5) : backupWords;
|
|
1627
1147
|
return selected.join(` `).slice(0, 80).trim() || `Untitled Chat`;
|
|
1628
1148
|
}
|
|
1629
|
-
|
|
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) {
|
|
1630
1162
|
try {
|
|
1631
|
-
const raw = await llmCall(
|
|
1163
|
+
const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
|
|
1632
1164
|
const title = raw.trim();
|
|
1633
|
-
|
|
1634
|
-
|
|
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));
|
|
1635
1170
|
return buildFallbackTitle(userMessage);
|
|
1636
1171
|
}
|
|
1637
1172
|
}
|
|
1638
1173
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1639
1174
|
const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
|
|
1640
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` : ``;
|
|
1641
|
-
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
|
|
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.` : ``;
|
|
1642
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.
|
|
1643
1178
|
|
|
1644
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.
|
|
@@ -1674,7 +1209,9 @@ Don't force onboarding. If someone just wants to chat or code, let them. When in
|
|
|
1674
1209
|
- ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
|
|
1675
1210
|
- The Electric Agents docs site is at ${opts.docsUrl}
|
|
1676
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).
|
|
1677
|
-
- For general coding questions unrelated to Electric Agents, use
|
|
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.` : ``;
|
|
1678
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.
|
|
1679
1216
|
|
|
1680
1217
|
# Greetings
|
|
@@ -1685,18 +1222,16 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1685
1222
|
- read: read a file
|
|
1686
1223
|
- write: create or overwrite a file
|
|
1687
1224
|
- edit: targeted string replacement in an existing file (you must read the file first)
|
|
1688
|
-
-
|
|
1225
|
+
- web_search: search the web
|
|
1689
1226
|
- fetch_url: fetch and convert a URL to markdown
|
|
1690
1227
|
- spawn_worker: dispatch a subagent for an isolated task
|
|
1691
|
-
- spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
|
|
1692
|
-
- prompt_coder: send a follow-up prompt to a coder you previously spawned
|
|
1693
1228
|
${docsTools}${skillsTools}
|
|
1694
1229
|
|
|
1695
1230
|
# Working with files
|
|
1696
1231
|
- Prefer edit over write when modifying existing files.
|
|
1697
1232
|
- You must read a file before you can edit it.
|
|
1698
1233
|
- Use absolute paths or paths relative to the current working directory.
|
|
1699
|
-
${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1234
|
+
${modelGuidance}${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1700
1235
|
|
|
1701
1236
|
# Risky actions
|
|
1702
1237
|
Pause and confirm with the user before:
|
|
@@ -1717,13 +1252,6 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1717
1252
|
|
|
1718
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.
|
|
1719
1254
|
|
|
1720
|
-
# When to spawn a coder
|
|
1721
|
-
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.
|
|
1722
|
-
|
|
1723
|
-
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.
|
|
1724
|
-
|
|
1725
|
-
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.
|
|
1726
|
-
|
|
1727
1255
|
# Reporting
|
|
1728
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.
|
|
1729
1257
|
|
|
@@ -1737,34 +1265,82 @@ function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
|
|
|
1737
1265
|
createWriteTool(workingDirectory, readSet),
|
|
1738
1266
|
createEditTool(workingDirectory, readSet),
|
|
1739
1267
|
braveSearchTool$1,
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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),
|
|
1744
1275
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1745
1276
|
];
|
|
1746
1277
|
}
|
|
1747
|
-
function
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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;
|
|
1755
1305
|
}
|
|
1756
|
-
return null;
|
|
1757
1306
|
}
|
|
1758
1307
|
function createAssistantHandler(options) {
|
|
1759
|
-
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
|
|
1308
|
+
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
|
|
1760
1309
|
const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
|
|
1761
1310
|
return async function assistantHandler(ctx, wake) {
|
|
1762
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);
|
|
1763
1315
|
const tools = [
|
|
1764
1316
|
...ctx.electricTools,
|
|
1765
|
-
...createHortonTools(
|
|
1766
|
-
|
|
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()
|
|
1767
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();
|
|
1768
1344
|
if (docsSupport) ctx.useContext({
|
|
1769
1345
|
sourceBudget: 1e5,
|
|
1770
1346
|
sources: {
|
|
@@ -1782,6 +1358,11 @@ function createAssistantHandler(options) {
|
|
|
1782
1358
|
content: () => ctx.timelineMessages(),
|
|
1783
1359
|
cache: `volatile`
|
|
1784
1360
|
},
|
|
1361
|
+
...agentsMd ? { agents_md: {
|
|
1362
|
+
content: () => agentsMd,
|
|
1363
|
+
max: 2e4,
|
|
1364
|
+
cache: `stable`
|
|
1365
|
+
} } : {},
|
|
1785
1366
|
...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
|
|
1786
1367
|
content: () => skillsRegistry.renderCatalog(2e3),
|
|
1787
1368
|
max: 2e3,
|
|
@@ -1800,41 +1381,46 @@ function createAssistantHandler(options) {
|
|
|
1800
1381
|
conversation: {
|
|
1801
1382
|
content: () => ctx.timelineMessages(),
|
|
1802
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`
|
|
1803
1403
|
}
|
|
1804
1404
|
}
|
|
1805
1405
|
});
|
|
1806
1406
|
ctx.useAgent({
|
|
1807
|
-
systemPrompt: buildHortonSystemPrompt(
|
|
1407
|
+
systemPrompt: buildHortonSystemPrompt(effectiveCwd, {
|
|
1808
1408
|
hasDocsSupport: Boolean(docsSupport),
|
|
1809
1409
|
hasSkills,
|
|
1810
|
-
docsUrl
|
|
1410
|
+
docsUrl,
|
|
1411
|
+
modelProvider: modelConfig.provider,
|
|
1412
|
+
modelId: String(modelConfig.model)
|
|
1811
1413
|
}),
|
|
1812
|
-
|
|
1414
|
+
...modelConfig,
|
|
1813
1415
|
tools,
|
|
1814
1416
|
...streamFn && { streamFn }
|
|
1815
1417
|
});
|
|
1816
1418
|
await ctx.agent.run();
|
|
1817
|
-
|
|
1818
|
-
const firstUserMessage = extractFirstUserMessage(ctx.events);
|
|
1819
|
-
if (firstUserMessage) {
|
|
1820
|
-
let title = null;
|
|
1821
|
-
try {
|
|
1822
|
-
const result = await generateTitle(firstUserMessage);
|
|
1823
|
-
if (result.length > 0) title = result;
|
|
1824
|
-
} catch (err) {
|
|
1825
|
-
serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1826
|
-
}
|
|
1827
|
-
if (title !== null) try {
|
|
1828
|
-
await ctx.setTag(`title`, title);
|
|
1829
|
-
} catch (err) {
|
|
1830
|
-
serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1419
|
+
await titlePromise;
|
|
1834
1420
|
};
|
|
1835
1421
|
}
|
|
1836
1422
|
function registerHorton(registry, options) {
|
|
1837
|
-
const { workingDirectory, streamFn, skillsRegistry = null } = options;
|
|
1423
|
+
const { workingDirectory, streamFn, skillsRegistry = null, modelCatalog } = options;
|
|
1838
1424
|
const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
|
|
1839
1425
|
if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
|
|
1840
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)`);
|
|
@@ -1849,10 +1435,17 @@ function registerHorton(registry, options) {
|
|
|
1849
1435
|
docsSupport,
|
|
1850
1436
|
docsSearchTool,
|
|
1851
1437
|
skillsRegistry,
|
|
1438
|
+
modelCatalog,
|
|
1852
1439
|
docsUrl
|
|
1853
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
|
+
});
|
|
1854
1446
|
registry.define(`horton`, {
|
|
1855
1447
|
description: `Friendly capable assistant — chat, code, research, dispatch`,
|
|
1448
|
+
creationSchema: hortonCreationSchema,
|
|
1856
1449
|
handler: assistantHandler
|
|
1857
1450
|
});
|
|
1858
1451
|
const typeNames = [`horton`];
|
|
@@ -1897,6 +1490,9 @@ function parseWorkerArgs(value) {
|
|
|
1897
1490
|
};
|
|
1898
1491
|
}
|
|
1899
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;
|
|
1900
1496
|
return args;
|
|
1901
1497
|
}
|
|
1902
1498
|
function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
@@ -1914,7 +1510,7 @@ function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
|
1914
1510
|
case `edit`:
|
|
1915
1511
|
out.push(createEditTool(workingDirectory, readSet));
|
|
1916
1512
|
break;
|
|
1917
|
-
case `
|
|
1513
|
+
case `web_search`:
|
|
1918
1514
|
out.push(braveSearchTool$1);
|
|
1919
1515
|
break;
|
|
1920
1516
|
case `fetch_url`:
|
|
@@ -2023,13 +1619,14 @@ function buildSharedStateTools(shared, schema, mode) {
|
|
|
2023
1619
|
return tools;
|
|
2024
1620
|
}
|
|
2025
1621
|
function registerWorker(registry, options) {
|
|
2026
|
-
const { workingDirectory, streamFn } = options;
|
|
1622
|
+
const { workingDirectory, streamFn, modelCatalog } = options;
|
|
2027
1623
|
registry.define(`worker`, {
|
|
2028
1624
|
description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
|
|
2029
1625
|
async handler(ctx) {
|
|
2030
1626
|
const args = parseWorkerArgs(ctx.args);
|
|
2031
1627
|
const readSet = new Set();
|
|
2032
1628
|
const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
|
|
1629
|
+
const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
|
|
2033
1630
|
const sharedStateTools = [];
|
|
2034
1631
|
if (args.sharedDb) {
|
|
2035
1632
|
const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
|
|
@@ -2037,7 +1634,7 @@ function registerWorker(registry, options) {
|
|
|
2037
1634
|
}
|
|
2038
1635
|
ctx.useAgent({
|
|
2039
1636
|
systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
|
|
2040
|
-
|
|
1637
|
+
...modelConfig,
|
|
2041
1638
|
tools: [...builtinTools, ...sharedStateTools],
|
|
2042
1639
|
...streamFn && { streamFn }
|
|
2043
1640
|
});
|
|
@@ -2127,7 +1724,6 @@ function stripQuotes(value) {
|
|
|
2127
1724
|
|
|
2128
1725
|
//#endregion
|
|
2129
1726
|
//#region src/skills/extract-meta.ts
|
|
2130
|
-
const EXTRACT_MODEL = `claude-haiku-4-5-20251001`;
|
|
2131
1727
|
const DEFAULT_MAX = 1e4;
|
|
2132
1728
|
async function extractSkillMeta(name, content) {
|
|
2133
1729
|
const preamble = parsePreamble(content);
|
|
@@ -2140,7 +1736,7 @@ async function extractSkillMeta(name, content) {
|
|
|
2140
1736
|
...preamble.userInvocable && { userInvocable: true },
|
|
2141
1737
|
max: preamble.max ?? DEFAULT_MAX
|
|
2142
1738
|
};
|
|
2143
|
-
|
|
1739
|
+
try {
|
|
2144
1740
|
return await llmExtract(name, content, preamble);
|
|
2145
1741
|
} catch (err) {
|
|
2146
1742
|
serverLog.warn(`[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2153,7 +1749,6 @@ async function extractSkillMeta(name, content) {
|
|
|
2153
1749
|
};
|
|
2154
1750
|
}
|
|
2155
1751
|
async function llmExtract(name, content, partial) {
|
|
2156
|
-
const client = new Anthropic();
|
|
2157
1752
|
const truncated = content.slice(0, 8e3);
|
|
2158
1753
|
const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}".
|
|
2159
1754
|
|
|
@@ -2167,15 +1762,14 @@ Return ONLY a JSON object with these fields:
|
|
|
2167
1762
|
- "keywords": array of 3-8 relevant keywords
|
|
2168
1763
|
|
|
2169
1764
|
Return raw JSON, no markdown fences.`;
|
|
2170
|
-
const
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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]`
|
|
2177
1772
|
});
|
|
2178
|
-
const text = res.content[0]?.type === `text` ? res.content[0].text : ``;
|
|
2179
1773
|
const parsed = JSON.parse(text);
|
|
2180
1774
|
return {
|
|
2181
1775
|
description: partial.description ?? parsed.description ?? humanize(name),
|
|
@@ -2200,7 +1794,7 @@ async function createSkillsRegistry(opts) {
|
|
|
2200
1794
|
if (appSkillsDir) await scanDir(appSkillsDir, files);
|
|
2201
1795
|
const catalog = new Map();
|
|
2202
1796
|
for (const [name, filePath] of files) {
|
|
2203
|
-
const content = await fs.readFile(filePath, `utf-8`);
|
|
1797
|
+
const content = await fs$1.readFile(filePath, `utf-8`);
|
|
2204
1798
|
const hash = sha256(content);
|
|
2205
1799
|
const cached = existingCache[name];
|
|
2206
1800
|
if (cached && cached.contentHash === hash && cached.source === filePath) {
|
|
@@ -2234,7 +1828,7 @@ async function createSkillsRegistry(opts) {
|
|
|
2234
1828
|
const meta = catalog.get(name);
|
|
2235
1829
|
if (!meta) return null;
|
|
2236
1830
|
try {
|
|
2237
|
-
return await fs.readFile(meta.source, `utf-8`);
|
|
1831
|
+
return await fs$1.readFile(meta.source, `utf-8`);
|
|
2238
1832
|
} catch {
|
|
2239
1833
|
return null;
|
|
2240
1834
|
}
|
|
@@ -2244,7 +1838,7 @@ async function createSkillsRegistry(opts) {
|
|
|
2244
1838
|
async function scanDir(dir, out) {
|
|
2245
1839
|
let entries;
|
|
2246
1840
|
try {
|
|
2247
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1841
|
+
entries = await fs$1.readdir(dir, { withFileTypes: true });
|
|
2248
1842
|
} catch {
|
|
2249
1843
|
return;
|
|
2250
1844
|
}
|
|
@@ -2256,7 +1850,7 @@ async function scanDir(dir, out) {
|
|
|
2256
1850
|
}
|
|
2257
1851
|
async function loadCache(cachePath) {
|
|
2258
1852
|
try {
|
|
2259
|
-
const raw = await fs.readFile(cachePath, `utf-8`);
|
|
1853
|
+
const raw = await fs$1.readFile(cachePath, `utf-8`);
|
|
2260
1854
|
return JSON.parse(raw);
|
|
2261
1855
|
} catch {
|
|
2262
1856
|
return {};
|
|
@@ -2265,8 +1859,9 @@ async function loadCache(cachePath) {
|
|
|
2265
1859
|
async function saveCache(cachePath, catalog, cacheDir) {
|
|
2266
1860
|
const obj = {};
|
|
2267
1861
|
for (const [name, meta] of catalog) obj[name] = meta;
|
|
2268
|
-
|
|
2269
|
-
await fs.writeFile(
|
|
1862
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1863
|
+
await fs$1.writeFile(path.join(cacheDir, `.gitignore`), `*\n`, `utf-8`);
|
|
1864
|
+
await fs$1.writeFile(cachePath, JSON.stringify(obj, null, 2), `utf-8`);
|
|
2270
1865
|
}
|
|
2271
1866
|
function sha256(content) {
|
|
2272
1867
|
return createHash(`sha256`).update(content).digest(`hex`);
|
|
@@ -2305,9 +1900,10 @@ function truncate(str, max) {
|
|
|
2305
1900
|
//#region src/bootstrap.ts
|
|
2306
1901
|
const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
|
|
2307
1902
|
async function createBuiltinAgentHandler(options) {
|
|
2308
|
-
const { agentServerUrl, serveEndpoint = `${agentServerUrl}${DEFAULT_BUILTIN_AGENT_HANDLER_PATH}`, workingDirectory, streamFn, createElectricTools } = options;
|
|
2309
|
-
|
|
2310
|
-
|
|
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`);
|
|
2311
1907
|
return null;
|
|
2312
1908
|
}
|
|
2313
1909
|
const cwd = workingDirectory ?? process.cwd();
|
|
@@ -2328,22 +1924,24 @@ async function createBuiltinAgentHandler(options) {
|
|
|
2328
1924
|
const typeNames = registerHorton(registry, {
|
|
2329
1925
|
workingDirectory: cwd,
|
|
2330
1926
|
streamFn,
|
|
2331
|
-
skillsRegistry
|
|
1927
|
+
skillsRegistry,
|
|
1928
|
+
modelCatalog
|
|
2332
1929
|
});
|
|
2333
1930
|
registerWorker(registry, {
|
|
2334
1931
|
workingDirectory: cwd,
|
|
2335
|
-
streamFn
|
|
1932
|
+
streamFn,
|
|
1933
|
+
modelCatalog
|
|
2336
1934
|
});
|
|
2337
1935
|
typeNames.push(`worker`);
|
|
2338
|
-
registerCodingSession(registry, { defaultWorkingDirectory: cwd });
|
|
2339
|
-
typeNames.push(`coder`);
|
|
2340
1936
|
const runtime = createRuntimeHandler({
|
|
2341
1937
|
baseUrl: agentServerUrl,
|
|
2342
1938
|
serveEndpoint,
|
|
2343
1939
|
registry,
|
|
2344
1940
|
subscriptionPathForType: (name) => `/${name}/*/main`,
|
|
2345
1941
|
idleTimeout: 5e3,
|
|
2346
|
-
createElectricTools
|
|
1942
|
+
createElectricTools,
|
|
1943
|
+
publicUrl,
|
|
1944
|
+
name: runtimeName ?? `builtin-agents`
|
|
2347
1945
|
});
|
|
2348
1946
|
return {
|
|
2349
1947
|
handler: runtime.onEnter,
|
|
@@ -2375,10 +1973,19 @@ var BuiltinAgentsServer = class {
|
|
|
2375
1973
|
bootstrap = null;
|
|
2376
1974
|
_url = null;
|
|
2377
1975
|
publicBaseUrl = null;
|
|
1976
|
+
_mcpRegistry = null;
|
|
1977
|
+
mcpWatcherCloser = null;
|
|
1978
|
+
mcpToolProviderName = null;
|
|
1979
|
+
mcpApplyInFlight = new Set();
|
|
1980
|
+
mcpStopping = false;
|
|
2378
1981
|
options;
|
|
2379
1982
|
constructor(options) {
|
|
2380
1983
|
this.options = options;
|
|
2381
1984
|
}
|
|
1985
|
+
/** Embedded MCP registry. `null` until `start()` has run. */
|
|
1986
|
+
get mcpRegistry() {
|
|
1987
|
+
return this._mcpRegistry;
|
|
1988
|
+
}
|
|
2382
1989
|
get url() {
|
|
2383
1990
|
if (!this._url) throw new Error(`Builtin agents server not started`);
|
|
2384
1991
|
return this._url;
|
|
@@ -2412,14 +2019,124 @@ var BuiltinAgentsServer = class {
|
|
|
2412
2019
|
this.publicBaseUrl = this.options.baseUrl ?? this._url;
|
|
2413
2020
|
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
2414
2021
|
const serveEndpoint = new URL(webhookPath, this.publicBaseUrl.endsWith(`/`) ? this.publicBaseUrl : `${this.publicBaseUrl}/`).toString();
|
|
2022
|
+
const publicUrl = this.options.mcpOAuthRedirectBase ?? this.publicBaseUrl;
|
|
2023
|
+
const mcpRegistry = createRegistry({
|
|
2024
|
+
publicUrl,
|
|
2025
|
+
openAuthorizeUrl: this.options.openAuthorizeUrl
|
|
2026
|
+
});
|
|
2027
|
+
this._mcpRegistry = mcpRegistry;
|
|
2028
|
+
const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
|
|
2029
|
+
const extras = this.options.extraMcpServers ?? [];
|
|
2030
|
+
const wirePersistence = async (cfg) => {
|
|
2031
|
+
const servers = [];
|
|
2032
|
+
for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
|
|
2033
|
+
const persist = await keychainPersistence({ server: s.name });
|
|
2034
|
+
servers.push({
|
|
2035
|
+
...s,
|
|
2036
|
+
auth: {
|
|
2037
|
+
...s.auth,
|
|
2038
|
+
...persist
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
} else servers.push(s);
|
|
2042
|
+
return {
|
|
2043
|
+
...cfg,
|
|
2044
|
+
servers
|
|
2045
|
+
};
|
|
2046
|
+
};
|
|
2047
|
+
const merge = (jsonCfg) => {
|
|
2048
|
+
const jsonServers = jsonCfg?.servers ?? [];
|
|
2049
|
+
const jsonNames = new Set(jsonServers.map((s) => s.name));
|
|
2050
|
+
const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
|
|
2051
|
+
return {
|
|
2052
|
+
servers: [...filteredExtras, ...jsonServers],
|
|
2053
|
+
raw: jsonCfg?.raw
|
|
2054
|
+
};
|
|
2055
|
+
};
|
|
2056
|
+
const onConfigError = this.options.onConfigError;
|
|
2057
|
+
const runApply = async (jsonCfg) => {
|
|
2058
|
+
if (this.mcpStopping) return;
|
|
2059
|
+
try {
|
|
2060
|
+
const wired = await wirePersistence(merge(jsonCfg));
|
|
2061
|
+
if (this.mcpStopping) return;
|
|
2062
|
+
await mcpRegistry.applyConfig(wired);
|
|
2063
|
+
} catch (e) {
|
|
2064
|
+
serverLog.error(`[mcp] applyConfig:`, e);
|
|
2065
|
+
try {
|
|
2066
|
+
onConfigError?.(e);
|
|
2067
|
+
} catch (cbErr) {
|
|
2068
|
+
serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
const applyMerged = (jsonCfg) => {
|
|
2073
|
+
const p = runApply(jsonCfg);
|
|
2074
|
+
this.mcpApplyInFlight.add(p);
|
|
2075
|
+
p.finally(() => this.mcpApplyInFlight.delete(p));
|
|
2076
|
+
return p;
|
|
2077
|
+
};
|
|
2078
|
+
if (mcpConfigPath) {
|
|
2079
|
+
try {
|
|
2080
|
+
const cfg = await loadConfig(mcpConfigPath, process.env);
|
|
2081
|
+
applyMerged(cfg);
|
|
2082
|
+
} catch (err) {
|
|
2083
|
+
if (err.code !== `ENOENT`) throw err;
|
|
2084
|
+
if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
|
|
2085
|
+
else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
|
|
2086
|
+
applyMerged(null);
|
|
2087
|
+
}
|
|
2088
|
+
try {
|
|
2089
|
+
this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
|
|
2090
|
+
onChange: (cfg) => void applyMerged(cfg),
|
|
2091
|
+
onError: (e) => serverLog.error(`[mcp] config error:`, e)
|
|
2092
|
+
});
|
|
2093
|
+
} catch (e) {
|
|
2094
|
+
serverLog.error(`[mcp] config watcher failed to start:`, e);
|
|
2095
|
+
}
|
|
2096
|
+
} else {
|
|
2097
|
+
if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
|
|
2098
|
+
applyMerged(null);
|
|
2099
|
+
}
|
|
2100
|
+
this.mcpToolProviderName = `mcp`;
|
|
2101
|
+
registerToolProvider({
|
|
2102
|
+
name: `mcp`,
|
|
2103
|
+
tools: () => {
|
|
2104
|
+
const tools = [];
|
|
2105
|
+
for (const entry of mcpRegistry.list()) {
|
|
2106
|
+
if (entry.status !== `ready`) continue;
|
|
2107
|
+
const live = mcpRegistry.get(entry.name);
|
|
2108
|
+
if (!live?.transport) continue;
|
|
2109
|
+
for (const t of entry.tools) tools.push(bridgeMcpTool({
|
|
2110
|
+
server: entry.name,
|
|
2111
|
+
tool: t,
|
|
2112
|
+
client: live.transport.client,
|
|
2113
|
+
timeoutMs: live.config.timeoutMs
|
|
2114
|
+
}));
|
|
2115
|
+
const caps = live.transport.client.getServerCapabilities?.();
|
|
2116
|
+
if (caps?.resources) tools.push(...buildResourceTools({
|
|
2117
|
+
server: entry.name,
|
|
2118
|
+
client: live.transport.client,
|
|
2119
|
+
timeoutMs: live.config.timeoutMs
|
|
2120
|
+
}));
|
|
2121
|
+
if (caps?.prompts) tools.push(...buildPromptTools({
|
|
2122
|
+
server: entry.name,
|
|
2123
|
+
client: live.transport.client,
|
|
2124
|
+
timeoutMs: live.config.timeoutMs
|
|
2125
|
+
}));
|
|
2126
|
+
}
|
|
2127
|
+
return tools;
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2415
2130
|
this.bootstrap = await createBuiltinAgentHandler({
|
|
2416
2131
|
agentServerUrl: this.options.agentServerUrl,
|
|
2417
2132
|
serveEndpoint,
|
|
2418
2133
|
workingDirectory: this.options.workingDirectory,
|
|
2419
2134
|
streamFn: this.options.mockStreamFn,
|
|
2420
|
-
createElectricTools: this.options.createElectricTools
|
|
2135
|
+
createElectricTools: this.options.createElectricTools,
|
|
2136
|
+
publicUrl,
|
|
2137
|
+
runtimeName: `builtin-agents`
|
|
2421
2138
|
});
|
|
2422
|
-
if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY must be set before starting builtin agents`);
|
|
2139
|
+
if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY or OPENAI_API_KEY must be set before starting builtin agents`);
|
|
2423
2140
|
await registerBuiltinAgentTypes(this.bootstrap);
|
|
2424
2141
|
serverLog.info(`[builtin-agents] webhook handler listening at ${serveEndpoint}`);
|
|
2425
2142
|
resolve(this._url);
|
|
@@ -2436,6 +2153,26 @@ var BuiltinAgentsServer = class {
|
|
|
2436
2153
|
await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
2437
2154
|
this.bootstrap = null;
|
|
2438
2155
|
}
|
|
2156
|
+
this.mcpStopping = true;
|
|
2157
|
+
if (this.mcpWatcherCloser) {
|
|
2158
|
+
try {
|
|
2159
|
+
this.mcpWatcherCloser();
|
|
2160
|
+
} catch (e) {
|
|
2161
|
+
serverLog.error(`[mcp] watcher close failed:`, e);
|
|
2162
|
+
}
|
|
2163
|
+
this.mcpWatcherCloser = null;
|
|
2164
|
+
}
|
|
2165
|
+
if (this.mcpApplyInFlight.size > 0) await Promise.allSettled([...this.mcpApplyInFlight]);
|
|
2166
|
+
if (this.mcpToolProviderName) {
|
|
2167
|
+
unregisterToolProvider(this.mcpToolProviderName);
|
|
2168
|
+
this.mcpToolProviderName = null;
|
|
2169
|
+
}
|
|
2170
|
+
if (this._mcpRegistry) {
|
|
2171
|
+
await this._mcpRegistry.close().catch((e) => {
|
|
2172
|
+
serverLog.error(`[mcp] registry close failed:`, e);
|
|
2173
|
+
});
|
|
2174
|
+
this._mcpRegistry = null;
|
|
2175
|
+
}
|
|
2439
2176
|
if (this.server) {
|
|
2440
2177
|
const server = this.server;
|
|
2441
2178
|
await new Promise((resolve) => {
|
|
@@ -2443,19 +2180,20 @@ var BuiltinAgentsServer = class {
|
|
|
2443
2180
|
});
|
|
2444
2181
|
this.server = null;
|
|
2445
2182
|
}
|
|
2183
|
+
this.mcpStopping = false;
|
|
2446
2184
|
this._url = null;
|
|
2447
2185
|
this.publicBaseUrl = null;
|
|
2448
2186
|
}
|
|
2449
2187
|
async handleRequest(req, res) {
|
|
2450
2188
|
const method = req.method?.toUpperCase();
|
|
2451
|
-
const
|
|
2189
|
+
const pathname = new URL(req.url ?? `/`, `http://localhost`).pathname;
|
|
2452
2190
|
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
2453
|
-
if (
|
|
2191
|
+
if (pathname === `/_electric/health` && method === `GET`) {
|
|
2454
2192
|
res.writeHead(200, { "content-type": `application/json` });
|
|
2455
2193
|
res.end(JSON.stringify({ status: `ok` }));
|
|
2456
2194
|
return;
|
|
2457
2195
|
}
|
|
2458
|
-
if (
|
|
2196
|
+
if (pathname === webhookPath && method === `POST` && this.bootstrap) {
|
|
2459
2197
|
await this.bootstrap.handler(req, res);
|
|
2460
2198
|
return;
|
|
2461
2199
|
}
|
|
@@ -2518,4 +2256,4 @@ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd
|
|
|
2518
2256
|
}
|
|
2519
2257
|
|
|
2520
2258
|
//#endregion
|
|
2521
|
-
export { BuiltinAgentsServer, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, WORKER_TOOL_NAMES, braveSearchTool, buildHortonSystemPrompt, createAgentHandler, createBuiltinAgentHandler, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, registerAgentTypes, registerBuiltinAgentTypes,
|
|
2259
|
+
export { BuiltinAgentsServer, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, WORKER_TOOL_NAMES, braveSearchTool, buildHortonSystemPrompt, createAgentHandler, createBuiltinAgentHandler, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
|