@electric-ax/agents 0.4.11 → 0.4.13
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 +235 -89
- package/dist/index.cjs +233 -87
- package/dist/index.d.cts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +243 -97
- package/docs/entities/patterns/blackboard.md +1 -1
- package/docs/entities/patterns/dispatcher.md +3 -3
- package/docs/entities/patterns/manager-worker.md +11 -23
- package/docs/entities/patterns/map-reduce.md +1 -1
- package/docs/entities/patterns/pipeline.md +3 -3
- package/docs/index.md +61 -39
- package/docs/quickstart.md +26 -22
- package/docs/reference/entity-handle.md +51 -25
- package/docs/reference/handler-context.md +1 -1
- package/docs/reference/wake-event.md +1 -1
- package/docs/usage/defining-tools.md +4 -5
- package/docs/usage/overview.md +10 -6
- package/docs/usage/shared-state.md +3 -3
- package/docs/usage/spawning-and-coordinating.md +34 -18
- package/docs/usage/writing-handlers.md +1 -1
- package/docs/walkthrough.md +1156 -0
- package/package.json +4 -3
- package/skills/quickstart/scaffold/package.json +1 -1
- package/skills/quickstart.md +16 -10
package/dist/index.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { mergeElectricPrincipalHeader } from "./server-headers-KD5yHFYT.js";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, completeWithLowCostModel, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillTools, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModels, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
|
|
5
|
-
import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createSendTool, createWriteTool
|
|
6
|
-
import
|
|
4
|
+
import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, completeWithLowCostModel, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillTools, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
|
|
5
|
+
import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
|
|
6
|
+
import { chooseDefaultSandbox, isE2BAvailable, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
|
|
7
|
+
import fsSync from "node:fs";
|
|
7
8
|
import pino from "pino";
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
10
|
-
import fs
|
|
11
|
+
import fs from "node:fs/promises";
|
|
11
12
|
import Database from "better-sqlite3";
|
|
12
13
|
import { Type } from "@sinclair/typebox";
|
|
13
14
|
import { load } from "sqlite-vec";
|
|
14
15
|
import { nanoid } from "nanoid";
|
|
15
16
|
import { getModels } from "@mariozechner/pi-ai";
|
|
16
17
|
import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
|
|
18
|
+
import { Agent, cacheStores, interceptors, setGlobalDispatcher } from "undici";
|
|
17
19
|
|
|
18
20
|
//#region src/log.ts
|
|
19
21
|
const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
|
|
@@ -27,7 +29,7 @@ function getLogger() {
|
|
|
27
29
|
try {
|
|
28
30
|
if (USE_FILE_LOGS) {
|
|
29
31
|
const logDir = process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`);
|
|
30
|
-
|
|
32
|
+
fsSync.mkdirSync(logDir, { recursive: true });
|
|
31
33
|
const logFile = path.join(logDir, `builtin-agents-${Date.now()}.jsonl`);
|
|
32
34
|
streams.push({ stream: pino.destination({
|
|
33
35
|
dest: logFile,
|
|
@@ -161,7 +163,7 @@ function normalizeWhitespace(value) {
|
|
|
161
163
|
}
|
|
162
164
|
async function collectMarkdownFiles(root) {
|
|
163
165
|
async function walk(dir) {
|
|
164
|
-
const entries = await fs
|
|
166
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
165
167
|
const files = [];
|
|
166
168
|
for (const entry of entries) {
|
|
167
169
|
const fullPath = path.join(dir, entry.name);
|
|
@@ -337,7 +339,7 @@ function resolveDocsRoot(workingDirectory) {
|
|
|
337
339
|
requireIndex: false
|
|
338
340
|
}
|
|
339
341
|
].filter((value) => Boolean(value));
|
|
340
|
-
for (const candidate of candidates) if (
|
|
342
|
+
for (const candidate of candidates) if (fsSync.existsSync(candidate.path) && (!candidate.requireIndex || fsSync.existsSync(path.join(candidate.path, `index.md`)))) return candidate.path;
|
|
341
343
|
return null;
|
|
342
344
|
}
|
|
343
345
|
var DocsKnowledgeBase = class {
|
|
@@ -358,7 +360,7 @@ var DocsKnowledgeBase = class {
|
|
|
358
360
|
this.readyPromise = this.ensureIngested();
|
|
359
361
|
}
|
|
360
362
|
openDatabase() {
|
|
361
|
-
|
|
363
|
+
fsSync.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
362
364
|
try {
|
|
363
365
|
const db$1 = new Database(this.dbPath);
|
|
364
366
|
load(db$1);
|
|
@@ -425,11 +427,11 @@ var DocsKnowledgeBase = class {
|
|
|
425
427
|
};
|
|
426
428
|
}
|
|
427
429
|
async ensureIngested() {
|
|
428
|
-
await fs
|
|
430
|
+
await fs.mkdir(path.dirname(this.dbPath), { recursive: true });
|
|
429
431
|
const files = (await collectMarkdownFiles(this.docsRoot)).sort();
|
|
430
432
|
const docs = await Promise.all(files.map(async (filePath) => ({
|
|
431
433
|
path: path.relative(this.docsRoot, filePath),
|
|
432
|
-
content: await fs
|
|
434
|
+
content: await fs.readFile(filePath, `utf8`)
|
|
433
435
|
})));
|
|
434
436
|
const fingerprint = createFingerprint(docs);
|
|
435
437
|
if (!this.db) {
|
|
@@ -764,7 +766,8 @@ function createSpawnWorkerTool(ctx, modelConfig) {
|
|
|
764
766
|
wake: {
|
|
765
767
|
on: `runFinished`,
|
|
766
768
|
includeResponse: true
|
|
767
|
-
}
|
|
769
|
+
},
|
|
770
|
+
sandbox: `inherit`
|
|
768
771
|
});
|
|
769
772
|
const workerUrl = handle.entityUrl;
|
|
770
773
|
return {
|
|
@@ -863,6 +866,15 @@ async function fetchAvailableModelIds(provider) {
|
|
|
863
866
|
function knownModelsForProvider(provider) {
|
|
864
867
|
return provider === MOONSHOT_PROVIDER ? getMoonshotModels() : getModels(provider);
|
|
865
868
|
}
|
|
869
|
+
function resolveBuiltinModelContextWindow(modelConfig) {
|
|
870
|
+
const modelId = String(modelConfig.model);
|
|
871
|
+
if (modelConfig.provider === MOONSHOT_PROVIDER) return getMoonshotModel(modelId)?.contextWindow ?? null;
|
|
872
|
+
if (!modelConfig.provider) return null;
|
|
873
|
+
return knownModelsForProvider(modelConfig.provider).find((model) => model.id === modelId)?.contextWindow ?? null;
|
|
874
|
+
}
|
|
875
|
+
function resolveBuiltinModelSourceBudget(modelConfig) {
|
|
876
|
+
return resolveBuiltinModelContextWindow(modelConfig) ?? 1e5;
|
|
877
|
+
}
|
|
866
878
|
function choiceForKnownModel(provider, model) {
|
|
867
879
|
return {
|
|
868
880
|
provider,
|
|
@@ -1152,19 +1164,19 @@ function getToolName(tool) {
|
|
|
1152
1164
|
const name = tool.name;
|
|
1153
1165
|
return typeof name === `string` ? name : null;
|
|
1154
1166
|
}
|
|
1155
|
-
function createHortonTools(
|
|
1167
|
+
function createHortonTools(sandbox, ctx, readSet, opts = {}) {
|
|
1156
1168
|
return [
|
|
1157
|
-
createBashTool(
|
|
1158
|
-
createReadFileTool(
|
|
1159
|
-
createWriteTool(
|
|
1160
|
-
createEditTool(
|
|
1169
|
+
createBashTool(sandbox),
|
|
1170
|
+
createReadFileTool(sandbox, readSet),
|
|
1171
|
+
createWriteTool(sandbox, readSet),
|
|
1172
|
+
createEditTool(sandbox, readSet),
|
|
1161
1173
|
braveSearchTool$1,
|
|
1162
|
-
...opts.modelCatalog && opts.modelConfig ? [createFetchUrlTool({
|
|
1174
|
+
...opts.modelCatalog && opts.modelConfig ? [createFetchUrlTool(sandbox, {
|
|
1163
1175
|
catalog: opts.modelCatalog,
|
|
1164
1176
|
modelConfig: opts.modelConfig,
|
|
1165
1177
|
log: (message) => serverLog.info(message),
|
|
1166
1178
|
logPrefix: opts.logPrefix ?? `[horton]`
|
|
1167
|
-
})] : [
|
|
1179
|
+
})] : [createFetchUrlTool(sandbox)],
|
|
1168
1180
|
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1169
1181
|
createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
|
|
1170
1182
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
@@ -1218,11 +1230,10 @@ async function extractFirstUserMessage(ctx) {
|
|
|
1218
1230
|
function messageSeq(message) {
|
|
1219
1231
|
return typeof message._seq === `number` ? message._seq : -1;
|
|
1220
1232
|
}
|
|
1221
|
-
function readAgentsMd(
|
|
1222
|
-
const agentsMdPath = path.join(workingDirectory, `AGENTS.md`);
|
|
1233
|
+
async function readAgentsMd(sandbox) {
|
|
1234
|
+
const agentsMdPath = path.posix.join(sandbox.workingDirectory, `AGENTS.md`);
|
|
1223
1235
|
try {
|
|
1224
|
-
|
|
1225
|
-
const content = fs.readFileSync(agentsMdPath, `utf8`);
|
|
1236
|
+
const content = (await sandbox.readFile(agentsMdPath)).toString(`utf8`);
|
|
1226
1237
|
return [
|
|
1227
1238
|
`<context_file kind="instructions" path="${agentsMdPath}">`,
|
|
1228
1239
|
content,
|
|
@@ -1233,16 +1244,17 @@ function readAgentsMd(workingDirectory) {
|
|
|
1233
1244
|
}
|
|
1234
1245
|
}
|
|
1235
1246
|
function createAssistantHandler(options) {
|
|
1236
|
-
const {
|
|
1247
|
+
const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
|
|
1237
1248
|
const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
|
|
1238
1249
|
return async function assistantHandler(ctx, wake) {
|
|
1239
1250
|
const readSet = new Set();
|
|
1240
|
-
const effectiveCwd = typeof ctx.args.workingDirectory === `string` && ctx.args.workingDirectory.trim().length > 0 ? ctx.args.workingDirectory : workingDirectory;
|
|
1241
1251
|
const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
|
|
1242
|
-
const
|
|
1252
|
+
const sourceBudget = resolveBuiltinModelSourceBudget(modelConfig);
|
|
1253
|
+
const sandboxCwd = ctx.sandbox.workingDirectory;
|
|
1254
|
+
const agentsMd = await readAgentsMd(ctx.sandbox);
|
|
1243
1255
|
const tools = [
|
|
1244
1256
|
...ctx.electricTools,
|
|
1245
|
-
...createHortonTools(
|
|
1257
|
+
...createHortonTools(ctx.sandbox, ctx, readSet, {
|
|
1246
1258
|
docsSearchTool,
|
|
1247
1259
|
modelConfig,
|
|
1248
1260
|
modelCatalog,
|
|
@@ -1271,7 +1283,7 @@ function createAssistantHandler(options) {
|
|
|
1271
1283
|
}
|
|
1272
1284
|
})() : Promise.resolve();
|
|
1273
1285
|
if (docsSupport) ctx.useContext({
|
|
1274
|
-
sourceBudget
|
|
1286
|
+
sourceBudget,
|
|
1275
1287
|
sources: {
|
|
1276
1288
|
docs_toc: {
|
|
1277
1289
|
content: () => docsSupport.renderCompressedToc(),
|
|
@@ -1300,7 +1312,7 @@ function createAssistantHandler(options) {
|
|
|
1300
1312
|
}
|
|
1301
1313
|
});
|
|
1302
1314
|
else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
|
|
1303
|
-
sourceBudget
|
|
1315
|
+
sourceBudget,
|
|
1304
1316
|
sources: {
|
|
1305
1317
|
skills_catalog: {
|
|
1306
1318
|
content: () => skillsRegistry.renderCatalog(2e3),
|
|
@@ -1319,7 +1331,7 @@ function createAssistantHandler(options) {
|
|
|
1319
1331
|
}
|
|
1320
1332
|
});
|
|
1321
1333
|
else if (agentsMd) ctx.useContext({
|
|
1322
|
-
sourceBudget
|
|
1334
|
+
sourceBudget,
|
|
1323
1335
|
sources: {
|
|
1324
1336
|
conversation: {
|
|
1325
1337
|
content: () => ctx.timelineMessages(),
|
|
@@ -1333,7 +1345,7 @@ function createAssistantHandler(options) {
|
|
|
1333
1345
|
}
|
|
1334
1346
|
});
|
|
1335
1347
|
ctx.useAgent({
|
|
1336
|
-
systemPrompt: buildHortonSystemPrompt(
|
|
1348
|
+
systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
|
|
1337
1349
|
hasDocsSupport: Boolean(docsSupport),
|
|
1338
1350
|
hasSkills,
|
|
1339
1351
|
docsUrl,
|
|
@@ -1361,7 +1373,6 @@ function registerHorton(registry, options) {
|
|
|
1361
1373
|
serverLog.warn(`[horton-docs] warmup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1362
1374
|
});
|
|
1363
1375
|
const assistantHandler = createAssistantHandler({
|
|
1364
|
-
workingDirectory,
|
|
1365
1376
|
streamFn,
|
|
1366
1377
|
docsSupport,
|
|
1367
1378
|
docsSearchTool,
|
|
@@ -1377,6 +1388,15 @@ function registerHorton(registry, options) {
|
|
|
1377
1388
|
registry.define(`horton`, {
|
|
1378
1389
|
description: `Friendly capable assistant — chat, code, research, dispatch`,
|
|
1379
1390
|
creationSchema: hortonCreationSchema,
|
|
1391
|
+
permissionGrants: [{
|
|
1392
|
+
subject_kind: `principal_kind`,
|
|
1393
|
+
subject_value: `user`,
|
|
1394
|
+
permission: `spawn`
|
|
1395
|
+
}, {
|
|
1396
|
+
subject_kind: `principal_kind`,
|
|
1397
|
+
subject_value: `user`,
|
|
1398
|
+
permission: `manage`
|
|
1399
|
+
}],
|
|
1380
1400
|
handler: assistantHandler
|
|
1381
1401
|
});
|
|
1382
1402
|
return [`horton`];
|
|
@@ -1418,26 +1438,29 @@ function parseWorkerArgs(value) {
|
|
|
1418
1438
|
if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
|
|
1419
1439
|
return args;
|
|
1420
1440
|
}
|
|
1421
|
-
function buildToolsForWorker(tools,
|
|
1441
|
+
function buildToolsForWorker(tools, sandbox, ctx, readSet, opts) {
|
|
1422
1442
|
const out = [];
|
|
1423
1443
|
for (const name of tools) switch (name) {
|
|
1424
1444
|
case `bash`:
|
|
1425
|
-
out.push(createBashTool(
|
|
1445
|
+
out.push(createBashTool(sandbox));
|
|
1426
1446
|
break;
|
|
1427
1447
|
case `read`:
|
|
1428
|
-
out.push(createReadFileTool(
|
|
1448
|
+
out.push(createReadFileTool(sandbox, readSet));
|
|
1429
1449
|
break;
|
|
1430
1450
|
case `write`:
|
|
1431
|
-
out.push(createWriteTool(
|
|
1451
|
+
out.push(createWriteTool(sandbox, readSet));
|
|
1432
1452
|
break;
|
|
1433
1453
|
case `edit`:
|
|
1434
|
-
out.push(createEditTool(
|
|
1454
|
+
out.push(createEditTool(sandbox, readSet));
|
|
1435
1455
|
break;
|
|
1436
1456
|
case `web_search`:
|
|
1437
1457
|
out.push(braveSearchTool$1);
|
|
1438
1458
|
break;
|
|
1439
1459
|
case `fetch_url`:
|
|
1440
|
-
out.push(
|
|
1460
|
+
out.push(createFetchUrlTool(sandbox, {
|
|
1461
|
+
catalog: opts.modelCatalog,
|
|
1462
|
+
modelConfig: opts.modelConfig
|
|
1463
|
+
}));
|
|
1441
1464
|
break;
|
|
1442
1465
|
case `spawn_worker`:
|
|
1443
1466
|
out.push(createSpawnWorkerTool(ctx));
|
|
@@ -1545,14 +1568,26 @@ function buildSharedStateTools(shared, schema, mode) {
|
|
|
1545
1568
|
return tools;
|
|
1546
1569
|
}
|
|
1547
1570
|
function registerWorker(registry, options) {
|
|
1548
|
-
const {
|
|
1571
|
+
const { streamFn, modelCatalog } = options;
|
|
1549
1572
|
registry.define(`worker`, {
|
|
1550
1573
|
description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
|
|
1574
|
+
permissionGrants: [{
|
|
1575
|
+
subject_kind: `principal_kind`,
|
|
1576
|
+
subject_value: `user`,
|
|
1577
|
+
permission: `spawn`
|
|
1578
|
+
}, {
|
|
1579
|
+
subject_kind: `principal_kind`,
|
|
1580
|
+
subject_value: `user`,
|
|
1581
|
+
permission: `manage`
|
|
1582
|
+
}],
|
|
1551
1583
|
async handler(ctx) {
|
|
1552
1584
|
const args = parseWorkerArgs(ctx.args);
|
|
1553
1585
|
const readSet = new Set();
|
|
1554
|
-
const builtinTools = buildToolsForWorker(args.tools, workingDirectory, ctx, readSet);
|
|
1555
1586
|
const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
|
|
1587
|
+
const builtinTools = buildToolsForWorker(args.tools, ctx.sandbox, ctx, readSet, {
|
|
1588
|
+
modelCatalog,
|
|
1589
|
+
modelConfig
|
|
1590
|
+
});
|
|
1556
1591
|
const sharedStateTools = [];
|
|
1557
1592
|
if (args.sharedDb) {
|
|
1558
1593
|
const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
|
|
@@ -1630,6 +1665,7 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1630
1665
|
modelCatalog
|
|
1631
1666
|
});
|
|
1632
1667
|
typeNames.push(`worker`);
|
|
1668
|
+
const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
|
|
1633
1669
|
const runtime = createRuntimeHandler({
|
|
1634
1670
|
baseUrl: agentServerUrl,
|
|
1635
1671
|
serveEndpoint,
|
|
@@ -1640,7 +1676,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1640
1676
|
idleTimeout: 5 * 6e4,
|
|
1641
1677
|
createElectricTools: createBuiltinElectricTools(createElectricTools),
|
|
1642
1678
|
publicUrl,
|
|
1643
|
-
name: runtimeName ?? `builtin-agents
|
|
1679
|
+
name: runtimeName ?? `builtin-agents`,
|
|
1680
|
+
sandboxProfiles
|
|
1644
1681
|
});
|
|
1645
1682
|
return {
|
|
1646
1683
|
handler: runtime.onEnter,
|
|
@@ -1664,6 +1701,97 @@ async function registerBuiltinAgentTypes(bootstrap) {
|
|
|
1664
1701
|
serverLog.info(`[builtin-agents] ${bootstrap.typeNames.length} built-in agent types ready: ${bootstrap.typeNames.join(`, `)}`);
|
|
1665
1702
|
}
|
|
1666
1703
|
const registerAgentTypes = registerBuiltinAgentTypes;
|
|
1704
|
+
/**
|
|
1705
|
+
* Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
|
|
1706
|
+
* re-run the boot sweep.
|
|
1707
|
+
*/
|
|
1708
|
+
let dockerSweptOnBoot = false;
|
|
1709
|
+
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1710
|
+
if (dockerSweptOnBoot) return;
|
|
1711
|
+
dockerSweptOnBoot = true;
|
|
1712
|
+
sweep().then((removed) => {
|
|
1713
|
+
if (removed.length > 0) serverLog.info(`[builtin-agents] docker sandbox boot sweep removed ${removed.length} leftover container(s)`);
|
|
1714
|
+
}).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Built-in sandbox profiles. `local` is always available. `docker` is
|
|
1718
|
+
* gated on Docker being reachable so a user without Docker installed
|
|
1719
|
+
* sees only what works — the UI never offers a non-functional choice.
|
|
1720
|
+
*/
|
|
1721
|
+
async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
1722
|
+
const profiles = [{
|
|
1723
|
+
name: `local`,
|
|
1724
|
+
label: `Local`,
|
|
1725
|
+
description: `Runs on the host without isolation. Full filesystem access.`,
|
|
1726
|
+
factory: ({ args }) => chooseDefaultSandbox(resolveCwd(args, workingDirectory))
|
|
1727
|
+
}];
|
|
1728
|
+
try {
|
|
1729
|
+
const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1730
|
+
if (await isDockerAvailable()) {
|
|
1731
|
+
const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1732
|
+
sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1733
|
+
profiles.push({
|
|
1734
|
+
name: `docker`,
|
|
1735
|
+
label: `Docker`,
|
|
1736
|
+
description: `Runs in a hardened Docker container: dropped capabilities, no privilege escalation, and CPU/memory/process limits. The chosen working directory is mounted read-write and, by default, network egress is unrestricted (allow-all).`,
|
|
1737
|
+
factory: ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1738
|
+
const cwd = readWorkingDirectoryArg(args);
|
|
1739
|
+
return dockerSandbox({
|
|
1740
|
+
initialNetworkPolicy: { mode: `allow-all` },
|
|
1741
|
+
extraMounts: cwd ? [{
|
|
1742
|
+
hostPath: cwd,
|
|
1743
|
+
containerPath: `/work`,
|
|
1744
|
+
readOnly: false
|
|
1745
|
+
}] : void 0,
|
|
1746
|
+
sandboxKey,
|
|
1747
|
+
persistent,
|
|
1748
|
+
owner,
|
|
1749
|
+
entityType,
|
|
1750
|
+
entityUrl
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
} else serverLog.info(`[builtin-agents] docker daemon not reachable — docker sandbox profile not registered`);
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
serverLog.warn(`[builtin-agents] failed to probe docker availability: ${err instanceof Error ? err.message : String(err)}`);
|
|
1757
|
+
}
|
|
1758
|
+
if (process.env.E2B_API_KEY) if (await isE2BAvailable()) profiles.push({
|
|
1759
|
+
name: `e2b`,
|
|
1760
|
+
label: `E2B`,
|
|
1761
|
+
description: `Runs in a remote E2B microVM. Persistent sandboxes survive across wakes and are reachable from any runner.`,
|
|
1762
|
+
remote: true,
|
|
1763
|
+
factory: ({ sandboxKey, persistent, owner }) => remoteSandbox({
|
|
1764
|
+
provider: `e2b`,
|
|
1765
|
+
apiKey: process.env.E2B_API_KEY,
|
|
1766
|
+
sandboxKey,
|
|
1767
|
+
persistent,
|
|
1768
|
+
owner,
|
|
1769
|
+
initialNetworkPolicy: { mode: `allow-all` }
|
|
1770
|
+
})
|
|
1771
|
+
});
|
|
1772
|
+
else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
|
|
1773
|
+
console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
|
|
1774
|
+
return profiles;
|
|
1775
|
+
}
|
|
1776
|
+
function readWorkingDirectoryArg(args) {
|
|
1777
|
+
const v = args.workingDirectory;
|
|
1778
|
+
return typeof v === `string` && v.trim().length > 0 ? v : null;
|
|
1779
|
+
}
|
|
1780
|
+
function resolveCwd(args, fallback) {
|
|
1781
|
+
return readWorkingDirectoryArg(args) ?? fallback;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
//#endregion
|
|
1785
|
+
//#region src/durable-streams-cache.ts
|
|
1786
|
+
const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
|
|
1787
|
+
function installDurableStreamsFetchCache(options = {}) {
|
|
1788
|
+
if (options === false) return;
|
|
1789
|
+
const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
|
|
1790
|
+
location: options.sqliteLocation,
|
|
1791
|
+
maxCount: options.maxCount
|
|
1792
|
+
}) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
|
|
1793
|
+
setGlobalDispatcher(new Agent().compose(interceptors.cache({ store })));
|
|
1794
|
+
}
|
|
1667
1795
|
|
|
1668
1796
|
//#endregion
|
|
1669
1797
|
//#region src/server.ts
|
|
@@ -1674,6 +1802,8 @@ var BuiltinAgentsServer = class {
|
|
|
1674
1802
|
mcpToolProviderName = null;
|
|
1675
1803
|
mcpApplyInFlight = new Set();
|
|
1676
1804
|
mcpStopping = false;
|
|
1805
|
+
mcpExtras = [];
|
|
1806
|
+
mcpLastJsonConfig = null;
|
|
1677
1807
|
pullWakeRunner = null;
|
|
1678
1808
|
options;
|
|
1679
1809
|
constructor(options) {
|
|
@@ -1683,8 +1813,70 @@ var BuiltinAgentsServer = class {
|
|
|
1683
1813
|
get mcpRegistry() {
|
|
1684
1814
|
return this._mcpRegistry;
|
|
1685
1815
|
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Replace the in-memory `extras` list and re-apply the merged config
|
|
1818
|
+
* against the last-known workspace `mcp.json` state. Workspace
|
|
1819
|
+
* `mcp.json` still wins on name collision. No-op once `stop()` has
|
|
1820
|
+
* latched `mcpStopping`.
|
|
1821
|
+
*/
|
|
1822
|
+
async setExtraMcpServers(extras) {
|
|
1823
|
+
if (!this._mcpRegistry || this.mcpStopping) return;
|
|
1824
|
+
this.mcpExtras = extras;
|
|
1825
|
+
await this.applyMerged(this.mcpLastJsonConfig);
|
|
1826
|
+
}
|
|
1827
|
+
async wirePersistence(cfg) {
|
|
1828
|
+
const servers = [];
|
|
1829
|
+
for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
|
|
1830
|
+
const persist = await keychainPersistence({ server: s.name });
|
|
1831
|
+
servers.push({
|
|
1832
|
+
...s,
|
|
1833
|
+
auth: {
|
|
1834
|
+
...s.auth,
|
|
1835
|
+
...persist
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
} else servers.push(s);
|
|
1839
|
+
return {
|
|
1840
|
+
...cfg,
|
|
1841
|
+
servers
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
mergeMcp(jsonCfg) {
|
|
1845
|
+
const jsonServers = jsonCfg?.servers ?? [];
|
|
1846
|
+
const jsonNames = new Set(jsonServers.map((s) => s.name));
|
|
1847
|
+
const filteredExtras = this.mcpExtras.filter((s) => !jsonNames.has(s.name));
|
|
1848
|
+
return {
|
|
1849
|
+
servers: [...filteredExtras, ...jsonServers],
|
|
1850
|
+
raw: jsonCfg?.raw
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
async runApply(jsonCfg) {
|
|
1854
|
+
if (this.mcpStopping) return;
|
|
1855
|
+
const registry = this._mcpRegistry;
|
|
1856
|
+
if (!registry) return;
|
|
1857
|
+
try {
|
|
1858
|
+
const wired = await this.wirePersistence(this.mergeMcp(jsonCfg));
|
|
1859
|
+
if (this.mcpStopping) return;
|
|
1860
|
+
await registry.applyConfig(wired);
|
|
1861
|
+
} catch (e) {
|
|
1862
|
+
serverLog.error(`[mcp] applyConfig:`, e);
|
|
1863
|
+
try {
|
|
1864
|
+
this.options.onConfigError?.(e);
|
|
1865
|
+
} catch (cbErr) {
|
|
1866
|
+
serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
applyMerged(jsonCfg) {
|
|
1871
|
+
this.mcpLastJsonConfig = jsonCfg;
|
|
1872
|
+
const p = this.runApply(jsonCfg);
|
|
1873
|
+
this.mcpApplyInFlight.add(p);
|
|
1874
|
+
p.finally(() => this.mcpApplyInFlight.delete(p));
|
|
1875
|
+
return p;
|
|
1876
|
+
}
|
|
1686
1877
|
async start() {
|
|
1687
1878
|
if (this.bootstrap || this.pullWakeRunner) throw new Error(`Builtin agents runtime already started`);
|
|
1879
|
+
installDurableStreamsFetchCache(this.options.durableStreamsFetchCache);
|
|
1688
1880
|
const pullWake = this.options.pullWake;
|
|
1689
1881
|
if (!pullWake?.runnerId) throw new Error(`Builtin agents require a pull-wake runner id`);
|
|
1690
1882
|
try {
|
|
@@ -1695,76 +1887,28 @@ var BuiltinAgentsServer = class {
|
|
|
1695
1887
|
});
|
|
1696
1888
|
this._mcpRegistry = mcpRegistry;
|
|
1697
1889
|
const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
|
|
1698
|
-
|
|
1699
|
-
const wirePersistence = async (cfg) => {
|
|
1700
|
-
const servers = [];
|
|
1701
|
-
for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
|
|
1702
|
-
const persist = await keychainPersistence({ server: s.name });
|
|
1703
|
-
servers.push({
|
|
1704
|
-
...s,
|
|
1705
|
-
auth: {
|
|
1706
|
-
...s.auth,
|
|
1707
|
-
...persist
|
|
1708
|
-
}
|
|
1709
|
-
});
|
|
1710
|
-
} else servers.push(s);
|
|
1711
|
-
return {
|
|
1712
|
-
...cfg,
|
|
1713
|
-
servers
|
|
1714
|
-
};
|
|
1715
|
-
};
|
|
1716
|
-
const merge = (jsonCfg) => {
|
|
1717
|
-
const jsonServers = jsonCfg?.servers ?? [];
|
|
1718
|
-
const jsonNames = new Set(jsonServers.map((s) => s.name));
|
|
1719
|
-
const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
|
|
1720
|
-
return {
|
|
1721
|
-
servers: [...filteredExtras, ...jsonServers],
|
|
1722
|
-
raw: jsonCfg?.raw
|
|
1723
|
-
};
|
|
1724
|
-
};
|
|
1725
|
-
const onConfigError = this.options.onConfigError;
|
|
1726
|
-
const runApply = async (jsonCfg) => {
|
|
1727
|
-
if (this.mcpStopping) return;
|
|
1728
|
-
try {
|
|
1729
|
-
const wired = await wirePersistence(merge(jsonCfg));
|
|
1730
|
-
if (this.mcpStopping) return;
|
|
1731
|
-
await mcpRegistry.applyConfig(wired);
|
|
1732
|
-
} catch (e) {
|
|
1733
|
-
serverLog.error(`[mcp] applyConfig:`, e);
|
|
1734
|
-
try {
|
|
1735
|
-
onConfigError?.(e);
|
|
1736
|
-
} catch (cbErr) {
|
|
1737
|
-
serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
};
|
|
1741
|
-
const applyMerged = (jsonCfg) => {
|
|
1742
|
-
const p = runApply(jsonCfg);
|
|
1743
|
-
this.mcpApplyInFlight.add(p);
|
|
1744
|
-
p.finally(() => this.mcpApplyInFlight.delete(p));
|
|
1745
|
-
return p;
|
|
1746
|
-
};
|
|
1890
|
+
this.mcpExtras = this.options.extraMcpServers ?? [];
|
|
1747
1891
|
if (mcpConfigPath) {
|
|
1748
1892
|
try {
|
|
1749
1893
|
const cfg = await loadConfig(mcpConfigPath, process.env);
|
|
1750
|
-
applyMerged(cfg);
|
|
1894
|
+
this.applyMerged(cfg);
|
|
1751
1895
|
} catch (err) {
|
|
1752
1896
|
if (err.code !== `ENOENT`) throw err;
|
|
1753
|
-
if (
|
|
1754
|
-
else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${
|
|
1755
|
-
applyMerged(null);
|
|
1897
|
+
if (this.mcpExtras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
|
|
1898
|
+
else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${this.mcpExtras.length} server(s) from extras`);
|
|
1899
|
+
this.applyMerged(null);
|
|
1756
1900
|
}
|
|
1757
1901
|
try {
|
|
1758
1902
|
this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
|
|
1759
|
-
onChange: (cfg) => void applyMerged(cfg),
|
|
1903
|
+
onChange: (cfg) => void this.applyMerged(cfg),
|
|
1760
1904
|
onError: (e) => serverLog.error(`[mcp] config error:`, e)
|
|
1761
1905
|
});
|
|
1762
1906
|
} catch (e) {
|
|
1763
1907
|
serverLog.error(`[mcp] config watcher failed to start:`, e);
|
|
1764
1908
|
}
|
|
1765
1909
|
} else {
|
|
1766
|
-
if (
|
|
1767
|
-
applyMerged(null);
|
|
1910
|
+
if (this.mcpExtras.length > 0) serverLog.info(`[mcp] starting with ${this.mcpExtras.length} server(s) from extras`);
|
|
1911
|
+
this.applyMerged(null);
|
|
1768
1912
|
}
|
|
1769
1913
|
this.mcpToolProviderName = `mcp`;
|
|
1770
1914
|
registerToolProvider({
|
|
@@ -1872,6 +2016,7 @@ var BuiltinAgentsServer = class {
|
|
|
1872
2016
|
async registerPullWakeRunner(pullWake) {
|
|
1873
2017
|
const headers = new Headers(typeof pullWake.headers === `function` ? await pullWake.headers() : pullWake.headers);
|
|
1874
2018
|
headers.set(`content-type`, `application/json`);
|
|
2019
|
+
const profiles = this.bootstrap?.runtime.sandboxProfileDescriptors ?? [];
|
|
1875
2020
|
const response = await fetch(appendPathToUrl(this.options.agentServerUrl, `/_electric/runners`), {
|
|
1876
2021
|
method: `POST`,
|
|
1877
2022
|
headers,
|
|
@@ -1880,7 +2025,8 @@ var BuiltinAgentsServer = class {
|
|
|
1880
2025
|
owner_principal: pullWake.ownerPrincipal,
|
|
1881
2026
|
label: pullWake.label ?? `Built-in agents`,
|
|
1882
2027
|
kind: `local`,
|
|
1883
|
-
admin_status: `enabled
|
|
2028
|
+
admin_status: `enabled`,
|
|
2029
|
+
sandbox_profiles: profiles
|
|
1884
2030
|
})
|
|
1885
2031
|
});
|
|
1886
2032
|
if (!response.ok) throw new Error(`Failed to register pull-wake runner ${pullWake.runnerId}: ${response.status} ${await response.text()}`);
|
|
@@ -81,7 +81,7 @@ const proWorker = await ctx.spawn(
|
|
|
81
81
|
systemPrompt: PRO_WORKER_PROMPT,
|
|
82
82
|
sharedDb: { id: `debate-${ctx.entityUrl}`, schema: debateSchema },
|
|
83
83
|
},
|
|
84
|
-
{ initialMessage: proInitialMessage, wake: `runFinished
|
|
84
|
+
{ initialMessage: proInitialMessage, wake: { on: `runFinished`, includeResponse: true } }
|
|
85
85
|
)
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -47,7 +47,7 @@ The dispatcher exposes a `dispatch` tool. When the LLM classifies an incoming me
|
|
|
47
47
|
|
|
48
48
|
The tool then:
|
|
49
49
|
|
|
50
|
-
1. Spawns the requested entity type with `wake: 'runFinished'`.
|
|
50
|
+
1. Spawns the requested entity type with `wake: { on: 'runFinished', includeResponse: true }`.
|
|
51
51
|
2. Returns immediately with a status message. The dispatcher is re-invoked when the specialist finishes.
|
|
52
52
|
|
|
53
53
|
## Dispatch tool
|
|
@@ -59,7 +59,7 @@ await ctx.spawn(
|
|
|
59
59
|
type === `worker` ? { systemPrompt, tools: [`read`] } : { systemPrompt },
|
|
60
60
|
{
|
|
61
61
|
initialMessage: task,
|
|
62
|
-
wake: `runFinished`,
|
|
62
|
+
wake: { on: `runFinished`, includeResponse: true },
|
|
63
63
|
}
|
|
64
64
|
)
|
|
65
65
|
|
|
@@ -67,7 +67,7 @@ return {
|
|
|
67
67
|
content: [
|
|
68
68
|
{
|
|
69
69
|
type: `text` as const,
|
|
70
|
-
text: `Dispatched to "${type}" specialist (${id}).
|
|
70
|
+
text: `Dispatched to "${type}" specialist (${id}). The dispatcher will continue when it finishes.`,
|
|
71
71
|
},
|
|
72
72
|
],
|
|
73
73
|
details: { id, type },
|
|
@@ -42,9 +42,9 @@ The manager defines a handler-scoped tool called `analyze_with_perspectives`. Wh
|
|
|
42
42
|
|
|
43
43
|
1. Spawns 3 worker children -- optimist, pessimist, pragmatist -- each with a different system prompt.
|
|
44
44
|
2. Sends the same question to all three as `initialMessage`.
|
|
45
|
-
3. Uses `wake: 'runFinished'`
|
|
46
|
-
4. Collects results
|
|
47
|
-
5.
|
|
45
|
+
3. Uses `wake: { on: 'runFinished', includeResponse: true }` so the manager is re-invoked as each child completes.
|
|
46
|
+
4. Collects results from `runFinished` wake payloads or shared state after workers finish.
|
|
47
|
+
5. Runs a synthesis step after all child-completion wakes have been recorded.
|
|
48
48
|
|
|
49
49
|
On subsequent calls, the tool reuses existing children via `ctx.observe()` and `child.send()` instead of spawning new ones.
|
|
50
50
|
|
|
@@ -63,7 +63,7 @@ for (const perspective of PERSPECTIVES) {
|
|
|
63
63
|
`worker`,
|
|
64
64
|
childId,
|
|
65
65
|
{ systemPrompt: perspective.systemPrompt, tools: [`read`] },
|
|
66
|
-
{ initialMessage: question, wake: `runFinished
|
|
66
|
+
{ initialMessage: question, wake: { on: `runFinished`, includeResponse: true } }
|
|
67
67
|
)
|
|
68
68
|
children.insert({
|
|
69
69
|
key: perspective.id,
|
|
@@ -87,28 +87,16 @@ for (const perspective of PERSPECTIVES) {
|
|
|
87
87
|
|
|
88
88
|
## Collecting results
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Do not wait for worker output inside the same wake. Spawn workers with `wake: { on: "runFinished", includeResponse: true }`, record each worker URL in manager state, and return. On each later child-completion wake, store `wake.payload.finished_child.response` (or read structured output from shared state). Once all workers have reported, run the reduce/synthesis step.
|
|
91
91
|
|
|
92
92
|
```ts
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const latest = runs[runs.length - 1]?.trim()
|
|
100
|
-
return latest || fallback
|
|
93
|
+
const finished = wake.payload?.finished_child
|
|
94
|
+
if (finished) {
|
|
95
|
+
ctx.state.workers.update(finished.url, (draft) => {
|
|
96
|
+
draft.status = finished.run_status
|
|
97
|
+
draft.output = finished.response ?? ""
|
|
98
|
+
})
|
|
101
99
|
}
|
|
102
|
-
|
|
103
|
-
const results = await Promise.all(
|
|
104
|
-
handles.map(async ({ id, handle }) => ({
|
|
105
|
-
id,
|
|
106
|
-
text: await readLatestCompletedText(
|
|
107
|
-
handle,
|
|
108
|
-
`(no completed output from ${id})`
|
|
109
|
-
),
|
|
110
|
-
}))
|
|
111
|
-
)
|
|
112
100
|
```
|
|
113
101
|
|
|
114
102
|
## State
|