@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/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, fetchUrlTool } from "@electric-ax/agents-runtime/tools";
6
- import fs from "node:fs";
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$1 from "node:fs/promises";
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
- fs.mkdirSync(logDir, { recursive: true });
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$1.readdir(dir, { withFileTypes: true });
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 (fs.existsSync(candidate.path) && (!candidate.requireIndex || fs.existsSync(path.join(candidate.path, `index.md`)))) return candidate.path;
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
- fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
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$1.mkdir(path.dirname(this.dbPath), { recursive: true });
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$1.readFile(filePath, `utf8`)
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(workingDirectory, ctx, readSet, opts = {}) {
1167
+ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1156
1168
  return [
1157
- createBashTool(workingDirectory),
1158
- createReadFileTool(workingDirectory, readSet),
1159
- createWriteTool(workingDirectory, readSet),
1160
- createEditTool(workingDirectory, readSet),
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
- })] : [fetchUrlTool],
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(workingDirectory) {
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
- if (!fs.existsSync(agentsMdPath) || !fs.statSync(agentsMdPath).isFile()) return null;
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 { workingDirectory, streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
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 agentsMd = readAgentsMd(effectiveCwd);
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(effectiveCwd, ctx, readSet, {
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: 1e5,
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: 1e5,
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: 1e5,
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(effectiveCwd, {
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, workingDirectory, ctx, readSet) {
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(workingDirectory));
1445
+ out.push(createBashTool(sandbox));
1426
1446
  break;
1427
1447
  case `read`:
1428
- out.push(createReadFileTool(workingDirectory, readSet));
1448
+ out.push(createReadFileTool(sandbox, readSet));
1429
1449
  break;
1430
1450
  case `write`:
1431
- out.push(createWriteTool(workingDirectory, readSet));
1451
+ out.push(createWriteTool(sandbox, readSet));
1432
1452
  break;
1433
1453
  case `edit`:
1434
- out.push(createEditTool(workingDirectory, readSet));
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(fetchUrlTool);
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 { workingDirectory, streamFn, modelCatalog } = options;
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
- const extras = this.options.extraMcpServers ?? [];
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 (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
1754
- else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
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 (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
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}). You will be woken when it finishes.`,
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'` to wait for each child to complete.
46
- 4. Collects results with `Promise.all` and `handle.text()`.
47
- 5. Returns the combined perspectives to the LLM for synthesis.
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
- The `readLatestCompletedText` helper awaits the child's current run, reads all text outputs, and returns the last one:
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
- async function readLatestCompletedText(
94
- handle: EntityHandle,
95
- fallback: string
96
- ): Promise<string> {
97
- await handle.run
98
- const runs = await handle.text()
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
@@ -55,7 +55,7 @@ for (let i = 0; i < chunks.length; i++) {
55
55
  { systemPrompt: task, tools: [`read`] },
56
56
  {
57
57
  initialMessage: chunks[i],
58
- wake: `runFinished`,
58
+ wake: { on: `runFinished`, includeResponse: true },
59
59
  }
60
60
  )
61
61
  ctx.db.actions.children_insert({