@electric-ax/agents 0.4.13 → 0.4.15

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,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
- import { Agent, cacheStores, interceptors, setGlobalDispatcher } from "undici";
3
+ import { cacheStores, getGlobalDispatcher, interceptors, setGlobalDispatcher } from "undici";
4
4
  import fs from "node:fs";
5
5
  import pino from "pino";
6
6
  import { fileURLToPath } from "node:url";
7
- 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";
8
- import { braveSearchTool, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
9
- import { chooseDefaultSandbox, isE2BAvailable, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
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";
8
+ import { braveSearchTool, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createScheduleTools, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
9
+ import { chooseDefaultSandbox, isE2BAvailable, lazySandbox, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
10
10
  import { z } from "zod";
11
11
  import { createHash } from "node:crypto";
12
12
  import fs$1 from "node:fs/promises";
@@ -25,7 +25,7 @@ function installDurableStreamsFetchCache(options = {}) {
25
25
  location: options.sqliteLocation,
26
26
  maxCount: options.maxCount
27
27
  }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
28
- setGlobalDispatcher(new Agent().compose(interceptors.cache({ store })));
28
+ setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
29
29
  }
30
30
 
31
31
  //#endregion
@@ -806,6 +806,60 @@ function createSpawnWorkerTool(ctx, modelConfig) {
806
806
  };
807
807
  }
808
808
 
809
+ //#endregion
810
+ //#region src/tools/fork.ts
811
+ function createForkTool(ctx) {
812
+ return {
813
+ name: `fork`,
814
+ label: `Fork`,
815
+ description: `Fork a session at its latest completed agent response, producing a child copy of the conversation up to that point. The new fork is YOUR child — same parent-ownership model as a spawned worker — and it reports back to you the same way: when its next run finishes you'll be woken with its response. End your turn after forking.
816
+
817
+ Prefer supplying an 'initialMessage' so the fork is dispatched immediately in a single call — no follow-up 'send' needed. If you omit it, the fork boots idle and you'll need to call 'send' afterwards. For chat-rendered messages use the shape \`{ "text": "..." }\` so the prompt shows up in the chat UI.
818
+
819
+ Use this to explore multiple alternative continuations in parallel from the same starting point. End your current turn first so the fork includes your latest response — the anchor is always the most recently completed run.
820
+
821
+ Omit 'entityUrl' to fork your own session. Pass a different session's URL to fork that session instead (the new fork is still your child). The optional 'id' names the new fork's instance — useful when you want stable, predictable URLs (e.g. labelling branches in a parallel exploration); omit to let the server mint one.`,
822
+ parameters: Type.Object({
823
+ entityUrl: Type.Optional(Type.String({ description: `URL of the session to fork. Omit to fork your own session.` })),
824
+ id: Type.Optional(Type.String({ description: `Instance id for the new fork (the \`<id>\` in \`/horton/<id>\`). Mirrors spawn_worker's id parameter. Omit to let the server assign one.` })),
825
+ initialMessage: Type.Optional(Type.Any({ description: `Initial inbox message delivered to the fork by the server in the same round-trip — the fork wakes and starts running immediately, no follow-up 'send' needed. Use the shape \`{ "text": "..." }\` for chat-rendered prompts. Omit to leave the fork idle (then call 'send' separately).` })),
826
+ tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: `Optional tags stamped on the new fork, on top of those copied from the source. Useful for labelling experiments (e.g. \`{ "experiment": "ecosystem-maturity" }\`).` }))
827
+ }),
828
+ execute: async (_toolCallId, params) => {
829
+ const { entityUrl, id, initialMessage, tags } = params;
830
+ try {
831
+ const opts = {
832
+ ...initialMessage !== void 0 && { initialMessage },
833
+ ...tags !== void 0 && { tags }
834
+ };
835
+ const forkId = id ?? `fork-${nanoid(10)}`;
836
+ const handle = entityUrl !== void 0 ? await ctx.fork(entityUrl, forkId, opts) : await ctx.forkSelf(forkId, opts);
837
+ const dispatchNote = initialMessage !== void 0 ? `The initial message has been delivered to the fork — it will start running.` : `The fork boots idle — use the 'send' tool to dispatch a follow-up prompt.`;
838
+ return {
839
+ content: [{
840
+ type: `text`,
841
+ text: `Forked at ${handle.entityUrl}. ${dispatchNote} End your turn; you'll wake with the fork's response when its next run finishes (same as a spawned worker).`
842
+ }],
843
+ details: {
844
+ forked: true,
845
+ forkUrl: handle.entityUrl
846
+ }
847
+ };
848
+ } catch (err) {
849
+ const message = err instanceof Error ? err.message : `Unknown error`;
850
+ serverLog.warn(`[fork tool] failed to fork ${entityUrl ?? `<self>`}: ${message}`, err instanceof Error ? err : void 0);
851
+ return {
852
+ content: [{
853
+ type: `text`,
854
+ text: `Error forking session: ${message}`
855
+ }],
856
+ details: { forked: false }
857
+ };
858
+ }
859
+ }
860
+ };
861
+ }
862
+
809
863
  //#endregion
810
864
  //#region src/model-catalog.ts
811
865
  const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
@@ -989,6 +1043,8 @@ function modelInputSchemaDefs(catalog) {
989
1043
  //#region src/agents/horton.ts
990
1044
  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.";
991
1045
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1046
+ const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1047
+ const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
992
1048
  const TITLE_STOP_WORDS = new Set([
993
1049
  `a`,
994
1050
  `an`,
@@ -1068,6 +1124,17 @@ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1068
1124
  maxTokens: 64
1069
1125
  });
1070
1126
  }
1127
+ function withTimeout(promise, ms, description) {
1128
+ let timeout;
1129
+ const timeoutPromise = new Promise((_resolve, reject) => {
1130
+ timeout = setTimeout(() => {
1131
+ reject(new Error(`${description} timed out after ${ms}ms`));
1132
+ }, ms);
1133
+ });
1134
+ return Promise.race([promise, timeoutPromise]).finally(() => {
1135
+ if (timeout) clearTimeout(timeout);
1136
+ });
1137
+ }
1071
1138
  async function generateTitle(userMessage, llmCall, onFallback) {
1072
1139
  try {
1073
1140
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
@@ -1083,6 +1150,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1083
1150
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1084
1151
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1085
1152
  const eventSourceTools = opts.hasEventSourceTools ? `\n- list_event_sources: list external webhook/event feeds you can subscribe to, including available buckets and parameters\n- subscribe_event_source: subscribe yourself to one of those feeds or buckets so matching future events wake you\n- list_event_source_subscriptions: list your active event source subscriptions\n- unsubscribe_event_source: remove one of your event source subscriptions by id` : ``;
1153
+ const scheduleTools = opts.hasScheduleTools ? `\n- upsert_cron_schedule: create or update a recurring cron wake for yourself. Always include payload with the concrete instruction/message you should receive when the cron fires.\n- delete_schedule: delete one of your cron or future-send schedules by stable id\n- list_schedules: list your manifest-backed cron and future-send schedules` : ``;
1086
1154
  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` : ``;
1087
1155
  const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents or this framework, ALWAYS use search_electric_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.` : ``;
1088
1156
  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.
@@ -1136,8 +1204,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1136
1204
  - web_search: search the web
1137
1205
  - fetch_url: fetch and convert a URL to markdown
1138
1206
  - spawn_worker: dispatch a subagent for an isolated task
1207
+ - fork: spawn a child session that inherits this conversation's history up to the latest completed response. Same parent-ownership model as spawn_worker — when the fork's next run finishes, you'll wake with its response.
1139
1208
  - send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
1140
- ${eventSourceTools}${docsTools}${skillsTools}
1209
+ ${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
1141
1210
 
1142
1211
  # Working with files
1143
1212
  - Prefer edit over write when modifying existing files.
@@ -1164,6 +1233,20 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
1164
1233
 
1165
1234
  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.
1166
1235
 
1236
+ # When to fork (vs spawn_worker)
1237
+ \`fork\` is the **sibling primitive to spawn_worker** — both create a child you own, both report back to you on a future wake. The difference is only in what the child boots with:
1238
+
1239
+ - **spawn_worker** → child boots with an **empty context**; you brief it from scratch via a system prompt + initial message. Use when the worker doesn't need to know what we've said so far.
1240
+ - **fork** → child boots with **a copy of THIS conversation's history** up to your latest completed response. Use when each child needs to know what we've already established (the user's framing, your prior analysis, an earlier decision, the constraints, etc.).
1241
+
1242
+ **Trigger pattern: prefer fork when generating multiple variants the user wants to compare.** If the user asks for "three different X" or "two takes on Y" or "evaluate these N approaches" and each variant should reflect the conversation we've had so far, **don't inline the variants in one response** — fork once per variant, send each a tailored follow-up, and synthesize when they report back. Inlining feels faster but the variants end up cross-contaminating in your single response; forks keep them honestly independent. The exception is trivial generation (a list of names, a couple of one-liners) where each variant takes a sentence — there, inline is fine.
1243
+
1244
+ Workflow when forking yourself for parallel exploration:
1245
+ 1. **End your current turn first.** The fork's history stops at your *latest completed* run. Anything you say mid-turn is NOT in the fork. If you want your analysis baked into each fork, finish it and end the turn before calling fork.
1246
+ 2. On the next wake, call \`fork\` once per branch with a different \`initialMessage\` per call — that's how the branches diverge from a shared starting point. Each fork is YOUR child, just like a spawned worker, and the server delivers \`initialMessage\` to the fork in the same round-trip, so the fork starts running immediately (no follow-up \`send\` needed). Use the shape \`{ text: "..." }\` for the message so it renders in the chat UI.
1247
+ 3. End your turn. You'll wake automatically when each fork's run finishes (same wake mechanism as spawn_worker); the wake message identifies the fork and includes its response.
1248
+ 4. If you're waiting on multiple forks, don't synthesize on the first wake — quietly end the turn with "got N of M, waiting" until you have what you need to compare.
1249
+
1167
1250
  # Reporting
1168
1251
  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.
1169
1252
 
@@ -1189,6 +1272,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1189
1272
  logPrefix: opts.logPrefix ?? `[horton]`
1190
1273
  })] : [createFetchUrlTool(sandbox)],
1191
1274
  createSpawnWorkerTool(ctx, opts.modelConfig),
1275
+ createForkTool(ctx),
1192
1276
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1193
1277
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1194
1278
  ];
@@ -1197,8 +1281,12 @@ function payloadToTitleText(payload) {
1197
1281
  if (typeof payload === `string`) return payload;
1198
1282
  if (payload == null) return ``;
1199
1283
  if (typeof payload === `object`) {
1200
- const text = payload.text;
1201
- return typeof text === `string` ? text : JSON.stringify(payload);
1284
+ const record = payload;
1285
+ const text = record.text;
1286
+ if (typeof text === `string`) return text;
1287
+ const source = record.source;
1288
+ if (typeof source === `string`) return source;
1289
+ return JSON.stringify(payload);
1202
1290
  }
1203
1291
  return String(payload);
1204
1292
  }
@@ -1256,8 +1344,10 @@ async function readAgentsMd(sandbox) {
1256
1344
  }
1257
1345
  function createAssistantHandler(options) {
1258
1346
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1259
- const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1347
+ const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1348
+ const hasSkills = skillLoader.hasSkills;
1260
1349
  return async function assistantHandler(ctx, wake) {
1350
+ const loadedSkills = await skillLoader.load(ctx);
1261
1351
  const readSet = new Set();
1262
1352
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1263
1353
  const sourceBudget = resolveBuiltinModelSourceBudget(modelConfig);
@@ -1271,24 +1361,26 @@ function createAssistantHandler(options) {
1271
1361
  modelCatalog,
1272
1362
  logPrefix: `[horton ${ctx.entityUrl}]`
1273
1363
  }),
1274
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
1364
+ ...loadedSkills.tools,
1275
1365
  ...mcp.tools()
1276
1366
  ];
1277
1367
  const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
1368
+ const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
1278
1369
  const titlePromise = !ctx.tags.title ? (async () => {
1279
1370
  const firstUserMessage = await extractFirstUserMessage(ctx);
1280
1371
  if (!firstUserMessage) return;
1281
1372
  let title = null;
1282
1373
  try {
1283
- const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1374
+ const result = await generateTitle(firstUserMessage, (prompt) => withTimeout(createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`)(prompt), TITLE_GENERATION_TIMEOUT_MS, `title generation`), (reason) => {
1284
1375
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1285
1376
  });
1286
1377
  if (result.length > 0) title = result;
1287
1378
  } catch (err) {
1288
1379
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1380
+ title = buildFallbackTitle(firstUserMessage);
1289
1381
  }
1290
1382
  if (title !== null) try {
1291
- await ctx.setTag(`title`, title);
1383
+ await withTimeout(ctx.setTag(`title`, title), TITLE_GENERATION_TIMEOUT_MS, `set title tag`);
1292
1384
  } catch (err) {
1293
1385
  serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1294
1386
  }
@@ -1315,21 +1407,13 @@ function createAssistantHandler(options) {
1315
1407
  max: 2e4,
1316
1408
  cache: `stable`
1317
1409
  } } : {},
1318
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1319
- content: () => skillsRegistry.renderCatalog(2e3),
1320
- max: 2e3,
1321
- cache: `stable`
1322
- } } : {}
1410
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? loadedSkills.sources : {}
1323
1411
  }
1324
1412
  });
1325
1413
  else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
1326
1414
  sourceBudget,
1327
1415
  sources: {
1328
- skills_catalog: {
1329
- content: () => skillsRegistry.renderCatalog(2e3),
1330
- max: 2e3,
1331
- cache: `stable`
1332
- },
1416
+ ...loadedSkills.sources,
1333
1417
  conversation: {
1334
1418
  content: () => ctx.timelineMessages(),
1335
1419
  cache: `volatile`
@@ -1362,7 +1446,8 @@ function createAssistantHandler(options) {
1362
1446
  docsUrl,
1363
1447
  modelProvider: modelConfig.provider,
1364
1448
  modelId: String(modelConfig.model),
1365
- hasEventSourceTools
1449
+ hasEventSourceTools,
1450
+ hasScheduleTools
1366
1451
  }),
1367
1452
  ...modelConfig,
1368
1453
  tools,
@@ -1408,6 +1493,7 @@ function registerHorton(registry, options) {
1408
1493
  subject_value: `user`,
1409
1494
  permission: `manage`
1410
1495
  }],
1496
+ slashCommands: buildSkillSlashCommands(skillsRegistry),
1411
1497
  handler: assistantHandler
1412
1498
  });
1413
1499
  return [`horton`];
@@ -1633,7 +1719,10 @@ function dedupeToolsByName(tools) {
1633
1719
  }
1634
1720
  function createBuiltinElectricTools(custom) {
1635
1721
  return async (context) => {
1636
- const builtinTools = createEventSourceTools(context);
1722
+ const builtinTools = [...createEventSourceTools(context), ...createScheduleTools({
1723
+ ...context,
1724
+ db: context.db
1725
+ })];
1637
1726
  const customTools = custom ? await custom(context) : [];
1638
1727
  return dedupeToolsByName([...builtinTools, ...customTools]);
1639
1728
  };
@@ -1675,7 +1764,7 @@ async function createBuiltinAgentHandler(options) {
1675
1764
  modelCatalog
1676
1765
  });
1677
1766
  typeNames.push(`worker`);
1678
- const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
1767
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
1679
1768
  const runtime = createRuntimeHandler({
1680
1769
  baseUrl: agentServerUrl,
1681
1770
  serveEndpoint,
@@ -1694,7 +1783,8 @@ async function createBuiltinAgentHandler(options) {
1694
1783
  runtime,
1695
1784
  registry,
1696
1785
  typeNames,
1697
- skillsRegistry
1786
+ skillsRegistry,
1787
+ shutdownSandboxes
1698
1788
  };
1699
1789
  }
1700
1790
  async function registerBuiltinAgentTypes(bootstrap) {
@@ -1705,18 +1795,22 @@ async function registerBuiltinAgentTypes(bootstrap) {
1705
1795
  * Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
1706
1796
  * re-run the boot sweep.
1707
1797
  */
1708
- let dockerSweptOnBoot = false;
1798
+ let dockerBootSweep = null;
1709
1799
  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)`);
1800
+ dockerBootSweep ??= sweep().then((reclaimed) => {
1801
+ if (reclaimed.length > 0) serverLog.info(`[builtin-agents] docker sandbox boot sweep reclaimed ${reclaimed.length} leftover container(s)`);
1714
1802
  }).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
1803
+ return dockerBootSweep;
1715
1804
  }
1716
1805
  /**
1717
1806
  * Built-in sandbox profiles. `local` is always available. `docker` is
1718
1807
  * gated on Docker being reachable so a user without Docker installed
1719
1808
  * sees only what works — the UI never offers a non-functional choice.
1809
+ *
1810
+ * Also returns `shutdownSandboxes` when a host-local provider registered: an
1811
+ * immediate teardown of this process's live containers that the embedding
1812
+ * server must run on shutdown (the providers' debounced idle teardowns die
1813
+ * with the process).
1720
1814
  */
1721
1815
  async function buildBuiltinSandboxProfiles(workingDirectory) {
1722
1816
  const profiles = [{
@@ -1725,29 +1819,36 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1725
1819
  description: `Runs on the host without isolation. Full filesystem access.`,
1726
1820
  factory: ({ args }) => chooseDefaultSandbox(resolveCwd(args, workingDirectory))
1727
1821
  }];
1822
+ let shutdownSandboxes = null;
1728
1823
  try {
1729
1824
  const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1730
1825
  if (await isDockerAvailable()) {
1731
- const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1732
- sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
1826
+ const { dockerSandbox, reclaimDockerSandboxByKey, shutdownAllDockerSandboxes, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1827
+ await sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
1828
+ shutdownSandboxes = shutdownAllDockerSandboxes;
1733
1829
  profiles.push({
1734
1830
  name: `docker`,
1735
1831
  label: `Docker`,
1736
1832
  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 }) => {
1833
+ factory: async ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
1738
1834
  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
1835
+ return lazySandbox({
1836
+ name: `docker`,
1837
+ workingDirectory: `/work`,
1838
+ factory: () => dockerSandbox({
1839
+ initialNetworkPolicy: { mode: `allow-all` },
1840
+ extraMounts: cwd ? [{
1841
+ hostPath: cwd,
1842
+ containerPath: `/work`,
1843
+ readOnly: false
1844
+ }] : void 0,
1845
+ sandboxKey,
1846
+ persistent,
1847
+ owner,
1848
+ entityType,
1849
+ entityUrl
1850
+ }),
1851
+ reclaim: owner ? () => reclaimDockerSandboxByKey(sandboxKey) : void 0
1751
1852
  });
1752
1853
  }
1753
1854
  });
@@ -1771,7 +1872,10 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1771
1872
  });
1772
1873
  else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
1773
1874
  console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
1774
- return profiles;
1875
+ return {
1876
+ profiles,
1877
+ shutdownSandboxes
1878
+ };
1775
1879
  }
1776
1880
  function readWorkingDirectoryArg(args) {
1777
1881
  const v = args.workingDirectory;
@@ -1977,6 +2081,9 @@ var BuiltinAgentsServer = class {
1977
2081
  await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
1978
2082
  serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
1979
2083
  }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2084
+ if (this.bootstrap.shutdownSandboxes) await Promise.race([this.bootstrap.shutdownSandboxes().catch((err) => {
2085
+ serverLog.error(`[builtin-agents] sandbox shutdown failed during shutdown:`, err);
2086
+ }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
1980
2087
  this.bootstrap = null;
1981
2088
  }
1982
2089
  this.mcpStopping = true;
package/dist/index.cjs CHANGED
@@ -818,6 +818,60 @@ function createSpawnWorkerTool(ctx, modelConfig) {
818
818
  };
819
819
  }
820
820
 
821
+ //#endregion
822
+ //#region src/tools/fork.ts
823
+ function createForkTool(ctx) {
824
+ return {
825
+ name: `fork`,
826
+ label: `Fork`,
827
+ description: `Fork a session at its latest completed agent response, producing a child copy of the conversation up to that point. The new fork is YOUR child — same parent-ownership model as a spawned worker — and it reports back to you the same way: when its next run finishes you'll be woken with its response. End your turn after forking.
828
+
829
+ Prefer supplying an 'initialMessage' so the fork is dispatched immediately in a single call — no follow-up 'send' needed. If you omit it, the fork boots idle and you'll need to call 'send' afterwards. For chat-rendered messages use the shape \`{ "text": "..." }\` so the prompt shows up in the chat UI.
830
+
831
+ Use this to explore multiple alternative continuations in parallel from the same starting point. End your current turn first so the fork includes your latest response — the anchor is always the most recently completed run.
832
+
833
+ Omit 'entityUrl' to fork your own session. Pass a different session's URL to fork that session instead (the new fork is still your child). The optional 'id' names the new fork's instance — useful when you want stable, predictable URLs (e.g. labelling branches in a parallel exploration); omit to let the server mint one.`,
834
+ parameters: __sinclair_typebox.Type.Object({
835
+ entityUrl: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String({ description: `URL of the session to fork. Omit to fork your own session.` })),
836
+ id: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String({ description: `Instance id for the new fork (the \`<id>\` in \`/horton/<id>\`). Mirrors spawn_worker's id parameter. Omit to let the server assign one.` })),
837
+ initialMessage: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Any({ description: `Initial inbox message delivered to the fork by the server in the same round-trip — the fork wakes and starts running immediately, no follow-up 'send' needed. Use the shape \`{ "text": "..." }\` for chat-rendered prompts. Omit to leave the fork idle (then call 'send' separately).` })),
838
+ tags: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String(), { description: `Optional tags stamped on the new fork, on top of those copied from the source. Useful for labelling experiments (e.g. \`{ "experiment": "ecosystem-maturity" }\`).` }))
839
+ }),
840
+ execute: async (_toolCallId, params) => {
841
+ const { entityUrl, id, initialMessage, tags } = params;
842
+ try {
843
+ const opts = {
844
+ ...initialMessage !== void 0 && { initialMessage },
845
+ ...tags !== void 0 && { tags }
846
+ };
847
+ const forkId = id ?? `fork-${(0, nanoid.nanoid)(10)}`;
848
+ const handle = entityUrl !== void 0 ? await ctx.fork(entityUrl, forkId, opts) : await ctx.forkSelf(forkId, opts);
849
+ const dispatchNote = initialMessage !== void 0 ? `The initial message has been delivered to the fork — it will start running.` : `The fork boots idle — use the 'send' tool to dispatch a follow-up prompt.`;
850
+ return {
851
+ content: [{
852
+ type: `text`,
853
+ text: `Forked at ${handle.entityUrl}. ${dispatchNote} End your turn; you'll wake with the fork's response when its next run finishes (same as a spawned worker).`
854
+ }],
855
+ details: {
856
+ forked: true,
857
+ forkUrl: handle.entityUrl
858
+ }
859
+ };
860
+ } catch (err) {
861
+ const message = err instanceof Error ? err.message : `Unknown error`;
862
+ serverLog.warn(`[fork tool] failed to fork ${entityUrl ?? `<self>`}: ${message}`, err instanceof Error ? err : void 0);
863
+ return {
864
+ content: [{
865
+ type: `text`,
866
+ text: `Error forking session: ${message}`
867
+ }],
868
+ details: { forked: false }
869
+ };
870
+ }
871
+ }
872
+ };
873
+ }
874
+
821
875
  //#endregion
822
876
  //#region src/model-catalog.ts
823
877
  const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
@@ -1002,6 +1056,8 @@ function modelInputSchemaDefs(catalog) {
1002
1056
  const HORTON_MODEL = `claude-sonnet-4-6`;
1003
1057
  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.";
1004
1058
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1059
+ const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1060
+ const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
1005
1061
  const TITLE_STOP_WORDS = new Set([
1006
1062
  `a`,
1007
1063
  `an`,
@@ -1081,6 +1137,17 @@ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1081
1137
  maxTokens: 64
1082
1138
  });
1083
1139
  }
1140
+ function withTimeout(promise, ms, description) {
1141
+ let timeout;
1142
+ const timeoutPromise = new Promise((_resolve, reject) => {
1143
+ timeout = setTimeout(() => {
1144
+ reject(new Error(`${description} timed out after ${ms}ms`));
1145
+ }, ms);
1146
+ });
1147
+ return Promise.race([promise, timeoutPromise]).finally(() => {
1148
+ if (timeout) clearTimeout(timeout);
1149
+ });
1150
+ }
1084
1151
  async function generateTitle(userMessage, llmCall, onFallback) {
1085
1152
  try {
1086
1153
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
@@ -1096,6 +1163,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1096
1163
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1097
1164
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1098
1165
  const eventSourceTools = opts.hasEventSourceTools ? `\n- list_event_sources: list external webhook/event feeds you can subscribe to, including available buckets and parameters\n- subscribe_event_source: subscribe yourself to one of those feeds or buckets so matching future events wake you\n- list_event_source_subscriptions: list your active event source subscriptions\n- unsubscribe_event_source: remove one of your event source subscriptions by id` : ``;
1166
+ const scheduleTools = opts.hasScheduleTools ? `\n- upsert_cron_schedule: create or update a recurring cron wake for yourself. Always include payload with the concrete instruction/message you should receive when the cron fires.\n- delete_schedule: delete one of your cron or future-send schedules by stable id\n- list_schedules: list your manifest-backed cron and future-send schedules` : ``;
1099
1167
  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` : ``;
1100
1168
  const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents or this framework, ALWAYS use search_electric_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.` : ``;
1101
1169
  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.
@@ -1149,8 +1217,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1149
1217
  - web_search: search the web
1150
1218
  - fetch_url: fetch and convert a URL to markdown
1151
1219
  - spawn_worker: dispatch a subagent for an isolated task
1220
+ - fork: spawn a child session that inherits this conversation's history up to the latest completed response. Same parent-ownership model as spawn_worker — when the fork's next run finishes, you'll wake with its response.
1152
1221
  - send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
1153
- ${eventSourceTools}${docsTools}${skillsTools}
1222
+ ${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
1154
1223
 
1155
1224
  # Working with files
1156
1225
  - Prefer edit over write when modifying existing files.
@@ -1177,6 +1246,20 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
1177
1246
 
1178
1247
  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.
1179
1248
 
1249
+ # When to fork (vs spawn_worker)
1250
+ \`fork\` is the **sibling primitive to spawn_worker** — both create a child you own, both report back to you on a future wake. The difference is only in what the child boots with:
1251
+
1252
+ - **spawn_worker** → child boots with an **empty context**; you brief it from scratch via a system prompt + initial message. Use when the worker doesn't need to know what we've said so far.
1253
+ - **fork** → child boots with **a copy of THIS conversation's history** up to your latest completed response. Use when each child needs to know what we've already established (the user's framing, your prior analysis, an earlier decision, the constraints, etc.).
1254
+
1255
+ **Trigger pattern: prefer fork when generating multiple variants the user wants to compare.** If the user asks for "three different X" or "two takes on Y" or "evaluate these N approaches" and each variant should reflect the conversation we've had so far, **don't inline the variants in one response** — fork once per variant, send each a tailored follow-up, and synthesize when they report back. Inlining feels faster but the variants end up cross-contaminating in your single response; forks keep them honestly independent. The exception is trivial generation (a list of names, a couple of one-liners) where each variant takes a sentence — there, inline is fine.
1256
+
1257
+ Workflow when forking yourself for parallel exploration:
1258
+ 1. **End your current turn first.** The fork's history stops at your *latest completed* run. Anything you say mid-turn is NOT in the fork. If you want your analysis baked into each fork, finish it and end the turn before calling fork.
1259
+ 2. On the next wake, call \`fork\` once per branch with a different \`initialMessage\` per call — that's how the branches diverge from a shared starting point. Each fork is YOUR child, just like a spawned worker, and the server delivers \`initialMessage\` to the fork in the same round-trip, so the fork starts running immediately (no follow-up \`send\` needed). Use the shape \`{ text: "..." }\` for the message so it renders in the chat UI.
1260
+ 3. End your turn. You'll wake automatically when each fork's run finishes (same wake mechanism as spawn_worker); the wake message identifies the fork and includes its response.
1261
+ 4. If you're waiting on multiple forks, don't synthesize on the first wake — quietly end the turn with "got N of M, waiting" until you have what you need to compare.
1262
+
1180
1263
  # Reporting
1181
1264
  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.
1182
1265
 
@@ -1202,6 +1285,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1202
1285
  logPrefix: opts.logPrefix ?? `[horton]`
1203
1286
  })] : [(0, __electric_ax_agents_runtime_tools.createFetchUrlTool)(sandbox)],
1204
1287
  createSpawnWorkerTool(ctx, opts.modelConfig),
1288
+ createForkTool(ctx),
1205
1289
  (0, __electric_ax_agents_runtime_tools.createSendTool)(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1206
1290
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1207
1291
  ];
@@ -1210,8 +1294,12 @@ function payloadToTitleText(payload) {
1210
1294
  if (typeof payload === `string`) return payload;
1211
1295
  if (payload == null) return ``;
1212
1296
  if (typeof payload === `object`) {
1213
- const text = payload.text;
1214
- return typeof text === `string` ? text : JSON.stringify(payload);
1297
+ const record = payload;
1298
+ const text = record.text;
1299
+ if (typeof text === `string`) return text;
1300
+ const source = record.source;
1301
+ if (typeof source === `string`) return source;
1302
+ return JSON.stringify(payload);
1215
1303
  }
1216
1304
  return String(payload);
1217
1305
  }
@@ -1269,8 +1357,10 @@ async function readAgentsMd(sandbox) {
1269
1357
  }
1270
1358
  function createAssistantHandler(options) {
1271
1359
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1272
- const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1360
+ const skillLoader = (0, __electric_ax_agents_runtime.createContextSkillLoader)(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1361
+ const hasSkills = skillLoader.hasSkills;
1273
1362
  return async function assistantHandler(ctx, wake) {
1363
+ const loadedSkills = await skillLoader.load(ctx);
1274
1364
  const readSet = new Set();
1275
1365
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1276
1366
  const sourceBudget = resolveBuiltinModelSourceBudget(modelConfig);
@@ -1284,24 +1374,26 @@ function createAssistantHandler(options) {
1284
1374
  modelCatalog,
1285
1375
  logPrefix: `[horton ${ctx.entityUrl}]`
1286
1376
  }),
1287
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? (0, __electric_ax_agents_runtime.createSkillTools)(skillsRegistry, ctx) : [],
1377
+ ...loadedSkills.tools,
1288
1378
  ...__electric_ax_agents_mcp.mcp.tools()
1289
1379
  ];
1290
1380
  const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
1381
+ const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
1291
1382
  const titlePromise = !ctx.tags.title ? (async () => {
1292
1383
  const firstUserMessage = await extractFirstUserMessage(ctx);
1293
1384
  if (!firstUserMessage) return;
1294
1385
  let title = null;
1295
1386
  try {
1296
- const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1387
+ const result = await generateTitle(firstUserMessage, (prompt) => withTimeout(createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`)(prompt), TITLE_GENERATION_TIMEOUT_MS, `title generation`), (reason) => {
1297
1388
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1298
1389
  });
1299
1390
  if (result.length > 0) title = result;
1300
1391
  } catch (err) {
1301
1392
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1393
+ title = buildFallbackTitle(firstUserMessage);
1302
1394
  }
1303
1395
  if (title !== null) try {
1304
- await ctx.setTag(`title`, title);
1396
+ await withTimeout(ctx.setTag(`title`, title), TITLE_GENERATION_TIMEOUT_MS, `set title tag`);
1305
1397
  } catch (err) {
1306
1398
  serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1307
1399
  }
@@ -1328,21 +1420,13 @@ function createAssistantHandler(options) {
1328
1420
  max: 2e4,
1329
1421
  cache: `stable`
1330
1422
  } } : {},
1331
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1332
- content: () => skillsRegistry.renderCatalog(2e3),
1333
- max: 2e3,
1334
- cache: `stable`
1335
- } } : {}
1423
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? loadedSkills.sources : {}
1336
1424
  }
1337
1425
  });
1338
1426
  else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
1339
1427
  sourceBudget,
1340
1428
  sources: {
1341
- skills_catalog: {
1342
- content: () => skillsRegistry.renderCatalog(2e3),
1343
- max: 2e3,
1344
- cache: `stable`
1345
- },
1429
+ ...loadedSkills.sources,
1346
1430
  conversation: {
1347
1431
  content: () => ctx.timelineMessages(),
1348
1432
  cache: `volatile`
@@ -1375,7 +1459,8 @@ function createAssistantHandler(options) {
1375
1459
  docsUrl,
1376
1460
  modelProvider: modelConfig.provider,
1377
1461
  modelId: String(modelConfig.model),
1378
- hasEventSourceTools
1462
+ hasEventSourceTools,
1463
+ hasScheduleTools
1379
1464
  }),
1380
1465
  ...modelConfig,
1381
1466
  tools,
@@ -1421,6 +1506,7 @@ function registerHorton(registry, options) {
1421
1506
  subject_value: `user`,
1422
1507
  permission: `manage`
1423
1508
  }],
1509
+ slashCommands: (0, __electric_ax_agents_runtime.buildSkillSlashCommands)(skillsRegistry),
1424
1510
  handler: assistantHandler
1425
1511
  });
1426
1512
  return [`horton`];
@@ -1647,7 +1733,10 @@ function dedupeToolsByName(tools) {
1647
1733
  }
1648
1734
  function createBuiltinElectricTools(custom) {
1649
1735
  return async (context) => {
1650
- const builtinTools = (0, __electric_ax_agents_runtime_tools.createEventSourceTools)(context);
1736
+ const builtinTools = [...(0, __electric_ax_agents_runtime_tools.createEventSourceTools)(context), ...(0, __electric_ax_agents_runtime_tools.createScheduleTools)({
1737
+ ...context,
1738
+ db: context.db
1739
+ })];
1651
1740
  const customTools = custom ? await custom(context) : [];
1652
1741
  return dedupeToolsByName([...builtinTools, ...customTools]);
1653
1742
  };
@@ -1689,7 +1778,7 @@ async function createBuiltinAgentHandler(options) {
1689
1778
  modelCatalog
1690
1779
  });
1691
1780
  typeNames.push(`worker`);
1692
- const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
1781
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
1693
1782
  const runtime = (0, __electric_ax_agents_runtime.createRuntimeHandler)({
1694
1783
  baseUrl: agentServerUrl,
1695
1784
  serveEndpoint,
@@ -1708,7 +1797,8 @@ async function createBuiltinAgentHandler(options) {
1708
1797
  runtime,
1709
1798
  registry,
1710
1799
  typeNames,
1711
- skillsRegistry
1800
+ skillsRegistry,
1801
+ shutdownSandboxes
1712
1802
  };
1713
1803
  }
1714
1804
  async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
@@ -1729,18 +1819,22 @@ const registerAgentTypes = registerBuiltinAgentTypes;
1729
1819
  * Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
1730
1820
  * re-run the boot sweep.
1731
1821
  */
1732
- let dockerSweptOnBoot = false;
1822
+ let dockerBootSweep = null;
1733
1823
  function sweepOrphanedDockerSandboxesOnce(sweep) {
1734
- if (dockerSweptOnBoot) return;
1735
- dockerSweptOnBoot = true;
1736
- sweep().then((removed) => {
1737
- if (removed.length > 0) serverLog.info(`[builtin-agents] docker sandbox boot sweep removed ${removed.length} leftover container(s)`);
1824
+ dockerBootSweep ??= sweep().then((reclaimed) => {
1825
+ if (reclaimed.length > 0) serverLog.info(`[builtin-agents] docker sandbox boot sweep reclaimed ${reclaimed.length} leftover container(s)`);
1738
1826
  }).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
1827
+ return dockerBootSweep;
1739
1828
  }
1740
1829
  /**
1741
1830
  * Built-in sandbox profiles. `local` is always available. `docker` is
1742
1831
  * gated on Docker being reachable so a user without Docker installed
1743
1832
  * sees only what works — the UI never offers a non-functional choice.
1833
+ *
1834
+ * Also returns `shutdownSandboxes` when a host-local provider registered: an
1835
+ * immediate teardown of this process's live containers that the embedding
1836
+ * server must run on shutdown (the providers' debounced idle teardowns die
1837
+ * with the process).
1744
1838
  */
1745
1839
  async function buildBuiltinSandboxProfiles(workingDirectory) {
1746
1840
  const profiles = [{
@@ -1749,29 +1843,36 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1749
1843
  description: `Runs on the host without isolation. Full filesystem access.`,
1750
1844
  factory: ({ args }) => (0, __electric_ax_agents_runtime_sandbox.chooseDefaultSandbox)(resolveCwd(args, workingDirectory))
1751
1845
  }];
1846
+ let shutdownSandboxes = null;
1752
1847
  try {
1753
1848
  const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1754
1849
  if (await isDockerAvailable()) {
1755
- const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1756
- sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
1850
+ const { dockerSandbox, reclaimDockerSandboxByKey, shutdownAllDockerSandboxes, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1851
+ await sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
1852
+ shutdownSandboxes = shutdownAllDockerSandboxes;
1757
1853
  profiles.push({
1758
1854
  name: `docker`,
1759
1855
  label: `Docker`,
1760
1856
  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).`,
1761
- factory: ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
1857
+ factory: async ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
1762
1858
  const cwd = readWorkingDirectoryArg(args);
1763
- return dockerSandbox({
1764
- initialNetworkPolicy: { mode: `allow-all` },
1765
- extraMounts: cwd ? [{
1766
- hostPath: cwd,
1767
- containerPath: `/work`,
1768
- readOnly: false
1769
- }] : void 0,
1770
- sandboxKey,
1771
- persistent,
1772
- owner,
1773
- entityType,
1774
- entityUrl
1859
+ return (0, __electric_ax_agents_runtime_sandbox.lazySandbox)({
1860
+ name: `docker`,
1861
+ workingDirectory: `/work`,
1862
+ factory: () => dockerSandbox({
1863
+ initialNetworkPolicy: { mode: `allow-all` },
1864
+ extraMounts: cwd ? [{
1865
+ hostPath: cwd,
1866
+ containerPath: `/work`,
1867
+ readOnly: false
1868
+ }] : void 0,
1869
+ sandboxKey,
1870
+ persistent,
1871
+ owner,
1872
+ entityType,
1873
+ entityUrl
1874
+ }),
1875
+ reclaim: owner ? () => reclaimDockerSandboxByKey(sandboxKey) : void 0
1775
1876
  });
1776
1877
  }
1777
1878
  });
@@ -1795,7 +1896,10 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1795
1896
  });
1796
1897
  else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
1797
1898
  console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
1798
- return profiles;
1899
+ return {
1900
+ profiles,
1901
+ shutdownSandboxes
1902
+ };
1799
1903
  }
1800
1904
  function readWorkingDirectoryArg(args) {
1801
1905
  const v = args.workingDirectory;
@@ -1814,7 +1918,7 @@ function installDurableStreamsFetchCache(options = {}) {
1814
1918
  location: options.sqliteLocation,
1815
1919
  maxCount: options.maxCount
1816
1920
  }) : new undici.cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1817
- (0, undici.setGlobalDispatcher)(new undici.Agent().compose(undici.interceptors.cache({ store })));
1921
+ (0, undici.setGlobalDispatcher)((0, undici.getGlobalDispatcher)().compose(undici.interceptors.cache({ store })));
1818
1922
  }
1819
1923
 
1820
1924
  //#endregion
@@ -2013,6 +2117,9 @@ var BuiltinAgentsServer = class {
2013
2117
  await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
2014
2118
  serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
2015
2119
  }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2120
+ if (this.bootstrap.shutdownSandboxes) await Promise.race([this.bootstrap.shutdownSandboxes().catch((err) => {
2121
+ serverLog.error(`[builtin-agents] sandbox shutdown failed during shutdown:`, err);
2122
+ }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2016
2123
  this.bootstrap = null;
2017
2124
  }
2018
2125
  this.mcpStopping = true;
@@ -2153,6 +2260,7 @@ exports.builtinModelProviderLabel = builtinModelProviderLabel
2153
2260
  exports.createAgentHandler = createAgentHandler
2154
2261
  exports.createBuiltinAgentHandler = createBuiltinAgentHandler
2155
2262
  exports.createBuiltinElectricTools = createBuiltinElectricTools
2263
+ exports.createForkTool = createForkTool
2156
2264
  exports.createHortonDocsSupport = createHortonDocsSupport
2157
2265
  exports.createHortonTools = createHortonTools
2158
2266
  exports.createSpawnWorkerTool = createSpawnWorkerTool
package/dist/index.d.cts CHANGED
@@ -14,6 +14,13 @@ interface AgentHandlerResult {
14
14
  registry: EntityRegistry;
15
15
  typeNames: Array<string>;
16
16
  skillsRegistry: SkillsRegistry | null;
17
+ /**
18
+ * Immediately tears down the idle sandboxes this process created (set when
19
+ * a provider with host-local state — docker — is registered). MUST be called
20
+ * on shutdown, after wakes drain: the providers' debounced idle teardowns
21
+ * die with the process, which would leave containers running.
22
+ */
23
+ shutdownSandboxes: (() => Promise<void>) | null;
17
24
  }
18
25
  type BuiltinElectricToolsFactory = NonNullable<ProcessWakeConfig[`createElectricTools`]>;
19
26
  interface BuiltinAgentHandlerOptions {
@@ -190,6 +197,7 @@ declare function generateTitle(userMessage: string, llmCall: (prompt: string) =>
190
197
  declare function buildHortonSystemPrompt(workingDirectory: string, opts?: {
191
198
  hasDocsSupport?: boolean;
192
199
  hasEventSourceTools?: boolean;
200
+ hasScheduleTools?: boolean;
193
201
  hasSkills?: boolean;
194
202
  docsUrl?: string;
195
203
  modelProvider?: string;
@@ -223,6 +231,10 @@ declare const WORKER_TOOL_NAMES: readonly ["bash", "read", "write", "edit", "web
223
231
  type WorkerToolName = (typeof WORKER_TOOL_NAMES)[number];
224
232
  declare function createSpawnWorkerTool(ctx: HandlerContext, modelConfig?: BuiltinAgentModelConfig): AgentTool$1;
225
233
 
234
+ //#endregion
235
+ //#region src/tools/fork.d.ts
236
+ declare function createForkTool(ctx: HandlerContext): AgentTool$1;
237
+
226
238
  //#endregion
227
239
  //#region src/docs/knowledge-base.d.ts
228
240
  interface HortonDocsSupport {
@@ -242,4 +254,4 @@ declare function createHortonDocsSupport(workingDirectory: string, opts?: {
242
254
  }): HortonDocsSupport | null;
243
255
 
244
256
  //#endregion
245
- export { AgentHandlerResult, BuiltinAgentHandlerOptions, BuiltinAgentsEntrypointOptions, BuiltinAgentsEntrypointServer, BuiltinAgentsServer, BuiltinAgentsServerOptions, BuiltinElectricToolsFactory, BuiltinModelCatalogOptions, BuiltinModelChoice, BuiltinModelProvider, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, McpConfig, McpListedEntry, McpRegistry, McpServerConfig, RegistrySnapshot, RegistrySubscriber, RunBuiltinAgentsEntrypointOptions, WORKER_TOOL_NAMES, WorkerToolName, braveSearchTool, buildHortonSystemPrompt, builtinModelProviderLabel, createAgentHandler, createBuiltinAgentHandler, createBuiltinElectricTools, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, listBuiltinModelChoices, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
257
+ export { AgentHandlerResult, BuiltinAgentHandlerOptions, BuiltinAgentsEntrypointOptions, BuiltinAgentsEntrypointServer, BuiltinAgentsServer, BuiltinAgentsServerOptions, BuiltinElectricToolsFactory, BuiltinModelCatalogOptions, BuiltinModelChoice, BuiltinModelProvider, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, McpConfig, McpListedEntry, McpRegistry, McpServerConfig, RegistrySnapshot, RegistrySubscriber, RunBuiltinAgentsEntrypointOptions, WORKER_TOOL_NAMES, WorkerToolName, braveSearchTool, buildHortonSystemPrompt, builtinModelProviderLabel, createAgentHandler, createBuiltinAgentHandler, createBuiltinElectricTools, createForkTool, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, listBuiltinModelChoices, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,13 @@ interface AgentHandlerResult {
14
14
  registry: EntityRegistry;
15
15
  typeNames: Array<string>;
16
16
  skillsRegistry: SkillsRegistry | null;
17
+ /**
18
+ * Immediately tears down the idle sandboxes this process created (set when
19
+ * a provider with host-local state — docker — is registered). MUST be called
20
+ * on shutdown, after wakes drain: the providers' debounced idle teardowns
21
+ * die with the process, which would leave containers running.
22
+ */
23
+ shutdownSandboxes: (() => Promise<void>) | null;
17
24
  }
18
25
  type BuiltinElectricToolsFactory = NonNullable<ProcessWakeConfig[`createElectricTools`]>;
19
26
  interface BuiltinAgentHandlerOptions {
@@ -190,6 +197,7 @@ declare function generateTitle(userMessage: string, llmCall: (prompt: string) =>
190
197
  declare function buildHortonSystemPrompt(workingDirectory: string, opts?: {
191
198
  hasDocsSupport?: boolean;
192
199
  hasEventSourceTools?: boolean;
200
+ hasScheduleTools?: boolean;
193
201
  hasSkills?: boolean;
194
202
  docsUrl?: string;
195
203
  modelProvider?: string;
@@ -223,6 +231,10 @@ declare const WORKER_TOOL_NAMES: readonly ["bash", "read", "write", "edit", "web
223
231
  type WorkerToolName = (typeof WORKER_TOOL_NAMES)[number];
224
232
  declare function createSpawnWorkerTool(ctx: HandlerContext, modelConfig?: BuiltinAgentModelConfig): AgentTool$1;
225
233
 
234
+ //#endregion
235
+ //#region src/tools/fork.d.ts
236
+ declare function createForkTool(ctx: HandlerContext): AgentTool$1;
237
+
226
238
  //#endregion
227
239
  //#region src/docs/knowledge-base.d.ts
228
240
  interface HortonDocsSupport {
@@ -242,4 +254,4 @@ declare function createHortonDocsSupport(workingDirectory: string, opts?: {
242
254
  }): HortonDocsSupport | null;
243
255
 
244
256
  //#endregion
245
- export { AgentHandlerResult, BuiltinAgentHandlerOptions, BuiltinAgentsEntrypointOptions, BuiltinAgentsEntrypointServer, BuiltinAgentsServer, BuiltinAgentsServerOptions, BuiltinElectricToolsFactory, BuiltinModelCatalogOptions, BuiltinModelChoice, BuiltinModelProvider, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, McpConfig, McpListedEntry, McpRegistry, McpServerConfig, RegistrySnapshot, RegistrySubscriber, RunBuiltinAgentsEntrypointOptions, WORKER_TOOL_NAMES, WorkerToolName, braveSearchTool, buildHortonSystemPrompt, builtinModelProviderLabel, createAgentHandler, createBuiltinAgentHandler, createBuiltinElectricTools, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, listBuiltinModelChoices, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
257
+ export { AgentHandlerResult, BuiltinAgentHandlerOptions, BuiltinAgentsEntrypointOptions, BuiltinAgentsEntrypointServer, BuiltinAgentsServer, BuiltinAgentsServerOptions, BuiltinElectricToolsFactory, BuiltinModelCatalogOptions, BuiltinModelChoice, BuiltinModelProvider, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, McpConfig, McpListedEntry, McpRegistry, McpServerConfig, RegistrySnapshot, RegistrySubscriber, RunBuiltinAgentsEntrypointOptions, WORKER_TOOL_NAMES, WorkerToolName, braveSearchTool, buildHortonSystemPrompt, builtinModelProviderLabel, createAgentHandler, createBuiltinAgentHandler, createBuiltinElectricTools, createForkTool, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, listBuiltinModelChoices, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
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, 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";
4
+ 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";
5
+ import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createScheduleTools, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
6
+ import { chooseDefaultSandbox, isE2BAvailable, lazySandbox, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
7
7
  import fsSync from "node:fs";
8
8
  import pino from "pino";
9
9
  import { z } from "zod";
@@ -15,7 +15,7 @@ import { load } from "sqlite-vec";
15
15
  import { nanoid } from "nanoid";
16
16
  import { getModels } from "@mariozechner/pi-ai";
17
17
  import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
18
- import { Agent, cacheStores, interceptors, setGlobalDispatcher } from "undici";
18
+ import { cacheStores, getGlobalDispatcher, interceptors, setGlobalDispatcher } from "undici";
19
19
 
20
20
  //#region src/log.ts
21
21
  const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
@@ -794,6 +794,60 @@ function createSpawnWorkerTool(ctx, modelConfig) {
794
794
  };
795
795
  }
796
796
 
797
+ //#endregion
798
+ //#region src/tools/fork.ts
799
+ function createForkTool(ctx) {
800
+ return {
801
+ name: `fork`,
802
+ label: `Fork`,
803
+ description: `Fork a session at its latest completed agent response, producing a child copy of the conversation up to that point. The new fork is YOUR child — same parent-ownership model as a spawned worker — and it reports back to you the same way: when its next run finishes you'll be woken with its response. End your turn after forking.
804
+
805
+ Prefer supplying an 'initialMessage' so the fork is dispatched immediately in a single call — no follow-up 'send' needed. If you omit it, the fork boots idle and you'll need to call 'send' afterwards. For chat-rendered messages use the shape \`{ "text": "..." }\` so the prompt shows up in the chat UI.
806
+
807
+ Use this to explore multiple alternative continuations in parallel from the same starting point. End your current turn first so the fork includes your latest response — the anchor is always the most recently completed run.
808
+
809
+ Omit 'entityUrl' to fork your own session. Pass a different session's URL to fork that session instead (the new fork is still your child). The optional 'id' names the new fork's instance — useful when you want stable, predictable URLs (e.g. labelling branches in a parallel exploration); omit to let the server mint one.`,
810
+ parameters: Type.Object({
811
+ entityUrl: Type.Optional(Type.String({ description: `URL of the session to fork. Omit to fork your own session.` })),
812
+ id: Type.Optional(Type.String({ description: `Instance id for the new fork (the \`<id>\` in \`/horton/<id>\`). Mirrors spawn_worker's id parameter. Omit to let the server assign one.` })),
813
+ initialMessage: Type.Optional(Type.Any({ description: `Initial inbox message delivered to the fork by the server in the same round-trip — the fork wakes and starts running immediately, no follow-up 'send' needed. Use the shape \`{ "text": "..." }\` for chat-rendered prompts. Omit to leave the fork idle (then call 'send' separately).` })),
814
+ tags: Type.Optional(Type.Record(Type.String(), Type.String(), { description: `Optional tags stamped on the new fork, on top of those copied from the source. Useful for labelling experiments (e.g. \`{ "experiment": "ecosystem-maturity" }\`).` }))
815
+ }),
816
+ execute: async (_toolCallId, params) => {
817
+ const { entityUrl, id, initialMessage, tags } = params;
818
+ try {
819
+ const opts = {
820
+ ...initialMessage !== void 0 && { initialMessage },
821
+ ...tags !== void 0 && { tags }
822
+ };
823
+ const forkId = id ?? `fork-${nanoid(10)}`;
824
+ const handle = entityUrl !== void 0 ? await ctx.fork(entityUrl, forkId, opts) : await ctx.forkSelf(forkId, opts);
825
+ const dispatchNote = initialMessage !== void 0 ? `The initial message has been delivered to the fork — it will start running.` : `The fork boots idle — use the 'send' tool to dispatch a follow-up prompt.`;
826
+ return {
827
+ content: [{
828
+ type: `text`,
829
+ text: `Forked at ${handle.entityUrl}. ${dispatchNote} End your turn; you'll wake with the fork's response when its next run finishes (same as a spawned worker).`
830
+ }],
831
+ details: {
832
+ forked: true,
833
+ forkUrl: handle.entityUrl
834
+ }
835
+ };
836
+ } catch (err) {
837
+ const message = err instanceof Error ? err.message : `Unknown error`;
838
+ serverLog.warn(`[fork tool] failed to fork ${entityUrl ?? `<self>`}: ${message}`, err instanceof Error ? err : void 0);
839
+ return {
840
+ content: [{
841
+ type: `text`,
842
+ text: `Error forking session: ${message}`
843
+ }],
844
+ details: { forked: false }
845
+ };
846
+ }
847
+ }
848
+ };
849
+ }
850
+
797
851
  //#endregion
798
852
  //#region src/model-catalog.ts
799
853
  const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
@@ -978,6 +1032,8 @@ function modelInputSchemaDefs(catalog) {
978
1032
  const HORTON_MODEL = `claude-sonnet-4-6`;
979
1033
  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.";
980
1034
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1035
+ const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1036
+ const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
981
1037
  const TITLE_STOP_WORDS = new Set([
982
1038
  `a`,
983
1039
  `an`,
@@ -1057,6 +1113,17 @@ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1057
1113
  maxTokens: 64
1058
1114
  });
1059
1115
  }
1116
+ function withTimeout(promise, ms, description) {
1117
+ let timeout;
1118
+ const timeoutPromise = new Promise((_resolve, reject) => {
1119
+ timeout = setTimeout(() => {
1120
+ reject(new Error(`${description} timed out after ${ms}ms`));
1121
+ }, ms);
1122
+ });
1123
+ return Promise.race([promise, timeoutPromise]).finally(() => {
1124
+ if (timeout) clearTimeout(timeout);
1125
+ });
1126
+ }
1060
1127
  async function generateTitle(userMessage, llmCall, onFallback) {
1061
1128
  try {
1062
1129
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
@@ -1072,6 +1139,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1072
1139
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1073
1140
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1074
1141
  const eventSourceTools = opts.hasEventSourceTools ? `\n- list_event_sources: list external webhook/event feeds you can subscribe to, including available buckets and parameters\n- subscribe_event_source: subscribe yourself to one of those feeds or buckets so matching future events wake you\n- list_event_source_subscriptions: list your active event source subscriptions\n- unsubscribe_event_source: remove one of your event source subscriptions by id` : ``;
1142
+ const scheduleTools = opts.hasScheduleTools ? `\n- upsert_cron_schedule: create or update a recurring cron wake for yourself. Always include payload with the concrete instruction/message you should receive when the cron fires.\n- delete_schedule: delete one of your cron or future-send schedules by stable id\n- list_schedules: list your manifest-backed cron and future-send schedules` : ``;
1075
1143
  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` : ``;
1076
1144
  const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents or this framework, ALWAYS use search_electric_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.` : ``;
1077
1145
  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.
@@ -1125,8 +1193,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1125
1193
  - web_search: search the web
1126
1194
  - fetch_url: fetch and convert a URL to markdown
1127
1195
  - spawn_worker: dispatch a subagent for an isolated task
1196
+ - fork: spawn a child session that inherits this conversation's history up to the latest completed response. Same parent-ownership model as spawn_worker — when the fork's next run finishes, you'll wake with its response.
1128
1197
  - send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
1129
- ${eventSourceTools}${docsTools}${skillsTools}
1198
+ ${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
1130
1199
 
1131
1200
  # Working with files
1132
1201
  - Prefer edit over write when modifying existing files.
@@ -1153,6 +1222,20 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
1153
1222
 
1154
1223
  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.
1155
1224
 
1225
+ # When to fork (vs spawn_worker)
1226
+ \`fork\` is the **sibling primitive to spawn_worker** — both create a child you own, both report back to you on a future wake. The difference is only in what the child boots with:
1227
+
1228
+ - **spawn_worker** → child boots with an **empty context**; you brief it from scratch via a system prompt + initial message. Use when the worker doesn't need to know what we've said so far.
1229
+ - **fork** → child boots with **a copy of THIS conversation's history** up to your latest completed response. Use when each child needs to know what we've already established (the user's framing, your prior analysis, an earlier decision, the constraints, etc.).
1230
+
1231
+ **Trigger pattern: prefer fork when generating multiple variants the user wants to compare.** If the user asks for "three different X" or "two takes on Y" or "evaluate these N approaches" and each variant should reflect the conversation we've had so far, **don't inline the variants in one response** — fork once per variant, send each a tailored follow-up, and synthesize when they report back. Inlining feels faster but the variants end up cross-contaminating in your single response; forks keep them honestly independent. The exception is trivial generation (a list of names, a couple of one-liners) where each variant takes a sentence — there, inline is fine.
1232
+
1233
+ Workflow when forking yourself for parallel exploration:
1234
+ 1. **End your current turn first.** The fork's history stops at your *latest completed* run. Anything you say mid-turn is NOT in the fork. If you want your analysis baked into each fork, finish it and end the turn before calling fork.
1235
+ 2. On the next wake, call \`fork\` once per branch with a different \`initialMessage\` per call — that's how the branches diverge from a shared starting point. Each fork is YOUR child, just like a spawned worker, and the server delivers \`initialMessage\` to the fork in the same round-trip, so the fork starts running immediately (no follow-up \`send\` needed). Use the shape \`{ text: "..." }\` for the message so it renders in the chat UI.
1236
+ 3. End your turn. You'll wake automatically when each fork's run finishes (same wake mechanism as spawn_worker); the wake message identifies the fork and includes its response.
1237
+ 4. If you're waiting on multiple forks, don't synthesize on the first wake — quietly end the turn with "got N of M, waiting" until you have what you need to compare.
1238
+
1156
1239
  # Reporting
1157
1240
  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.
1158
1241
 
@@ -1178,6 +1261,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1178
1261
  logPrefix: opts.logPrefix ?? `[horton]`
1179
1262
  })] : [createFetchUrlTool(sandbox)],
1180
1263
  createSpawnWorkerTool(ctx, opts.modelConfig),
1264
+ createForkTool(ctx),
1181
1265
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1182
1266
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1183
1267
  ];
@@ -1186,8 +1270,12 @@ function payloadToTitleText(payload) {
1186
1270
  if (typeof payload === `string`) return payload;
1187
1271
  if (payload == null) return ``;
1188
1272
  if (typeof payload === `object`) {
1189
- const text = payload.text;
1190
- return typeof text === `string` ? text : JSON.stringify(payload);
1273
+ const record = payload;
1274
+ const text = record.text;
1275
+ if (typeof text === `string`) return text;
1276
+ const source = record.source;
1277
+ if (typeof source === `string`) return source;
1278
+ return JSON.stringify(payload);
1191
1279
  }
1192
1280
  return String(payload);
1193
1281
  }
@@ -1245,8 +1333,10 @@ async function readAgentsMd(sandbox) {
1245
1333
  }
1246
1334
  function createAssistantHandler(options) {
1247
1335
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1248
- const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1336
+ const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1337
+ const hasSkills = skillLoader.hasSkills;
1249
1338
  return async function assistantHandler(ctx, wake) {
1339
+ const loadedSkills = await skillLoader.load(ctx);
1250
1340
  const readSet = new Set();
1251
1341
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1252
1342
  const sourceBudget = resolveBuiltinModelSourceBudget(modelConfig);
@@ -1260,24 +1350,26 @@ function createAssistantHandler(options) {
1260
1350
  modelCatalog,
1261
1351
  logPrefix: `[horton ${ctx.entityUrl}]`
1262
1352
  }),
1263
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
1353
+ ...loadedSkills.tools,
1264
1354
  ...mcp.tools()
1265
1355
  ];
1266
1356
  const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
1357
+ const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
1267
1358
  const titlePromise = !ctx.tags.title ? (async () => {
1268
1359
  const firstUserMessage = await extractFirstUserMessage(ctx);
1269
1360
  if (!firstUserMessage) return;
1270
1361
  let title = null;
1271
1362
  try {
1272
- const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1363
+ const result = await generateTitle(firstUserMessage, (prompt) => withTimeout(createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`)(prompt), TITLE_GENERATION_TIMEOUT_MS, `title generation`), (reason) => {
1273
1364
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1274
1365
  });
1275
1366
  if (result.length > 0) title = result;
1276
1367
  } catch (err) {
1277
1368
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1369
+ title = buildFallbackTitle(firstUserMessage);
1278
1370
  }
1279
1371
  if (title !== null) try {
1280
- await ctx.setTag(`title`, title);
1372
+ await withTimeout(ctx.setTag(`title`, title), TITLE_GENERATION_TIMEOUT_MS, `set title tag`);
1281
1373
  } catch (err) {
1282
1374
  serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1283
1375
  }
@@ -1304,21 +1396,13 @@ function createAssistantHandler(options) {
1304
1396
  max: 2e4,
1305
1397
  cache: `stable`
1306
1398
  } } : {},
1307
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1308
- content: () => skillsRegistry.renderCatalog(2e3),
1309
- max: 2e3,
1310
- cache: `stable`
1311
- } } : {}
1399
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? loadedSkills.sources : {}
1312
1400
  }
1313
1401
  });
1314
1402
  else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
1315
1403
  sourceBudget,
1316
1404
  sources: {
1317
- skills_catalog: {
1318
- content: () => skillsRegistry.renderCatalog(2e3),
1319
- max: 2e3,
1320
- cache: `stable`
1321
- },
1405
+ ...loadedSkills.sources,
1322
1406
  conversation: {
1323
1407
  content: () => ctx.timelineMessages(),
1324
1408
  cache: `volatile`
@@ -1351,7 +1435,8 @@ function createAssistantHandler(options) {
1351
1435
  docsUrl,
1352
1436
  modelProvider: modelConfig.provider,
1353
1437
  modelId: String(modelConfig.model),
1354
- hasEventSourceTools
1438
+ hasEventSourceTools,
1439
+ hasScheduleTools
1355
1440
  }),
1356
1441
  ...modelConfig,
1357
1442
  tools,
@@ -1397,6 +1482,7 @@ function registerHorton(registry, options) {
1397
1482
  subject_value: `user`,
1398
1483
  permission: `manage`
1399
1484
  }],
1485
+ slashCommands: buildSkillSlashCommands(skillsRegistry),
1400
1486
  handler: assistantHandler
1401
1487
  });
1402
1488
  return [`horton`];
@@ -1623,7 +1709,10 @@ function dedupeToolsByName(tools) {
1623
1709
  }
1624
1710
  function createBuiltinElectricTools(custom) {
1625
1711
  return async (context) => {
1626
- const builtinTools = createEventSourceTools(context);
1712
+ const builtinTools = [...createEventSourceTools(context), ...createScheduleTools({
1713
+ ...context,
1714
+ db: context.db
1715
+ })];
1627
1716
  const customTools = custom ? await custom(context) : [];
1628
1717
  return dedupeToolsByName([...builtinTools, ...customTools]);
1629
1718
  };
@@ -1665,7 +1754,7 @@ async function createBuiltinAgentHandler(options) {
1665
1754
  modelCatalog
1666
1755
  });
1667
1756
  typeNames.push(`worker`);
1668
- const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
1757
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
1669
1758
  const runtime = createRuntimeHandler({
1670
1759
  baseUrl: agentServerUrl,
1671
1760
  serveEndpoint,
@@ -1684,7 +1773,8 @@ async function createBuiltinAgentHandler(options) {
1684
1773
  runtime,
1685
1774
  registry,
1686
1775
  typeNames,
1687
- skillsRegistry
1776
+ skillsRegistry,
1777
+ shutdownSandboxes
1688
1778
  };
1689
1779
  }
1690
1780
  async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
@@ -1705,18 +1795,22 @@ const registerAgentTypes = registerBuiltinAgentTypes;
1705
1795
  * Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
1706
1796
  * re-run the boot sweep.
1707
1797
  */
1708
- let dockerSweptOnBoot = false;
1798
+ let dockerBootSweep = null;
1709
1799
  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)`);
1800
+ dockerBootSweep ??= sweep().then((reclaimed) => {
1801
+ if (reclaimed.length > 0) serverLog.info(`[builtin-agents] docker sandbox boot sweep reclaimed ${reclaimed.length} leftover container(s)`);
1714
1802
  }).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
1803
+ return dockerBootSweep;
1715
1804
  }
1716
1805
  /**
1717
1806
  * Built-in sandbox profiles. `local` is always available. `docker` is
1718
1807
  * gated on Docker being reachable so a user without Docker installed
1719
1808
  * sees only what works — the UI never offers a non-functional choice.
1809
+ *
1810
+ * Also returns `shutdownSandboxes` when a host-local provider registered: an
1811
+ * immediate teardown of this process's live containers that the embedding
1812
+ * server must run on shutdown (the providers' debounced idle teardowns die
1813
+ * with the process).
1720
1814
  */
1721
1815
  async function buildBuiltinSandboxProfiles(workingDirectory) {
1722
1816
  const profiles = [{
@@ -1725,29 +1819,36 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1725
1819
  description: `Runs on the host without isolation. Full filesystem access.`,
1726
1820
  factory: ({ args }) => chooseDefaultSandbox(resolveCwd(args, workingDirectory))
1727
1821
  }];
1822
+ let shutdownSandboxes = null;
1728
1823
  try {
1729
1824
  const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1730
1825
  if (await isDockerAvailable()) {
1731
- const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1732
- sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
1826
+ const { dockerSandbox, reclaimDockerSandboxByKey, shutdownAllDockerSandboxes, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
1827
+ await sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
1828
+ shutdownSandboxes = shutdownAllDockerSandboxes;
1733
1829
  profiles.push({
1734
1830
  name: `docker`,
1735
1831
  label: `Docker`,
1736
1832
  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 }) => {
1833
+ factory: async ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
1738
1834
  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
1835
+ return lazySandbox({
1836
+ name: `docker`,
1837
+ workingDirectory: `/work`,
1838
+ factory: () => dockerSandbox({
1839
+ initialNetworkPolicy: { mode: `allow-all` },
1840
+ extraMounts: cwd ? [{
1841
+ hostPath: cwd,
1842
+ containerPath: `/work`,
1843
+ readOnly: false
1844
+ }] : void 0,
1845
+ sandboxKey,
1846
+ persistent,
1847
+ owner,
1848
+ entityType,
1849
+ entityUrl
1850
+ }),
1851
+ reclaim: owner ? () => reclaimDockerSandboxByKey(sandboxKey) : void 0
1751
1852
  });
1752
1853
  }
1753
1854
  });
@@ -1771,7 +1872,10 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1771
1872
  });
1772
1873
  else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
1773
1874
  console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
1774
- return profiles;
1875
+ return {
1876
+ profiles,
1877
+ shutdownSandboxes
1878
+ };
1775
1879
  }
1776
1880
  function readWorkingDirectoryArg(args) {
1777
1881
  const v = args.workingDirectory;
@@ -1790,7 +1894,7 @@ function installDurableStreamsFetchCache(options = {}) {
1790
1894
  location: options.sqliteLocation,
1791
1895
  maxCount: options.maxCount
1792
1896
  }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1793
- setGlobalDispatcher(new Agent().compose(interceptors.cache({ store })));
1897
+ setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
1794
1898
  }
1795
1899
 
1796
1900
  //#endregion
@@ -1989,6 +2093,9 @@ var BuiltinAgentsServer = class {
1989
2093
  await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
1990
2094
  serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
1991
2095
  }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
2096
+ if (this.bootstrap.shutdownSandboxes) await Promise.race([this.bootstrap.shutdownSandboxes().catch((err) => {
2097
+ serverLog.error(`[builtin-agents] sandbox shutdown failed during shutdown:`, err);
2098
+ }), new Promise((resolve) => setTimeout(resolve, 5e3))]);
1992
2099
  this.bootstrap = null;
1993
2100
  }
1994
2101
  this.mcpStopping = true;
@@ -2114,4 +2221,4 @@ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd
2114
2221
  }
2115
2222
 
2116
2223
  //#endregion
2117
- export { BuiltinAgentsServer, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, WORKER_TOOL_NAMES, braveSearchTool, buildHortonSystemPrompt, builtinModelProviderLabel, createAgentHandler, createBuiltinAgentHandler, createBuiltinElectricTools, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, listBuiltinModelChoices, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
2224
+ export { BuiltinAgentsServer, DEFAULT_BUILTIN_AGENT_HANDLER_PATH, HORTON_MODEL, WORKER_TOOL_NAMES, braveSearchTool, buildHortonSystemPrompt, builtinModelProviderLabel, createAgentHandler, createBuiltinAgentHandler, createBuiltinElectricTools, createForkTool, createHortonDocsSupport, createHortonTools, createSpawnWorkerTool, generateTitle, listBuiltinModelChoices, registerAgentTypes, registerBuiltinAgentTypes, registerHorton, registerWorker, resolveBuiltinAgentsEntrypointOptions, runBuiltinAgentsEntrypoint };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "Built-in Electric Agents runtimes such as Horton and worker",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,7 +50,7 @@
50
50
  "undici": "^7.24.7",
51
51
  "zod": "^4.3.6",
52
52
  "@electric-ax/agents-mcp": "0.2.2",
53
- "@electric-ax/agents-runtime": "0.3.9"
53
+ "@electric-ax/agents-runtime": "0.3.11"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/better-sqlite3": "^7.6.13",