@electric-ax/agents 0.4.14 → 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.
- package/dist/entrypoint.js +124 -30
- package/dist/index.cjs +122 -27
- package/dist/index.d.cts +13 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +125 -31
- package/package.json +2 -2
package/dist/entrypoint.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
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
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, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
|
|
9
|
-
import { chooseDefaultSandbox, isE2BAvailable, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
|
|
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(
|
|
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`;
|
|
@@ -1096,6 +1150,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
|
|
|
1096
1150
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1097
1151
|
const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
|
|
1098
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` : ``;
|
|
1099
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` : ``;
|
|
1100
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.` : ``;
|
|
1101
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.
|
|
@@ -1149,8 +1204,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1149
1204
|
- web_search: search the web
|
|
1150
1205
|
- fetch_url: fetch and convert a URL to markdown
|
|
1151
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.
|
|
1152
1208
|
- 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}
|
|
1209
|
+
${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
|
|
1154
1210
|
|
|
1155
1211
|
# Working with files
|
|
1156
1212
|
- Prefer edit over write when modifying existing files.
|
|
@@ -1177,6 +1233,20 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1177
1233
|
|
|
1178
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.
|
|
1179
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
|
+
|
|
1180
1250
|
# Reporting
|
|
1181
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.
|
|
1182
1252
|
|
|
@@ -1202,6 +1272,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
|
|
|
1202
1272
|
logPrefix: opts.logPrefix ?? `[horton]`
|
|
1203
1273
|
})] : [createFetchUrlTool(sandbox)],
|
|
1204
1274
|
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1275
|
+
createForkTool(ctx),
|
|
1205
1276
|
createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
|
|
1206
1277
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1207
1278
|
];
|
|
@@ -1294,6 +1365,7 @@ function createAssistantHandler(options) {
|
|
|
1294
1365
|
...mcp.tools()
|
|
1295
1366
|
];
|
|
1296
1367
|
const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
|
|
1368
|
+
const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
|
|
1297
1369
|
const titlePromise = !ctx.tags.title ? (async () => {
|
|
1298
1370
|
const firstUserMessage = await extractFirstUserMessage(ctx);
|
|
1299
1371
|
if (!firstUserMessage) return;
|
|
@@ -1374,7 +1446,8 @@ function createAssistantHandler(options) {
|
|
|
1374
1446
|
docsUrl,
|
|
1375
1447
|
modelProvider: modelConfig.provider,
|
|
1376
1448
|
modelId: String(modelConfig.model),
|
|
1377
|
-
hasEventSourceTools
|
|
1449
|
+
hasEventSourceTools,
|
|
1450
|
+
hasScheduleTools
|
|
1378
1451
|
}),
|
|
1379
1452
|
...modelConfig,
|
|
1380
1453
|
tools,
|
|
@@ -1646,7 +1719,10 @@ function dedupeToolsByName(tools) {
|
|
|
1646
1719
|
}
|
|
1647
1720
|
function createBuiltinElectricTools(custom) {
|
|
1648
1721
|
return async (context) => {
|
|
1649
|
-
const builtinTools = createEventSourceTools(context)
|
|
1722
|
+
const builtinTools = [...createEventSourceTools(context), ...createScheduleTools({
|
|
1723
|
+
...context,
|
|
1724
|
+
db: context.db
|
|
1725
|
+
})];
|
|
1650
1726
|
const customTools = custom ? await custom(context) : [];
|
|
1651
1727
|
return dedupeToolsByName([...builtinTools, ...customTools]);
|
|
1652
1728
|
};
|
|
@@ -1688,7 +1764,7 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1688
1764
|
modelCatalog
|
|
1689
1765
|
});
|
|
1690
1766
|
typeNames.push(`worker`);
|
|
1691
|
-
const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
|
|
1767
|
+
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
|
|
1692
1768
|
const runtime = createRuntimeHandler({
|
|
1693
1769
|
baseUrl: agentServerUrl,
|
|
1694
1770
|
serveEndpoint,
|
|
@@ -1707,7 +1783,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1707
1783
|
runtime,
|
|
1708
1784
|
registry,
|
|
1709
1785
|
typeNames,
|
|
1710
|
-
skillsRegistry
|
|
1786
|
+
skillsRegistry,
|
|
1787
|
+
shutdownSandboxes
|
|
1711
1788
|
};
|
|
1712
1789
|
}
|
|
1713
1790
|
async function registerBuiltinAgentTypes(bootstrap) {
|
|
@@ -1718,18 +1795,22 @@ async function registerBuiltinAgentTypes(bootstrap) {
|
|
|
1718
1795
|
* Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
|
|
1719
1796
|
* re-run the boot sweep.
|
|
1720
1797
|
*/
|
|
1721
|
-
let
|
|
1798
|
+
let dockerBootSweep = null;
|
|
1722
1799
|
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
sweep().then((removed) => {
|
|
1726
|
-
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)`);
|
|
1727
1802
|
}).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1803
|
+
return dockerBootSweep;
|
|
1728
1804
|
}
|
|
1729
1805
|
/**
|
|
1730
1806
|
* Built-in sandbox profiles. `local` is always available. `docker` is
|
|
1731
1807
|
* gated on Docker being reachable so a user without Docker installed
|
|
1732
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).
|
|
1733
1814
|
*/
|
|
1734
1815
|
async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
1735
1816
|
const profiles = [{
|
|
@@ -1738,29 +1819,36 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1738
1819
|
description: `Runs on the host without isolation. Full filesystem access.`,
|
|
1739
1820
|
factory: ({ args }) => chooseDefaultSandbox(resolveCwd(args, workingDirectory))
|
|
1740
1821
|
}];
|
|
1822
|
+
let shutdownSandboxes = null;
|
|
1741
1823
|
try {
|
|
1742
1824
|
const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1743
1825
|
if (await isDockerAvailable()) {
|
|
1744
|
-
const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1745
|
-
sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1826
|
+
const { dockerSandbox, reclaimDockerSandboxByKey, shutdownAllDockerSandboxes, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1827
|
+
await sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1828
|
+
shutdownSandboxes = shutdownAllDockerSandboxes;
|
|
1746
1829
|
profiles.push({
|
|
1747
1830
|
name: `docker`,
|
|
1748
1831
|
label: `Docker`,
|
|
1749
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).`,
|
|
1750
|
-
factory: ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1833
|
+
factory: async ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1751
1834
|
const cwd = readWorkingDirectoryArg(args);
|
|
1752
|
-
return
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
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
|
|
1764
1852
|
});
|
|
1765
1853
|
}
|
|
1766
1854
|
});
|
|
@@ -1784,7 +1872,10 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1784
1872
|
});
|
|
1785
1873
|
else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
|
|
1786
1874
|
console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
|
|
1787
|
-
return
|
|
1875
|
+
return {
|
|
1876
|
+
profiles,
|
|
1877
|
+
shutdownSandboxes
|
|
1878
|
+
};
|
|
1788
1879
|
}
|
|
1789
1880
|
function readWorkingDirectoryArg(args) {
|
|
1790
1881
|
const v = args.workingDirectory;
|
|
@@ -1990,6 +2081,9 @@ var BuiltinAgentsServer = class {
|
|
|
1990
2081
|
await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
|
|
1991
2082
|
serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
|
|
1992
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))]);
|
|
1993
2087
|
this.bootstrap = null;
|
|
1994
2088
|
}
|
|
1995
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`;
|
|
@@ -1109,6 +1163,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
|
|
|
1109
1163
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1110
1164
|
const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
|
|
1111
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` : ``;
|
|
1112
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` : ``;
|
|
1113
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.` : ``;
|
|
1114
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.
|
|
@@ -1162,8 +1217,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1162
1217
|
- web_search: search the web
|
|
1163
1218
|
- fetch_url: fetch and convert a URL to markdown
|
|
1164
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.
|
|
1165
1221
|
- send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
|
|
1166
|
-
${eventSourceTools}${docsTools}${skillsTools}
|
|
1222
|
+
${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
|
|
1167
1223
|
|
|
1168
1224
|
# Working with files
|
|
1169
1225
|
- Prefer edit over write when modifying existing files.
|
|
@@ -1190,6 +1246,20 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1190
1246
|
|
|
1191
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.
|
|
1192
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
|
+
|
|
1193
1263
|
# Reporting
|
|
1194
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.
|
|
1195
1265
|
|
|
@@ -1215,6 +1285,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
|
|
|
1215
1285
|
logPrefix: opts.logPrefix ?? `[horton]`
|
|
1216
1286
|
})] : [(0, __electric_ax_agents_runtime_tools.createFetchUrlTool)(sandbox)],
|
|
1217
1287
|
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1288
|
+
createForkTool(ctx),
|
|
1218
1289
|
(0, __electric_ax_agents_runtime_tools.createSendTool)(ctx.send, { selfEntityUrl: ctx.entityUrl }),
|
|
1219
1290
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1220
1291
|
];
|
|
@@ -1307,6 +1378,7 @@ function createAssistantHandler(options) {
|
|
|
1307
1378
|
...__electric_ax_agents_mcp.mcp.tools()
|
|
1308
1379
|
];
|
|
1309
1380
|
const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
|
|
1381
|
+
const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
|
|
1310
1382
|
const titlePromise = !ctx.tags.title ? (async () => {
|
|
1311
1383
|
const firstUserMessage = await extractFirstUserMessage(ctx);
|
|
1312
1384
|
if (!firstUserMessage) return;
|
|
@@ -1387,7 +1459,8 @@ function createAssistantHandler(options) {
|
|
|
1387
1459
|
docsUrl,
|
|
1388
1460
|
modelProvider: modelConfig.provider,
|
|
1389
1461
|
modelId: String(modelConfig.model),
|
|
1390
|
-
hasEventSourceTools
|
|
1462
|
+
hasEventSourceTools,
|
|
1463
|
+
hasScheduleTools
|
|
1391
1464
|
}),
|
|
1392
1465
|
...modelConfig,
|
|
1393
1466
|
tools,
|
|
@@ -1660,7 +1733,10 @@ function dedupeToolsByName(tools) {
|
|
|
1660
1733
|
}
|
|
1661
1734
|
function createBuiltinElectricTools(custom) {
|
|
1662
1735
|
return async (context) => {
|
|
1663
|
-
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
|
+
})];
|
|
1664
1740
|
const customTools = custom ? await custom(context) : [];
|
|
1665
1741
|
return dedupeToolsByName([...builtinTools, ...customTools]);
|
|
1666
1742
|
};
|
|
@@ -1702,7 +1778,7 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1702
1778
|
modelCatalog
|
|
1703
1779
|
});
|
|
1704
1780
|
typeNames.push(`worker`);
|
|
1705
|
-
const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
|
|
1781
|
+
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
|
|
1706
1782
|
const runtime = (0, __electric_ax_agents_runtime.createRuntimeHandler)({
|
|
1707
1783
|
baseUrl: agentServerUrl,
|
|
1708
1784
|
serveEndpoint,
|
|
@@ -1721,7 +1797,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1721
1797
|
runtime,
|
|
1722
1798
|
registry,
|
|
1723
1799
|
typeNames,
|
|
1724
|
-
skillsRegistry
|
|
1800
|
+
skillsRegistry,
|
|
1801
|
+
shutdownSandboxes
|
|
1725
1802
|
};
|
|
1726
1803
|
}
|
|
1727
1804
|
async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
|
|
@@ -1742,18 +1819,22 @@ const registerAgentTypes = registerBuiltinAgentTypes;
|
|
|
1742
1819
|
* Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
|
|
1743
1820
|
* re-run the boot sweep.
|
|
1744
1821
|
*/
|
|
1745
|
-
let
|
|
1822
|
+
let dockerBootSweep = null;
|
|
1746
1823
|
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
sweep().then((removed) => {
|
|
1750
|
-
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)`);
|
|
1751
1826
|
}).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1827
|
+
return dockerBootSweep;
|
|
1752
1828
|
}
|
|
1753
1829
|
/**
|
|
1754
1830
|
* Built-in sandbox profiles. `local` is always available. `docker` is
|
|
1755
1831
|
* gated on Docker being reachable so a user without Docker installed
|
|
1756
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).
|
|
1757
1838
|
*/
|
|
1758
1839
|
async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
1759
1840
|
const profiles = [{
|
|
@@ -1762,29 +1843,36 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1762
1843
|
description: `Runs on the host without isolation. Full filesystem access.`,
|
|
1763
1844
|
factory: ({ args }) => (0, __electric_ax_agents_runtime_sandbox.chooseDefaultSandbox)(resolveCwd(args, workingDirectory))
|
|
1764
1845
|
}];
|
|
1846
|
+
let shutdownSandboxes = null;
|
|
1765
1847
|
try {
|
|
1766
1848
|
const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1767
1849
|
if (await isDockerAvailable()) {
|
|
1768
|
-
const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1769
|
-
sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1850
|
+
const { dockerSandbox, reclaimDockerSandboxByKey, shutdownAllDockerSandboxes, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1851
|
+
await sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1852
|
+
shutdownSandboxes = shutdownAllDockerSandboxes;
|
|
1770
1853
|
profiles.push({
|
|
1771
1854
|
name: `docker`,
|
|
1772
1855
|
label: `Docker`,
|
|
1773
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).`,
|
|
1774
|
-
factory: ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1857
|
+
factory: async ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1775
1858
|
const cwd = readWorkingDirectoryArg(args);
|
|
1776
|
-
return
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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
|
|
1788
1876
|
});
|
|
1789
1877
|
}
|
|
1790
1878
|
});
|
|
@@ -1808,7 +1896,10 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1808
1896
|
});
|
|
1809
1897
|
else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
|
|
1810
1898
|
console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
|
|
1811
|
-
return
|
|
1899
|
+
return {
|
|
1900
|
+
profiles,
|
|
1901
|
+
shutdownSandboxes
|
|
1902
|
+
};
|
|
1812
1903
|
}
|
|
1813
1904
|
function readWorkingDirectoryArg(args) {
|
|
1814
1905
|
const v = args.workingDirectory;
|
|
@@ -1827,7 +1918,7 @@ function installDurableStreamsFetchCache(options = {}) {
|
|
|
1827
1918
|
location: options.sqliteLocation,
|
|
1828
1919
|
maxCount: options.maxCount
|
|
1829
1920
|
}) : new undici.cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
|
|
1830
|
-
(0, undici.setGlobalDispatcher)(
|
|
1921
|
+
(0, undici.setGlobalDispatcher)((0, undici.getGlobalDispatcher)().compose(undici.interceptors.cache({ store })));
|
|
1831
1922
|
}
|
|
1832
1923
|
|
|
1833
1924
|
//#endregion
|
|
@@ -2026,6 +2117,9 @@ var BuiltinAgentsServer = class {
|
|
|
2026
2117
|
await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
|
|
2027
2118
|
serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
|
|
2028
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))]);
|
|
2029
2123
|
this.bootstrap = null;
|
|
2030
2124
|
}
|
|
2031
2125
|
this.mcpStopping = true;
|
|
@@ -2166,6 +2260,7 @@ exports.builtinModelProviderLabel = builtinModelProviderLabel
|
|
|
2166
2260
|
exports.createAgentHandler = createAgentHandler
|
|
2167
2261
|
exports.createBuiltinAgentHandler = createBuiltinAgentHandler
|
|
2168
2262
|
exports.createBuiltinElectricTools = createBuiltinElectricTools
|
|
2263
|
+
exports.createForkTool = createForkTool
|
|
2169
2264
|
exports.createHortonDocsSupport = createHortonDocsSupport
|
|
2170
2265
|
exports.createHortonTools = createHortonTools
|
|
2171
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
|
@@ -2,8 +2,8 @@ import { mergeElectricPrincipalHeader } from "./server-headers-KD5yHFYT.js";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
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, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
|
|
6
|
-
import { chooseDefaultSandbox, isE2BAvailable, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
|
|
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 {
|
|
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`;
|
|
@@ -1085,6 +1139,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
|
|
|
1085
1139
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1086
1140
|
const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
|
|
1087
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` : ``;
|
|
1088
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` : ``;
|
|
1089
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.` : ``;
|
|
1090
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.
|
|
@@ -1138,8 +1193,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1138
1193
|
- web_search: search the web
|
|
1139
1194
|
- fetch_url: fetch and convert a URL to markdown
|
|
1140
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.
|
|
1141
1197
|
- send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
|
|
1142
|
-
${eventSourceTools}${docsTools}${skillsTools}
|
|
1198
|
+
${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
|
|
1143
1199
|
|
|
1144
1200
|
# Working with files
|
|
1145
1201
|
- Prefer edit over write when modifying existing files.
|
|
@@ -1166,6 +1222,20 @@ When you spawn a worker, write its system prompt the way you'd brief a colleague
|
|
|
1166
1222
|
|
|
1167
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.
|
|
1168
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
|
+
|
|
1169
1239
|
# Reporting
|
|
1170
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.
|
|
1171
1241
|
|
|
@@ -1191,6 +1261,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
|
|
|
1191
1261
|
logPrefix: opts.logPrefix ?? `[horton]`
|
|
1192
1262
|
})] : [createFetchUrlTool(sandbox)],
|
|
1193
1263
|
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1264
|
+
createForkTool(ctx),
|
|
1194
1265
|
createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
|
|
1195
1266
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1196
1267
|
];
|
|
@@ -1283,6 +1354,7 @@ function createAssistantHandler(options) {
|
|
|
1283
1354
|
...mcp.tools()
|
|
1284
1355
|
];
|
|
1285
1356
|
const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
|
|
1357
|
+
const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
|
|
1286
1358
|
const titlePromise = !ctx.tags.title ? (async () => {
|
|
1287
1359
|
const firstUserMessage = await extractFirstUserMessage(ctx);
|
|
1288
1360
|
if (!firstUserMessage) return;
|
|
@@ -1363,7 +1435,8 @@ function createAssistantHandler(options) {
|
|
|
1363
1435
|
docsUrl,
|
|
1364
1436
|
modelProvider: modelConfig.provider,
|
|
1365
1437
|
modelId: String(modelConfig.model),
|
|
1366
|
-
hasEventSourceTools
|
|
1438
|
+
hasEventSourceTools,
|
|
1439
|
+
hasScheduleTools
|
|
1367
1440
|
}),
|
|
1368
1441
|
...modelConfig,
|
|
1369
1442
|
tools,
|
|
@@ -1636,7 +1709,10 @@ function dedupeToolsByName(tools) {
|
|
|
1636
1709
|
}
|
|
1637
1710
|
function createBuiltinElectricTools(custom) {
|
|
1638
1711
|
return async (context) => {
|
|
1639
|
-
const builtinTools = createEventSourceTools(context)
|
|
1712
|
+
const builtinTools = [...createEventSourceTools(context), ...createScheduleTools({
|
|
1713
|
+
...context,
|
|
1714
|
+
db: context.db
|
|
1715
|
+
})];
|
|
1640
1716
|
const customTools = custom ? await custom(context) : [];
|
|
1641
1717
|
return dedupeToolsByName([...builtinTools, ...customTools]);
|
|
1642
1718
|
};
|
|
@@ -1678,7 +1754,7 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1678
1754
|
modelCatalog
|
|
1679
1755
|
});
|
|
1680
1756
|
typeNames.push(`worker`);
|
|
1681
|
-
const sandboxProfiles = await buildBuiltinSandboxProfiles(cwd);
|
|
1757
|
+
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
|
|
1682
1758
|
const runtime = createRuntimeHandler({
|
|
1683
1759
|
baseUrl: agentServerUrl,
|
|
1684
1760
|
serveEndpoint,
|
|
@@ -1697,7 +1773,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1697
1773
|
runtime,
|
|
1698
1774
|
registry,
|
|
1699
1775
|
typeNames,
|
|
1700
|
-
skillsRegistry
|
|
1776
|
+
skillsRegistry,
|
|
1777
|
+
shutdownSandboxes
|
|
1701
1778
|
};
|
|
1702
1779
|
}
|
|
1703
1780
|
async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
|
|
@@ -1718,18 +1795,22 @@ const registerAgentTypes = registerBuiltinAgentTypes;
|
|
|
1718
1795
|
* Guard so repeated `buildBuiltinSandboxProfiles` calls in one process don't
|
|
1719
1796
|
* re-run the boot sweep.
|
|
1720
1797
|
*/
|
|
1721
|
-
let
|
|
1798
|
+
let dockerBootSweep = null;
|
|
1722
1799
|
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
sweep().then((removed) => {
|
|
1726
|
-
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)`);
|
|
1727
1802
|
}).catch((err) => serverLog.warn(`[builtin-agents] docker sandbox boot sweep error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1803
|
+
return dockerBootSweep;
|
|
1728
1804
|
}
|
|
1729
1805
|
/**
|
|
1730
1806
|
* Built-in sandbox profiles. `local` is always available. `docker` is
|
|
1731
1807
|
* gated on Docker being reachable so a user without Docker installed
|
|
1732
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).
|
|
1733
1814
|
*/
|
|
1734
1815
|
async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
1735
1816
|
const profiles = [{
|
|
@@ -1738,29 +1819,36 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1738
1819
|
description: `Runs on the host without isolation. Full filesystem access.`,
|
|
1739
1820
|
factory: ({ args }) => chooseDefaultSandbox(resolveCwd(args, workingDirectory))
|
|
1740
1821
|
}];
|
|
1822
|
+
let shutdownSandboxes = null;
|
|
1741
1823
|
try {
|
|
1742
1824
|
const { isDockerAvailable } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1743
1825
|
if (await isDockerAvailable()) {
|
|
1744
|
-
const { dockerSandbox, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1745
|
-
sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1826
|
+
const { dockerSandbox, reclaimDockerSandboxByKey, shutdownAllDockerSandboxes, sweepOrphanedDockerSandboxes } = await import(`@electric-ax/agents-runtime/sandbox/docker`);
|
|
1827
|
+
await sweepOrphanedDockerSandboxesOnce(sweepOrphanedDockerSandboxes);
|
|
1828
|
+
shutdownSandboxes = shutdownAllDockerSandboxes;
|
|
1746
1829
|
profiles.push({
|
|
1747
1830
|
name: `docker`,
|
|
1748
1831
|
label: `Docker`,
|
|
1749
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).`,
|
|
1750
|
-
factory: ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1833
|
+
factory: async ({ args, sandboxKey, persistent, owner, entityType, entityUrl }) => {
|
|
1751
1834
|
const cwd = readWorkingDirectoryArg(args);
|
|
1752
|
-
return
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
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
|
|
1764
1852
|
});
|
|
1765
1853
|
}
|
|
1766
1854
|
});
|
|
@@ -1784,7 +1872,10 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1784
1872
|
});
|
|
1785
1873
|
else serverLog.info(`[builtin-agents] E2B_API_KEY set but the "e2b" package is not installed — e2b sandbox profile not registered`);
|
|
1786
1874
|
console.log(`[builtin-agents] sandbox profiles advertised: ${profiles.map((p) => p.name).join(`, `)}`);
|
|
1787
|
-
return
|
|
1875
|
+
return {
|
|
1876
|
+
profiles,
|
|
1877
|
+
shutdownSandboxes
|
|
1878
|
+
};
|
|
1788
1879
|
}
|
|
1789
1880
|
function readWorkingDirectoryArg(args) {
|
|
1790
1881
|
const v = args.workingDirectory;
|
|
@@ -1803,7 +1894,7 @@ function installDurableStreamsFetchCache(options = {}) {
|
|
|
1803
1894
|
location: options.sqliteLocation,
|
|
1804
1895
|
maxCount: options.maxCount
|
|
1805
1896
|
}) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
|
|
1806
|
-
setGlobalDispatcher(
|
|
1897
|
+
setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
|
|
1807
1898
|
}
|
|
1808
1899
|
|
|
1809
1900
|
//#endregion
|
|
@@ -2002,6 +2093,9 @@ var BuiltinAgentsServer = class {
|
|
|
2002
2093
|
await Promise.race([this.bootstrap.runtime.drainWakes().catch((err) => {
|
|
2003
2094
|
serverLog.error(`[builtin-agents] drainWakes failed during shutdown:`, err);
|
|
2004
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))]);
|
|
2005
2099
|
this.bootstrap = null;
|
|
2006
2100
|
}
|
|
2007
2101
|
this.mcpStopping = true;
|
|
@@ -2127,4 +2221,4 @@ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd
|
|
|
2127
2221
|
}
|
|
2128
2222
|
|
|
2129
2223
|
//#endregion
|
|
2130
|
-
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.
|
|
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.
|
|
53
|
+
"@electric-ax/agents-runtime": "0.3.11"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/better-sqlite3": "^7.6.13",
|