@electric-ax/agents 0.2.4 → 0.4.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 +541 -810
- package/dist/index.cjs +537 -804
- package/dist/index.d.cts +84 -45
- package/dist/index.d.ts +86 -47
- package/dist/index.js +558 -824
- package/docs/entities/agents/horton.md +2 -5
- package/docs/index.md +4 -2
- package/docs/quickstart.md +2 -2
- package/docs/reference/handler-context.md +0 -35
- package/docs/reference/mcp-registry.md +189 -0
- package/docs/reference/mcp-server-config.md +226 -0
- package/docs/usage/clients-and-react.md +0 -4
- package/docs/usage/embedded-builtins.md +26 -16
- package/docs/usage/mcp-servers.md +354 -0
- package/docs/usage/overview.md +1 -3
- package/docs/usage/programmatic-runtime-client.md +1 -1
- package/docs/usage/writing-handlers.md +0 -5
- package/package.json +7 -5
- package/docs/entities/agents/coder.md +0 -99
package/dist/index.js
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { appendPathToUrl, completeWithLowCostModel, createEntityRegistry, createPullWakeRunner, 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 {
|
|
18
|
-
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";
|
|
19
17
|
|
|
20
18
|
//#region src/log.ts
|
|
21
19
|
const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
22
|
-
|
|
20
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
23
21
|
const LOG_FILE = path.join(LOG_DIR, `builtin-agents-${Date.now()}.jsonl`);
|
|
24
22
|
const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
25
|
-
const
|
|
26
|
-
const
|
|
23
|
+
const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
|
|
24
|
+
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
|
|
25
|
+
const streams = [{ stream: pino.destination({
|
|
26
|
+
dest: LOG_FILE,
|
|
27
|
+
sync: IS_ELECTRON_MAIN
|
|
28
|
+
}) }];
|
|
27
29
|
if (USE_PRETTY_LOGS) streams.push({ stream: pino.transport({
|
|
28
30
|
target: `pino-pretty`,
|
|
29
31
|
options: {
|
|
@@ -70,516 +72,6 @@ const serverLog = {
|
|
|
70
72
|
}
|
|
71
73
|
};
|
|
72
74
|
|
|
73
|
-
//#endregion
|
|
74
|
-
//#region src/agents/coding-session.ts
|
|
75
|
-
const defaultCliRunner = { async run(opts) {
|
|
76
|
-
return new Promise((resolve, reject) => {
|
|
77
|
-
const isClaude = opts.agent === `claude`;
|
|
78
|
-
const bin = isClaude ? `claude` : `codex`;
|
|
79
|
-
const args = isClaude ? opts.sessionId ? [
|
|
80
|
-
`-r`,
|
|
81
|
-
opts.sessionId,
|
|
82
|
-
`--dangerously-skip-permissions`,
|
|
83
|
-
`-p`
|
|
84
|
-
] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
|
|
85
|
-
`exec`,
|
|
86
|
-
`--skip-git-repo-check`,
|
|
87
|
-
`resume`,
|
|
88
|
-
opts.sessionId,
|
|
89
|
-
opts.prompt
|
|
90
|
-
] : [
|
|
91
|
-
`exec`,
|
|
92
|
-
`--skip-git-repo-check`,
|
|
93
|
-
opts.prompt
|
|
94
|
-
];
|
|
95
|
-
const child = spawn(bin, args, {
|
|
96
|
-
cwd: opts.cwd,
|
|
97
|
-
stdio: [
|
|
98
|
-
isClaude ? `pipe` : `ignore`,
|
|
99
|
-
`pipe`,
|
|
100
|
-
`pipe`
|
|
101
|
-
]
|
|
102
|
-
});
|
|
103
|
-
const MAX_BUF_CHARS = 4096;
|
|
104
|
-
let stdout = ``;
|
|
105
|
-
let stderr = ``;
|
|
106
|
-
child.stdout?.on(`data`, (d) => {
|
|
107
|
-
if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
|
|
108
|
-
});
|
|
109
|
-
child.stderr?.on(`data`, (d) => {
|
|
110
|
-
if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
|
|
111
|
-
});
|
|
112
|
-
child.on(`error`, reject);
|
|
113
|
-
child.on(`exit`, (code) => {
|
|
114
|
-
resolve({
|
|
115
|
-
exitCode: code ?? -1,
|
|
116
|
-
stdout,
|
|
117
|
-
stderr
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
if (isClaude && child.stdin) {
|
|
121
|
-
child.stdin.write(opts.prompt);
|
|
122
|
-
child.stdin.end();
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
} };
|
|
126
|
-
async function discoverNewestSession(agent, cwd, excludeIds) {
|
|
127
|
-
const all = await discoverSessions(agent);
|
|
128
|
-
const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
|
|
129
|
-
if (candidates.length === 0) return null;
|
|
130
|
-
return candidates[0].sessionId;
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Compute the candidate directories where Claude Code stores per-cwd
|
|
134
|
-
* session JSONL files. Claude resolves the cwd to its realpath when
|
|
135
|
-
* choosing the directory name (so /tmp/foo on macOS lands under
|
|
136
|
-
* `-private-tmp-foo`), but the entity may have been spawned with the
|
|
137
|
-
* non-realpath form. Return both candidates so the caller can union
|
|
138
|
-
* their contents.
|
|
139
|
-
*/
|
|
140
|
-
async function getClaudeProjectDirs(cwd) {
|
|
141
|
-
const home = homedir();
|
|
142
|
-
const make = (c) => path.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
|
|
143
|
-
const dirs = [make(cwd)];
|
|
144
|
-
try {
|
|
145
|
-
const real = await promises.realpath(cwd);
|
|
146
|
-
if (real !== cwd) dirs.push(make(real));
|
|
147
|
-
} catch {}
|
|
148
|
-
return dirs;
|
|
149
|
-
}
|
|
150
|
-
async function listClaudeJsonlIdsByCwd(cwd) {
|
|
151
|
-
const ids = new Set();
|
|
152
|
-
for (const dir of await getClaudeProjectDirs(cwd)) try {
|
|
153
|
-
const files = await promises.readdir(dir);
|
|
154
|
-
for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
|
|
155
|
-
} catch {}
|
|
156
|
-
return ids;
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Deterministic-path discovery for a freshly created session. After the
|
|
160
|
-
* Claude CLI runs in `-p` mode it writes the new JSONL straight into
|
|
161
|
-
* `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
|
|
162
|
-
* `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
|
|
163
|
-
* so `discoverSessions` can miss it. Compute the expected dir directly
|
|
164
|
-
* and diff its contents against a pre-run snapshot. Returns the newest
|
|
165
|
-
* fresh sessionId or null. Codex falls back to discoverNewestSession.
|
|
166
|
-
*/
|
|
167
|
-
async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
|
|
168
|
-
if (agent === `claude`) {
|
|
169
|
-
const dirs = await getClaudeProjectDirs(cwd);
|
|
170
|
-
let best = null;
|
|
171
|
-
for (const dir of dirs) try {
|
|
172
|
-
const files = await promises.readdir(dir);
|
|
173
|
-
for (const f of files) {
|
|
174
|
-
if (!f.endsWith(`.jsonl`)) continue;
|
|
175
|
-
const id = f.slice(0, -`.jsonl`.length);
|
|
176
|
-
if (preDirectIds.has(id)) continue;
|
|
177
|
-
const st = await promises.stat(path.join(dir, f)).catch(() => null);
|
|
178
|
-
if (!st) continue;
|
|
179
|
-
if (!best || st.mtimeMs > best.mtime) best = {
|
|
180
|
-
id,
|
|
181
|
-
mtime: st.mtimeMs
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
} catch {}
|
|
185
|
-
if (best) return best.id;
|
|
186
|
-
}
|
|
187
|
-
return discoverNewestSession(agent, cwd, preDiscoveredIds);
|
|
188
|
-
}
|
|
189
|
-
const sessionMetaRowSchema = z.object({
|
|
190
|
-
key: z.literal(`current`),
|
|
191
|
-
electricSessionId: z.string(),
|
|
192
|
-
nativeSessionId: z.string().optional(),
|
|
193
|
-
agent: z.enum([`claude`, `codex`]),
|
|
194
|
-
cwd: z.string(),
|
|
195
|
-
status: z.enum([
|
|
196
|
-
`initializing`,
|
|
197
|
-
`idle`,
|
|
198
|
-
`running`,
|
|
199
|
-
`error`
|
|
200
|
-
]),
|
|
201
|
-
error: z.string().optional(),
|
|
202
|
-
currentPromptInboxKey: z.string().optional()
|
|
203
|
-
});
|
|
204
|
-
const cursorStateRowSchema = z.object({
|
|
205
|
-
key: z.literal(`current`),
|
|
206
|
-
cursor: z.string(),
|
|
207
|
-
lastProcessedInboxKey: z.string().optional()
|
|
208
|
-
});
|
|
209
|
-
const eventRowSchema = z.object({
|
|
210
|
-
key: z.string(),
|
|
211
|
-
ts: z.number(),
|
|
212
|
-
type: z.string(),
|
|
213
|
-
callId: z.string().optional(),
|
|
214
|
-
payload: z.looseObject({})
|
|
215
|
-
});
|
|
216
|
-
const creationArgsSchema = z.object({
|
|
217
|
-
agent: z.enum([`claude`, `codex`]),
|
|
218
|
-
cwd: z.string().optional(),
|
|
219
|
-
nativeSessionId: z.string().optional(),
|
|
220
|
-
importFrom: z.object({
|
|
221
|
-
agent: z.enum([`claude`, `codex`]),
|
|
222
|
-
sessionId: z.string()
|
|
223
|
-
}).optional()
|
|
224
|
-
});
|
|
225
|
-
const promptMessageSchema = z.object({ text: z.string() });
|
|
226
|
-
/**
|
|
227
|
-
* Stable key for an events-collection row, derived from the event's content.
|
|
228
|
-
* Lets us re-insert the same event without producing duplicates — the caller
|
|
229
|
-
* (or the collection's uniqueness guard) uses this to de-dup across retries,
|
|
230
|
-
* replays, and crash recovery. Sorts chronologically by ts, then by type.
|
|
231
|
-
*/
|
|
232
|
-
function eventKey(event) {
|
|
233
|
-
const tsPart = String(event.ts).padStart(16, `0`);
|
|
234
|
-
return `${tsPart}_${event.type}_${contentHashHex(event)}`;
|
|
235
|
-
}
|
|
236
|
-
function contentHashHex(event) {
|
|
237
|
-
const json = JSON.stringify(event);
|
|
238
|
-
let h = 5381;
|
|
239
|
-
for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
|
|
240
|
-
return h.toString(16).padStart(8, `0`);
|
|
241
|
-
}
|
|
242
|
-
function buildEventRow(event) {
|
|
243
|
-
const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
|
|
244
|
-
return {
|
|
245
|
-
key: eventKey(event),
|
|
246
|
-
ts: event.ts,
|
|
247
|
-
type: event.type,
|
|
248
|
-
...callId !== void 0 ? { callId } : {},
|
|
249
|
-
payload: event
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
function appendIfNew(ctx, event) {
|
|
253
|
-
const row = buildEventRow(event);
|
|
254
|
-
if (ctx.events.get(row.key) !== void 0) return;
|
|
255
|
-
ctx.actions.events_insert({ row });
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Mirror every event that lands in the JSONL file while `runWork` is
|
|
259
|
-
* executing (i.e. while the CLI is running). Returns the advanced cursor
|
|
260
|
-
* and the `runWork` result once everything has settled and every append
|
|
261
|
-
* has been persisted to the entity's durable stream.
|
|
262
|
-
*
|
|
263
|
-
* If setup fails (e.g. the session file can't be resolved), `runWork`
|
|
264
|
-
* still runs — but nothing is mirrored and `setupError` is populated so
|
|
265
|
-
* the caller can surface the condition. If `runWork` throws, the error
|
|
266
|
-
* propagates after the watcher has been cleaned up.
|
|
267
|
-
*/
|
|
268
|
-
async function runWithLiveMirror(opts) {
|
|
269
|
-
let cursor = null;
|
|
270
|
-
let setupError = void 0;
|
|
271
|
-
try {
|
|
272
|
-
const session = await resolveSession(opts.nativeSessionId, opts.agent);
|
|
273
|
-
if (opts.serializedCursor) cursor = deserializeCursor({
|
|
274
|
-
...opts.serializedCursor,
|
|
275
|
-
path: session.path
|
|
276
|
-
});
|
|
277
|
-
else {
|
|
278
|
-
const initial = await loadSession({
|
|
279
|
-
sessionId: opts.nativeSessionId,
|
|
280
|
-
agent: opts.agent
|
|
281
|
-
});
|
|
282
|
-
for (const ev of initial.events) appendIfNew(opts.ctx, ev);
|
|
283
|
-
cursor = initial.cursor;
|
|
284
|
-
}
|
|
285
|
-
} catch (e) {
|
|
286
|
-
setupError = e;
|
|
287
|
-
}
|
|
288
|
-
if (!cursor) {
|
|
289
|
-
const result$1 = await opts.runWork();
|
|
290
|
-
return {
|
|
291
|
-
cursor: opts.serializedCursor,
|
|
292
|
-
setupError,
|
|
293
|
-
result: result$1
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
let activeCursor = cursor;
|
|
297
|
-
let busy = false;
|
|
298
|
-
let pending = false;
|
|
299
|
-
let stopped = false;
|
|
300
|
-
const drainOnce = async () => {
|
|
301
|
-
if (stopped && busy) return;
|
|
302
|
-
if (busy) {
|
|
303
|
-
pending = true;
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
busy = true;
|
|
307
|
-
try {
|
|
308
|
-
const res = await tailSession({ cursor: activeCursor });
|
|
309
|
-
activeCursor = res.cursor;
|
|
310
|
-
for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
|
|
311
|
-
} catch {} finally {
|
|
312
|
-
busy = false;
|
|
313
|
-
if (pending && !stopped) {
|
|
314
|
-
pending = false;
|
|
315
|
-
drainOnce();
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
const fileWatcher = watch(activeCursor.path, () => {
|
|
320
|
-
drainOnce();
|
|
321
|
-
});
|
|
322
|
-
const pollHandle = setInterval(() => {
|
|
323
|
-
drainOnce();
|
|
324
|
-
}, 1500);
|
|
325
|
-
let result;
|
|
326
|
-
try {
|
|
327
|
-
result = await opts.runWork();
|
|
328
|
-
} finally {
|
|
329
|
-
stopped = true;
|
|
330
|
-
clearInterval(pollHandle);
|
|
331
|
-
fileWatcher.close();
|
|
332
|
-
while (busy) await new Promise((r) => setTimeout(r, 10));
|
|
333
|
-
try {
|
|
334
|
-
const final = await tailSession({ cursor: activeCursor });
|
|
335
|
-
activeCursor = final.cursor;
|
|
336
|
-
for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
|
|
337
|
-
} catch {}
|
|
338
|
-
}
|
|
339
|
-
return {
|
|
340
|
-
cursor: serializeCursor(activeCursor),
|
|
341
|
-
setupError,
|
|
342
|
-
result
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
function registerCodingSession(registry, options = {}) {
|
|
346
|
-
const runner = options.cliRunner ?? defaultCliRunner;
|
|
347
|
-
const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
|
|
348
|
-
registry.define(`coder`, {
|
|
349
|
-
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.`,
|
|
350
|
-
creationSchema: creationArgsSchema,
|
|
351
|
-
inboxSchemas: { prompt: promptMessageSchema },
|
|
352
|
-
state: {
|
|
353
|
-
sessionMeta: {
|
|
354
|
-
schema: sessionMetaRowSchema,
|
|
355
|
-
type: CODING_SESSION_META_COLLECTION_TYPE,
|
|
356
|
-
primaryKey: `key`
|
|
357
|
-
},
|
|
358
|
-
cursorState: {
|
|
359
|
-
schema: cursorStateRowSchema,
|
|
360
|
-
type: CODING_SESSION_CURSOR_COLLECTION_TYPE,
|
|
361
|
-
primaryKey: `key`
|
|
362
|
-
},
|
|
363
|
-
events: {
|
|
364
|
-
schema: eventRowSchema,
|
|
365
|
-
type: CODING_SESSION_EVENT_COLLECTION_TYPE,
|
|
366
|
-
primaryKey: `key`
|
|
367
|
-
}
|
|
368
|
-
},
|
|
369
|
-
async handler(ctx, _wake) {
|
|
370
|
-
const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
|
|
371
|
-
if (!existingMeta) {
|
|
372
|
-
const args = creationArgsSchema.parse(ctx.args);
|
|
373
|
-
const cwd = args.cwd ?? defaultCwd;
|
|
374
|
-
const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
|
|
375
|
-
let resolvedNativeId = args.nativeSessionId;
|
|
376
|
-
if (args.importFrom) {
|
|
377
|
-
const result = await importLocalSession({
|
|
378
|
-
source: {
|
|
379
|
-
sessionId: args.importFrom.sessionId,
|
|
380
|
-
agent: args.importFrom.agent
|
|
381
|
-
},
|
|
382
|
-
target: {
|
|
383
|
-
agent: args.agent,
|
|
384
|
-
cwd
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
resolvedNativeId = result.sessionId;
|
|
388
|
-
}
|
|
389
|
-
const hasNative = resolvedNativeId !== void 0;
|
|
390
|
-
ctx.db.actions.sessionMeta_insert({ row: {
|
|
391
|
-
key: `current`,
|
|
392
|
-
electricSessionId,
|
|
393
|
-
...hasNative ? { nativeSessionId: resolvedNativeId } : {},
|
|
394
|
-
agent: args.agent,
|
|
395
|
-
cwd,
|
|
396
|
-
status: hasNative ? `idle` : `initializing`
|
|
397
|
-
} });
|
|
398
|
-
}
|
|
399
|
-
if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
|
|
400
|
-
key: `current`,
|
|
401
|
-
cursor: ``
|
|
402
|
-
} });
|
|
403
|
-
const metaRow = ctx.db.collections.sessionMeta.get(`current`);
|
|
404
|
-
const cursorRow = ctx.db.collections.cursorState.get(`current`);
|
|
405
|
-
if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
|
|
406
|
-
if (metaRow.nativeSessionId && !cursorRow.cursor) {
|
|
407
|
-
const mirrorCtx = {
|
|
408
|
-
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
409
|
-
actions: { events_insert: ctx.db.actions.events_insert }
|
|
410
|
-
};
|
|
411
|
-
try {
|
|
412
|
-
const initial = await loadSession({
|
|
413
|
-
sessionId: metaRow.nativeSessionId,
|
|
414
|
-
agent: metaRow.agent
|
|
415
|
-
});
|
|
416
|
-
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
417
|
-
const serialized = serializeCursor(initial.cursor);
|
|
418
|
-
ctx.db.actions.cursorState_update({
|
|
419
|
-
key: `current`,
|
|
420
|
-
updater: (d) => {
|
|
421
|
-
d.cursor = JSON.stringify(serialized);
|
|
422
|
-
}
|
|
423
|
-
});
|
|
424
|
-
} catch (e) {
|
|
425
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
426
|
-
ctx.db.actions.sessionMeta_update({
|
|
427
|
-
key: `current`,
|
|
428
|
-
updater: (d) => {
|
|
429
|
-
d.error = `initial mirror failed: ${message}`;
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
435
|
-
const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
|
|
436
|
-
const pending = inboxRows.filter((m) => m.key > lastKey);
|
|
437
|
-
if (pending.length === 0) {
|
|
438
|
-
if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
|
|
439
|
-
key: `current`,
|
|
440
|
-
updater: (d) => {
|
|
441
|
-
d.status = `idle`;
|
|
442
|
-
delete d.currentPromptInboxKey;
|
|
443
|
-
delete d.error;
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
let runningMeta = metaRow;
|
|
449
|
-
let runningCursor = cursorRow;
|
|
450
|
-
for (const inboxMsg of pending) {
|
|
451
|
-
const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
|
|
452
|
-
if (!parsed.success) {
|
|
453
|
-
ctx.db.actions.cursorState_update({
|
|
454
|
-
key: `current`,
|
|
455
|
-
updater: (d) => {
|
|
456
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
runningCursor = {
|
|
460
|
-
...runningCursor,
|
|
461
|
-
lastProcessedInboxKey: inboxMsg.key
|
|
462
|
-
};
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
const prompt = parsed.data.text;
|
|
466
|
-
const existingTitle = ctx.tags.title;
|
|
467
|
-
if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
|
|
468
|
-
ctx.db.actions.sessionMeta_update({
|
|
469
|
-
key: `current`,
|
|
470
|
-
updater: (d) => {
|
|
471
|
-
d.status = `running`;
|
|
472
|
-
d.currentPromptInboxKey = inboxMsg.key;
|
|
473
|
-
delete d.error;
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
const recordedRun = ctx.recordRun();
|
|
477
|
-
const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
|
|
478
|
-
try {
|
|
479
|
-
const mirrorCtx = {
|
|
480
|
-
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
481
|
-
actions: { events_insert: ctx.db.actions.events_insert }
|
|
482
|
-
};
|
|
483
|
-
let nextCursorJson = runningCursor.cursor;
|
|
484
|
-
if (!runningMeta.nativeSessionId) {
|
|
485
|
-
const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
|
|
486
|
-
const preDiscoveredIds = new Set((await discoverSessions(runningMeta.agent)).map((s) => s.sessionId));
|
|
487
|
-
const cliResult = await runner.run({
|
|
488
|
-
agent: runningMeta.agent,
|
|
489
|
-
cwd: runningMeta.cwd,
|
|
490
|
-
prompt
|
|
491
|
-
});
|
|
492
|
-
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>`}`);
|
|
493
|
-
const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
|
|
494
|
-
if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
|
|
495
|
-
ctx.db.actions.sessionMeta_update({
|
|
496
|
-
key: `current`,
|
|
497
|
-
updater: (d) => {
|
|
498
|
-
d.nativeSessionId = foundId;
|
|
499
|
-
}
|
|
500
|
-
});
|
|
501
|
-
runningMeta = {
|
|
502
|
-
...runningMeta,
|
|
503
|
-
nativeSessionId: foundId
|
|
504
|
-
};
|
|
505
|
-
const initial = await loadSession({
|
|
506
|
-
sessionId: foundId,
|
|
507
|
-
agent: runningMeta.agent
|
|
508
|
-
});
|
|
509
|
-
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
510
|
-
nextCursorJson = JSON.stringify(serializeCursor(initial.cursor));
|
|
511
|
-
} else {
|
|
512
|
-
const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
|
|
513
|
-
const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
|
|
514
|
-
agent: runningMeta.agent,
|
|
515
|
-
nativeSessionId: runningMeta.nativeSessionId,
|
|
516
|
-
serializedCursor,
|
|
517
|
-
ctx: mirrorCtx,
|
|
518
|
-
runWork: () => runner.run({
|
|
519
|
-
agent: runningMeta.agent,
|
|
520
|
-
sessionId: runningMeta.nativeSessionId,
|
|
521
|
-
cwd: runningMeta.cwd,
|
|
522
|
-
prompt
|
|
523
|
-
})
|
|
524
|
-
});
|
|
525
|
-
if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
|
|
526
|
-
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>`}`);
|
|
527
|
-
const persistedCursor = nextSerialized ?? serializedCursor;
|
|
528
|
-
nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
|
|
529
|
-
}
|
|
530
|
-
ctx.db.actions.cursorState_update({
|
|
531
|
-
key: `current`,
|
|
532
|
-
updater: (d) => {
|
|
533
|
-
d.cursor = nextCursorJson;
|
|
534
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
runningCursor = {
|
|
538
|
-
...runningCursor,
|
|
539
|
-
cursor: nextCursorJson,
|
|
540
|
-
lastProcessedInboxKey: inboxMsg.key
|
|
541
|
-
};
|
|
542
|
-
for (const row of ctx.db.collections.events.toArray) {
|
|
543
|
-
if (eventKeysBefore.has(row.key)) continue;
|
|
544
|
-
if (row.type !== `assistant_message`) continue;
|
|
545
|
-
const text = row.payload?.text;
|
|
546
|
-
if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
|
|
547
|
-
}
|
|
548
|
-
recordedRun.end({ status: `completed` });
|
|
549
|
-
} catch (e) {
|
|
550
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
551
|
-
recordedRun.end({
|
|
552
|
-
status: `failed`,
|
|
553
|
-
finishReason: `error`
|
|
554
|
-
});
|
|
555
|
-
ctx.db.actions.sessionMeta_update({
|
|
556
|
-
key: `current`,
|
|
557
|
-
updater: (d) => {
|
|
558
|
-
d.status = `error`;
|
|
559
|
-
d.error = message;
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
ctx.db.actions.cursorState_update({
|
|
563
|
-
key: `current`,
|
|
564
|
-
updater: (d) => {
|
|
565
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
566
|
-
}
|
|
567
|
-
});
|
|
568
|
-
throw e;
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
ctx.db.actions.sessionMeta_update({
|
|
572
|
-
key: `current`,
|
|
573
|
-
updater: (d) => {
|
|
574
|
-
d.status = `idle`;
|
|
575
|
-
delete d.currentPromptInboxKey;
|
|
576
|
-
delete d.error;
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
|
|
583
75
|
//#endregion
|
|
584
76
|
//#region src/docs/embed.ts
|
|
585
77
|
const EMBEDDING_DIMENSIONS = 128;
|
|
@@ -650,7 +142,7 @@ function normalizeWhitespace(value) {
|
|
|
650
142
|
}
|
|
651
143
|
async function collectMarkdownFiles(root) {
|
|
652
144
|
async function walk(dir) {
|
|
653
|
-
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
145
|
+
const entries = await fs$1.readdir(dir, { withFileTypes: true });
|
|
654
146
|
const files = [];
|
|
655
147
|
for (const entry of entries) {
|
|
656
148
|
const fullPath = path.join(dir, entry.name);
|
|
@@ -826,7 +318,7 @@ function resolveDocsRoot(workingDirectory) {
|
|
|
826
318
|
requireIndex: false
|
|
827
319
|
}
|
|
828
320
|
].filter((value) => Boolean(value));
|
|
829
|
-
for (const candidate of candidates) if (
|
|
321
|
+
for (const candidate of candidates) if (fs.existsSync(candidate.path) && (!candidate.requireIndex || fs.existsSync(path.join(candidate.path, `index.md`)))) return candidate.path;
|
|
830
322
|
return null;
|
|
831
323
|
}
|
|
832
324
|
var DocsKnowledgeBase = class {
|
|
@@ -847,7 +339,7 @@ var DocsKnowledgeBase = class {
|
|
|
847
339
|
this.readyPromise = this.ensureIngested();
|
|
848
340
|
}
|
|
849
341
|
openDatabase() {
|
|
850
|
-
|
|
342
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
851
343
|
try {
|
|
852
344
|
const db$1 = new Database(this.dbPath);
|
|
853
345
|
load(db$1);
|
|
@@ -914,11 +406,11 @@ var DocsKnowledgeBase = class {
|
|
|
914
406
|
};
|
|
915
407
|
}
|
|
916
408
|
async ensureIngested() {
|
|
917
|
-
await fs.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
409
|
+
await fs$1.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
918
410
|
const files = (await collectMarkdownFiles(this.docsRoot)).sort();
|
|
919
411
|
const docs = await Promise.all(files.map(async (filePath) => ({
|
|
920
412
|
path: path.relative(this.docsRoot, filePath),
|
|
921
|
-
content: await fs.readFile(filePath, `utf8`)
|
|
413
|
+
content: await fs$1.readFile(filePath, `utf8`)
|
|
922
414
|
})));
|
|
923
415
|
const fingerprint = createFingerprint(docs);
|
|
924
416
|
if (!this.db) {
|
|
@@ -1265,7 +757,7 @@ function createSkillTools(registry, ctx) {
|
|
|
1265
757
|
const mdFiles = allRefFiles.filter((f) => f.endsWith(`.md`));
|
|
1266
758
|
const refContents = [];
|
|
1267
759
|
for (const f of mdFiles) try {
|
|
1268
|
-
const refContent = await fs.readFile(path.join(skillDir, f), `utf-8`);
|
|
760
|
+
const refContent = await fs$1.readFile(path.join(skillDir, f), `utf-8`);
|
|
1269
761
|
const refId = `${skillContextId(name)}:${f}`;
|
|
1270
762
|
ctx.insertContext(refId, {
|
|
1271
763
|
name: `skill_reference`,
|
|
@@ -1365,10 +857,10 @@ function substituteArgs(content, rawArgs, argNames) {
|
|
|
1365
857
|
function listRefFiles(dir, prefix = ``) {
|
|
1366
858
|
try {
|
|
1367
859
|
const results = [];
|
|
1368
|
-
for (const entry of
|
|
860
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1369
861
|
const full = path.join(dir, entry);
|
|
1370
862
|
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
1371
|
-
if (
|
|
863
|
+
if (fs.statSync(full).isDirectory()) results.push(...listRefFiles(full, rel));
|
|
1372
864
|
else results.push(rel);
|
|
1373
865
|
}
|
|
1374
866
|
return results;
|
|
@@ -1384,11 +876,11 @@ const WORKER_TOOL_NAMES = [
|
|
|
1384
876
|
`read`,
|
|
1385
877
|
`write`,
|
|
1386
878
|
`edit`,
|
|
1387
|
-
`
|
|
879
|
+
`web_search`,
|
|
1388
880
|
`fetch_url`,
|
|
1389
881
|
`spawn_worker`
|
|
1390
882
|
];
|
|
1391
|
-
function createSpawnWorkerTool(ctx) {
|
|
883
|
+
function createSpawnWorkerTool(ctx, modelConfig) {
|
|
1392
884
|
return {
|
|
1393
885
|
name: `spawn_worker`,
|
|
1394
886
|
label: `Spawn Worker`,
|
|
@@ -1415,10 +907,16 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1415
907
|
details: { spawned: false }
|
|
1416
908
|
};
|
|
1417
909
|
const id = nanoid(10);
|
|
910
|
+
const workerModelArgs = modelConfig ? {
|
|
911
|
+
provider: modelConfig.provider,
|
|
912
|
+
model: modelConfig.model,
|
|
913
|
+
...modelConfig.reasoningEffort && { reasoningEffort: modelConfig.reasoningEffort }
|
|
914
|
+
} : {};
|
|
1418
915
|
try {
|
|
1419
916
|
const handle = await ctx.spawn(`worker`, id, {
|
|
1420
917
|
systemPrompt,
|
|
1421
|
-
tools
|
|
918
|
+
tools,
|
|
919
|
+
...workerModelArgs
|
|
1422
920
|
}, {
|
|
1423
921
|
initialMessage,
|
|
1424
922
|
wake: {
|
|
@@ -1452,140 +950,138 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1452
950
|
}
|
|
1453
951
|
|
|
1454
952
|
//#endregion
|
|
1455
|
-
//#region src/
|
|
1456
|
-
const
|
|
1457
|
-
|
|
953
|
+
//#region src/model-catalog.ts
|
|
954
|
+
const REASONING_EFFORT_VALUES = [
|
|
955
|
+
`auto`,
|
|
956
|
+
`minimal`,
|
|
957
|
+
`low`,
|
|
958
|
+
`medium`,
|
|
959
|
+
`high`
|
|
960
|
+
];
|
|
961
|
+
const DEFAULT_ANTHROPIC_MODEL = `claude-sonnet-4-6`;
|
|
962
|
+
const DEFAULT_OPENAI_MODEL = `gpt-4.1`;
|
|
963
|
+
const DEFAULT_CODEX_MODEL = `gpt-5.4`;
|
|
964
|
+
function modelValue(provider, id) {
|
|
965
|
+
return `${provider}:${id}`;
|
|
966
|
+
}
|
|
967
|
+
function providerLabel(provider) {
|
|
968
|
+
if (provider === `anthropic`) return `Anthropic`;
|
|
969
|
+
if (provider === `openai-codex`) return `OpenAI Codex`;
|
|
970
|
+
return `OpenAI`;
|
|
971
|
+
}
|
|
972
|
+
function configuredProviders() {
|
|
973
|
+
return detectAvailableProviders();
|
|
974
|
+
}
|
|
975
|
+
function mockFallbackCatalog() {
|
|
976
|
+
const fallback = {
|
|
977
|
+
provider: `anthropic`,
|
|
978
|
+
id: DEFAULT_ANTHROPIC_MODEL,
|
|
979
|
+
label: `Anthropic ${DEFAULT_ANTHROPIC_MODEL}`,
|
|
980
|
+
value: modelValue(`anthropic`, DEFAULT_ANTHROPIC_MODEL),
|
|
981
|
+
reasoning: true
|
|
982
|
+
};
|
|
1458
983
|
return {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
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.`,
|
|
1462
|
-
parameters: Type.Object({
|
|
1463
|
-
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.` }),
|
|
1464
|
-
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.` })),
|
|
1465
|
-
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.` }))
|
|
1466
|
-
}),
|
|
1467
|
-
execute: async (_toolCallId, params) => {
|
|
1468
|
-
const { prompt, agent, cwd } = params;
|
|
1469
|
-
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1470
|
-
content: [{
|
|
1471
|
-
type: `text`,
|
|
1472
|
-
text: `Error: prompt is required and must be a non-empty string.`
|
|
1473
|
-
}],
|
|
1474
|
-
details: { spawned: false }
|
|
1475
|
-
};
|
|
1476
|
-
const id = nanoid(10);
|
|
1477
|
-
const spawnArgs = { agent: agent ?? `claude` };
|
|
1478
|
-
if (cwd) spawnArgs.cwd = cwd;
|
|
1479
|
-
try {
|
|
1480
|
-
const handle = await ctx.spawn(`coder`, id, spawnArgs, {
|
|
1481
|
-
initialMessage: { text: prompt },
|
|
1482
|
-
wake: {
|
|
1483
|
-
on: `runFinished`,
|
|
1484
|
-
includeResponse: true
|
|
1485
|
-
}
|
|
1486
|
-
});
|
|
1487
|
-
const coderUrl = handle.entityUrl;
|
|
1488
|
-
return {
|
|
1489
|
-
content: [{
|
|
1490
|
-
type: `text`,
|
|
1491
|
-
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.`
|
|
1492
|
-
}],
|
|
1493
|
-
details: {
|
|
1494
|
-
spawned: true,
|
|
1495
|
-
coderUrl
|
|
1496
|
-
}
|
|
1497
|
-
};
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1500
|
-
return {
|
|
1501
|
-
content: [{
|
|
1502
|
-
type: `text`,
|
|
1503
|
-
text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1504
|
-
}],
|
|
1505
|
-
details: { spawned: false }
|
|
1506
|
-
};
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
984
|
+
choices: [fallback],
|
|
985
|
+
defaultChoice: fallback
|
|
1509
986
|
};
|
|
1510
987
|
}
|
|
1511
|
-
function
|
|
988
|
+
async function fetchAvailableModelIds(provider) {
|
|
989
|
+
try {
|
|
990
|
+
const res = provider === `anthropic` ? await fetch(`https://api.anthropic.com/v1/models`, {
|
|
991
|
+
headers: {
|
|
992
|
+
"x-api-key": process.env.ANTHROPIC_API_KEY ?? ``,
|
|
993
|
+
"anthropic-version": `2023-06-01`
|
|
994
|
+
},
|
|
995
|
+
signal: AbortSignal.timeout(3e3)
|
|
996
|
+
}) : await fetch(`https://api.openai.com/v1/models`, {
|
|
997
|
+
headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ``}` },
|
|
998
|
+
signal: AbortSignal.timeout(3e3)
|
|
999
|
+
});
|
|
1000
|
+
if (res.status === 401 || res.status === 403) return new Set();
|
|
1001
|
+
if (!res.ok) return null;
|
|
1002
|
+
const body = await res.json();
|
|
1003
|
+
const ids = new Set((body.data ?? []).map((model) => model.id).filter((id) => typeof id === `string`));
|
|
1004
|
+
return ids.size > 0 ? ids : null;
|
|
1005
|
+
} catch {
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async function choicesForProvider(provider) {
|
|
1010
|
+
const knownModels = getModels(provider);
|
|
1011
|
+
if (provider === `openai-codex`) return knownModels.map((model) => ({
|
|
1012
|
+
provider,
|
|
1013
|
+
id: model.id,
|
|
1014
|
+
label: `${providerLabel(provider)} ${model.name}`,
|
|
1015
|
+
value: modelValue(provider, model.id),
|
|
1016
|
+
reasoning: model.reasoning
|
|
1017
|
+
}));
|
|
1018
|
+
const availableIds = await fetchAvailableModelIds(provider);
|
|
1019
|
+
const models = availableIds === null ? knownModels : knownModels.filter((model) => availableIds.has(model.id));
|
|
1020
|
+
return models.map((model) => ({
|
|
1021
|
+
provider,
|
|
1022
|
+
id: model.id,
|
|
1023
|
+
label: `${providerLabel(provider)} ${model.name}`,
|
|
1024
|
+
value: modelValue(provider, model.id),
|
|
1025
|
+
reasoning: model.reasoning
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
function withProviderPayloadDefaults(config, choice, reasoningEffort) {
|
|
1029
|
+
if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
|
|
1030
|
+
const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
|
|
1031
|
+
const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
|
|
1512
1032
|
return {
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
type: `text`,
|
|
1525
|
-
text: `Error: coder_url must be a path like "/coder/<id>".`
|
|
1526
|
-
}],
|
|
1527
|
-
details: { sent: false }
|
|
1528
|
-
};
|
|
1529
|
-
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1530
|
-
content: [{
|
|
1531
|
-
type: `text`,
|
|
1532
|
-
text: `Error: prompt is required and must be a non-empty string.`
|
|
1533
|
-
}],
|
|
1534
|
-
details: { sent: false }
|
|
1033
|
+
...config,
|
|
1034
|
+
onPayload: (payload) => {
|
|
1035
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1036
|
+
const body = payload;
|
|
1037
|
+
const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
|
|
1038
|
+
return {
|
|
1039
|
+
...body,
|
|
1040
|
+
reasoning: {
|
|
1041
|
+
...existingReasoning,
|
|
1042
|
+
effort
|
|
1043
|
+
}
|
|
1535
1044
|
};
|
|
1536
|
-
try {
|
|
1537
|
-
ctx.send(coder_url, { text: prompt });
|
|
1538
|
-
return {
|
|
1539
|
-
content: [{
|
|
1540
|
-
type: `text`,
|
|
1541
|
-
text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
|
|
1542
|
-
}],
|
|
1543
|
-
details: {
|
|
1544
|
-
sent: true,
|
|
1545
|
-
coderUrl: coder_url
|
|
1546
|
-
}
|
|
1547
|
-
};
|
|
1548
|
-
} catch (err) {
|
|
1549
|
-
serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1550
|
-
return {
|
|
1551
|
-
content: [{
|
|
1552
|
-
type: `text`,
|
|
1553
|
-
text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1554
|
-
}],
|
|
1555
|
-
details: { sent: false }
|
|
1556
|
-
};
|
|
1557
|
-
}
|
|
1558
1045
|
}
|
|
1559
1046
|
};
|
|
1560
1047
|
}
|
|
1048
|
+
function parseReasoningEffort(value) {
|
|
1049
|
+
return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
|
|
1050
|
+
}
|
|
1051
|
+
async function createBuiltinModelCatalog(options = {}) {
|
|
1052
|
+
const providers = configuredProviders();
|
|
1053
|
+
if (providers.length === 0 && options.allowMockFallback) return mockFallbackCatalog();
|
|
1054
|
+
const choices = (await Promise.all(providers.map((provider) => choicesForProvider(provider)))).flat();
|
|
1055
|
+
if (choices.length === 0) return options.allowMockFallback ? mockFallbackCatalog() : null;
|
|
1056
|
+
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];
|
|
1057
|
+
return {
|
|
1058
|
+
choices,
|
|
1059
|
+
defaultChoice
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function resolveBuiltinModelConfig(catalog, args) {
|
|
1063
|
+
const modelArg = args.model;
|
|
1064
|
+
const providerArg = args.provider;
|
|
1065
|
+
const reasoningEffort = parseReasoningEffort(args.reasoningEffort);
|
|
1066
|
+
const selected = typeof modelArg === `string` ? catalog.choices.find((choice$1) => choice$1.value === modelArg || choice$1.id === modelArg && choice$1.provider === providerArg) : void 0;
|
|
1067
|
+
const choice = selected ?? catalog.defaultChoice;
|
|
1068
|
+
const config = {
|
|
1069
|
+
provider: choice.provider,
|
|
1070
|
+
model: choice.id,
|
|
1071
|
+
...reasoningEffort && { reasoningEffort },
|
|
1072
|
+
...choice.provider === `openai-codex` && { getApiKey: () => readCodexAccessToken() }
|
|
1073
|
+
};
|
|
1074
|
+
return withProviderPayloadDefaults(config, choice, reasoningEffort);
|
|
1075
|
+
}
|
|
1076
|
+
function modelChoiceValues(catalog) {
|
|
1077
|
+
return catalog.choices.map((choice) => choice.value);
|
|
1078
|
+
}
|
|
1561
1079
|
|
|
1562
1080
|
//#endregion
|
|
1563
1081
|
//#region src/agents/horton.ts
|
|
1564
|
-
const TITLE_MODEL = `claude-haiku-4-5-20251001`;
|
|
1565
1082
|
const HORTON_MODEL = `claude-sonnet-4-6`;
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
if (!anthropic) anthropic = new Anthropic();
|
|
1569
|
-
return anthropic;
|
|
1570
|
-
}
|
|
1571
|
-
async function defaultHaikuCall(prompt) {
|
|
1572
|
-
const client = getClient();
|
|
1573
|
-
const res = await client.messages.create({
|
|
1574
|
-
model: TITLE_MODEL,
|
|
1575
|
-
max_tokens: 64,
|
|
1576
|
-
messages: [{
|
|
1577
|
-
role: `user`,
|
|
1578
|
-
content: prompt
|
|
1579
|
-
}]
|
|
1580
|
-
});
|
|
1581
|
-
const block = res.content[0];
|
|
1582
|
-
return block?.type === `text` ? block.text : ``;
|
|
1583
|
-
}
|
|
1584
|
-
const TITLE_PROMPT = (userMessage) => `Summarize the following user request in 3-5 words for use as a chat session title.
|
|
1585
|
-
Respond with only the title, no quotes, no punctuation, no preamble.
|
|
1586
|
-
|
|
1587
|
-
User request:
|
|
1588
|
-
${userMessage}`;
|
|
1083
|
+
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.";
|
|
1084
|
+
const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
|
|
1589
1085
|
const TITLE_STOP_WORDS = new Set([
|
|
1590
1086
|
`a`,
|
|
1591
1087
|
`an`,
|
|
@@ -1653,19 +1149,34 @@ function buildFallbackTitle(userMessage) {
|
|
|
1653
1149
|
const selected = informativeWords.length >= 2 ? informativeWords.slice(0, 5) : backupWords;
|
|
1654
1150
|
return selected.join(` `).slice(0, 80).trim() || `Untitled Chat`;
|
|
1655
1151
|
}
|
|
1656
|
-
|
|
1152
|
+
function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
|
|
1153
|
+
return (prompt) => completeWithLowCostModel({
|
|
1154
|
+
catalog,
|
|
1155
|
+
modelConfig,
|
|
1156
|
+
log: (message) => serverLog.info(message),
|
|
1157
|
+
logPrefix,
|
|
1158
|
+
purpose: `title generation`,
|
|
1159
|
+
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
1160
|
+
prompt,
|
|
1161
|
+
maxTokens: 64
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
async function generateTitle(userMessage, llmCall, onFallback) {
|
|
1657
1165
|
try {
|
|
1658
|
-
const raw = await llmCall(
|
|
1166
|
+
const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
|
|
1659
1167
|
const title = raw.trim();
|
|
1660
|
-
|
|
1661
|
-
|
|
1168
|
+
if (title.length > 0) return title;
|
|
1169
|
+
onFallback?.(`empty LLM title response`);
|
|
1170
|
+
return buildFallbackTitle(userMessage);
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
onFallback?.(err instanceof Error ? err.message : String(err));
|
|
1662
1173
|
return buildFallbackTitle(userMessage);
|
|
1663
1174
|
}
|
|
1664
1175
|
}
|
|
1665
1176
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1666
1177
|
const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
|
|
1667
1178
|
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` : ``;
|
|
1668
|
-
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
|
|
1179
|
+
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.` : ``;
|
|
1669
1180
|
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.
|
|
1670
1181
|
|
|
1671
1182
|
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.
|
|
@@ -1701,7 +1212,9 @@ Don't force onboarding. If someone just wants to chat or code, let them. When in
|
|
|
1701
1212
|
- ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
|
|
1702
1213
|
- The Electric Agents docs site is at ${opts.docsUrl}
|
|
1703
1214
|
- 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).
|
|
1704
|
-
- For general coding questions unrelated to Electric Agents, use
|
|
1215
|
+
- For general coding questions unrelated to Electric Agents, use web_search or your own knowledge.` : ``;
|
|
1216
|
+
const modelGuidance = opts.modelProvider && opts.modelId ? `\n# Runtime model
|
|
1217
|
+
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.` : ``;
|
|
1705
1218
|
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.
|
|
1706
1219
|
|
|
1707
1220
|
# Greetings
|
|
@@ -1712,18 +1225,16 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1712
1225
|
- read: read a file
|
|
1713
1226
|
- write: create or overwrite a file
|
|
1714
1227
|
- edit: targeted string replacement in an existing file (you must read the file first)
|
|
1715
|
-
-
|
|
1228
|
+
- web_search: search the web
|
|
1716
1229
|
- fetch_url: fetch and convert a URL to markdown
|
|
1717
1230
|
- spawn_worker: dispatch a subagent for an isolated task
|
|
1718
|
-
- spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
|
|
1719
|
-
- prompt_coder: send a follow-up prompt to a coder you previously spawned
|
|
1720
1231
|
${docsTools}${skillsTools}
|
|
1721
1232
|
|
|
1722
1233
|
# Working with files
|
|
1723
1234
|
- Prefer edit over write when modifying existing files.
|
|
1724
1235
|
- You must read a file before you can edit it.
|
|
1725
1236
|
- Use absolute paths or paths relative to the current working directory.
|
|
1726
|
-
${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1237
|
+
${modelGuidance}${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1727
1238
|
|
|
1728
1239
|
# Risky actions
|
|
1729
1240
|
Pause and confirm with the user before:
|
|
@@ -1744,13 +1255,6 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1744
1255
|
|
|
1745
1256
|
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.
|
|
1746
1257
|
|
|
1747
|
-
# When to spawn a coder
|
|
1748
|
-
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.
|
|
1749
|
-
|
|
1750
|
-
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.
|
|
1751
|
-
|
|
1752
|
-
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.
|
|
1753
|
-
|
|
1754
1258
|
# Reporting
|
|
1755
1259
|
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.
|
|
1756
1260
|
|
|
@@ -1764,34 +1268,82 @@ function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
|
|
|
1764
1268
|
createWriteTool(workingDirectory, readSet),
|
|
1765
1269
|
createEditTool(workingDirectory, readSet),
|
|
1766
1270
|
braveSearchTool$1,
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1271
|
+
...opts.modelCatalog && opts.modelConfig ? [createFetchUrlTool({
|
|
1272
|
+
catalog: opts.modelCatalog,
|
|
1273
|
+
modelConfig: opts.modelConfig,
|
|
1274
|
+
log: (message) => serverLog.info(message),
|
|
1275
|
+
logPrefix: opts.logPrefix ?? `[horton]`
|
|
1276
|
+
})] : [fetchUrlTool],
|
|
1277
|
+
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1771
1278
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1772
1279
|
];
|
|
1773
1280
|
}
|
|
1774
|
-
function
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1281
|
+
function payloadToTitleText(payload) {
|
|
1282
|
+
if (typeof payload === `string`) return payload;
|
|
1283
|
+
if (payload == null) return ``;
|
|
1284
|
+
if (typeof payload === `object`) {
|
|
1285
|
+
const text = payload.text;
|
|
1286
|
+
return typeof text === `string` ? text : JSON.stringify(payload);
|
|
1287
|
+
}
|
|
1288
|
+
return String(payload);
|
|
1289
|
+
}
|
|
1290
|
+
async function extractFirstUserMessage(ctx) {
|
|
1291
|
+
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());
|
|
1292
|
+
if (!firstMessage) return null;
|
|
1293
|
+
const text = payloadToTitleText(firstMessage.payload);
|
|
1294
|
+
return text.length > 0 ? text : null;
|
|
1295
|
+
}
|
|
1296
|
+
function readAgentsMd(workingDirectory) {
|
|
1297
|
+
const agentsMdPath = path.join(workingDirectory, `AGENTS.md`);
|
|
1298
|
+
try {
|
|
1299
|
+
if (!fs.existsSync(agentsMdPath) || !fs.statSync(agentsMdPath).isFile()) return null;
|
|
1300
|
+
const content = fs.readFileSync(agentsMdPath, `utf8`);
|
|
1301
|
+
return [
|
|
1302
|
+
`<context_file kind="instructions" path="${agentsMdPath}">`,
|
|
1303
|
+
content,
|
|
1304
|
+
`</context_file>`
|
|
1305
|
+
].join(`\n`);
|
|
1306
|
+
} catch {
|
|
1307
|
+
return null;
|
|
1782
1308
|
}
|
|
1783
|
-
return null;
|
|
1784
1309
|
}
|
|
1785
1310
|
function createAssistantHandler(options) {
|
|
1786
|
-
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
|
|
1311
|
+
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
|
|
1787
1312
|
const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
|
|
1788
1313
|
return async function assistantHandler(ctx, wake) {
|
|
1789
1314
|
const readSet = new Set();
|
|
1315
|
+
const effectiveCwd = typeof ctx.args.workingDirectory === `string` && ctx.args.workingDirectory.trim().length > 0 ? ctx.args.workingDirectory : workingDirectory;
|
|
1316
|
+
const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
|
|
1317
|
+
const agentsMd = readAgentsMd(effectiveCwd);
|
|
1790
1318
|
const tools = [
|
|
1791
1319
|
...ctx.electricTools,
|
|
1792
|
-
...createHortonTools(
|
|
1793
|
-
|
|
1320
|
+
...createHortonTools(effectiveCwd, ctx, readSet, {
|
|
1321
|
+
docsSearchTool,
|
|
1322
|
+
modelConfig,
|
|
1323
|
+
modelCatalog,
|
|
1324
|
+
logPrefix: `[horton ${ctx.entityUrl}]`
|
|
1325
|
+
}),
|
|
1326
|
+
...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
|
|
1327
|
+
...mcp.tools()
|
|
1794
1328
|
];
|
|
1329
|
+
const titlePromise = ctx.firstWake && !ctx.tags.title ? (async () => {
|
|
1330
|
+
const firstUserMessage = await extractFirstUserMessage(ctx);
|
|
1331
|
+
if (!firstUserMessage) return;
|
|
1332
|
+
let title = null;
|
|
1333
|
+
try {
|
|
1334
|
+
const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
|
|
1335
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
|
|
1336
|
+
});
|
|
1337
|
+
if (result.length > 0) title = result;
|
|
1338
|
+
} catch (err) {
|
|
1339
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1340
|
+
}
|
|
1341
|
+
if (title !== null) try {
|
|
1342
|
+
await ctx.setTag(`title`, title);
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1345
|
+
}
|
|
1346
|
+
})() : Promise.resolve();
|
|
1795
1347
|
if (docsSupport) ctx.useContext({
|
|
1796
1348
|
sourceBudget: 1e5,
|
|
1797
1349
|
sources: {
|
|
@@ -1809,6 +1361,11 @@ function createAssistantHandler(options) {
|
|
|
1809
1361
|
content: () => ctx.timelineMessages(),
|
|
1810
1362
|
cache: `volatile`
|
|
1811
1363
|
},
|
|
1364
|
+
...agentsMd ? { agents_md: {
|
|
1365
|
+
content: () => agentsMd,
|
|
1366
|
+
max: 2e4,
|
|
1367
|
+
cache: `stable`
|
|
1368
|
+
} } : {},
|
|
1812
1369
|
...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
|
|
1813
1370
|
content: () => skillsRegistry.renderCatalog(2e3),
|
|
1814
1371
|
max: 2e3,
|
|
@@ -1827,41 +1384,46 @@ function createAssistantHandler(options) {
|
|
|
1827
1384
|
conversation: {
|
|
1828
1385
|
content: () => ctx.timelineMessages(),
|
|
1829
1386
|
cache: `volatile`
|
|
1387
|
+
},
|
|
1388
|
+
...agentsMd ? { agents_md: {
|
|
1389
|
+
content: () => agentsMd,
|
|
1390
|
+
max: 2e4,
|
|
1391
|
+
cache: `stable`
|
|
1392
|
+
} } : {}
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
else if (agentsMd) ctx.useContext({
|
|
1396
|
+
sourceBudget: 1e5,
|
|
1397
|
+
sources: {
|
|
1398
|
+
conversation: {
|
|
1399
|
+
content: () => ctx.timelineMessages(),
|
|
1400
|
+
cache: `volatile`
|
|
1401
|
+
},
|
|
1402
|
+
agents_md: {
|
|
1403
|
+
content: () => agentsMd,
|
|
1404
|
+
max: 2e4,
|
|
1405
|
+
cache: `stable`
|
|
1830
1406
|
}
|
|
1831
1407
|
}
|
|
1832
1408
|
});
|
|
1833
1409
|
ctx.useAgent({
|
|
1834
|
-
systemPrompt: buildHortonSystemPrompt(
|
|
1410
|
+
systemPrompt: buildHortonSystemPrompt(effectiveCwd, {
|
|
1835
1411
|
hasDocsSupport: Boolean(docsSupport),
|
|
1836
1412
|
hasSkills,
|
|
1837
|
-
docsUrl
|
|
1413
|
+
docsUrl,
|
|
1414
|
+
modelProvider: modelConfig.provider,
|
|
1415
|
+
modelId: String(modelConfig.model)
|
|
1838
1416
|
}),
|
|
1839
|
-
|
|
1417
|
+
...modelConfig,
|
|
1840
1418
|
tools,
|
|
1841
1419
|
...streamFn && { streamFn }
|
|
1842
1420
|
});
|
|
1843
1421
|
await ctx.agent.run();
|
|
1844
|
-
|
|
1845
|
-
const firstUserMessage = extractFirstUserMessage(ctx.events);
|
|
1846
|
-
if (firstUserMessage) {
|
|
1847
|
-
let title = null;
|
|
1848
|
-
try {
|
|
1849
|
-
const result = await generateTitle(firstUserMessage);
|
|
1850
|
-
if (result.length > 0) title = result;
|
|
1851
|
-
} catch (err) {
|
|
1852
|
-
serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1853
|
-
}
|
|
1854
|
-
if (title !== null) try {
|
|
1855
|
-
await ctx.setTag(`title`, title);
|
|
1856
|
-
} catch (err) {
|
|
1857
|
-
serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1422
|
+
await titlePromise;
|
|
1861
1423
|
};
|
|
1862
1424
|
}
|
|
1863
1425
|
function registerHorton(registry, options) {
|
|
1864
|
-
const { workingDirectory, streamFn, skillsRegistry = null } = options;
|
|
1426
|
+
const { workingDirectory, streamFn, skillsRegistry = null, modelCatalog } = options;
|
|
1865
1427
|
const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
|
|
1866
1428
|
if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
|
|
1867
1429
|
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)`);
|
|
@@ -1876,10 +1438,17 @@ function registerHorton(registry, options) {
|
|
|
1876
1438
|
docsSupport,
|
|
1877
1439
|
docsSearchTool,
|
|
1878
1440
|
skillsRegistry,
|
|
1441
|
+
modelCatalog,
|
|
1879
1442
|
docsUrl
|
|
1880
1443
|
});
|
|
1444
|
+
const hortonCreationSchema = z.object({
|
|
1445
|
+
model: z.enum(modelChoiceValues(modelCatalog)).default(modelCatalog.defaultChoice.value),
|
|
1446
|
+
reasoningEffort: z.enum(REASONING_EFFORT_VALUES).default(`auto`).describe(`Reasoning effort for compatible reasoning models. Auto uses a safe provider default.`),
|
|
1447
|
+
workingDirectory: z.string().optional().describe(`Working directory for file operations. Defaults to the server's configured cwd.`)
|
|
1448
|
+
});
|
|
1881
1449
|
registry.define(`horton`, {
|
|
1882
1450
|
description: `Friendly capable assistant — chat, code, research, dispatch`,
|
|
1451
|
+
creationSchema: hortonCreationSchema,
|
|
1883
1452
|
handler: assistantHandler
|
|
1884
1453
|
});
|
|
1885
1454
|
const typeNames = [`horton`];
|
|
@@ -1924,6 +1493,9 @@ function parseWorkerArgs(value) {
|
|
|
1924
1493
|
};
|
|
1925
1494
|
}
|
|
1926
1495
|
if (tools.length === 0 && !args.sharedDb) throw new Error(`[worker] must provide tools and/or sharedDb`);
|
|
1496
|
+
if (typeof value.model === `string`) args.model = value.model;
|
|
1497
|
+
if (typeof value.provider === `string`) args.provider = value.provider;
|
|
1498
|
+
if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
|
|
1927
1499
|
return args;
|
|
1928
1500
|
}
|
|
1929
1501
|
function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
@@ -1941,7 +1513,7 @@ function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
|
1941
1513
|
case `edit`:
|
|
1942
1514
|
out.push(createEditTool(workingDirectory, readSet));
|
|
1943
1515
|
break;
|
|
1944
|
-
case `
|
|
1516
|
+
case `web_search`:
|
|
1945
1517
|
out.push(braveSearchTool$1);
|
|
1946
1518
|
break;
|
|
1947
1519
|
case `fetch_url`:
|
|
@@ -2050,13 +1622,14 @@ function buildSharedStateTools(shared, schema, mode) {
|
|
|
2050
1622
|
return tools;
|
|
2051
1623
|
}
|
|
2052
1624
|
function registerWorker(registry, options) {
|
|
2053
|
-
const { workingDirectory, streamFn } = options;
|
|
1625
|
+
const { workingDirectory, streamFn, modelCatalog } = options;
|
|
2054
1626
|
registry.define(`worker`, {
|
|
2055
1627
|
description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
|
|
2056
1628
|
async handler(ctx) {
|
|
2057
1629
|
const args = parseWorkerArgs(ctx.args);
|
|
2058
1630
|
const readSet = new Set();
|
|
2059
1631
|
const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
|
|
1632
|
+
const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
|
|
2060
1633
|
const sharedStateTools = [];
|
|
2061
1634
|
if (args.sharedDb) {
|
|
2062
1635
|
const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
|
|
@@ -2064,7 +1637,7 @@ function registerWorker(registry, options) {
|
|
|
2064
1637
|
}
|
|
2065
1638
|
ctx.useAgent({
|
|
2066
1639
|
systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
|
|
2067
|
-
|
|
1640
|
+
...modelConfig,
|
|
2068
1641
|
tools: [...builtinTools, ...sharedStateTools],
|
|
2069
1642
|
...streamFn && { streamFn }
|
|
2070
1643
|
});
|
|
@@ -2154,7 +1727,6 @@ function stripQuotes(value) {
|
|
|
2154
1727
|
|
|
2155
1728
|
//#endregion
|
|
2156
1729
|
//#region src/skills/extract-meta.ts
|
|
2157
|
-
const EXTRACT_MODEL = `claude-haiku-4-5-20251001`;
|
|
2158
1730
|
const DEFAULT_MAX = 1e4;
|
|
2159
1731
|
async function extractSkillMeta(name, content) {
|
|
2160
1732
|
const preamble = parsePreamble(content);
|
|
@@ -2167,7 +1739,7 @@ async function extractSkillMeta(name, content) {
|
|
|
2167
1739
|
...preamble.userInvocable && { userInvocable: true },
|
|
2168
1740
|
max: preamble.max ?? DEFAULT_MAX
|
|
2169
1741
|
};
|
|
2170
|
-
|
|
1742
|
+
try {
|
|
2171
1743
|
return await llmExtract(name, content, preamble);
|
|
2172
1744
|
} catch (err) {
|
|
2173
1745
|
serverLog.warn(`[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2180,7 +1752,6 @@ async function extractSkillMeta(name, content) {
|
|
|
2180
1752
|
};
|
|
2181
1753
|
}
|
|
2182
1754
|
async function llmExtract(name, content, partial) {
|
|
2183
|
-
const client = new Anthropic();
|
|
2184
1755
|
const truncated = content.slice(0, 8e3);
|
|
2185
1756
|
const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}".
|
|
2186
1757
|
|
|
@@ -2194,15 +1765,14 @@ Return ONLY a JSON object with these fields:
|
|
|
2194
1765
|
- "keywords": array of 3-8 relevant keywords
|
|
2195
1766
|
|
|
2196
1767
|
Return raw JSON, no markdown fences.`;
|
|
2197
|
-
const
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
1768
|
+
const text = await completeWithLowCostModel({
|
|
1769
|
+
purpose: `skill metadata extraction`,
|
|
1770
|
+
systemPrompt: `Extract metadata from skill documents. Return only valid JSON that matches the requested schema.`,
|
|
1771
|
+
prompt,
|
|
1772
|
+
maxTokens: 256,
|
|
1773
|
+
log: (message) => serverLog.info(message),
|
|
1774
|
+
logPrefix: `[skills]`
|
|
2204
1775
|
});
|
|
2205
|
-
const text = res.content[0]?.type === `text` ? res.content[0].text : ``;
|
|
2206
1776
|
const parsed = JSON.parse(text);
|
|
2207
1777
|
return {
|
|
2208
1778
|
description: partial.description ?? parsed.description ?? humanize(name),
|
|
@@ -2227,7 +1797,7 @@ async function createSkillsRegistry(opts) {
|
|
|
2227
1797
|
if (appSkillsDir) await scanDir(appSkillsDir, files);
|
|
2228
1798
|
const catalog = new Map();
|
|
2229
1799
|
for (const [name, filePath] of files) {
|
|
2230
|
-
const content = await fs.readFile(filePath, `utf-8`);
|
|
1800
|
+
const content = await fs$1.readFile(filePath, `utf-8`);
|
|
2231
1801
|
const hash = sha256(content);
|
|
2232
1802
|
const cached = existingCache[name];
|
|
2233
1803
|
if (cached && cached.contentHash === hash && cached.source === filePath) {
|
|
@@ -2261,7 +1831,7 @@ async function createSkillsRegistry(opts) {
|
|
|
2261
1831
|
const meta = catalog.get(name);
|
|
2262
1832
|
if (!meta) return null;
|
|
2263
1833
|
try {
|
|
2264
|
-
return await fs.readFile(meta.source, `utf-8`);
|
|
1834
|
+
return await fs$1.readFile(meta.source, `utf-8`);
|
|
2265
1835
|
} catch {
|
|
2266
1836
|
return null;
|
|
2267
1837
|
}
|
|
@@ -2271,7 +1841,7 @@ async function createSkillsRegistry(opts) {
|
|
|
2271
1841
|
async function scanDir(dir, out) {
|
|
2272
1842
|
let entries;
|
|
2273
1843
|
try {
|
|
2274
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1844
|
+
entries = await fs$1.readdir(dir, { withFileTypes: true });
|
|
2275
1845
|
} catch {
|
|
2276
1846
|
return;
|
|
2277
1847
|
}
|
|
@@ -2283,7 +1853,7 @@ async function scanDir(dir, out) {
|
|
|
2283
1853
|
}
|
|
2284
1854
|
async function loadCache(cachePath) {
|
|
2285
1855
|
try {
|
|
2286
|
-
const raw = await fs.readFile(cachePath, `utf-8`);
|
|
1856
|
+
const raw = await fs$1.readFile(cachePath, `utf-8`);
|
|
2287
1857
|
return JSON.parse(raw);
|
|
2288
1858
|
} catch {
|
|
2289
1859
|
return {};
|
|
@@ -2292,9 +1862,9 @@ async function loadCache(cachePath) {
|
|
|
2292
1862
|
async function saveCache(cachePath, catalog, cacheDir) {
|
|
2293
1863
|
const obj = {};
|
|
2294
1864
|
for (const [name, meta] of catalog) obj[name] = meta;
|
|
2295
|
-
|
|
2296
|
-
await fs.writeFile(path.join(cacheDir, `.gitignore`), `*\n`, `utf-8`);
|
|
2297
|
-
await fs.writeFile(cachePath, JSON.stringify(obj, null, 2), `utf-8`);
|
|
1865
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1866
|
+
await fs$1.writeFile(path.join(cacheDir, `.gitignore`), `*\n`, `utf-8`);
|
|
1867
|
+
await fs$1.writeFile(cachePath, JSON.stringify(obj, null, 2), `utf-8`);
|
|
2298
1868
|
}
|
|
2299
1869
|
function sha256(content) {
|
|
2300
1870
|
return createHash(`sha256`).update(content).digest(`hex`);
|
|
@@ -2333,9 +1903,10 @@ function truncate(str, max) {
|
|
|
2333
1903
|
//#region src/bootstrap.ts
|
|
2334
1904
|
const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
|
|
2335
1905
|
async function createBuiltinAgentHandler(options) {
|
|
2336
|
-
const { agentServerUrl, serveEndpoint
|
|
2337
|
-
|
|
2338
|
-
|
|
1906
|
+
const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, createElectricTools, publicUrl, runtimeName, serverHeaders, defaultDispatchPolicyForType } = options;
|
|
1907
|
+
const modelCatalog = await createBuiltinModelCatalog({ allowMockFallback: Boolean(streamFn) });
|
|
1908
|
+
if (!modelCatalog) {
|
|
1909
|
+
serverLog.warn(`[builtin-agents] no supported model provider API key found — set ANTHROPIC_API_KEY or OPENAI_API_KEY`);
|
|
2339
1910
|
return null;
|
|
2340
1911
|
}
|
|
2341
1912
|
const cwd = workingDirectory ?? process.cwd();
|
|
@@ -2356,22 +1927,26 @@ async function createBuiltinAgentHandler(options) {
|
|
|
2356
1927
|
const typeNames = registerHorton(registry, {
|
|
2357
1928
|
workingDirectory: cwd,
|
|
2358
1929
|
streamFn,
|
|
2359
|
-
skillsRegistry
|
|
1930
|
+
skillsRegistry,
|
|
1931
|
+
modelCatalog
|
|
2360
1932
|
});
|
|
2361
1933
|
registerWorker(registry, {
|
|
2362
1934
|
workingDirectory: cwd,
|
|
2363
|
-
streamFn
|
|
1935
|
+
streamFn,
|
|
1936
|
+
modelCatalog
|
|
2364
1937
|
});
|
|
2365
1938
|
typeNames.push(`worker`);
|
|
2366
|
-
registerCodingSession(registry, { defaultWorkingDirectory: cwd });
|
|
2367
|
-
typeNames.push(`coder`);
|
|
2368
1939
|
const runtime = createRuntimeHandler({
|
|
2369
1940
|
baseUrl: agentServerUrl,
|
|
2370
1941
|
serveEndpoint,
|
|
2371
1942
|
registry,
|
|
2372
1943
|
subscriptionPathForType: (name) => `/${name}/*/main`,
|
|
1944
|
+
defaultDispatchPolicyForType,
|
|
1945
|
+
serverHeaders,
|
|
2373
1946
|
idleTimeout: 5e3,
|
|
2374
|
-
createElectricTools
|
|
1947
|
+
createElectricTools,
|
|
1948
|
+
publicUrl,
|
|
1949
|
+
name: runtimeName ?? `builtin-agents`
|
|
2375
1950
|
});
|
|
2376
1951
|
return {
|
|
2377
1952
|
handler: runtime.onEnter,
|
|
@@ -2399,103 +1974,226 @@ const registerAgentTypes = registerBuiltinAgentTypes;
|
|
|
2399
1974
|
//#endregion
|
|
2400
1975
|
//#region src/server.ts
|
|
2401
1976
|
var BuiltinAgentsServer = class {
|
|
2402
|
-
server = null;
|
|
2403
1977
|
bootstrap = null;
|
|
2404
|
-
|
|
2405
|
-
|
|
1978
|
+
_mcpRegistry = null;
|
|
1979
|
+
mcpWatcherCloser = null;
|
|
1980
|
+
mcpToolProviderName = null;
|
|
1981
|
+
mcpApplyInFlight = new Set();
|
|
1982
|
+
mcpStopping = false;
|
|
1983
|
+
pullWakeRunner = null;
|
|
2406
1984
|
options;
|
|
2407
1985
|
constructor(options) {
|
|
2408
1986
|
this.options = options;
|
|
2409
1987
|
}
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
return this.
|
|
2413
|
-
}
|
|
2414
|
-
get registeredBaseUrl() {
|
|
2415
|
-
if (!this.publicBaseUrl) throw new Error(`Builtin agents server not started`);
|
|
2416
|
-
return this.publicBaseUrl;
|
|
1988
|
+
/** Embedded MCP registry. `null` until `start()` has run. */
|
|
1989
|
+
get mcpRegistry() {
|
|
1990
|
+
return this._mcpRegistry;
|
|
2417
1991
|
}
|
|
2418
1992
|
async start() {
|
|
2419
|
-
if (this.
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
}
|
|
2428
|
-
});
|
|
1993
|
+
if (this.bootstrap || this.pullWakeRunner) throw new Error(`Builtin agents runtime already started`);
|
|
1994
|
+
const pullWake = this.options.pullWake;
|
|
1995
|
+
if (!pullWake?.runnerId) throw new Error(`Builtin agents require a pull-wake runner id`);
|
|
1996
|
+
try {
|
|
1997
|
+
const publicUrl = this.options.mcpOAuthRedirectBase ?? this.options.agentServerUrl;
|
|
1998
|
+
const mcpRegistry = createRegistry({
|
|
1999
|
+
publicUrl,
|
|
2000
|
+
openAuthorizeUrl: this.options.openAuthorizeUrl
|
|
2429
2001
|
});
|
|
2430
|
-
this.
|
|
2431
|
-
const
|
|
2432
|
-
this.
|
|
2002
|
+
this._mcpRegistry = mcpRegistry;
|
|
2003
|
+
const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
|
|
2004
|
+
const extras = this.options.extraMcpServers ?? [];
|
|
2005
|
+
const wirePersistence = async (cfg) => {
|
|
2006
|
+
const servers = [];
|
|
2007
|
+
for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
|
|
2008
|
+
const persist = await keychainPersistence({ server: s.name });
|
|
2009
|
+
servers.push({
|
|
2010
|
+
...s,
|
|
2011
|
+
auth: {
|
|
2012
|
+
...s.auth,
|
|
2013
|
+
...persist
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
} else servers.push(s);
|
|
2017
|
+
return {
|
|
2018
|
+
...cfg,
|
|
2019
|
+
servers
|
|
2020
|
+
};
|
|
2021
|
+
};
|
|
2022
|
+
const merge = (jsonCfg) => {
|
|
2023
|
+
const jsonServers = jsonCfg?.servers ?? [];
|
|
2024
|
+
const jsonNames = new Set(jsonServers.map((s) => s.name));
|
|
2025
|
+
const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
|
|
2026
|
+
return {
|
|
2027
|
+
servers: [...filteredExtras, ...jsonServers],
|
|
2028
|
+
raw: jsonCfg?.raw
|
|
2029
|
+
};
|
|
2030
|
+
};
|
|
2031
|
+
const onConfigError = this.options.onConfigError;
|
|
2032
|
+
const runApply = async (jsonCfg) => {
|
|
2033
|
+
if (this.mcpStopping) return;
|
|
2433
2034
|
try {
|
|
2434
|
-
const
|
|
2435
|
-
if (
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2035
|
+
const wired = await wirePersistence(merge(jsonCfg));
|
|
2036
|
+
if (this.mcpStopping) return;
|
|
2037
|
+
await mcpRegistry.applyConfig(wired);
|
|
2038
|
+
} catch (e) {
|
|
2039
|
+
serverLog.error(`[mcp] applyConfig:`, e);
|
|
2040
|
+
try {
|
|
2041
|
+
onConfigError?.(e);
|
|
2042
|
+
} catch (cbErr) {
|
|
2043
|
+
serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
const applyMerged = (jsonCfg) => {
|
|
2048
|
+
const p = runApply(jsonCfg);
|
|
2049
|
+
this.mcpApplyInFlight.add(p);
|
|
2050
|
+
p.finally(() => this.mcpApplyInFlight.delete(p));
|
|
2051
|
+
return p;
|
|
2052
|
+
};
|
|
2053
|
+
if (mcpConfigPath) {
|
|
2054
|
+
try {
|
|
2055
|
+
const cfg = await loadConfig(mcpConfigPath, process.env);
|
|
2056
|
+
applyMerged(cfg);
|
|
2057
|
+
} catch (err) {
|
|
2058
|
+
if (err.code !== `ENOENT`) throw err;
|
|
2059
|
+
if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
|
|
2060
|
+
else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
|
|
2061
|
+
applyMerged(null);
|
|
2062
|
+
}
|
|
2063
|
+
try {
|
|
2064
|
+
this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
|
|
2065
|
+
onChange: (cfg) => void applyMerged(cfg),
|
|
2066
|
+
onError: (e) => serverLog.error(`[mcp] config error:`, e)
|
|
2449
2067
|
});
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
serverLog.error(`[mcp] config watcher failed to start:`, e);
|
|
2070
|
+
}
|
|
2071
|
+
} else {
|
|
2072
|
+
if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
|
|
2073
|
+
applyMerged(null);
|
|
2074
|
+
}
|
|
2075
|
+
this.mcpToolProviderName = `mcp`;
|
|
2076
|
+
registerToolProvider({
|
|
2077
|
+
name: `mcp`,
|
|
2078
|
+
tools: () => {
|
|
2079
|
+
const tools = [];
|
|
2080
|
+
for (const entry of mcpRegistry.list()) {
|
|
2081
|
+
if (entry.status !== `ready`) continue;
|
|
2082
|
+
const live = mcpRegistry.get(entry.name);
|
|
2083
|
+
if (!live?.transport) continue;
|
|
2084
|
+
for (const t of entry.tools) tools.push(bridgeMcpTool({
|
|
2085
|
+
server: entry.name,
|
|
2086
|
+
tool: t,
|
|
2087
|
+
client: live.transport.client,
|
|
2088
|
+
timeoutMs: live.config.timeoutMs
|
|
2089
|
+
}));
|
|
2090
|
+
const caps = live.transport.client.getServerCapabilities?.();
|
|
2091
|
+
if (caps?.resources) tools.push(...buildResourceTools({
|
|
2092
|
+
server: entry.name,
|
|
2093
|
+
client: live.transport.client,
|
|
2094
|
+
timeoutMs: live.config.timeoutMs
|
|
2095
|
+
}));
|
|
2096
|
+
if (caps?.prompts) tools.push(...buildPromptTools({
|
|
2097
|
+
server: entry.name,
|
|
2098
|
+
client: live.transport.client,
|
|
2099
|
+
timeoutMs: live.config.timeoutMs
|
|
2100
|
+
}));
|
|
2101
|
+
}
|
|
2102
|
+
return tools;
|
|
2457
2103
|
}
|
|
2458
2104
|
});
|
|
2459
|
-
|
|
2105
|
+
this.bootstrap = await createBuiltinAgentHandler({
|
|
2106
|
+
agentServerUrl: this.options.agentServerUrl,
|
|
2107
|
+
workingDirectory: this.options.workingDirectory,
|
|
2108
|
+
streamFn: this.options.mockStreamFn,
|
|
2109
|
+
createElectricTools: this.options.createElectricTools,
|
|
2110
|
+
publicUrl,
|
|
2111
|
+
runtimeName: `builtin-agents`,
|
|
2112
|
+
serverHeaders: pullWake.headers
|
|
2113
|
+
});
|
|
2114
|
+
if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY or OPENAI_API_KEY must be set before starting builtin agents`);
|
|
2115
|
+
await registerBuiltinAgentTypes(this.bootstrap);
|
|
2116
|
+
const registeredRunner = pullWake.registerRunner ? await this.registerPullWakeRunner(pullWake) : null;
|
|
2117
|
+
this.pullWakeRunner = createPullWakeRunner({
|
|
2118
|
+
baseUrl: this.options.agentServerUrl,
|
|
2119
|
+
runnerId: pullWake.runnerId,
|
|
2120
|
+
runtime: this.bootstrap.runtime,
|
|
2121
|
+
headers: pullWake.headers,
|
|
2122
|
+
claimHeaders: pullWake.claimHeaders,
|
|
2123
|
+
claimTokenHeader: pullWake.claimTokenHeader,
|
|
2124
|
+
heartbeatIntervalMs: pullWake.heartbeatIntervalMs,
|
|
2125
|
+
leaseMs: pullWake.leaseMs,
|
|
2126
|
+
offset: registeredRunner?.wake_stream_offset,
|
|
2127
|
+
onError: (error) => {
|
|
2128
|
+
serverLog.error(`[builtin-agents] pull-wake runner failed`, error);
|
|
2129
|
+
return true;
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
this.pullWakeRunner.start();
|
|
2133
|
+
serverLog.info(`[builtin-agents] pull-wake runner started: ${pullWake.runnerId}`);
|
|
2134
|
+
return `pull-wake:${pullWake.runnerId}`;
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
await this.stop().catch(() => {});
|
|
2137
|
+
throw error;
|
|
2138
|
+
}
|
|
2460
2139
|
}
|
|
2461
2140
|
async stop() {
|
|
2141
|
+
if (this.pullWakeRunner) {
|
|
2142
|
+
await this.pullWakeRunner.stop().catch((e) => {
|
|
2143
|
+
serverLog.error(`[builtin-agents] pull-wake runner stop failed`, e);
|
|
2144
|
+
});
|
|
2145
|
+
this.pullWakeRunner = null;
|
|
2146
|
+
}
|
|
2462
2147
|
if (this.bootstrap) {
|
|
2463
2148
|
this.bootstrap.runtime.abortWakes();
|
|
2464
|
-
await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {
|
|
2149
|
+
await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
|
|
2150
|
+
serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
|
|
2151
|
+
}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
2465
2152
|
this.bootstrap = null;
|
|
2466
2153
|
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
})
|
|
2472
|
-
|
|
2154
|
+
this.mcpStopping = true;
|
|
2155
|
+
if (this.mcpWatcherCloser) {
|
|
2156
|
+
try {
|
|
2157
|
+
this.mcpWatcherCloser();
|
|
2158
|
+
} catch (e) {
|
|
2159
|
+
serverLog.error(`[mcp] watcher close failed:`, e);
|
|
2160
|
+
}
|
|
2161
|
+
this.mcpWatcherCloser = null;
|
|
2473
2162
|
}
|
|
2474
|
-
this.
|
|
2475
|
-
this.
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
const method = req.method?.toUpperCase();
|
|
2479
|
-
const path$1 = new URL(req.url ?? `/`, `http://localhost`).pathname;
|
|
2480
|
-
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
2481
|
-
if (path$1 === `/_electric/health` && method === `GET`) {
|
|
2482
|
-
res.writeHead(200, { "content-type": `application/json` });
|
|
2483
|
-
res.end(JSON.stringify({ status: `ok` }));
|
|
2484
|
-
return;
|
|
2163
|
+
if (this.mcpApplyInFlight.size > 0) await Promise.allSettled([...this.mcpApplyInFlight]);
|
|
2164
|
+
if (this.mcpToolProviderName) {
|
|
2165
|
+
unregisterToolProvider(this.mcpToolProviderName);
|
|
2166
|
+
this.mcpToolProviderName = null;
|
|
2485
2167
|
}
|
|
2486
|
-
if (
|
|
2487
|
-
await this.
|
|
2488
|
-
|
|
2168
|
+
if (this._mcpRegistry) {
|
|
2169
|
+
await this._mcpRegistry.close().catch((e) => {
|
|
2170
|
+
serverLog.error(`[mcp] registry close failed:`, e);
|
|
2171
|
+
});
|
|
2172
|
+
this._mcpRegistry = null;
|
|
2489
2173
|
}
|
|
2490
|
-
|
|
2491
|
-
|
|
2174
|
+
this.mcpStopping = false;
|
|
2175
|
+
}
|
|
2176
|
+
async registerPullWakeRunner(pullWake) {
|
|
2177
|
+
const headers = new Headers(typeof pullWake.headers === `function` ? await pullWake.headers() : pullWake.headers);
|
|
2178
|
+
headers.set(`content-type`, `application/json`);
|
|
2179
|
+
const response = await fetch(appendPathToUrl(this.options.agentServerUrl, `/_electric/runners`), {
|
|
2180
|
+
method: `POST`,
|
|
2181
|
+
headers,
|
|
2182
|
+
body: JSON.stringify({
|
|
2183
|
+
id: pullWake.runnerId,
|
|
2184
|
+
owner_user_id: pullWake.ownerUserId,
|
|
2185
|
+
label: pullWake.label ?? `Built-in agents`,
|
|
2186
|
+
kind: `local`,
|
|
2187
|
+
admin_status: `enabled`
|
|
2188
|
+
})
|
|
2189
|
+
});
|
|
2190
|
+
if (!response.ok) throw new Error(`Failed to register pull-wake runner ${pullWake.runnerId}: ${response.status} ${await response.text()}`);
|
|
2191
|
+
return await response.json();
|
|
2492
2192
|
}
|
|
2493
2193
|
};
|
|
2494
2194
|
|
|
2495
2195
|
//#endregion
|
|
2496
2196
|
//#region src/entrypoint-lib.ts
|
|
2497
|
-
const DEFAULT_HOST = `127.0.0.1`;
|
|
2498
|
-
const DEFAULT_PORT = 4448;
|
|
2499
2197
|
function readEnv(env, names) {
|
|
2500
2198
|
for (const name of names) {
|
|
2501
2199
|
const value = env[name]?.trim();
|
|
@@ -2508,13 +2206,6 @@ function readRequiredEnv(env, names, description) {
|
|
|
2508
2206
|
if (value) return value;
|
|
2509
2207
|
throw new Error(`Missing ${description}. Set one of: ${names.map((name) => `"${name}"`).join(`, `)}`);
|
|
2510
2208
|
}
|
|
2511
|
-
function readPort(env) {
|
|
2512
|
-
const raw = readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_PORT`, `PORT`]);
|
|
2513
|
-
if (!raw) return DEFAULT_PORT;
|
|
2514
|
-
const port = Number(raw);
|
|
2515
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid builtin agents port "${raw}". Expected an integer between 1 and 65535.`);
|
|
2516
|
-
return port;
|
|
2517
|
-
}
|
|
2518
2209
|
function validateUrl(name, value) {
|
|
2519
2210
|
try {
|
|
2520
2211
|
new URL(value);
|
|
@@ -2523,20 +2214,63 @@ function validateUrl(name, value) {
|
|
|
2523
2214
|
throw new Error(`Invalid ${name}: "${value}"`);
|
|
2524
2215
|
}
|
|
2525
2216
|
}
|
|
2217
|
+
function buildAssertedAuthHeaders(env) {
|
|
2218
|
+
const headers = {};
|
|
2219
|
+
const email = readEnv(env, [`ELECTRIC_ASSERTED_AUTH_EMAIL`]);
|
|
2220
|
+
const name = readEnv(env, [`ELECTRIC_ASSERTED_AUTH_NAME`]);
|
|
2221
|
+
if (email) headers[`X-Electric-Asserted-Email`] = email;
|
|
2222
|
+
if (name) headers[`X-Electric-Asserted-Name`] = name;
|
|
2223
|
+
return Object.keys(headers).length > 0 ? headers : void 0;
|
|
2224
|
+
}
|
|
2225
|
+
function parseAdditionalServerHeaders(env) {
|
|
2226
|
+
const raw = readEnv(env, [`ELECTRIC_AGENTS_SERVER_HEADERS`]);
|
|
2227
|
+
if (!raw) return void 0;
|
|
2228
|
+
let parsed;
|
|
2229
|
+
try {
|
|
2230
|
+
parsed = JSON.parse(raw);
|
|
2231
|
+
} catch {
|
|
2232
|
+
throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: expected JSON`);
|
|
2233
|
+
}
|
|
2234
|
+
if (!parsed || typeof parsed !== `object` || Array.isArray(parsed)) throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: expected a JSON object`);
|
|
2235
|
+
const headers = new Headers();
|
|
2236
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
2237
|
+
if (typeof value !== `string`) throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: header "${name}" must be a string`);
|
|
2238
|
+
headers.set(name, value);
|
|
2239
|
+
}
|
|
2240
|
+
const normalized = Object.fromEntries(headers.entries());
|
|
2241
|
+
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
2242
|
+
}
|
|
2243
|
+
function mergeHeaders(...sources) {
|
|
2244
|
+
const headers = new Headers();
|
|
2245
|
+
for (const source of sources) {
|
|
2246
|
+
if (!source) continue;
|
|
2247
|
+
new Headers(source).forEach((value, key) => headers.set(key, value));
|
|
2248
|
+
}
|
|
2249
|
+
const merged = Object.fromEntries(headers.entries());
|
|
2250
|
+
return Object.keys(merged).length > 0 ? merged : void 0;
|
|
2251
|
+
}
|
|
2252
|
+
function hasHeader(headers, name) {
|
|
2253
|
+
return headers ? new Headers(headers).has(name) : false;
|
|
2254
|
+
}
|
|
2526
2255
|
function resolveBuiltinAgentsEntrypointOptions(env = process.env, cwd = process.cwd()) {
|
|
2527
2256
|
const agentServerUrl = validateUrl(`agent server URL`, readRequiredEnv(env, [`ELECTRIC_AGENTS_SERVER_URL`, `ELECTRIC_AGENTS_BASE_URL`], `agent server base URL`));
|
|
2528
|
-
const
|
|
2257
|
+
const runnerId = readRequiredEnv(env, [`ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID`, `PULL_WAKE_RUNNER_ID`], `pull-wake runner id`);
|
|
2258
|
+
const serverHeaders = mergeHeaders(buildAssertedAuthHeaders(env), parseAdditionalServerHeaders(env));
|
|
2529
2259
|
return {
|
|
2530
2260
|
agentServerUrl,
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2261
|
+
workingDirectory: readEnv(env, [`ELECTRIC_AGENTS_WORKING_DIRECTORY`, `WORKING_DIRECTORY`]) ?? cwd,
|
|
2262
|
+
pullWake: {
|
|
2263
|
+
runnerId,
|
|
2264
|
+
registerRunner: readEnv(env, [`ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER`]) === `true` || readEnv(env, [`ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER`]) === `1`,
|
|
2265
|
+
headers: serverHeaders,
|
|
2266
|
+
claimHeaders: serverHeaders,
|
|
2267
|
+
claimTokenHeader: hasHeader(serverHeaders, `authorization`) ? `electric-claim-token` : void 0
|
|
2268
|
+
}
|
|
2535
2269
|
};
|
|
2536
2270
|
}
|
|
2537
|
-
async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer
|
|
2271
|
+
async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer = (options$1) => new BuiltinAgentsServer(options$1) } = {}) {
|
|
2538
2272
|
const options = resolveBuiltinAgentsEntrypointOptions(env, cwd);
|
|
2539
|
-
const server = createServer
|
|
2273
|
+
const server = createServer(options);
|
|
2540
2274
|
const url = await server.start();
|
|
2541
2275
|
return {
|
|
2542
2276
|
options,
|
|
@@ -2546,4 +2280,4 @@ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd
|
|
|
2546
2280
|
}
|
|
2547
2281
|
|
|
2548
2282
|
//#endregion
|
|
2549
|
-
export { BuiltinAgentsServer, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, WORKER_TOOL_NAMES, braveSearchTool, buildHortonSystemPrompt, createAgentHandler, createBuiltinAgentHandler, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, registerAgentTypes, registerBuiltinAgentTypes,
|
|
2283
|
+
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 };
|