@electric-ax/agents 0.4.12 → 0.4.14

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.
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
+ import { Agent, cacheStores, interceptors, setGlobalDispatcher } from "undici";
3
4
  import fs from "node:fs";
4
5
  import pino from "pino";
5
6
  import { fileURLToPath } from "node:url";
6
- 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";
7
+ import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, buildSkillSlashCommands, completeWithLowCostModel, createContextSkillLoader, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
7
8
  import { braveSearchTool, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
8
9
  import { chooseDefaultSandbox, isE2BAvailable, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
9
10
  import { z } from "zod";
@@ -16,6 +17,18 @@ import { nanoid } from "nanoid";
16
17
  import { getModels } from "@mariozechner/pi-ai";
17
18
  import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
18
19
 
20
+ //#region src/durable-streams-cache.ts
21
+ const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
22
+ function installDurableStreamsFetchCache(options = {}) {
23
+ if (options === false) return;
24
+ const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
25
+ location: options.sqliteLocation,
26
+ maxCount: options.maxCount
27
+ }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
28
+ setGlobalDispatcher(new Agent().compose(interceptors.cache({ store })));
29
+ }
30
+
31
+ //#endregion
19
32
  //#region src/log.ts
20
33
  const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
21
34
  const IS_ELECTRON_MAIN = Boolean(process.versions.electron);
@@ -865,6 +878,15 @@ async function fetchAvailableModelIds(provider) {
865
878
  function knownModelsForProvider(provider) {
866
879
  return provider === MOONSHOT_PROVIDER ? getMoonshotModels() : getModels(provider);
867
880
  }
881
+ function resolveBuiltinModelContextWindow(modelConfig) {
882
+ const modelId = String(modelConfig.model);
883
+ if (modelConfig.provider === MOONSHOT_PROVIDER) return getMoonshotModel(modelId)?.contextWindow ?? null;
884
+ if (!modelConfig.provider) return null;
885
+ return knownModelsForProvider(modelConfig.provider).find((model) => model.id === modelId)?.contextWindow ?? null;
886
+ }
887
+ function resolveBuiltinModelSourceBudget(modelConfig) {
888
+ return resolveBuiltinModelContextWindow(modelConfig) ?? 1e5;
889
+ }
868
890
  function choiceForKnownModel(provider, model) {
869
891
  return {
870
892
  provider,
@@ -967,6 +989,8 @@ function modelInputSchemaDefs(catalog) {
967
989
  //#region src/agents/horton.ts
968
990
  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.";
969
991
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
992
+ const TITLE_GENERATION_TIMEOUT_MS = 8e3;
993
+ const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
970
994
  const TITLE_STOP_WORDS = new Set([
971
995
  `a`,
972
996
  `an`,
@@ -1046,6 +1070,17 @@ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1046
1070
  maxTokens: 64
1047
1071
  });
1048
1072
  }
1073
+ function withTimeout(promise, ms, description) {
1074
+ let timeout;
1075
+ const timeoutPromise = new Promise((_resolve, reject) => {
1076
+ timeout = setTimeout(() => {
1077
+ reject(new Error(`${description} timed out after ${ms}ms`));
1078
+ }, ms);
1079
+ });
1080
+ return Promise.race([promise, timeoutPromise]).finally(() => {
1081
+ if (timeout) clearTimeout(timeout);
1082
+ });
1083
+ }
1049
1084
  async function generateTitle(userMessage, llmCall, onFallback) {
1050
1085
  try {
1051
1086
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
@@ -1175,8 +1210,12 @@ function payloadToTitleText(payload) {
1175
1210
  if (typeof payload === `string`) return payload;
1176
1211
  if (payload == null) return ``;
1177
1212
  if (typeof payload === `object`) {
1178
- const text = payload.text;
1179
- return typeof text === `string` ? text : JSON.stringify(payload);
1213
+ const record = payload;
1214
+ const text = record.text;
1215
+ if (typeof text === `string`) return text;
1216
+ const source = record.source;
1217
+ if (typeof source === `string`) return source;
1218
+ return JSON.stringify(payload);
1180
1219
  }
1181
1220
  return String(payload);
1182
1221
  }
@@ -1234,10 +1273,13 @@ async function readAgentsMd(sandbox) {
1234
1273
  }
1235
1274
  function createAssistantHandler(options) {
1236
1275
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1237
- const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1276
+ const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1277
+ const hasSkills = skillLoader.hasSkills;
1238
1278
  return async function assistantHandler(ctx, wake) {
1279
+ const loadedSkills = await skillLoader.load(ctx);
1239
1280
  const readSet = new Set();
1240
1281
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1282
+ const sourceBudget = resolveBuiltinModelSourceBudget(modelConfig);
1241
1283
  const sandboxCwd = ctx.sandbox.workingDirectory;
1242
1284
  const agentsMd = await readAgentsMd(ctx.sandbox);
1243
1285
  const tools = [
@@ -1248,7 +1290,7 @@ function createAssistantHandler(options) {
1248
1290
  modelCatalog,
1249
1291
  logPrefix: `[horton ${ctx.entityUrl}]`
1250
1292
  }),
1251
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
1293
+ ...loadedSkills.tools,
1252
1294
  ...mcp.tools()
1253
1295
  ];
1254
1296
  const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
@@ -1257,21 +1299,22 @@ function createAssistantHandler(options) {
1257
1299
  if (!firstUserMessage) return;
1258
1300
  let title = null;
1259
1301
  try {
1260
- const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1302
+ const result = await generateTitle(firstUserMessage, (prompt) => withTimeout(createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`)(prompt), TITLE_GENERATION_TIMEOUT_MS, `title generation`), (reason) => {
1261
1303
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1262
1304
  });
1263
1305
  if (result.length > 0) title = result;
1264
1306
  } catch (err) {
1265
1307
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1308
+ title = buildFallbackTitle(firstUserMessage);
1266
1309
  }
1267
1310
  if (title !== null) try {
1268
- await ctx.setTag(`title`, title);
1311
+ await withTimeout(ctx.setTag(`title`, title), TITLE_GENERATION_TIMEOUT_MS, `set title tag`);
1269
1312
  } catch (err) {
1270
1313
  serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1271
1314
  }
1272
1315
  })() : Promise.resolve();
1273
1316
  if (docsSupport) ctx.useContext({
1274
- sourceBudget: 1e5,
1317
+ sourceBudget,
1275
1318
  sources: {
1276
1319
  docs_toc: {
1277
1320
  content: () => docsSupport.renderCompressedToc(),
@@ -1292,21 +1335,13 @@ function createAssistantHandler(options) {
1292
1335
  max: 2e4,
1293
1336
  cache: `stable`
1294
1337
  } } : {},
1295
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1296
- content: () => skillsRegistry.renderCatalog(2e3),
1297
- max: 2e3,
1298
- cache: `stable`
1299
- } } : {}
1338
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? loadedSkills.sources : {}
1300
1339
  }
1301
1340
  });
1302
1341
  else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
1303
- sourceBudget: 1e5,
1342
+ sourceBudget,
1304
1343
  sources: {
1305
- skills_catalog: {
1306
- content: () => skillsRegistry.renderCatalog(2e3),
1307
- max: 2e3,
1308
- cache: `stable`
1309
- },
1344
+ ...loadedSkills.sources,
1310
1345
  conversation: {
1311
1346
  content: () => ctx.timelineMessages(),
1312
1347
  cache: `volatile`
@@ -1319,7 +1354,7 @@ function createAssistantHandler(options) {
1319
1354
  }
1320
1355
  });
1321
1356
  else if (agentsMd) ctx.useContext({
1322
- sourceBudget: 1e5,
1357
+ sourceBudget,
1323
1358
  sources: {
1324
1359
  conversation: {
1325
1360
  content: () => ctx.timelineMessages(),
@@ -1376,6 +1411,16 @@ function registerHorton(registry, options) {
1376
1411
  registry.define(`horton`, {
1377
1412
  description: `Friendly capable assistant — chat, code, research, dispatch`,
1378
1413
  creationSchema: hortonCreationSchema,
1414
+ permissionGrants: [{
1415
+ subject_kind: `principal_kind`,
1416
+ subject_value: `user`,
1417
+ permission: `spawn`
1418
+ }, {
1419
+ subject_kind: `principal_kind`,
1420
+ subject_value: `user`,
1421
+ permission: `manage`
1422
+ }],
1423
+ slashCommands: buildSkillSlashCommands(skillsRegistry),
1379
1424
  handler: assistantHandler
1380
1425
  });
1381
1426
  return [`horton`];
@@ -1417,7 +1462,7 @@ function parseWorkerArgs(value) {
1417
1462
  if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
1418
1463
  return args;
1419
1464
  }
1420
- function buildToolsForWorker(tools, sandbox, ctx, readSet) {
1465
+ function buildToolsForWorker(tools, sandbox, ctx, readSet, opts) {
1421
1466
  const out = [];
1422
1467
  for (const name of tools) switch (name) {
1423
1468
  case `bash`:
@@ -1436,7 +1481,10 @@ function buildToolsForWorker(tools, sandbox, ctx, readSet) {
1436
1481
  out.push(braveSearchTool);
1437
1482
  break;
1438
1483
  case `fetch_url`:
1439
- out.push(createFetchUrlTool(sandbox));
1484
+ out.push(createFetchUrlTool(sandbox, {
1485
+ catalog: opts.modelCatalog,
1486
+ modelConfig: opts.modelConfig
1487
+ }));
1440
1488
  break;
1441
1489
  case `spawn_worker`:
1442
1490
  out.push(createSpawnWorkerTool(ctx));
@@ -1547,11 +1595,23 @@ function registerWorker(registry, options) {
1547
1595
  const { streamFn, modelCatalog } = options;
1548
1596
  registry.define(`worker`, {
1549
1597
  description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
1598
+ permissionGrants: [{
1599
+ subject_kind: `principal_kind`,
1600
+ subject_value: `user`,
1601
+ permission: `spawn`
1602
+ }, {
1603
+ subject_kind: `principal_kind`,
1604
+ subject_value: `user`,
1605
+ permission: `manage`
1606
+ }],
1550
1607
  async handler(ctx) {
1551
1608
  const args = parseWorkerArgs(ctx.args);
1552
1609
  const readSet = new Set();
1553
- const builtinTools = buildToolsForWorker(args.tools, ctx.sandbox, ctx, readSet);
1554
1610
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
1611
+ const builtinTools = buildToolsForWorker(args.tools, ctx.sandbox, ctx, readSet, {
1612
+ modelCatalog,
1613
+ modelConfig
1614
+ });
1555
1615
  const sharedStateTools = [];
1556
1616
  if (args.sharedDb) {
1557
1617
  const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
@@ -1743,6 +1803,8 @@ var BuiltinAgentsServer = class {
1743
1803
  mcpToolProviderName = null;
1744
1804
  mcpApplyInFlight = new Set();
1745
1805
  mcpStopping = false;
1806
+ mcpExtras = [];
1807
+ mcpLastJsonConfig = null;
1746
1808
  pullWakeRunner = null;
1747
1809
  options;
1748
1810
  constructor(options) {
@@ -1752,8 +1814,70 @@ var BuiltinAgentsServer = class {
1752
1814
  get mcpRegistry() {
1753
1815
  return this._mcpRegistry;
1754
1816
  }
1817
+ /**
1818
+ * Replace the in-memory `extras` list and re-apply the merged config
1819
+ * against the last-known workspace `mcp.json` state. Workspace
1820
+ * `mcp.json` still wins on name collision. No-op once `stop()` has
1821
+ * latched `mcpStopping`.
1822
+ */
1823
+ async setExtraMcpServers(extras) {
1824
+ if (!this._mcpRegistry || this.mcpStopping) return;
1825
+ this.mcpExtras = extras;
1826
+ await this.applyMerged(this.mcpLastJsonConfig);
1827
+ }
1828
+ async wirePersistence(cfg) {
1829
+ const servers = [];
1830
+ for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
1831
+ const persist = await keychainPersistence({ server: s.name });
1832
+ servers.push({
1833
+ ...s,
1834
+ auth: {
1835
+ ...s.auth,
1836
+ ...persist
1837
+ }
1838
+ });
1839
+ } else servers.push(s);
1840
+ return {
1841
+ ...cfg,
1842
+ servers
1843
+ };
1844
+ }
1845
+ mergeMcp(jsonCfg) {
1846
+ const jsonServers = jsonCfg?.servers ?? [];
1847
+ const jsonNames = new Set(jsonServers.map((s) => s.name));
1848
+ const filteredExtras = this.mcpExtras.filter((s) => !jsonNames.has(s.name));
1849
+ return {
1850
+ servers: [...filteredExtras, ...jsonServers],
1851
+ raw: jsonCfg?.raw
1852
+ };
1853
+ }
1854
+ async runApply(jsonCfg) {
1855
+ if (this.mcpStopping) return;
1856
+ const registry = this._mcpRegistry;
1857
+ if (!registry) return;
1858
+ try {
1859
+ const wired = await this.wirePersistence(this.mergeMcp(jsonCfg));
1860
+ if (this.mcpStopping) return;
1861
+ await registry.applyConfig(wired);
1862
+ } catch (e) {
1863
+ serverLog.error(`[mcp] applyConfig:`, e);
1864
+ try {
1865
+ this.options.onConfigError?.(e);
1866
+ } catch (cbErr) {
1867
+ serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
1868
+ }
1869
+ }
1870
+ }
1871
+ applyMerged(jsonCfg) {
1872
+ this.mcpLastJsonConfig = jsonCfg;
1873
+ const p = this.runApply(jsonCfg);
1874
+ this.mcpApplyInFlight.add(p);
1875
+ p.finally(() => this.mcpApplyInFlight.delete(p));
1876
+ return p;
1877
+ }
1755
1878
  async start() {
1756
1879
  if (this.bootstrap || this.pullWakeRunner) throw new Error(`Builtin agents runtime already started`);
1880
+ installDurableStreamsFetchCache(this.options.durableStreamsFetchCache);
1757
1881
  const pullWake = this.options.pullWake;
1758
1882
  if (!pullWake?.runnerId) throw new Error(`Builtin agents require a pull-wake runner id`);
1759
1883
  try {
@@ -1764,76 +1888,28 @@ var BuiltinAgentsServer = class {
1764
1888
  });
1765
1889
  this._mcpRegistry = mcpRegistry;
1766
1890
  const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
1767
- const extras = this.options.extraMcpServers ?? [];
1768
- const wirePersistence = async (cfg) => {
1769
- const servers = [];
1770
- for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
1771
- const persist = await keychainPersistence({ server: s.name });
1772
- servers.push({
1773
- ...s,
1774
- auth: {
1775
- ...s.auth,
1776
- ...persist
1777
- }
1778
- });
1779
- } else servers.push(s);
1780
- return {
1781
- ...cfg,
1782
- servers
1783
- };
1784
- };
1785
- const merge = (jsonCfg) => {
1786
- const jsonServers = jsonCfg?.servers ?? [];
1787
- const jsonNames = new Set(jsonServers.map((s) => s.name));
1788
- const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
1789
- return {
1790
- servers: [...filteredExtras, ...jsonServers],
1791
- raw: jsonCfg?.raw
1792
- };
1793
- };
1794
- const onConfigError = this.options.onConfigError;
1795
- const runApply = async (jsonCfg) => {
1796
- if (this.mcpStopping) return;
1797
- try {
1798
- const wired = await wirePersistence(merge(jsonCfg));
1799
- if (this.mcpStopping) return;
1800
- await mcpRegistry.applyConfig(wired);
1801
- } catch (e) {
1802
- serverLog.error(`[mcp] applyConfig:`, e);
1803
- try {
1804
- onConfigError?.(e);
1805
- } catch (cbErr) {
1806
- serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
1807
- }
1808
- }
1809
- };
1810
- const applyMerged = (jsonCfg) => {
1811
- const p = runApply(jsonCfg);
1812
- this.mcpApplyInFlight.add(p);
1813
- p.finally(() => this.mcpApplyInFlight.delete(p));
1814
- return p;
1815
- };
1891
+ this.mcpExtras = this.options.extraMcpServers ?? [];
1816
1892
  if (mcpConfigPath) {
1817
1893
  try {
1818
1894
  const cfg = await loadConfig(mcpConfigPath, process.env);
1819
- applyMerged(cfg);
1895
+ this.applyMerged(cfg);
1820
1896
  } catch (err) {
1821
1897
  if (err.code !== `ENOENT`) throw err;
1822
- if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
1823
- else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
1824
- applyMerged(null);
1898
+ if (this.mcpExtras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
1899
+ else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${this.mcpExtras.length} server(s) from extras`);
1900
+ this.applyMerged(null);
1825
1901
  }
1826
1902
  try {
1827
1903
  this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
1828
- onChange: (cfg) => void applyMerged(cfg),
1904
+ onChange: (cfg) => void this.applyMerged(cfg),
1829
1905
  onError: (e) => serverLog.error(`[mcp] config error:`, e)
1830
1906
  });
1831
1907
  } catch (e) {
1832
1908
  serverLog.error(`[mcp] config watcher failed to start:`, e);
1833
1909
  }
1834
1910
  } else {
1835
- if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
1836
- applyMerged(null);
1911
+ if (this.mcpExtras.length > 0) serverLog.info(`[mcp] starting with ${this.mcpExtras.length} server(s) from extras`);
1912
+ this.applyMerged(null);
1837
1913
  }
1838
1914
  this.mcpToolProviderName = `mcp`;
1839
1915
  registerToolProvider({