@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.
- package/dist/entrypoint.js +154 -47
- package/dist/index.cjs +151 -43
- package/dist/index.d.cts +13 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +155 -48
- 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
|
-
import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, completeWithLowCostModel, createEntityRegistry, createPullWakeRunner, createRuntimeHandler,
|
|
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(
|
|
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
|
|
1201
|
-
|
|
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
|
|
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
|
-
...
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
1798
|
+
let dockerBootSweep = null;
|
|
1709
1799
|
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
|
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
|
|
1214
|
-
|
|
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
|
|
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
|
-
...
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
1822
|
+
let dockerBootSweep = null;
|
|
1733
1823
|
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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
|
|
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)(
|
|
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,
|
|
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 {
|
|
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
|
|
1190
|
-
|
|
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
|
|
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
|
-
...
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
1798
|
+
let dockerBootSweep = null;
|
|
1709
1799
|
function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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",
|