@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.cjs
CHANGED
|
@@ -27,27 +27,29 @@ const node_url = __toESM(require("node:url"));
|
|
|
27
27
|
const __electric_ax_agents_runtime = __toESM(require("@electric-ax/agents-runtime"));
|
|
28
28
|
const node_fs = __toESM(require("node:fs"));
|
|
29
29
|
const pino = __toESM(require("pino"));
|
|
30
|
-
const
|
|
31
|
-
const node_os = __toESM(require("node:os"));
|
|
30
|
+
const __durable_streams_state = __toESM(require("@durable-streams/state"));
|
|
32
31
|
const zod = __toESM(require("zod"));
|
|
33
|
-
const agent_session_protocol = __toESM(require("agent-session-protocol"));
|
|
34
|
-
const __anthropic_ai_sdk = __toESM(require("@anthropic-ai/sdk"));
|
|
35
32
|
const node_crypto = __toESM(require("node:crypto"));
|
|
36
33
|
const node_fs_promises = __toESM(require("node:fs/promises"));
|
|
37
34
|
const better_sqlite3 = __toESM(require("better-sqlite3"));
|
|
38
35
|
const __sinclair_typebox = __toESM(require("@sinclair/typebox"));
|
|
39
36
|
const sqlite_vec = __toESM(require("sqlite-vec"));
|
|
40
37
|
const nanoid = __toESM(require("nanoid"));
|
|
38
|
+
const __mariozechner_pi_ai = __toESM(require("@mariozechner/pi-ai"));
|
|
41
39
|
const __electric_ax_agents_runtime_tools = __toESM(require("@electric-ax/agents-runtime/tools"));
|
|
42
|
-
const
|
|
40
|
+
const __electric_ax_agents_mcp = __toESM(require("@electric-ax/agents-mcp"));
|
|
43
41
|
|
|
44
42
|
//#region src/log.ts
|
|
45
43
|
const LOG_DIR = process.env.ELECTRIC_AGENTS_LOG_DIR ?? node_path.default.resolve(process.cwd(), `logs`);
|
|
46
44
|
node_fs.default.mkdirSync(LOG_DIR, { recursive: true });
|
|
47
45
|
const LOG_FILE = node_path.default.join(LOG_DIR, `builtin-agents-${Date.now()}.jsonl`);
|
|
48
46
|
const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
49
|
-
const
|
|
50
|
-
const
|
|
47
|
+
const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
|
|
48
|
+
const USE_PRETTY_LOGS = LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN;
|
|
49
|
+
const streams = [{ stream: pino.default.destination({
|
|
50
|
+
dest: LOG_FILE,
|
|
51
|
+
sync: IS_ELECTRON_MAIN
|
|
52
|
+
}) }];
|
|
51
53
|
if (USE_PRETTY_LOGS) streams.push({ stream: pino.default.transport({
|
|
52
54
|
target: `pino-pretty`,
|
|
53
55
|
options: {
|
|
@@ -94,516 +96,6 @@ const serverLog = {
|
|
|
94
96
|
}
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
//#endregion
|
|
98
|
-
//#region src/agents/coding-session.ts
|
|
99
|
-
const defaultCliRunner = { async run(opts) {
|
|
100
|
-
return new Promise((resolve, reject) => {
|
|
101
|
-
const isClaude = opts.agent === `claude`;
|
|
102
|
-
const bin = isClaude ? `claude` : `codex`;
|
|
103
|
-
const args = isClaude ? opts.sessionId ? [
|
|
104
|
-
`-r`,
|
|
105
|
-
opts.sessionId,
|
|
106
|
-
`--dangerously-skip-permissions`,
|
|
107
|
-
`-p`
|
|
108
|
-
] : [`--dangerously-skip-permissions`, `-p`] : opts.sessionId ? [
|
|
109
|
-
`exec`,
|
|
110
|
-
`--skip-git-repo-check`,
|
|
111
|
-
`resume`,
|
|
112
|
-
opts.sessionId,
|
|
113
|
-
opts.prompt
|
|
114
|
-
] : [
|
|
115
|
-
`exec`,
|
|
116
|
-
`--skip-git-repo-check`,
|
|
117
|
-
opts.prompt
|
|
118
|
-
];
|
|
119
|
-
const child = (0, node_child_process.spawn)(bin, args, {
|
|
120
|
-
cwd: opts.cwd,
|
|
121
|
-
stdio: [
|
|
122
|
-
isClaude ? `pipe` : `ignore`,
|
|
123
|
-
`pipe`,
|
|
124
|
-
`pipe`
|
|
125
|
-
]
|
|
126
|
-
});
|
|
127
|
-
const MAX_BUF_CHARS = 4096;
|
|
128
|
-
let stdout = ``;
|
|
129
|
-
let stderr = ``;
|
|
130
|
-
child.stdout?.on(`data`, (d) => {
|
|
131
|
-
if (stdout.length < MAX_BUF_CHARS) stdout += d.toString().slice(0, MAX_BUF_CHARS - stdout.length);
|
|
132
|
-
});
|
|
133
|
-
child.stderr?.on(`data`, (d) => {
|
|
134
|
-
if (stderr.length < MAX_BUF_CHARS) stderr += d.toString().slice(0, MAX_BUF_CHARS - stderr.length);
|
|
135
|
-
});
|
|
136
|
-
child.on(`error`, reject);
|
|
137
|
-
child.on(`exit`, (code) => {
|
|
138
|
-
resolve({
|
|
139
|
-
exitCode: code ?? -1,
|
|
140
|
-
stdout,
|
|
141
|
-
stderr
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
if (isClaude && child.stdin) {
|
|
145
|
-
child.stdin.write(opts.prompt);
|
|
146
|
-
child.stdin.end();
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
} };
|
|
150
|
-
async function discoverNewestSession(agent, cwd, excludeIds) {
|
|
151
|
-
const all = await (0, agent_session_protocol.discoverSessions)(agent);
|
|
152
|
-
const candidates = all.filter((s) => !excludeIds.has(s.sessionId) && (!s.cwd || s.cwd === cwd));
|
|
153
|
-
if (candidates.length === 0) return null;
|
|
154
|
-
return candidates[0].sessionId;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Compute the candidate directories where Claude Code stores per-cwd
|
|
158
|
-
* session JSONL files. Claude resolves the cwd to its realpath when
|
|
159
|
-
* choosing the directory name (so /tmp/foo on macOS lands under
|
|
160
|
-
* `-private-tmp-foo`), but the entity may have been spawned with the
|
|
161
|
-
* non-realpath form. Return both candidates so the caller can union
|
|
162
|
-
* their contents.
|
|
163
|
-
*/
|
|
164
|
-
async function getClaudeProjectDirs(cwd) {
|
|
165
|
-
const home = (0, node_os.homedir)();
|
|
166
|
-
const make = (c) => node_path.default.join(home, `.claude`, `projects`, c.replace(/\//g, `-`));
|
|
167
|
-
const dirs = [make(cwd)];
|
|
168
|
-
try {
|
|
169
|
-
const real = await node_fs.promises.realpath(cwd);
|
|
170
|
-
if (real !== cwd) dirs.push(make(real));
|
|
171
|
-
} catch {}
|
|
172
|
-
return dirs;
|
|
173
|
-
}
|
|
174
|
-
async function listClaudeJsonlIdsByCwd(cwd) {
|
|
175
|
-
const ids = new Set();
|
|
176
|
-
for (const dir of await getClaudeProjectDirs(cwd)) try {
|
|
177
|
-
const files = await node_fs.promises.readdir(dir);
|
|
178
|
-
for (const f of files) if (f.endsWith(`.jsonl`)) ids.add(f.slice(0, -`.jsonl`.length));
|
|
179
|
-
} catch {}
|
|
180
|
-
return ids;
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Deterministic-path discovery for a freshly created session. After the
|
|
184
|
-
* Claude CLI runs in `-p` mode it writes the new JSONL straight into
|
|
185
|
-
* `~/.claude/projects/<sanitize(cwd)>/<id>.jsonl` *without* leaving a
|
|
186
|
-
* `~/.claude/sessions/<pid>.json` lock file (those are interactive-only),
|
|
187
|
-
* so `discoverSessions` can miss it. Compute the expected dir directly
|
|
188
|
-
* and diff its contents against a pre-run snapshot. Returns the newest
|
|
189
|
-
* fresh sessionId or null. Codex falls back to discoverNewestSession.
|
|
190
|
-
*/
|
|
191
|
-
async function findNewSessionAfterRun(agent, cwd, preDirectIds, preDiscoveredIds) {
|
|
192
|
-
if (agent === `claude`) {
|
|
193
|
-
const dirs = await getClaudeProjectDirs(cwd);
|
|
194
|
-
let best = null;
|
|
195
|
-
for (const dir of dirs) try {
|
|
196
|
-
const files = await node_fs.promises.readdir(dir);
|
|
197
|
-
for (const f of files) {
|
|
198
|
-
if (!f.endsWith(`.jsonl`)) continue;
|
|
199
|
-
const id = f.slice(0, -`.jsonl`.length);
|
|
200
|
-
if (preDirectIds.has(id)) continue;
|
|
201
|
-
const st = await node_fs.promises.stat(node_path.default.join(dir, f)).catch(() => null);
|
|
202
|
-
if (!st) continue;
|
|
203
|
-
if (!best || st.mtimeMs > best.mtime) best = {
|
|
204
|
-
id,
|
|
205
|
-
mtime: st.mtimeMs
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
} catch {}
|
|
209
|
-
if (best) return best.id;
|
|
210
|
-
}
|
|
211
|
-
return discoverNewestSession(agent, cwd, preDiscoveredIds);
|
|
212
|
-
}
|
|
213
|
-
const sessionMetaRowSchema = zod.z.object({
|
|
214
|
-
key: zod.z.literal(`current`),
|
|
215
|
-
electricSessionId: zod.z.string(),
|
|
216
|
-
nativeSessionId: zod.z.string().optional(),
|
|
217
|
-
agent: zod.z.enum([`claude`, `codex`]),
|
|
218
|
-
cwd: zod.z.string(),
|
|
219
|
-
status: zod.z.enum([
|
|
220
|
-
`initializing`,
|
|
221
|
-
`idle`,
|
|
222
|
-
`running`,
|
|
223
|
-
`error`
|
|
224
|
-
]),
|
|
225
|
-
error: zod.z.string().optional(),
|
|
226
|
-
currentPromptInboxKey: zod.z.string().optional()
|
|
227
|
-
});
|
|
228
|
-
const cursorStateRowSchema = zod.z.object({
|
|
229
|
-
key: zod.z.literal(`current`),
|
|
230
|
-
cursor: zod.z.string(),
|
|
231
|
-
lastProcessedInboxKey: zod.z.string().optional()
|
|
232
|
-
});
|
|
233
|
-
const eventRowSchema = zod.z.object({
|
|
234
|
-
key: zod.z.string(),
|
|
235
|
-
ts: zod.z.number(),
|
|
236
|
-
type: zod.z.string(),
|
|
237
|
-
callId: zod.z.string().optional(),
|
|
238
|
-
payload: zod.z.looseObject({})
|
|
239
|
-
});
|
|
240
|
-
const creationArgsSchema = zod.z.object({
|
|
241
|
-
agent: zod.z.enum([`claude`, `codex`]),
|
|
242
|
-
cwd: zod.z.string().optional(),
|
|
243
|
-
nativeSessionId: zod.z.string().optional(),
|
|
244
|
-
importFrom: zod.z.object({
|
|
245
|
-
agent: zod.z.enum([`claude`, `codex`]),
|
|
246
|
-
sessionId: zod.z.string()
|
|
247
|
-
}).optional()
|
|
248
|
-
});
|
|
249
|
-
const promptMessageSchema = zod.z.object({ text: zod.z.string() });
|
|
250
|
-
/**
|
|
251
|
-
* Stable key for an events-collection row, derived from the event's content.
|
|
252
|
-
* Lets us re-insert the same event without producing duplicates — the caller
|
|
253
|
-
* (or the collection's uniqueness guard) uses this to de-dup across retries,
|
|
254
|
-
* replays, and crash recovery. Sorts chronologically by ts, then by type.
|
|
255
|
-
*/
|
|
256
|
-
function eventKey(event) {
|
|
257
|
-
const tsPart = String(event.ts).padStart(16, `0`);
|
|
258
|
-
return `${tsPart}_${event.type}_${contentHashHex(event)}`;
|
|
259
|
-
}
|
|
260
|
-
function contentHashHex(event) {
|
|
261
|
-
const json = JSON.stringify(event);
|
|
262
|
-
let h = 5381;
|
|
263
|
-
for (let i = 0; i < json.length; i++) h = (h * 33 ^ json.charCodeAt(i)) >>> 0;
|
|
264
|
-
return h.toString(16).padStart(8, `0`);
|
|
265
|
-
}
|
|
266
|
-
function buildEventRow(event) {
|
|
267
|
-
const callId = `callId` in event && typeof event.callId === `string` ? event.callId : void 0;
|
|
268
|
-
return {
|
|
269
|
-
key: eventKey(event),
|
|
270
|
-
ts: event.ts,
|
|
271
|
-
type: event.type,
|
|
272
|
-
...callId !== void 0 ? { callId } : {},
|
|
273
|
-
payload: event
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
function appendIfNew(ctx, event) {
|
|
277
|
-
const row = buildEventRow(event);
|
|
278
|
-
if (ctx.events.get(row.key) !== void 0) return;
|
|
279
|
-
ctx.actions.events_insert({ row });
|
|
280
|
-
}
|
|
281
|
-
/**
|
|
282
|
-
* Mirror every event that lands in the JSONL file while `runWork` is
|
|
283
|
-
* executing (i.e. while the CLI is running). Returns the advanced cursor
|
|
284
|
-
* and the `runWork` result once everything has settled and every append
|
|
285
|
-
* has been persisted to the entity's durable stream.
|
|
286
|
-
*
|
|
287
|
-
* If setup fails (e.g. the session file can't be resolved), `runWork`
|
|
288
|
-
* still runs — but nothing is mirrored and `setupError` is populated so
|
|
289
|
-
* the caller can surface the condition. If `runWork` throws, the error
|
|
290
|
-
* propagates after the watcher has been cleaned up.
|
|
291
|
-
*/
|
|
292
|
-
async function runWithLiveMirror(opts) {
|
|
293
|
-
let cursor = null;
|
|
294
|
-
let setupError = void 0;
|
|
295
|
-
try {
|
|
296
|
-
const session = await (0, agent_session_protocol.resolveSession)(opts.nativeSessionId, opts.agent);
|
|
297
|
-
if (opts.serializedCursor) cursor = (0, agent_session_protocol.deserializeCursor)({
|
|
298
|
-
...opts.serializedCursor,
|
|
299
|
-
path: session.path
|
|
300
|
-
});
|
|
301
|
-
else {
|
|
302
|
-
const initial = await (0, agent_session_protocol.loadSession)({
|
|
303
|
-
sessionId: opts.nativeSessionId,
|
|
304
|
-
agent: opts.agent
|
|
305
|
-
});
|
|
306
|
-
for (const ev of initial.events) appendIfNew(opts.ctx, ev);
|
|
307
|
-
cursor = initial.cursor;
|
|
308
|
-
}
|
|
309
|
-
} catch (e) {
|
|
310
|
-
setupError = e;
|
|
311
|
-
}
|
|
312
|
-
if (!cursor) {
|
|
313
|
-
const result$1 = await opts.runWork();
|
|
314
|
-
return {
|
|
315
|
-
cursor: opts.serializedCursor,
|
|
316
|
-
setupError,
|
|
317
|
-
result: result$1
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
let activeCursor = cursor;
|
|
321
|
-
let busy = false;
|
|
322
|
-
let pending = false;
|
|
323
|
-
let stopped = false;
|
|
324
|
-
const drainOnce = async () => {
|
|
325
|
-
if (stopped && busy) return;
|
|
326
|
-
if (busy) {
|
|
327
|
-
pending = true;
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
busy = true;
|
|
331
|
-
try {
|
|
332
|
-
const res = await (0, agent_session_protocol.tailSession)({ cursor: activeCursor });
|
|
333
|
-
activeCursor = res.cursor;
|
|
334
|
-
for (const ev of res.newEvents) appendIfNew(opts.ctx, ev);
|
|
335
|
-
} catch {} finally {
|
|
336
|
-
busy = false;
|
|
337
|
-
if (pending && !stopped) {
|
|
338
|
-
pending = false;
|
|
339
|
-
drainOnce();
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
const fileWatcher = (0, node_fs.watch)(activeCursor.path, () => {
|
|
344
|
-
drainOnce();
|
|
345
|
-
});
|
|
346
|
-
const pollHandle = setInterval(() => {
|
|
347
|
-
drainOnce();
|
|
348
|
-
}, 1500);
|
|
349
|
-
let result;
|
|
350
|
-
try {
|
|
351
|
-
result = await opts.runWork();
|
|
352
|
-
} finally {
|
|
353
|
-
stopped = true;
|
|
354
|
-
clearInterval(pollHandle);
|
|
355
|
-
fileWatcher.close();
|
|
356
|
-
while (busy) await new Promise((r) => setTimeout(r, 10));
|
|
357
|
-
try {
|
|
358
|
-
const final = await (0, agent_session_protocol.tailSession)({ cursor: activeCursor });
|
|
359
|
-
activeCursor = final.cursor;
|
|
360
|
-
for (const ev of final.newEvents) appendIfNew(opts.ctx, ev);
|
|
361
|
-
} catch {}
|
|
362
|
-
}
|
|
363
|
-
return {
|
|
364
|
-
cursor: (0, agent_session_protocol.serializeCursor)(activeCursor),
|
|
365
|
-
setupError,
|
|
366
|
-
result
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
function registerCodingSession(registry, options = {}) {
|
|
370
|
-
const runner = options.cliRunner ?? defaultCliRunner;
|
|
371
|
-
const defaultCwd = options.defaultWorkingDirectory ?? process.cwd();
|
|
372
|
-
registry.define(`coder`, {
|
|
373
|
-
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.`,
|
|
374
|
-
creationSchema: creationArgsSchema,
|
|
375
|
-
inboxSchemas: { prompt: promptMessageSchema },
|
|
376
|
-
state: {
|
|
377
|
-
sessionMeta: {
|
|
378
|
-
schema: sessionMetaRowSchema,
|
|
379
|
-
type: __electric_ax_agents_runtime.CODING_SESSION_META_COLLECTION_TYPE,
|
|
380
|
-
primaryKey: `key`
|
|
381
|
-
},
|
|
382
|
-
cursorState: {
|
|
383
|
-
schema: cursorStateRowSchema,
|
|
384
|
-
type: __electric_ax_agents_runtime.CODING_SESSION_CURSOR_COLLECTION_TYPE,
|
|
385
|
-
primaryKey: `key`
|
|
386
|
-
},
|
|
387
|
-
events: {
|
|
388
|
-
schema: eventRowSchema,
|
|
389
|
-
type: __electric_ax_agents_runtime.CODING_SESSION_EVENT_COLLECTION_TYPE,
|
|
390
|
-
primaryKey: `key`
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
async handler(ctx, _wake) {
|
|
394
|
-
const existingMeta = ctx.db.collections.sessionMeta.get(`current`);
|
|
395
|
-
if (!existingMeta) {
|
|
396
|
-
const args = creationArgsSchema.parse(ctx.args);
|
|
397
|
-
const cwd = args.cwd ?? defaultCwd;
|
|
398
|
-
const electricSessionId = ctx.entityUrl.split(`/`).pop() ?? ctx.entityUrl;
|
|
399
|
-
let resolvedNativeId = args.nativeSessionId;
|
|
400
|
-
if (args.importFrom) {
|
|
401
|
-
const result = await (0, agent_session_protocol.importLocalSession)({
|
|
402
|
-
source: {
|
|
403
|
-
sessionId: args.importFrom.sessionId,
|
|
404
|
-
agent: args.importFrom.agent
|
|
405
|
-
},
|
|
406
|
-
target: {
|
|
407
|
-
agent: args.agent,
|
|
408
|
-
cwd
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
resolvedNativeId = result.sessionId;
|
|
412
|
-
}
|
|
413
|
-
const hasNative = resolvedNativeId !== void 0;
|
|
414
|
-
ctx.db.actions.sessionMeta_insert({ row: {
|
|
415
|
-
key: `current`,
|
|
416
|
-
electricSessionId,
|
|
417
|
-
...hasNative ? { nativeSessionId: resolvedNativeId } : {},
|
|
418
|
-
agent: args.agent,
|
|
419
|
-
cwd,
|
|
420
|
-
status: hasNative ? `idle` : `initializing`
|
|
421
|
-
} });
|
|
422
|
-
}
|
|
423
|
-
if (!ctx.db.collections.cursorState.get(`current`)) ctx.db.actions.cursorState_insert({ row: {
|
|
424
|
-
key: `current`,
|
|
425
|
-
cursor: ``
|
|
426
|
-
} });
|
|
427
|
-
const metaRow = ctx.db.collections.sessionMeta.get(`current`);
|
|
428
|
-
const cursorRow = ctx.db.collections.cursorState.get(`current`);
|
|
429
|
-
if (!metaRow || !cursorRow) throw new Error(`[coding-session] expected sessionMeta and cursorState rows to exist after init`);
|
|
430
|
-
if (metaRow.nativeSessionId && !cursorRow.cursor) {
|
|
431
|
-
const mirrorCtx = {
|
|
432
|
-
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
433
|
-
actions: { events_insert: ctx.db.actions.events_insert }
|
|
434
|
-
};
|
|
435
|
-
try {
|
|
436
|
-
const initial = await (0, agent_session_protocol.loadSession)({
|
|
437
|
-
sessionId: metaRow.nativeSessionId,
|
|
438
|
-
agent: metaRow.agent
|
|
439
|
-
});
|
|
440
|
-
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
441
|
-
const serialized = (0, agent_session_protocol.serializeCursor)(initial.cursor);
|
|
442
|
-
ctx.db.actions.cursorState_update({
|
|
443
|
-
key: `current`,
|
|
444
|
-
updater: (d) => {
|
|
445
|
-
d.cursor = JSON.stringify(serialized);
|
|
446
|
-
}
|
|
447
|
-
});
|
|
448
|
-
} catch (e) {
|
|
449
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
450
|
-
ctx.db.actions.sessionMeta_update({
|
|
451
|
-
key: `current`,
|
|
452
|
-
updater: (d) => {
|
|
453
|
-
d.error = `initial mirror failed: ${message}`;
|
|
454
|
-
}
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
const inboxRows = ctx.db.collections.inbox.toArray.slice().sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
459
|
-
const lastKey = cursorRow.lastProcessedInboxKey ?? ``;
|
|
460
|
-
const pending = inboxRows.filter((m) => m.key > lastKey);
|
|
461
|
-
if (pending.length === 0) {
|
|
462
|
-
if (metaRow.status === `running` || metaRow.status === `error`) ctx.db.actions.sessionMeta_update({
|
|
463
|
-
key: `current`,
|
|
464
|
-
updater: (d) => {
|
|
465
|
-
d.status = `idle`;
|
|
466
|
-
delete d.currentPromptInboxKey;
|
|
467
|
-
delete d.error;
|
|
468
|
-
}
|
|
469
|
-
});
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
let runningMeta = metaRow;
|
|
473
|
-
let runningCursor = cursorRow;
|
|
474
|
-
for (const inboxMsg of pending) {
|
|
475
|
-
const parsed = promptMessageSchema.safeParse(inboxMsg.payload);
|
|
476
|
-
if (!parsed.success) {
|
|
477
|
-
ctx.db.actions.cursorState_update({
|
|
478
|
-
key: `current`,
|
|
479
|
-
updater: (d) => {
|
|
480
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
481
|
-
}
|
|
482
|
-
});
|
|
483
|
-
runningCursor = {
|
|
484
|
-
...runningCursor,
|
|
485
|
-
lastProcessedInboxKey: inboxMsg.key
|
|
486
|
-
};
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
const prompt = parsed.data.text;
|
|
490
|
-
const existingTitle = ctx.tags.title;
|
|
491
|
-
if (typeof existingTitle !== `string` || existingTitle.length === 0) ctx.setTag(`title`, prompt.slice(0, 80));
|
|
492
|
-
ctx.db.actions.sessionMeta_update({
|
|
493
|
-
key: `current`,
|
|
494
|
-
updater: (d) => {
|
|
495
|
-
d.status = `running`;
|
|
496
|
-
d.currentPromptInboxKey = inboxMsg.key;
|
|
497
|
-
delete d.error;
|
|
498
|
-
}
|
|
499
|
-
});
|
|
500
|
-
const recordedRun = ctx.recordRun();
|
|
501
|
-
const eventKeysBefore = new Set(ctx.db.collections.events.toArray.map((e) => e.key));
|
|
502
|
-
try {
|
|
503
|
-
const mirrorCtx = {
|
|
504
|
-
events: { get: (k) => ctx.db.collections.events.get(k) },
|
|
505
|
-
actions: { events_insert: ctx.db.actions.events_insert }
|
|
506
|
-
};
|
|
507
|
-
let nextCursorJson = runningCursor.cursor;
|
|
508
|
-
if (!runningMeta.nativeSessionId) {
|
|
509
|
-
const preDirectIds = runningMeta.agent === `claude` ? await listClaudeJsonlIdsByCwd(runningMeta.cwd) : new Set();
|
|
510
|
-
const preDiscoveredIds = new Set((await (0, agent_session_protocol.discoverSessions)(runningMeta.agent)).map((s) => s.sessionId));
|
|
511
|
-
const cliResult = await runner.run({
|
|
512
|
-
agent: runningMeta.agent,
|
|
513
|
-
cwd: runningMeta.cwd,
|
|
514
|
-
prompt
|
|
515
|
-
});
|
|
516
|
-
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>`}`);
|
|
517
|
-
const foundId = await findNewSessionAfterRun(runningMeta.agent, runningMeta.cwd, preDirectIds, preDiscoveredIds);
|
|
518
|
-
if (!foundId) throw new Error(`[coding-session] ${runningMeta.agent} CLI succeeded but no new session file was found`);
|
|
519
|
-
ctx.db.actions.sessionMeta_update({
|
|
520
|
-
key: `current`,
|
|
521
|
-
updater: (d) => {
|
|
522
|
-
d.nativeSessionId = foundId;
|
|
523
|
-
}
|
|
524
|
-
});
|
|
525
|
-
runningMeta = {
|
|
526
|
-
...runningMeta,
|
|
527
|
-
nativeSessionId: foundId
|
|
528
|
-
};
|
|
529
|
-
const initial = await (0, agent_session_protocol.loadSession)({
|
|
530
|
-
sessionId: foundId,
|
|
531
|
-
agent: runningMeta.agent
|
|
532
|
-
});
|
|
533
|
-
for (const ev of initial.events) appendIfNew(mirrorCtx, ev);
|
|
534
|
-
nextCursorJson = JSON.stringify((0, agent_session_protocol.serializeCursor)(initial.cursor));
|
|
535
|
-
} else {
|
|
536
|
-
const serializedCursor = runningCursor.cursor ? JSON.parse(runningCursor.cursor) : null;
|
|
537
|
-
const { cursor: nextSerialized, setupError, result: cliResult } = await runWithLiveMirror({
|
|
538
|
-
agent: runningMeta.agent,
|
|
539
|
-
nativeSessionId: runningMeta.nativeSessionId,
|
|
540
|
-
serializedCursor,
|
|
541
|
-
ctx: mirrorCtx,
|
|
542
|
-
runWork: () => runner.run({
|
|
543
|
-
agent: runningMeta.agent,
|
|
544
|
-
sessionId: runningMeta.nativeSessionId,
|
|
545
|
-
cwd: runningMeta.cwd,
|
|
546
|
-
prompt
|
|
547
|
-
})
|
|
548
|
-
});
|
|
549
|
-
if (setupError) throw setupError instanceof Error ? setupError : new Error(String(setupError));
|
|
550
|
-
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>`}`);
|
|
551
|
-
const persistedCursor = nextSerialized ?? serializedCursor;
|
|
552
|
-
nextCursorJson = persistedCursor ? JSON.stringify(persistedCursor) : ``;
|
|
553
|
-
}
|
|
554
|
-
ctx.db.actions.cursorState_update({
|
|
555
|
-
key: `current`,
|
|
556
|
-
updater: (d) => {
|
|
557
|
-
d.cursor = nextCursorJson;
|
|
558
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
runningCursor = {
|
|
562
|
-
...runningCursor,
|
|
563
|
-
cursor: nextCursorJson,
|
|
564
|
-
lastProcessedInboxKey: inboxMsg.key
|
|
565
|
-
};
|
|
566
|
-
for (const row of ctx.db.collections.events.toArray) {
|
|
567
|
-
if (eventKeysBefore.has(row.key)) continue;
|
|
568
|
-
if (row.type !== `assistant_message`) continue;
|
|
569
|
-
const text = row.payload?.text;
|
|
570
|
-
if (typeof text === `string` && text.length > 0) recordedRun.attachResponse(text);
|
|
571
|
-
}
|
|
572
|
-
recordedRun.end({ status: `completed` });
|
|
573
|
-
} catch (e) {
|
|
574
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
575
|
-
recordedRun.end({
|
|
576
|
-
status: `failed`,
|
|
577
|
-
finishReason: `error`
|
|
578
|
-
});
|
|
579
|
-
ctx.db.actions.sessionMeta_update({
|
|
580
|
-
key: `current`,
|
|
581
|
-
updater: (d) => {
|
|
582
|
-
d.status = `error`;
|
|
583
|
-
d.error = message;
|
|
584
|
-
}
|
|
585
|
-
});
|
|
586
|
-
ctx.db.actions.cursorState_update({
|
|
587
|
-
key: `current`,
|
|
588
|
-
updater: (d) => {
|
|
589
|
-
d.lastProcessedInboxKey = inboxMsg.key;
|
|
590
|
-
}
|
|
591
|
-
});
|
|
592
|
-
throw e;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
ctx.db.actions.sessionMeta_update({
|
|
596
|
-
key: `current`,
|
|
597
|
-
updater: (d) => {
|
|
598
|
-
d.status = `idle`;
|
|
599
|
-
delete d.currentPromptInboxKey;
|
|
600
|
-
delete d.error;
|
|
601
|
-
}
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
|
|
607
99
|
//#endregion
|
|
608
100
|
//#region src/docs/embed.ts
|
|
609
101
|
const EMBEDDING_DIMENSIONS = 128;
|
|
@@ -1408,11 +900,11 @@ const WORKER_TOOL_NAMES = [
|
|
|
1408
900
|
`read`,
|
|
1409
901
|
`write`,
|
|
1410
902
|
`edit`,
|
|
1411
|
-
`
|
|
903
|
+
`web_search`,
|
|
1412
904
|
`fetch_url`,
|
|
1413
905
|
`spawn_worker`
|
|
1414
906
|
];
|
|
1415
|
-
function createSpawnWorkerTool(ctx) {
|
|
907
|
+
function createSpawnWorkerTool(ctx, modelConfig) {
|
|
1416
908
|
return {
|
|
1417
909
|
name: `spawn_worker`,
|
|
1418
910
|
label: `Spawn Worker`,
|
|
@@ -1439,10 +931,16 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1439
931
|
details: { spawned: false }
|
|
1440
932
|
};
|
|
1441
933
|
const id = (0, nanoid.nanoid)(10);
|
|
934
|
+
const workerModelArgs = modelConfig ? {
|
|
935
|
+
provider: modelConfig.provider,
|
|
936
|
+
model: modelConfig.model,
|
|
937
|
+
...modelConfig.reasoningEffort && { reasoningEffort: modelConfig.reasoningEffort }
|
|
938
|
+
} : {};
|
|
1442
939
|
try {
|
|
1443
940
|
const handle = await ctx.spawn(`worker`, id, {
|
|
1444
941
|
systemPrompt,
|
|
1445
|
-
tools
|
|
942
|
+
tools,
|
|
943
|
+
...workerModelArgs
|
|
1446
944
|
}, {
|
|
1447
945
|
initialMessage,
|
|
1448
946
|
wake: {
|
|
@@ -1476,140 +974,138 @@ function createSpawnWorkerTool(ctx) {
|
|
|
1476
974
|
}
|
|
1477
975
|
|
|
1478
976
|
//#endregion
|
|
1479
|
-
//#region src/
|
|
1480
|
-
const
|
|
1481
|
-
|
|
977
|
+
//#region src/model-catalog.ts
|
|
978
|
+
const REASONING_EFFORT_VALUES = [
|
|
979
|
+
`auto`,
|
|
980
|
+
`minimal`,
|
|
981
|
+
`low`,
|
|
982
|
+
`medium`,
|
|
983
|
+
`high`
|
|
984
|
+
];
|
|
985
|
+
const DEFAULT_ANTHROPIC_MODEL = `claude-sonnet-4-6`;
|
|
986
|
+
const DEFAULT_OPENAI_MODEL = `gpt-4.1`;
|
|
987
|
+
const DEFAULT_CODEX_MODEL = `gpt-5.4`;
|
|
988
|
+
function modelValue(provider, id) {
|
|
989
|
+
return `${provider}:${id}`;
|
|
990
|
+
}
|
|
991
|
+
function providerLabel(provider) {
|
|
992
|
+
if (provider === `anthropic`) return `Anthropic`;
|
|
993
|
+
if (provider === `openai-codex`) return `OpenAI Codex`;
|
|
994
|
+
return `OpenAI`;
|
|
995
|
+
}
|
|
996
|
+
function configuredProviders() {
|
|
997
|
+
return (0, __electric_ax_agents_runtime.detectAvailableProviders)();
|
|
998
|
+
}
|
|
999
|
+
function mockFallbackCatalog() {
|
|
1000
|
+
const fallback = {
|
|
1001
|
+
provider: `anthropic`,
|
|
1002
|
+
id: DEFAULT_ANTHROPIC_MODEL,
|
|
1003
|
+
label: `Anthropic ${DEFAULT_ANTHROPIC_MODEL}`,
|
|
1004
|
+
value: modelValue(`anthropic`, DEFAULT_ANTHROPIC_MODEL),
|
|
1005
|
+
reasoning: true
|
|
1006
|
+
};
|
|
1482
1007
|
return {
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
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.`,
|
|
1486
|
-
parameters: __sinclair_typebox.Type.Object({
|
|
1487
|
-
prompt: __sinclair_typebox.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.` }),
|
|
1488
|
-
agent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union(CODER_AGENT_NAMES.map((n) => __sinclair_typebox.Type.Literal(n)), { description: `Which coding agent to use. Defaults to "claude". Use "codex" only if the user explicitly asks for it.` })),
|
|
1489
|
-
cwd: __sinclair_typebox.Type.Optional(__sinclair_typebox.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.` }))
|
|
1490
|
-
}),
|
|
1491
|
-
execute: async (_toolCallId, params) => {
|
|
1492
|
-
const { prompt, agent, cwd } = params;
|
|
1493
|
-
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1494
|
-
content: [{
|
|
1495
|
-
type: `text`,
|
|
1496
|
-
text: `Error: prompt is required and must be a non-empty string.`
|
|
1497
|
-
}],
|
|
1498
|
-
details: { spawned: false }
|
|
1499
|
-
};
|
|
1500
|
-
const id = (0, nanoid.nanoid)(10);
|
|
1501
|
-
const spawnArgs = { agent: agent ?? `claude` };
|
|
1502
|
-
if (cwd) spawnArgs.cwd = cwd;
|
|
1503
|
-
try {
|
|
1504
|
-
const handle = await ctx.spawn(`coder`, id, spawnArgs, {
|
|
1505
|
-
initialMessage: { text: prompt },
|
|
1506
|
-
wake: {
|
|
1507
|
-
on: `runFinished`,
|
|
1508
|
-
includeResponse: true
|
|
1509
|
-
}
|
|
1510
|
-
});
|
|
1511
|
-
const coderUrl = handle.entityUrl;
|
|
1512
|
-
return {
|
|
1513
|
-
content: [{
|
|
1514
|
-
type: `text`,
|
|
1515
|
-
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.`
|
|
1516
|
-
}],
|
|
1517
|
-
details: {
|
|
1518
|
-
spawned: true,
|
|
1519
|
-
coderUrl
|
|
1520
|
-
}
|
|
1521
|
-
};
|
|
1522
|
-
} catch (err) {
|
|
1523
|
-
serverLog.warn(`[spawn_coder tool] failed to spawn coder ${id}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1524
|
-
return {
|
|
1525
|
-
content: [{
|
|
1526
|
-
type: `text`,
|
|
1527
|
-
text: `Error spawning coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1528
|
-
}],
|
|
1529
|
-
details: { spawned: false }
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1008
|
+
choices: [fallback],
|
|
1009
|
+
defaultChoice: fallback
|
|
1533
1010
|
};
|
|
1534
1011
|
}
|
|
1535
|
-
function
|
|
1012
|
+
async function fetchAvailableModelIds(provider) {
|
|
1013
|
+
try {
|
|
1014
|
+
const res = provider === `anthropic` ? await fetch(`https://api.anthropic.com/v1/models`, {
|
|
1015
|
+
headers: {
|
|
1016
|
+
"x-api-key": process.env.ANTHROPIC_API_KEY ?? ``,
|
|
1017
|
+
"anthropic-version": `2023-06-01`
|
|
1018
|
+
},
|
|
1019
|
+
signal: AbortSignal.timeout(3e3)
|
|
1020
|
+
}) : await fetch(`https://api.openai.com/v1/models`, {
|
|
1021
|
+
headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ``}` },
|
|
1022
|
+
signal: AbortSignal.timeout(3e3)
|
|
1023
|
+
});
|
|
1024
|
+
if (res.status === 401 || res.status === 403) return new Set();
|
|
1025
|
+
if (!res.ok) return null;
|
|
1026
|
+
const body = await res.json();
|
|
1027
|
+
const ids = new Set((body.data ?? []).map((model) => model.id).filter((id) => typeof id === `string`));
|
|
1028
|
+
return ids.size > 0 ? ids : null;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
async function choicesForProvider(provider) {
|
|
1034
|
+
const knownModels = (0, __mariozechner_pi_ai.getModels)(provider);
|
|
1035
|
+
if (provider === `openai-codex`) return knownModels.map((model) => ({
|
|
1036
|
+
provider,
|
|
1037
|
+
id: model.id,
|
|
1038
|
+
label: `${providerLabel(provider)} ${model.name}`,
|
|
1039
|
+
value: modelValue(provider, model.id),
|
|
1040
|
+
reasoning: model.reasoning
|
|
1041
|
+
}));
|
|
1042
|
+
const availableIds = await fetchAvailableModelIds(provider);
|
|
1043
|
+
const models = availableIds === null ? knownModels : knownModels.filter((model) => availableIds.has(model.id));
|
|
1044
|
+
return models.map((model) => ({
|
|
1045
|
+
provider,
|
|
1046
|
+
id: model.id,
|
|
1047
|
+
label: `${providerLabel(provider)} ${model.name}`,
|
|
1048
|
+
value: modelValue(provider, model.id),
|
|
1049
|
+
reasoning: model.reasoning
|
|
1050
|
+
}));
|
|
1051
|
+
}
|
|
1052
|
+
function withProviderPayloadDefaults(config, choice, reasoningEffort) {
|
|
1053
|
+
if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
|
|
1054
|
+
const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
|
|
1055
|
+
const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
|
|
1536
1056
|
return {
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
type: `text`,
|
|
1549
|
-
text: `Error: coder_url must be a path like "/coder/<id>".`
|
|
1550
|
-
}],
|
|
1551
|
-
details: { sent: false }
|
|
1552
|
-
};
|
|
1553
|
-
if (typeof prompt !== `string` || prompt.length === 0) return {
|
|
1554
|
-
content: [{
|
|
1555
|
-
type: `text`,
|
|
1556
|
-
text: `Error: prompt is required and must be a non-empty string.`
|
|
1557
|
-
}],
|
|
1558
|
-
details: { sent: false }
|
|
1057
|
+
...config,
|
|
1058
|
+
onPayload: (payload) => {
|
|
1059
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1060
|
+
const body = payload;
|
|
1061
|
+
const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
|
|
1062
|
+
return {
|
|
1063
|
+
...body,
|
|
1064
|
+
reasoning: {
|
|
1065
|
+
...existingReasoning,
|
|
1066
|
+
effort
|
|
1067
|
+
}
|
|
1559
1068
|
};
|
|
1560
|
-
try {
|
|
1561
|
-
ctx.send(coder_url, { text: prompt });
|
|
1562
|
-
return {
|
|
1563
|
-
content: [{
|
|
1564
|
-
type: `text`,
|
|
1565
|
-
text: `Prompt queued for ${coder_url}. End your turn — you'll be woken when the coder's reply lands.`
|
|
1566
|
-
}],
|
|
1567
|
-
details: {
|
|
1568
|
-
sent: true,
|
|
1569
|
-
coderUrl: coder_url
|
|
1570
|
-
}
|
|
1571
|
-
};
|
|
1572
|
-
} catch (err) {
|
|
1573
|
-
serverLog.warn(`[prompt_coder tool] failed to send to ${coder_url}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
1574
|
-
return {
|
|
1575
|
-
content: [{
|
|
1576
|
-
type: `text`,
|
|
1577
|
-
text: `Error sending prompt to coder: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
1578
|
-
}],
|
|
1579
|
-
details: { sent: false }
|
|
1580
|
-
};
|
|
1581
|
-
}
|
|
1582
1069
|
}
|
|
1583
1070
|
};
|
|
1584
1071
|
}
|
|
1072
|
+
function parseReasoningEffort(value) {
|
|
1073
|
+
return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
|
|
1074
|
+
}
|
|
1075
|
+
async function createBuiltinModelCatalog(options = {}) {
|
|
1076
|
+
const providers = configuredProviders();
|
|
1077
|
+
if (providers.length === 0 && options.allowMockFallback) return mockFallbackCatalog();
|
|
1078
|
+
const choices = (await Promise.all(providers.map((provider) => choicesForProvider(provider)))).flat();
|
|
1079
|
+
if (choices.length === 0) return options.allowMockFallback ? mockFallbackCatalog() : null;
|
|
1080
|
+
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];
|
|
1081
|
+
return {
|
|
1082
|
+
choices,
|
|
1083
|
+
defaultChoice
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function resolveBuiltinModelConfig(catalog, args) {
|
|
1087
|
+
const modelArg = args.model;
|
|
1088
|
+
const providerArg = args.provider;
|
|
1089
|
+
const reasoningEffort = parseReasoningEffort(args.reasoningEffort);
|
|
1090
|
+
const selected = typeof modelArg === `string` ? catalog.choices.find((choice$1) => choice$1.value === modelArg || choice$1.id === modelArg && choice$1.provider === providerArg) : void 0;
|
|
1091
|
+
const choice = selected ?? catalog.defaultChoice;
|
|
1092
|
+
const config = {
|
|
1093
|
+
provider: choice.provider,
|
|
1094
|
+
model: choice.id,
|
|
1095
|
+
...reasoningEffort && { reasoningEffort },
|
|
1096
|
+
...choice.provider === `openai-codex` && { getApiKey: () => (0, __electric_ax_agents_runtime.readCodexAccessToken)() }
|
|
1097
|
+
};
|
|
1098
|
+
return withProviderPayloadDefaults(config, choice, reasoningEffort);
|
|
1099
|
+
}
|
|
1100
|
+
function modelChoiceValues(catalog) {
|
|
1101
|
+
return catalog.choices.map((choice) => choice.value);
|
|
1102
|
+
}
|
|
1585
1103
|
|
|
1586
1104
|
//#endregion
|
|
1587
1105
|
//#region src/agents/horton.ts
|
|
1588
|
-
const TITLE_MODEL = `claude-haiku-4-5-20251001`;
|
|
1589
1106
|
const HORTON_MODEL = `claude-sonnet-4-6`;
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
if (!anthropic) anthropic = new __anthropic_ai_sdk.default();
|
|
1593
|
-
return anthropic;
|
|
1594
|
-
}
|
|
1595
|
-
async function defaultHaikuCall(prompt) {
|
|
1596
|
-
const client = getClient();
|
|
1597
|
-
const res = await client.messages.create({
|
|
1598
|
-
model: TITLE_MODEL,
|
|
1599
|
-
max_tokens: 64,
|
|
1600
|
-
messages: [{
|
|
1601
|
-
role: `user`,
|
|
1602
|
-
content: prompt
|
|
1603
|
-
}]
|
|
1604
|
-
});
|
|
1605
|
-
const block = res.content[0];
|
|
1606
|
-
return block?.type === `text` ? block.text : ``;
|
|
1607
|
-
}
|
|
1608
|
-
const TITLE_PROMPT = (userMessage) => `Summarize the following user request in 3-5 words for use as a chat session title.
|
|
1609
|
-
Respond with only the title, no quotes, no punctuation, no preamble.
|
|
1610
|
-
|
|
1611
|
-
User request:
|
|
1612
|
-
${userMessage}`;
|
|
1107
|
+
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.";
|
|
1108
|
+
const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
|
|
1613
1109
|
const TITLE_STOP_WORDS = new Set([
|
|
1614
1110
|
`a`,
|
|
1615
1111
|
`an`,
|
|
@@ -1677,19 +1173,34 @@ function buildFallbackTitle(userMessage) {
|
|
|
1677
1173
|
const selected = informativeWords.length >= 2 ? informativeWords.slice(0, 5) : backupWords;
|
|
1678
1174
|
return selected.join(` `).slice(0, 80).trim() || `Untitled Chat`;
|
|
1679
1175
|
}
|
|
1680
|
-
|
|
1176
|
+
function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
|
|
1177
|
+
return (prompt) => (0, __electric_ax_agents_runtime.completeWithLowCostModel)({
|
|
1178
|
+
catalog,
|
|
1179
|
+
modelConfig,
|
|
1180
|
+
log: (message) => serverLog.info(message),
|
|
1181
|
+
logPrefix,
|
|
1182
|
+
purpose: `title generation`,
|
|
1183
|
+
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
1184
|
+
prompt,
|
|
1185
|
+
maxTokens: 64
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
async function generateTitle(userMessage, llmCall, onFallback) {
|
|
1681
1189
|
try {
|
|
1682
|
-
const raw = await llmCall(
|
|
1190
|
+
const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
|
|
1683
1191
|
const title = raw.trim();
|
|
1684
|
-
|
|
1685
|
-
|
|
1192
|
+
if (title.length > 0) return title;
|
|
1193
|
+
onFallback?.(`empty LLM title response`);
|
|
1194
|
+
return buildFallbackTitle(userMessage);
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
onFallback?.(err instanceof Error ? err.message : String(err));
|
|
1686
1197
|
return buildFallbackTitle(userMessage);
|
|
1687
1198
|
}
|
|
1688
1199
|
}
|
|
1689
1200
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1690
1201
|
const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` : ``;
|
|
1691
1202
|
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` : ``;
|
|
1692
|
-
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
|
|
1203
|
+
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.` : ``;
|
|
1693
1204
|
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.
|
|
1694
1205
|
|
|
1695
1206
|
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.
|
|
@@ -1725,7 +1236,9 @@ Don't force onboarding. If someone just wants to chat or code, let them. When in
|
|
|
1725
1236
|
- ${opts.hasDocsSupport ? `If search_durable_agents_docs is available, use it first (faster, hybrid search).` : `Use fetch_url to look up documentation pages.`}
|
|
1726
1237
|
- The Electric Agents docs site is at ${opts.docsUrl}
|
|
1727
1238
|
- 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).
|
|
1728
|
-
- For general coding questions unrelated to Electric Agents, use
|
|
1239
|
+
- For general coding questions unrelated to Electric Agents, use web_search or your own knowledge.` : ``;
|
|
1240
|
+
const modelGuidance = opts.modelProvider && opts.modelId ? `\n# Runtime model
|
|
1241
|
+
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.` : ``;
|
|
1729
1242
|
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.
|
|
1730
1243
|
|
|
1731
1244
|
# Greetings
|
|
@@ -1736,18 +1249,16 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1736
1249
|
- read: read a file
|
|
1737
1250
|
- write: create or overwrite a file
|
|
1738
1251
|
- edit: targeted string replacement in an existing file (you must read the file first)
|
|
1739
|
-
-
|
|
1252
|
+
- web_search: search the web
|
|
1740
1253
|
- fetch_url: fetch and convert a URL to markdown
|
|
1741
1254
|
- spawn_worker: dispatch a subagent for an isolated task
|
|
1742
|
-
- spawn_coder: spawn a long-lived coding agent (Claude Code or Codex CLI) for code changes, file edits, debugging
|
|
1743
|
-
- prompt_coder: send a follow-up prompt to a coder you previously spawned
|
|
1744
1255
|
${docsTools}${skillsTools}
|
|
1745
1256
|
|
|
1746
1257
|
# Working with files
|
|
1747
1258
|
- Prefer edit over write when modifying existing files.
|
|
1748
1259
|
- You must read a file before you can edit it.
|
|
1749
1260
|
- Use absolute paths or paths relative to the current working directory.
|
|
1750
|
-
${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1261
|
+
${modelGuidance}${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1751
1262
|
|
|
1752
1263
|
# Risky actions
|
|
1753
1264
|
Pause and confirm with the user before:
|
|
@@ -1768,13 +1279,6 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1768
1279
|
|
|
1769
1280
|
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.
|
|
1770
1281
|
|
|
1771
|
-
# When to spawn a coder
|
|
1772
|
-
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.
|
|
1773
|
-
|
|
1774
|
-
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.
|
|
1775
|
-
|
|
1776
|
-
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.
|
|
1777
|
-
|
|
1778
1282
|
# Reporting
|
|
1779
1283
|
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.
|
|
1780
1284
|
|
|
@@ -1788,34 +1292,82 @@ function createHortonTools(workingDirectory, ctx, readSet, opts = {}) {
|
|
|
1788
1292
|
(0, __electric_ax_agents_runtime_tools.createWriteTool)(workingDirectory, readSet),
|
|
1789
1293
|
(0, __electric_ax_agents_runtime_tools.createEditTool)(workingDirectory, readSet),
|
|
1790
1294
|
__electric_ax_agents_runtime_tools.braveSearchTool,
|
|
1791
|
-
__electric_ax_agents_runtime_tools.
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1295
|
+
...opts.modelCatalog && opts.modelConfig ? [(0, __electric_ax_agents_runtime_tools.createFetchUrlTool)({
|
|
1296
|
+
catalog: opts.modelCatalog,
|
|
1297
|
+
modelConfig: opts.modelConfig,
|
|
1298
|
+
log: (message) => serverLog.info(message),
|
|
1299
|
+
logPrefix: opts.logPrefix ?? `[horton]`
|
|
1300
|
+
})] : [__electric_ax_agents_runtime_tools.fetchUrlTool],
|
|
1301
|
+
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1795
1302
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1796
1303
|
];
|
|
1797
1304
|
}
|
|
1798
|
-
function
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1305
|
+
function payloadToTitleText(payload) {
|
|
1306
|
+
if (typeof payload === `string`) return payload;
|
|
1307
|
+
if (payload == null) return ``;
|
|
1308
|
+
if (typeof payload === `object`) {
|
|
1309
|
+
const text = payload.text;
|
|
1310
|
+
return typeof text === `string` ? text : JSON.stringify(payload);
|
|
1311
|
+
}
|
|
1312
|
+
return String(payload);
|
|
1313
|
+
}
|
|
1314
|
+
async function extractFirstUserMessage(ctx) {
|
|
1315
|
+
const firstMessage = await (0, __durable_streams_state.queryOnce)((q) => q.from({ inbox: ctx.db.collections.inbox }).where(({ inbox }) => (0, __durable_streams_state.not)((0, __durable_streams_state.eq)(inbox.from, `system`))).orderBy(({ inbox }) => inbox._seq, `asc`).findOne());
|
|
1316
|
+
if (!firstMessage) return null;
|
|
1317
|
+
const text = payloadToTitleText(firstMessage.payload);
|
|
1318
|
+
return text.length > 0 ? text : null;
|
|
1319
|
+
}
|
|
1320
|
+
function readAgentsMd(workingDirectory) {
|
|
1321
|
+
const agentsMdPath = node_path.default.join(workingDirectory, `AGENTS.md`);
|
|
1322
|
+
try {
|
|
1323
|
+
if (!node_fs.default.existsSync(agentsMdPath) || !node_fs.default.statSync(agentsMdPath).isFile()) return null;
|
|
1324
|
+
const content = node_fs.default.readFileSync(agentsMdPath, `utf8`);
|
|
1325
|
+
return [
|
|
1326
|
+
`<context_file kind="instructions" path="${agentsMdPath}">`,
|
|
1327
|
+
content,
|
|
1328
|
+
`</context_file>`
|
|
1329
|
+
].join(`\n`);
|
|
1330
|
+
} catch {
|
|
1331
|
+
return null;
|
|
1806
1332
|
}
|
|
1807
|
-
return null;
|
|
1808
1333
|
}
|
|
1809
1334
|
function createAssistantHandler(options) {
|
|
1810
|
-
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, docsUrl } = options;
|
|
1335
|
+
const { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
|
|
1811
1336
|
const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
|
|
1812
1337
|
return async function assistantHandler(ctx, wake) {
|
|
1813
1338
|
const readSet = new Set();
|
|
1339
|
+
const effectiveCwd = typeof ctx.args.workingDirectory === `string` && ctx.args.workingDirectory.trim().length > 0 ? ctx.args.workingDirectory : workingDirectory;
|
|
1340
|
+
const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
|
|
1341
|
+
const agentsMd = readAgentsMd(effectiveCwd);
|
|
1814
1342
|
const tools = [
|
|
1815
1343
|
...ctx.electricTools,
|
|
1816
|
-
...createHortonTools(
|
|
1817
|
-
|
|
1344
|
+
...createHortonTools(effectiveCwd, ctx, readSet, {
|
|
1345
|
+
docsSearchTool,
|
|
1346
|
+
modelConfig,
|
|
1347
|
+
modelCatalog,
|
|
1348
|
+
logPrefix: `[horton ${ctx.entityUrl}]`
|
|
1349
|
+
}),
|
|
1350
|
+
...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
|
|
1351
|
+
...__electric_ax_agents_mcp.mcp.tools()
|
|
1818
1352
|
];
|
|
1353
|
+
const titlePromise = ctx.firstWake && !ctx.tags.title ? (async () => {
|
|
1354
|
+
const firstUserMessage = await extractFirstUserMessage(ctx);
|
|
1355
|
+
if (!firstUserMessage) return;
|
|
1356
|
+
let title = null;
|
|
1357
|
+
try {
|
|
1358
|
+
const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
|
|
1359
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
|
|
1360
|
+
});
|
|
1361
|
+
if (result.length > 0) title = result;
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1364
|
+
}
|
|
1365
|
+
if (title !== null) try {
|
|
1366
|
+
await ctx.setTag(`title`, title);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1369
|
+
}
|
|
1370
|
+
})() : Promise.resolve();
|
|
1819
1371
|
if (docsSupport) ctx.useContext({
|
|
1820
1372
|
sourceBudget: 1e5,
|
|
1821
1373
|
sources: {
|
|
@@ -1833,6 +1385,11 @@ function createAssistantHandler(options) {
|
|
|
1833
1385
|
content: () => ctx.timelineMessages(),
|
|
1834
1386
|
cache: `volatile`
|
|
1835
1387
|
},
|
|
1388
|
+
...agentsMd ? { agents_md: {
|
|
1389
|
+
content: () => agentsMd,
|
|
1390
|
+
max: 2e4,
|
|
1391
|
+
cache: `stable`
|
|
1392
|
+
} } : {},
|
|
1836
1393
|
...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
|
|
1837
1394
|
content: () => skillsRegistry.renderCatalog(2e3),
|
|
1838
1395
|
max: 2e3,
|
|
@@ -1851,41 +1408,46 @@ function createAssistantHandler(options) {
|
|
|
1851
1408
|
conversation: {
|
|
1852
1409
|
content: () => ctx.timelineMessages(),
|
|
1853
1410
|
cache: `volatile`
|
|
1411
|
+
},
|
|
1412
|
+
...agentsMd ? { agents_md: {
|
|
1413
|
+
content: () => agentsMd,
|
|
1414
|
+
max: 2e4,
|
|
1415
|
+
cache: `stable`
|
|
1416
|
+
} } : {}
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
else if (agentsMd) ctx.useContext({
|
|
1420
|
+
sourceBudget: 1e5,
|
|
1421
|
+
sources: {
|
|
1422
|
+
conversation: {
|
|
1423
|
+
content: () => ctx.timelineMessages(),
|
|
1424
|
+
cache: `volatile`
|
|
1425
|
+
},
|
|
1426
|
+
agents_md: {
|
|
1427
|
+
content: () => agentsMd,
|
|
1428
|
+
max: 2e4,
|
|
1429
|
+
cache: `stable`
|
|
1854
1430
|
}
|
|
1855
1431
|
}
|
|
1856
1432
|
});
|
|
1857
1433
|
ctx.useAgent({
|
|
1858
|
-
systemPrompt: buildHortonSystemPrompt(
|
|
1434
|
+
systemPrompt: buildHortonSystemPrompt(effectiveCwd, {
|
|
1859
1435
|
hasDocsSupport: Boolean(docsSupport),
|
|
1860
1436
|
hasSkills,
|
|
1861
|
-
docsUrl
|
|
1437
|
+
docsUrl,
|
|
1438
|
+
modelProvider: modelConfig.provider,
|
|
1439
|
+
modelId: String(modelConfig.model)
|
|
1862
1440
|
}),
|
|
1863
|
-
|
|
1441
|
+
...modelConfig,
|
|
1864
1442
|
tools,
|
|
1865
1443
|
...streamFn && { streamFn }
|
|
1866
1444
|
});
|
|
1867
1445
|
await ctx.agent.run();
|
|
1868
|
-
|
|
1869
|
-
const firstUserMessage = extractFirstUserMessage(ctx.events);
|
|
1870
|
-
if (firstUserMessage) {
|
|
1871
|
-
let title = null;
|
|
1872
|
-
try {
|
|
1873
|
-
const result = await generateTitle(firstUserMessage);
|
|
1874
|
-
if (result.length > 0) title = result;
|
|
1875
|
-
} catch (err) {
|
|
1876
|
-
serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1877
|
-
}
|
|
1878
|
-
if (title !== null) try {
|
|
1879
|
-
await ctx.setTag(`title`, title);
|
|
1880
|
-
} catch (err) {
|
|
1881
|
-
serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1446
|
+
await titlePromise;
|
|
1885
1447
|
};
|
|
1886
1448
|
}
|
|
1887
1449
|
function registerHorton(registry, options) {
|
|
1888
|
-
const { workingDirectory, streamFn, skillsRegistry = null } = options;
|
|
1450
|
+
const { workingDirectory, streamFn, skillsRegistry = null, modelCatalog } = options;
|
|
1889
1451
|
const docsUrl = options.docsUrl ?? process.env.HORTON_DOCS_URL;
|
|
1890
1452
|
if (process.env.BRAVE_SEARCH_API_KEY) serverLog.info(`[horton] Web search: using Brave Search API`);
|
|
1891
1453
|
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)`);
|
|
@@ -1900,10 +1462,17 @@ function registerHorton(registry, options) {
|
|
|
1900
1462
|
docsSupport,
|
|
1901
1463
|
docsSearchTool,
|
|
1902
1464
|
skillsRegistry,
|
|
1465
|
+
modelCatalog,
|
|
1903
1466
|
docsUrl
|
|
1904
1467
|
});
|
|
1468
|
+
const hortonCreationSchema = zod.z.object({
|
|
1469
|
+
model: zod.z.enum(modelChoiceValues(modelCatalog)).default(modelCatalog.defaultChoice.value),
|
|
1470
|
+
reasoningEffort: zod.z.enum(REASONING_EFFORT_VALUES).default(`auto`).describe(`Reasoning effort for compatible reasoning models. Auto uses a safe provider default.`),
|
|
1471
|
+
workingDirectory: zod.z.string().optional().describe(`Working directory for file operations. Defaults to the server's configured cwd.`)
|
|
1472
|
+
});
|
|
1905
1473
|
registry.define(`horton`, {
|
|
1906
1474
|
description: `Friendly capable assistant — chat, code, research, dispatch`,
|
|
1475
|
+
creationSchema: hortonCreationSchema,
|
|
1907
1476
|
handler: assistantHandler
|
|
1908
1477
|
});
|
|
1909
1478
|
const typeNames = [`horton`];
|
|
@@ -1948,6 +1517,9 @@ function parseWorkerArgs(value) {
|
|
|
1948
1517
|
};
|
|
1949
1518
|
}
|
|
1950
1519
|
if (tools.length === 0 && !args.sharedDb) throw new Error(`[worker] must provide tools and/or sharedDb`);
|
|
1520
|
+
if (typeof value.model === `string`) args.model = value.model;
|
|
1521
|
+
if (typeof value.provider === `string`) args.provider = value.provider;
|
|
1522
|
+
if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
|
|
1951
1523
|
return args;
|
|
1952
1524
|
}
|
|
1953
1525
|
function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
@@ -1965,7 +1537,7 @@ function buildToolsForWorker(tools, workingDirectory, ctx, readSet) {
|
|
|
1965
1537
|
case `edit`:
|
|
1966
1538
|
out.push((0, __electric_ax_agents_runtime_tools.createEditTool)(workingDirectory, readSet));
|
|
1967
1539
|
break;
|
|
1968
|
-
case `
|
|
1540
|
+
case `web_search`:
|
|
1969
1541
|
out.push(__electric_ax_agents_runtime_tools.braveSearchTool);
|
|
1970
1542
|
break;
|
|
1971
1543
|
case `fetch_url`:
|
|
@@ -2074,13 +1646,14 @@ function buildSharedStateTools(shared, schema, mode) {
|
|
|
2074
1646
|
return tools;
|
|
2075
1647
|
}
|
|
2076
1648
|
function registerWorker(registry, options) {
|
|
2077
|
-
const { workingDirectory, streamFn } = options;
|
|
1649
|
+
const { workingDirectory, streamFn, modelCatalog } = options;
|
|
2078
1650
|
registry.define(`worker`, {
|
|
2079
1651
|
description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
|
|
2080
1652
|
async handler(ctx) {
|
|
2081
1653
|
const args = parseWorkerArgs(ctx.args);
|
|
2082
1654
|
const readSet = new Set();
|
|
2083
1655
|
const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
|
|
1656
|
+
const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
|
|
2084
1657
|
const sharedStateTools = [];
|
|
2085
1658
|
if (args.sharedDb) {
|
|
2086
1659
|
const shared = await ctx.observe((0, __electric_ax_agents_runtime.db)(args.sharedDb.id, args.sharedDb.schema));
|
|
@@ -2088,7 +1661,7 @@ function registerWorker(registry, options) {
|
|
|
2088
1661
|
}
|
|
2089
1662
|
ctx.useAgent({
|
|
2090
1663
|
systemPrompt: `${args.systemPrompt}${WORKER_PROMPT_FOOTER}`,
|
|
2091
|
-
|
|
1664
|
+
...modelConfig,
|
|
2092
1665
|
tools: [...builtinTools, ...sharedStateTools],
|
|
2093
1666
|
...streamFn && { streamFn }
|
|
2094
1667
|
});
|
|
@@ -2178,7 +1751,6 @@ function stripQuotes(value) {
|
|
|
2178
1751
|
|
|
2179
1752
|
//#endregion
|
|
2180
1753
|
//#region src/skills/extract-meta.ts
|
|
2181
|
-
const EXTRACT_MODEL = `claude-haiku-4-5-20251001`;
|
|
2182
1754
|
const DEFAULT_MAX = 1e4;
|
|
2183
1755
|
async function extractSkillMeta(name, content) {
|
|
2184
1756
|
const preamble = parsePreamble(content);
|
|
@@ -2191,7 +1763,7 @@ async function extractSkillMeta(name, content) {
|
|
|
2191
1763
|
...preamble.userInvocable && { userInvocable: true },
|
|
2192
1764
|
max: preamble.max ?? DEFAULT_MAX
|
|
2193
1765
|
};
|
|
2194
|
-
|
|
1766
|
+
try {
|
|
2195
1767
|
return await llmExtract(name, content, preamble);
|
|
2196
1768
|
} catch (err) {
|
|
2197
1769
|
serverLog.warn(`[skills] LLM metadata extraction failed for "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2204,7 +1776,6 @@ async function extractSkillMeta(name, content) {
|
|
|
2204
1776
|
};
|
|
2205
1777
|
}
|
|
2206
1778
|
async function llmExtract(name, content, partial) {
|
|
2207
|
-
const client = new __anthropic_ai_sdk.default();
|
|
2208
1779
|
const truncated = content.slice(0, 8e3);
|
|
2209
1780
|
const prompt = `Analyze this skill document and extract metadata. The skill is named "${name}".
|
|
2210
1781
|
|
|
@@ -2218,15 +1789,14 @@ Return ONLY a JSON object with these fields:
|
|
|
2218
1789
|
- "keywords": array of 3-8 relevant keywords
|
|
2219
1790
|
|
|
2220
1791
|
Return raw JSON, no markdown fences.`;
|
|
2221
|
-
const
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
1792
|
+
const text = await (0, __electric_ax_agents_runtime.completeWithLowCostModel)({
|
|
1793
|
+
purpose: `skill metadata extraction`,
|
|
1794
|
+
systemPrompt: `Extract metadata from skill documents. Return only valid JSON that matches the requested schema.`,
|
|
1795
|
+
prompt,
|
|
1796
|
+
maxTokens: 256,
|
|
1797
|
+
log: (message) => serverLog.info(message),
|
|
1798
|
+
logPrefix: `[skills]`
|
|
2228
1799
|
});
|
|
2229
|
-
const text = res.content[0]?.type === `text` ? res.content[0].text : ``;
|
|
2230
1800
|
const parsed = JSON.parse(text);
|
|
2231
1801
|
return {
|
|
2232
1802
|
description: partial.description ?? parsed.description ?? humanize(name),
|
|
@@ -2357,9 +1927,10 @@ function truncate(str, max) {
|
|
|
2357
1927
|
//#region src/bootstrap.ts
|
|
2358
1928
|
const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = `/_electric/builtin-agent-handler`;
|
|
2359
1929
|
async function createBuiltinAgentHandler(options) {
|
|
2360
|
-
const { agentServerUrl, serveEndpoint
|
|
2361
|
-
|
|
2362
|
-
|
|
1930
|
+
const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, createElectricTools, publicUrl, runtimeName, serverHeaders, defaultDispatchPolicyForType } = options;
|
|
1931
|
+
const modelCatalog = await createBuiltinModelCatalog({ allowMockFallback: Boolean(streamFn) });
|
|
1932
|
+
if (!modelCatalog) {
|
|
1933
|
+
serverLog.warn(`[builtin-agents] no supported model provider API key found — set ANTHROPIC_API_KEY or OPENAI_API_KEY`);
|
|
2363
1934
|
return null;
|
|
2364
1935
|
}
|
|
2365
1936
|
const cwd = workingDirectory ?? process.cwd();
|
|
@@ -2380,22 +1951,26 @@ async function createBuiltinAgentHandler(options) {
|
|
|
2380
1951
|
const typeNames = registerHorton(registry, {
|
|
2381
1952
|
workingDirectory: cwd,
|
|
2382
1953
|
streamFn,
|
|
2383
|
-
skillsRegistry
|
|
1954
|
+
skillsRegistry,
|
|
1955
|
+
modelCatalog
|
|
2384
1956
|
});
|
|
2385
1957
|
registerWorker(registry, {
|
|
2386
1958
|
workingDirectory: cwd,
|
|
2387
|
-
streamFn
|
|
1959
|
+
streamFn,
|
|
1960
|
+
modelCatalog
|
|
2388
1961
|
});
|
|
2389
1962
|
typeNames.push(`worker`);
|
|
2390
|
-
registerCodingSession(registry, { defaultWorkingDirectory: cwd });
|
|
2391
|
-
typeNames.push(`coder`);
|
|
2392
1963
|
const runtime = (0, __electric_ax_agents_runtime.createRuntimeHandler)({
|
|
2393
1964
|
baseUrl: agentServerUrl,
|
|
2394
1965
|
serveEndpoint,
|
|
2395
1966
|
registry,
|
|
2396
1967
|
subscriptionPathForType: (name) => `/${name}/*/main`,
|
|
1968
|
+
defaultDispatchPolicyForType,
|
|
1969
|
+
serverHeaders,
|
|
2397
1970
|
idleTimeout: 5e3,
|
|
2398
|
-
createElectricTools
|
|
1971
|
+
createElectricTools,
|
|
1972
|
+
publicUrl,
|
|
1973
|
+
name: runtimeName ?? `builtin-agents`
|
|
2399
1974
|
});
|
|
2400
1975
|
return {
|
|
2401
1976
|
handler: runtime.onEnter,
|
|
@@ -2423,103 +1998,226 @@ const registerAgentTypes = registerBuiltinAgentTypes;
|
|
|
2423
1998
|
//#endregion
|
|
2424
1999
|
//#region src/server.ts
|
|
2425
2000
|
var BuiltinAgentsServer = class {
|
|
2426
|
-
server = null;
|
|
2427
2001
|
bootstrap = null;
|
|
2428
|
-
|
|
2429
|
-
|
|
2002
|
+
_mcpRegistry = null;
|
|
2003
|
+
mcpWatcherCloser = null;
|
|
2004
|
+
mcpToolProviderName = null;
|
|
2005
|
+
mcpApplyInFlight = new Set();
|
|
2006
|
+
mcpStopping = false;
|
|
2007
|
+
pullWakeRunner = null;
|
|
2430
2008
|
options;
|
|
2431
2009
|
constructor(options) {
|
|
2432
2010
|
this.options = options;
|
|
2433
2011
|
}
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
return this.
|
|
2437
|
-
}
|
|
2438
|
-
get registeredBaseUrl() {
|
|
2439
|
-
if (!this.publicBaseUrl) throw new Error(`Builtin agents server not started`);
|
|
2440
|
-
return this.publicBaseUrl;
|
|
2012
|
+
/** Embedded MCP registry. `null` until `start()` has run. */
|
|
2013
|
+
get mcpRegistry() {
|
|
2014
|
+
return this._mcpRegistry;
|
|
2441
2015
|
}
|
|
2442
2016
|
async start() {
|
|
2443
|
-
if (this.
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
}
|
|
2452
|
-
});
|
|
2017
|
+
if (this.bootstrap || this.pullWakeRunner) throw new Error(`Builtin agents runtime already started`);
|
|
2018
|
+
const pullWake = this.options.pullWake;
|
|
2019
|
+
if (!pullWake?.runnerId) throw new Error(`Builtin agents require a pull-wake runner id`);
|
|
2020
|
+
try {
|
|
2021
|
+
const publicUrl = this.options.mcpOAuthRedirectBase ?? this.options.agentServerUrl;
|
|
2022
|
+
const mcpRegistry = (0, __electric_ax_agents_mcp.createRegistry)({
|
|
2023
|
+
publicUrl,
|
|
2024
|
+
openAuthorizeUrl: this.options.openAuthorizeUrl
|
|
2453
2025
|
});
|
|
2454
|
-
this.
|
|
2455
|
-
const
|
|
2456
|
-
this.
|
|
2026
|
+
this._mcpRegistry = mcpRegistry;
|
|
2027
|
+
const mcpConfigPath = this.options.loadProjectMcpConfig ? node_path.default.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
|
|
2028
|
+
const extras = this.options.extraMcpServers ?? [];
|
|
2029
|
+
const wirePersistence = async (cfg) => {
|
|
2030
|
+
const servers = [];
|
|
2031
|
+
for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
|
|
2032
|
+
const persist = await (0, __electric_ax_agents_mcp.keychainPersistence)({ server: s.name });
|
|
2033
|
+
servers.push({
|
|
2034
|
+
...s,
|
|
2035
|
+
auth: {
|
|
2036
|
+
...s.auth,
|
|
2037
|
+
...persist
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
} else servers.push(s);
|
|
2041
|
+
return {
|
|
2042
|
+
...cfg,
|
|
2043
|
+
servers
|
|
2044
|
+
};
|
|
2045
|
+
};
|
|
2046
|
+
const merge = (jsonCfg) => {
|
|
2047
|
+
const jsonServers = jsonCfg?.servers ?? [];
|
|
2048
|
+
const jsonNames = new Set(jsonServers.map((s) => s.name));
|
|
2049
|
+
const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
|
|
2050
|
+
return {
|
|
2051
|
+
servers: [...filteredExtras, ...jsonServers],
|
|
2052
|
+
raw: jsonCfg?.raw
|
|
2053
|
+
};
|
|
2054
|
+
};
|
|
2055
|
+
const onConfigError = this.options.onConfigError;
|
|
2056
|
+
const runApply = async (jsonCfg) => {
|
|
2057
|
+
if (this.mcpStopping) return;
|
|
2058
|
+
try {
|
|
2059
|
+
const wired = await wirePersistence(merge(jsonCfg));
|
|
2060
|
+
if (this.mcpStopping) return;
|
|
2061
|
+
await mcpRegistry.applyConfig(wired);
|
|
2062
|
+
} catch (e) {
|
|
2063
|
+
serverLog.error(`[mcp] applyConfig:`, e);
|
|
2064
|
+
try {
|
|
2065
|
+
onConfigError?.(e);
|
|
2066
|
+
} catch (cbErr) {
|
|
2067
|
+
serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
const applyMerged = (jsonCfg) => {
|
|
2072
|
+
const p = runApply(jsonCfg);
|
|
2073
|
+
this.mcpApplyInFlight.add(p);
|
|
2074
|
+
p.finally(() => this.mcpApplyInFlight.delete(p));
|
|
2075
|
+
return p;
|
|
2076
|
+
};
|
|
2077
|
+
if (mcpConfigPath) {
|
|
2078
|
+
try {
|
|
2079
|
+
const cfg = await (0, __electric_ax_agents_mcp.loadConfig)(mcpConfigPath, process.env);
|
|
2080
|
+
applyMerged(cfg);
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
if (err.code !== `ENOENT`) throw err;
|
|
2083
|
+
if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
|
|
2084
|
+
else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
|
|
2085
|
+
applyMerged(null);
|
|
2086
|
+
}
|
|
2457
2087
|
try {
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
const resolvedHost = host === `0.0.0.0` ? `127.0.0.1` : host;
|
|
2462
|
-
this._url = `http://${resolvedHost}:${addr.port}`;
|
|
2463
|
-
} else throw new Error(`Could not determine builtin agents server address`);
|
|
2464
|
-
this.publicBaseUrl = this.options.baseUrl ?? this._url;
|
|
2465
|
-
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
2466
|
-
const serveEndpoint = new URL(webhookPath, this.publicBaseUrl.endsWith(`/`) ? this.publicBaseUrl : `${this.publicBaseUrl}/`).toString();
|
|
2467
|
-
this.bootstrap = await createBuiltinAgentHandler({
|
|
2468
|
-
agentServerUrl: this.options.agentServerUrl,
|
|
2469
|
-
serveEndpoint,
|
|
2470
|
-
workingDirectory: this.options.workingDirectory,
|
|
2471
|
-
streamFn: this.options.mockStreamFn,
|
|
2472
|
-
createElectricTools: this.options.createElectricTools
|
|
2088
|
+
this.mcpWatcherCloser = await (0, __electric_ax_agents_mcp.watchConfig)(mcpConfigPath, {
|
|
2089
|
+
onChange: (cfg) => void applyMerged(cfg),
|
|
2090
|
+
onError: (e) => serverLog.error(`[mcp] config error:`, e)
|
|
2473
2091
|
});
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2092
|
+
} catch (e) {
|
|
2093
|
+
serverLog.error(`[mcp] config watcher failed to start:`, e);
|
|
2094
|
+
}
|
|
2095
|
+
} else {
|
|
2096
|
+
if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
|
|
2097
|
+
applyMerged(null);
|
|
2098
|
+
}
|
|
2099
|
+
this.mcpToolProviderName = `mcp`;
|
|
2100
|
+
(0, __electric_ax_agents_runtime.registerToolProvider)({
|
|
2101
|
+
name: `mcp`,
|
|
2102
|
+
tools: () => {
|
|
2103
|
+
const tools = [];
|
|
2104
|
+
for (const entry of mcpRegistry.list()) {
|
|
2105
|
+
if (entry.status !== `ready`) continue;
|
|
2106
|
+
const live = mcpRegistry.get(entry.name);
|
|
2107
|
+
if (!live?.transport) continue;
|
|
2108
|
+
for (const t of entry.tools) tools.push((0, __electric_ax_agents_mcp.bridgeMcpTool)({
|
|
2109
|
+
server: entry.name,
|
|
2110
|
+
tool: t,
|
|
2111
|
+
client: live.transport.client,
|
|
2112
|
+
timeoutMs: live.config.timeoutMs
|
|
2113
|
+
}));
|
|
2114
|
+
const caps = live.transport.client.getServerCapabilities?.();
|
|
2115
|
+
if (caps?.resources) tools.push(...(0, __electric_ax_agents_mcp.buildResourceTools)({
|
|
2116
|
+
server: entry.name,
|
|
2117
|
+
client: live.transport.client,
|
|
2118
|
+
timeoutMs: live.config.timeoutMs
|
|
2119
|
+
}));
|
|
2120
|
+
if (caps?.prompts) tools.push(...(0, __electric_ax_agents_mcp.buildPromptTools)({
|
|
2121
|
+
server: entry.name,
|
|
2122
|
+
client: live.transport.client,
|
|
2123
|
+
timeoutMs: live.config.timeoutMs
|
|
2124
|
+
}));
|
|
2125
|
+
}
|
|
2126
|
+
return tools;
|
|
2481
2127
|
}
|
|
2482
2128
|
});
|
|
2483
|
-
|
|
2129
|
+
this.bootstrap = await createBuiltinAgentHandler({
|
|
2130
|
+
agentServerUrl: this.options.agentServerUrl,
|
|
2131
|
+
workingDirectory: this.options.workingDirectory,
|
|
2132
|
+
streamFn: this.options.mockStreamFn,
|
|
2133
|
+
createElectricTools: this.options.createElectricTools,
|
|
2134
|
+
publicUrl,
|
|
2135
|
+
runtimeName: `builtin-agents`,
|
|
2136
|
+
serverHeaders: pullWake.headers
|
|
2137
|
+
});
|
|
2138
|
+
if (!this.bootstrap) throw new Error(`ANTHROPIC_API_KEY or OPENAI_API_KEY must be set before starting builtin agents`);
|
|
2139
|
+
await registerBuiltinAgentTypes(this.bootstrap);
|
|
2140
|
+
const registeredRunner = pullWake.registerRunner ? await this.registerPullWakeRunner(pullWake) : null;
|
|
2141
|
+
this.pullWakeRunner = (0, __electric_ax_agents_runtime.createPullWakeRunner)({
|
|
2142
|
+
baseUrl: this.options.agentServerUrl,
|
|
2143
|
+
runnerId: pullWake.runnerId,
|
|
2144
|
+
runtime: this.bootstrap.runtime,
|
|
2145
|
+
headers: pullWake.headers,
|
|
2146
|
+
claimHeaders: pullWake.claimHeaders,
|
|
2147
|
+
claimTokenHeader: pullWake.claimTokenHeader,
|
|
2148
|
+
heartbeatIntervalMs: pullWake.heartbeatIntervalMs,
|
|
2149
|
+
leaseMs: pullWake.leaseMs,
|
|
2150
|
+
offset: registeredRunner?.wake_stream_offset,
|
|
2151
|
+
onError: (error) => {
|
|
2152
|
+
serverLog.error(`[builtin-agents] pull-wake runner failed`, error);
|
|
2153
|
+
return true;
|
|
2154
|
+
}
|
|
2155
|
+
});
|
|
2156
|
+
this.pullWakeRunner.start();
|
|
2157
|
+
serverLog.info(`[builtin-agents] pull-wake runner started: ${pullWake.runnerId}`);
|
|
2158
|
+
return `pull-wake:${pullWake.runnerId}`;
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
await this.stop().catch(() => {});
|
|
2161
|
+
throw error;
|
|
2162
|
+
}
|
|
2484
2163
|
}
|
|
2485
2164
|
async stop() {
|
|
2165
|
+
if (this.pullWakeRunner) {
|
|
2166
|
+
await this.pullWakeRunner.stop().catch((e) => {
|
|
2167
|
+
serverLog.error(`[builtin-agents] pull-wake runner stop failed`, e);
|
|
2168
|
+
});
|
|
2169
|
+
this.pullWakeRunner = null;
|
|
2170
|
+
}
|
|
2486
2171
|
if (this.bootstrap) {
|
|
2487
2172
|
this.bootstrap.runtime.abortWakes();
|
|
2488
|
-
await Promise.race([this.bootstrap.runtime.drainWakes().catch(() => {
|
|
2173
|
+
await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
|
|
2174
|
+
serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
|
|
2175
|
+
}), new Promise((resolve) => setTimeout(resolve, 5e3))]);
|
|
2489
2176
|
this.bootstrap = null;
|
|
2490
2177
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
})
|
|
2496
|
-
|
|
2178
|
+
this.mcpStopping = true;
|
|
2179
|
+
if (this.mcpWatcherCloser) {
|
|
2180
|
+
try {
|
|
2181
|
+
this.mcpWatcherCloser();
|
|
2182
|
+
} catch (e) {
|
|
2183
|
+
serverLog.error(`[mcp] watcher close failed:`, e);
|
|
2184
|
+
}
|
|
2185
|
+
this.mcpWatcherCloser = null;
|
|
2497
2186
|
}
|
|
2498
|
-
this.
|
|
2499
|
-
this.
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
const method = req.method?.toUpperCase();
|
|
2503
|
-
const path$6 = new URL(req.url ?? `/`, `http://localhost`).pathname;
|
|
2504
|
-
const webhookPath = this.options.webhookPath ?? DEFAULT_BUILTIN_AGENT_HANDLER_PATH;
|
|
2505
|
-
if (path$6 === `/_electric/health` && method === `GET`) {
|
|
2506
|
-
res.writeHead(200, { "content-type": `application/json` });
|
|
2507
|
-
res.end(JSON.stringify({ status: `ok` }));
|
|
2508
|
-
return;
|
|
2187
|
+
if (this.mcpApplyInFlight.size > 0) await Promise.allSettled([...this.mcpApplyInFlight]);
|
|
2188
|
+
if (this.mcpToolProviderName) {
|
|
2189
|
+
(0, __electric_ax_agents_runtime.unregisterToolProvider)(this.mcpToolProviderName);
|
|
2190
|
+
this.mcpToolProviderName = null;
|
|
2509
2191
|
}
|
|
2510
|
-
if (
|
|
2511
|
-
await this.
|
|
2512
|
-
|
|
2192
|
+
if (this._mcpRegistry) {
|
|
2193
|
+
await this._mcpRegistry.close().catch((e) => {
|
|
2194
|
+
serverLog.error(`[mcp] registry close failed:`, e);
|
|
2195
|
+
});
|
|
2196
|
+
this._mcpRegistry = null;
|
|
2513
2197
|
}
|
|
2514
|
-
|
|
2515
|
-
|
|
2198
|
+
this.mcpStopping = false;
|
|
2199
|
+
}
|
|
2200
|
+
async registerPullWakeRunner(pullWake) {
|
|
2201
|
+
const headers = new Headers(typeof pullWake.headers === `function` ? await pullWake.headers() : pullWake.headers);
|
|
2202
|
+
headers.set(`content-type`, `application/json`);
|
|
2203
|
+
const response = await fetch((0, __electric_ax_agents_runtime.appendPathToUrl)(this.options.agentServerUrl, `/_electric/runners`), {
|
|
2204
|
+
method: `POST`,
|
|
2205
|
+
headers,
|
|
2206
|
+
body: JSON.stringify({
|
|
2207
|
+
id: pullWake.runnerId,
|
|
2208
|
+
owner_user_id: pullWake.ownerUserId,
|
|
2209
|
+
label: pullWake.label ?? `Built-in agents`,
|
|
2210
|
+
kind: `local`,
|
|
2211
|
+
admin_status: `enabled`
|
|
2212
|
+
})
|
|
2213
|
+
});
|
|
2214
|
+
if (!response.ok) throw new Error(`Failed to register pull-wake runner ${pullWake.runnerId}: ${response.status} ${await response.text()}`);
|
|
2215
|
+
return await response.json();
|
|
2516
2216
|
}
|
|
2517
2217
|
};
|
|
2518
2218
|
|
|
2519
2219
|
//#endregion
|
|
2520
2220
|
//#region src/entrypoint-lib.ts
|
|
2521
|
-
const DEFAULT_HOST = `127.0.0.1`;
|
|
2522
|
-
const DEFAULT_PORT = 4448;
|
|
2523
2221
|
function readEnv(env, names) {
|
|
2524
2222
|
for (const name of names) {
|
|
2525
2223
|
const value = env[name]?.trim();
|
|
@@ -2532,13 +2230,6 @@ function readRequiredEnv(env, names, description) {
|
|
|
2532
2230
|
if (value) return value;
|
|
2533
2231
|
throw new Error(`Missing ${description}. Set one of: ${names.map((name) => `"${name}"`).join(`, `)}`);
|
|
2534
2232
|
}
|
|
2535
|
-
function readPort(env) {
|
|
2536
|
-
const raw = readEnv(env, [`ELECTRIC_AGENTS_BUILTIN_PORT`, `PORT`]);
|
|
2537
|
-
if (!raw) return DEFAULT_PORT;
|
|
2538
|
-
const port = Number(raw);
|
|
2539
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid builtin agents port "${raw}". Expected an integer between 1 and 65535.`);
|
|
2540
|
-
return port;
|
|
2541
|
-
}
|
|
2542
2233
|
function validateUrl(name, value) {
|
|
2543
2234
|
try {
|
|
2544
2235
|
new URL(value);
|
|
@@ -2547,20 +2238,63 @@ function validateUrl(name, value) {
|
|
|
2547
2238
|
throw new Error(`Invalid ${name}: "${value}"`);
|
|
2548
2239
|
}
|
|
2549
2240
|
}
|
|
2241
|
+
function buildAssertedAuthHeaders(env) {
|
|
2242
|
+
const headers = {};
|
|
2243
|
+
const email = readEnv(env, [`ELECTRIC_ASSERTED_AUTH_EMAIL`]);
|
|
2244
|
+
const name = readEnv(env, [`ELECTRIC_ASSERTED_AUTH_NAME`]);
|
|
2245
|
+
if (email) headers[`X-Electric-Asserted-Email`] = email;
|
|
2246
|
+
if (name) headers[`X-Electric-Asserted-Name`] = name;
|
|
2247
|
+
return Object.keys(headers).length > 0 ? headers : void 0;
|
|
2248
|
+
}
|
|
2249
|
+
function parseAdditionalServerHeaders(env) {
|
|
2250
|
+
const raw = readEnv(env, [`ELECTRIC_AGENTS_SERVER_HEADERS`]);
|
|
2251
|
+
if (!raw) return void 0;
|
|
2252
|
+
let parsed;
|
|
2253
|
+
try {
|
|
2254
|
+
parsed = JSON.parse(raw);
|
|
2255
|
+
} catch {
|
|
2256
|
+
throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: expected JSON`);
|
|
2257
|
+
}
|
|
2258
|
+
if (!parsed || typeof parsed !== `object` || Array.isArray(parsed)) throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: expected a JSON object`);
|
|
2259
|
+
const headers = new Headers();
|
|
2260
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
2261
|
+
if (typeof value !== `string`) throw new Error(`Invalid ELECTRIC_AGENTS_SERVER_HEADERS: header "${name}" must be a string`);
|
|
2262
|
+
headers.set(name, value);
|
|
2263
|
+
}
|
|
2264
|
+
const normalized = Object.fromEntries(headers.entries());
|
|
2265
|
+
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
2266
|
+
}
|
|
2267
|
+
function mergeHeaders(...sources) {
|
|
2268
|
+
const headers = new Headers();
|
|
2269
|
+
for (const source of sources) {
|
|
2270
|
+
if (!source) continue;
|
|
2271
|
+
new Headers(source).forEach((value, key) => headers.set(key, value));
|
|
2272
|
+
}
|
|
2273
|
+
const merged = Object.fromEntries(headers.entries());
|
|
2274
|
+
return Object.keys(merged).length > 0 ? merged : void 0;
|
|
2275
|
+
}
|
|
2276
|
+
function hasHeader(headers, name) {
|
|
2277
|
+
return headers ? new Headers(headers).has(name) : false;
|
|
2278
|
+
}
|
|
2550
2279
|
function resolveBuiltinAgentsEntrypointOptions(env = process.env, cwd = process.cwd()) {
|
|
2551
2280
|
const agentServerUrl = validateUrl(`agent server URL`, readRequiredEnv(env, [`ELECTRIC_AGENTS_SERVER_URL`, `ELECTRIC_AGENTS_BASE_URL`], `agent server base URL`));
|
|
2552
|
-
const
|
|
2281
|
+
const runnerId = readRequiredEnv(env, [`ELECTRIC_AGENTS_PULL_WAKE_RUNNER_ID`, `PULL_WAKE_RUNNER_ID`], `pull-wake runner id`);
|
|
2282
|
+
const serverHeaders = mergeHeaders(buildAssertedAuthHeaders(env), parseAdditionalServerHeaders(env));
|
|
2553
2283
|
return {
|
|
2554
2284
|
agentServerUrl,
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2285
|
+
workingDirectory: readEnv(env, [`ELECTRIC_AGENTS_WORKING_DIRECTORY`, `WORKING_DIRECTORY`]) ?? cwd,
|
|
2286
|
+
pullWake: {
|
|
2287
|
+
runnerId,
|
|
2288
|
+
registerRunner: readEnv(env, [`ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER`]) === `true` || readEnv(env, [`ELECTRIC_AGENTS_REGISTER_PULL_WAKE_RUNNER`]) === `1`,
|
|
2289
|
+
headers: serverHeaders,
|
|
2290
|
+
claimHeaders: serverHeaders,
|
|
2291
|
+
claimTokenHeader: hasHeader(serverHeaders, `authorization`) ? `electric-claim-token` : void 0
|
|
2292
|
+
}
|
|
2559
2293
|
};
|
|
2560
2294
|
}
|
|
2561
|
-
async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer
|
|
2295
|
+
async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd(), createServer = (options$1) => new BuiltinAgentsServer(options$1) } = {}) {
|
|
2562
2296
|
const options = resolveBuiltinAgentsEntrypointOptions(env, cwd);
|
|
2563
|
-
const server = createServer
|
|
2297
|
+
const server = createServer(options);
|
|
2564
2298
|
const url = await server.start();
|
|
2565
2299
|
return {
|
|
2566
2300
|
options,
|
|
@@ -2589,7 +2323,6 @@ exports.createSpawnWorkerTool = createSpawnWorkerTool
|
|
|
2589
2323
|
exports.generateTitle = generateTitle
|
|
2590
2324
|
exports.registerAgentTypes = registerAgentTypes
|
|
2591
2325
|
exports.registerBuiltinAgentTypes = registerBuiltinAgentTypes
|
|
2592
|
-
exports.registerCodingSession = registerCodingSession
|
|
2593
2326
|
exports.registerHorton = registerHorton
|
|
2594
2327
|
exports.registerWorker = registerWorker
|
|
2595
2328
|
exports.resolveBuiltinAgentsEntrypointOptions = resolveBuiltinAgentsEntrypointOptions
|