@electric-ax/agents 0.4.17 → 0.4.19
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 +269 -44
- package/dist/index.cjs +268 -42
- package/dist/index.d.cts +76 -32
- package/dist/index.d.ts +76 -32
- package/dist/index.js +270 -45
- package/package.json +3 -3
package/dist/entrypoint.js
CHANGED
|
@@ -4,8 +4,8 @@ import { cacheStores, getGlobalDispatcher, interceptors, setGlobalDispatcher } f
|
|
|
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, buildSkillSlashCommands, completeWithLowCostModel, createContextSkillLoader, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, pgSync, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
|
|
8
|
-
import { braveSearchTool, createBashTool, createEditTool,
|
|
7
|
+
import { GOAL_SLASH_COMMAND, MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, buildSkillSlashCommands, commentsCollection, completeWithLowCostModel, createContextSkillLoader, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillsRegistry, db, detectAvailableProviders, dispatchGoalCommand, formatTokenCount, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, isGoalCommandText, parseGoalCommand, pgSync, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
|
|
8
|
+
import { braveSearchTool, createBashTool, createEditTool, createFetchUrlTool, createMarkGoalCompleteTool, createReadFileTool, createScheduleTools, createSendTool, createWebhookSourceTools, createWriteTool } from "@electric-ax/agents-runtime/tools";
|
|
9
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";
|
|
@@ -814,7 +814,7 @@ function createSpawnWorkerTool(ctx, modelConfig) {
|
|
|
814
814
|
|
|
815
815
|
//#endregion
|
|
816
816
|
//#region src/tools/observe-pg-sync.ts
|
|
817
|
-
function asToolResult(value) {
|
|
817
|
+
function asToolResult$1(value) {
|
|
818
818
|
return {
|
|
819
819
|
content: [{
|
|
820
820
|
type: `text`,
|
|
@@ -832,9 +832,9 @@ function createObservePgSyncTool(ctx) {
|
|
|
832
832
|
return {
|
|
833
833
|
name: `observe_pg_sync`,
|
|
834
834
|
label: `Observe Postgres Sync`,
|
|
835
|
-
description: `Observe an Electric Postgres shape stream and wake this agent when matching row changes arrive.`,
|
|
835
|
+
description: `Observe an Electric Postgres shape stream and wake this agent when matching row changes arrive. Requires the HTTP(S) URL of an Electric shape endpoint — ask the user for it if you don't know it. Registration validates the endpoint up front and fails with Electric's error if the shape can't be fetched.`,
|
|
836
836
|
parameters: Type.Object({
|
|
837
|
-
url: Type.
|
|
837
|
+
url: Type.String({ description: `HTTP(S) URL of the Electric shape endpoint, e.g. http://localhost:3000/v1/shape. Not a postgres:// connection string. Never guess this — ask the user if it hasn't been provided.` }),
|
|
838
838
|
table: Type.String({
|
|
839
839
|
minLength: 1,
|
|
840
840
|
pattern: `\\S`,
|
|
@@ -851,6 +851,7 @@ function createObservePgSyncTool(ctx) {
|
|
|
851
851
|
}),
|
|
852
852
|
execute: async (_toolCallId, params) => {
|
|
853
853
|
const args = params;
|
|
854
|
+
if (typeof args.url !== `string` || args.url.trim().length === 0) throw new Error(`url is required`);
|
|
854
855
|
if (typeof args.table !== `string` || args.table.trim().length === 0) throw new Error(`table is required`);
|
|
855
856
|
const source = pgSync({
|
|
856
857
|
url: args.url,
|
|
@@ -865,16 +866,79 @@ function createObservePgSyncTool(ctx) {
|
|
|
865
866
|
...args.wake?.ops ? { ops: args.wake.ops } : {},
|
|
866
867
|
...args.wake?.debounceMs !== void 0 ? { debounceMs: args.wake.debounceMs } : {}
|
|
867
868
|
};
|
|
868
|
-
await ctx.observe(source, { wake });
|
|
869
|
-
return
|
|
870
|
-
|
|
871
|
-
|
|
869
|
+
const handle = await ctx.observe(source, { wake });
|
|
870
|
+
if (!handle.streamUrl) throw new Error(`pg-sync observation did not return a stream URL for ${handle.sourceRef}`);
|
|
871
|
+
return asToolResult$1({
|
|
872
|
+
sourceRef: handle.sourceRef,
|
|
873
|
+
streamUrl: handle.streamUrl,
|
|
872
874
|
wake
|
|
873
875
|
});
|
|
874
876
|
}
|
|
875
877
|
};
|
|
876
878
|
}
|
|
877
879
|
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region src/tools/unobserve-pg-sync.ts
|
|
882
|
+
function asToolResult(value) {
|
|
883
|
+
return {
|
|
884
|
+
content: [{
|
|
885
|
+
type: `text`,
|
|
886
|
+
text: typeof value === `string` ? value : JSON.stringify(value, null, 2)
|
|
887
|
+
}],
|
|
888
|
+
details: {}
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function isRecord$1(value) {
|
|
892
|
+
return typeof value === `object` && value !== null && !Array.isArray(value);
|
|
893
|
+
}
|
|
894
|
+
function listPgSyncObservations(ctx) {
|
|
895
|
+
const manifests = ctx.db.collections.manifests?.toArray;
|
|
896
|
+
if (!Array.isArray(manifests)) return [];
|
|
897
|
+
const observations = [];
|
|
898
|
+
for (const entry of manifests) {
|
|
899
|
+
if (!isRecord$1(entry) || entry.kind !== `source` || entry.sourceType !== `pgSync` || typeof entry.sourceRef !== `string`) continue;
|
|
900
|
+
const config = isRecord$1(entry.config) ? entry.config : {};
|
|
901
|
+
observations.push({
|
|
902
|
+
sourceRef: entry.sourceRef,
|
|
903
|
+
...typeof config.table === `string` ? { table: config.table } : {},
|
|
904
|
+
...typeof config.url === `string` ? { url: config.url } : {},
|
|
905
|
+
...typeof entry.streamUrl === `string` ? { streamUrl: entry.streamUrl } : {}
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return observations.sort((left, right) => left.sourceRef.localeCompare(right.sourceRef));
|
|
909
|
+
}
|
|
910
|
+
function createUnobservePgSyncTool(ctx) {
|
|
911
|
+
return {
|
|
912
|
+
name: `unobserve_pg_sync`,
|
|
913
|
+
label: `Stop Observing Postgres Sync`,
|
|
914
|
+
description: `Stop being woken by a Postgres shape stream you previously observed with observe_pg_sync. Identify the observation by its sourceRef (preferred) or table. Call with no arguments to list your active pg-sync observations. This only removes your own subscription; any other agents observing the same shape keep their stream.`,
|
|
915
|
+
parameters: Type.Object({
|
|
916
|
+
sourceRef: Type.Optional(Type.String({ description: `The sourceRef returned by observe_pg_sync. Preferred — unambiguous.` })),
|
|
917
|
+
table: Type.Optional(Type.String({ description: `The observed table name. Used only when sourceRef is not given; fails if more than one observation matches.` }))
|
|
918
|
+
}),
|
|
919
|
+
execute: async (_toolCallId, params) => {
|
|
920
|
+
const args = params;
|
|
921
|
+
const observations = listPgSyncObservations(ctx);
|
|
922
|
+
if (!args.sourceRef && !args.table) return asToolResult(observations.length > 0 ? { observations } : `You have no active pg-sync observations.`);
|
|
923
|
+
let sourceRef = args.sourceRef;
|
|
924
|
+
if (!sourceRef) {
|
|
925
|
+
const matches = observations.filter((o) => o.table === args.table);
|
|
926
|
+
if (matches.length === 0) return asToolResult(`No active pg-sync observation found for table "${args.table}".`);
|
|
927
|
+
if (matches.length > 1) return asToolResult({
|
|
928
|
+
error: `Multiple pg-sync observations match table "${args.table}"; pass a sourceRef instead.`,
|
|
929
|
+
matches
|
|
930
|
+
});
|
|
931
|
+
sourceRef = matches[0].sourceRef;
|
|
932
|
+
} else if (!observations.some((o) => o.sourceRef === sourceRef)) return asToolResult(`No active pg-sync observation found for sourceRef "${sourceRef}".`);
|
|
933
|
+
await ctx.unobserve(sourceRef);
|
|
934
|
+
return asToolResult({
|
|
935
|
+
unobserved: true,
|
|
936
|
+
sourceRef
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
878
942
|
//#endregion
|
|
879
943
|
//#region src/tools/fork.ts
|
|
880
944
|
function createForkTool(ctx) {
|
|
@@ -1087,25 +1151,66 @@ function filterChoicesByEnabledModels(choices, values) {
|
|
|
1087
1151
|
const filtered = choices.filter((choice) => enabled.has(choice.value));
|
|
1088
1152
|
return filtered.length > 0 ? filtered : choices;
|
|
1089
1153
|
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Anthropic-specific budget mapping for `reasoningEffort`.
|
|
1156
|
+
*
|
|
1157
|
+
* Anthropic's `thinking.budget_tokens` is a hard cap on tokens spent
|
|
1158
|
+
* inside the thinking block before the model must commit to its
|
|
1159
|
+
* answer. Docs require ≥ 1024; we scale from there. Numbers tuned so
|
|
1160
|
+
* `medium` is the spot most "show your work" requests land, and
|
|
1161
|
+
* `high` covers tougher reasoning without uncapped spend.
|
|
1162
|
+
*
|
|
1163
|
+
* Keep in sync with provider doc updates — Anthropic has shifted the
|
|
1164
|
+
* minimum once already (older models capped lower).
|
|
1165
|
+
*/
|
|
1166
|
+
const ANTHROPIC_THINKING_BUDGET_BY_EFFORT = {
|
|
1167
|
+
minimal: 1024,
|
|
1168
|
+
low: 2048,
|
|
1169
|
+
medium: 8192,
|
|
1170
|
+
high: 24576
|
|
1171
|
+
};
|
|
1090
1172
|
function withProviderPayloadDefaults(config, choice, reasoningEffort) {
|
|
1091
|
-
if (
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1173
|
+
if (!choice.reasoning) return config;
|
|
1174
|
+
if (choice.provider === `openai` || choice.provider === `openai-codex`) {
|
|
1175
|
+
const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
|
|
1176
|
+
const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
|
|
1177
|
+
return {
|
|
1178
|
+
...config,
|
|
1179
|
+
onPayload: (payload) => {
|
|
1180
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1181
|
+
const body = payload;
|
|
1182
|
+
const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
|
|
1183
|
+
return {
|
|
1184
|
+
...body,
|
|
1185
|
+
reasoning: {
|
|
1186
|
+
...existingReasoning,
|
|
1187
|
+
effort
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
if (choice.provider === `anthropic`) {
|
|
1194
|
+
const effectiveEffort = reasoningEffort ?? `minimal`;
|
|
1195
|
+
const budgetTokens = ANTHROPIC_THINKING_BUDGET_BY_EFFORT[effectiveEffort];
|
|
1196
|
+
return {
|
|
1197
|
+
...config,
|
|
1198
|
+
onPayload: (payload) => {
|
|
1199
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1200
|
+
const body = payload;
|
|
1201
|
+
const existingThinking = typeof body.thinking === `object` && body.thinking !== null ? body.thinking : {};
|
|
1202
|
+
return {
|
|
1203
|
+
...body,
|
|
1204
|
+
thinking: {
|
|
1205
|
+
...existingThinking,
|
|
1206
|
+
type: `enabled`,
|
|
1207
|
+
budget_tokens: budgetTokens
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
return config;
|
|
1109
1214
|
}
|
|
1110
1215
|
function parseReasoningEffort(value) {
|
|
1111
1216
|
return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
|
|
@@ -1265,7 +1370,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
|
|
|
1265
1370
|
}
|
|
1266
1371
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1267
1372
|
const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
|
|
1268
|
-
const
|
|
1373
|
+
const webhookSourceTools = opts.hasWebhookSourceTools ? `\n- list_webhook_sources: list external webhook feeds you can subscribe to, including available buckets and parameters\n- subscribe_webhook_source: subscribe yourself to one of those feeds or buckets so matching future webhooks wake you\n- list_webhook_source_subscriptions: list your active webhook source subscriptions\n- unsubscribe_webhook_source: remove one of your webhook source subscriptions by id` : ``;
|
|
1269
1374
|
const titleTool = `\n- set_title: set or rename this chat session's UI title`;
|
|
1270
1375
|
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` : ``;
|
|
1271
1376
|
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` : ``;
|
|
@@ -1322,9 +1427,10 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1322
1427
|
- fetch_url: fetch and convert a URL to markdown
|
|
1323
1428
|
- spawn_worker: dispatch a subagent for an isolated task
|
|
1324
1429
|
- 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.
|
|
1325
|
-
- observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
|
|
1430
|
+
- observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes (see "Observing Postgres tables")
|
|
1431
|
+
- unobserve_pg_sync: stop being woken by a pg-sync stream you previously observed (see "Observing Postgres tables")
|
|
1326
1432
|
- send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
|
|
1327
|
-
${
|
|
1433
|
+
${webhookSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
|
|
1328
1434
|
|
|
1329
1435
|
# Working with files
|
|
1330
1436
|
- Prefer edit over write when modifying existing files.
|
|
@@ -1332,6 +1438,14 @@ ${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
|
|
|
1332
1438
|
- Use absolute paths or paths relative to the current working directory.
|
|
1333
1439
|
${modelGuidance}${docsGuidance}${skillsGuidance}${onboardingGuidance}${docsUrlGuidance}
|
|
1334
1440
|
|
|
1441
|
+
# Observing Postgres tables
|
|
1442
|
+
observe_pg_sync subscribes you to row changes in a Postgres table via an Electric shape stream:
|
|
1443
|
+
- The \`url\` parameter is the HTTP(S) URL of an Electric shape endpoint (e.g. \`http://localhost:3000/v1/shape\`). It is NOT a \`postgres://\` connection string and there is no default — if the user hasn't given you the endpoint URL, ask for it. Never guess or invent one.
|
|
1444
|
+
- Registration validates the endpoint by fetching the shape log first. If it fails, the error includes Electric's response or the failure reason — use it to correct the table name, where clause, or URL, or relay it to the user.
|
|
1445
|
+
- Use \`where\` and \`columns\` to narrow the shape so you only wake on changes you care about; use \`wake.ops\` to filter by operation and \`wake.debounceMs\` to batch bursts.
|
|
1446
|
+
- The observation persists across wakes — register it once, don't re-register on every wake.
|
|
1447
|
+
- To stop, call unobserve_pg_sync with the sourceRef from observe_pg_sync (or the table name). Call it with no arguments to list your active observations. This only ends your own subscription.
|
|
1448
|
+
|
|
1335
1449
|
# Risky actions
|
|
1336
1450
|
Pause and confirm with the user before:
|
|
1337
1451
|
- Destructive operations (deleting files, rm -rf, dropping data, force-pushing)
|
|
@@ -1369,7 +1483,18 @@ Workflow when forking yourself for parallel exploration:
|
|
|
1369
1483
|
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.
|
|
1370
1484
|
|
|
1371
1485
|
Working directory: ${workingDirectory}
|
|
1372
|
-
The current year is ${new Date().getFullYear()}
|
|
1486
|
+
The current year is ${new Date().getFullYear()}.${buildGoalGuidance(opts.activeGoal)}`;
|
|
1487
|
+
}
|
|
1488
|
+
function buildGoalGuidance(goal) {
|
|
1489
|
+
if (!goal) return ``;
|
|
1490
|
+
const budgetLine = goal.tokenBudget === null ? `unlimited` : `${goal.tokensUsed} / ${goal.tokenBudget} tokens used`;
|
|
1491
|
+
return `
|
|
1492
|
+
|
|
1493
|
+
# Active goal
|
|
1494
|
+
- Objective: ${goal.objective}
|
|
1495
|
+
- Token budget: ${budgetLine}
|
|
1496
|
+
|
|
1497
|
+
The user set this goal with /goal set. Work autonomously toward it: do NOT ask the user clarifying questions or pause for confirmation — make reasonable assumptions and proceed. When you believe the goal is met, call the \`mark_goal_complete\` tool. If you hit a blocker that genuinely requires the user (e.g. credentials, a destructive action), call \`mark_goal_complete\` with a summary explaining what's needed. The runtime will abort this run automatically if you exceed the token budget.`;
|
|
1373
1498
|
}
|
|
1374
1499
|
function getToolName(tool) {
|
|
1375
1500
|
if (typeof tool !== `object` || tool === null) return null;
|
|
@@ -1392,8 +1517,10 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
|
|
|
1392
1517
|
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1393
1518
|
createForkTool(ctx),
|
|
1394
1519
|
createObservePgSyncTool(ctx),
|
|
1520
|
+
createUnobservePgSyncTool(ctx),
|
|
1395
1521
|
createSetTitleTool(ctx),
|
|
1396
1522
|
createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
|
|
1523
|
+
...ctx.getGoal()?.status === `active` ? [createMarkGoalCompleteTool(ctx)] : [],
|
|
1397
1524
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1398
1525
|
];
|
|
1399
1526
|
}
|
|
@@ -1462,11 +1589,58 @@ async function readAgentsMd(sandbox) {
|
|
|
1462
1589
|
return null;
|
|
1463
1590
|
}
|
|
1464
1591
|
}
|
|
1592
|
+
function extractWakeText(wake) {
|
|
1593
|
+
if (wake.type !== `inbox`) return null;
|
|
1594
|
+
const payload = wake.payload;
|
|
1595
|
+
if (typeof payload === `string`) return payload;
|
|
1596
|
+
if (payload && typeof payload === `object`) {
|
|
1597
|
+
const record = payload;
|
|
1598
|
+
if (typeof record.text === `string`) return record.text;
|
|
1599
|
+
if (typeof record.source === `string`) return record.source;
|
|
1600
|
+
}
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
async function tryHandleSlashCommand(ctx, wake) {
|
|
1604
|
+
const text = extractWakeText(wake);
|
|
1605
|
+
if (text === null) return false;
|
|
1606
|
+
if (isGoalCommandText(text)) {
|
|
1607
|
+
const command = parseGoalCommand(text);
|
|
1608
|
+
const result = dispatchGoalCommand(ctx, command);
|
|
1609
|
+
if (result.message) {
|
|
1610
|
+
serverLog.info(`[horton ${ctx.entityUrl}] ${result.message}`);
|
|
1611
|
+
writeSlashCommandReply(ctx, result.message);
|
|
1612
|
+
}
|
|
1613
|
+
if (command.kind === `set`) await kickoffGoalRun(ctx);
|
|
1614
|
+
return result.handled;
|
|
1615
|
+
}
|
|
1616
|
+
return false;
|
|
1617
|
+
}
|
|
1618
|
+
const GOAL_KICKOFF_TEXT = `Start working toward the active goal now. Call \`mark_goal_complete\` when you believe it is done.`;
|
|
1619
|
+
async function kickoffGoalRun(ctx) {
|
|
1620
|
+
const goal = ctx.getGoal();
|
|
1621
|
+
if (!goal || goal.status !== `active`) return;
|
|
1622
|
+
try {
|
|
1623
|
+
await ctx.send(ctx.entityUrl, {
|
|
1624
|
+
kind: `goal_kickoff`,
|
|
1625
|
+
text: GOAL_KICKOFF_TEXT
|
|
1626
|
+
}, { type: `inbox` });
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] failed to enqueue goal kickoff: ${err instanceof Error ? err.message : String(err)}`);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
function writeSlashCommandReply(ctx, text) {
|
|
1632
|
+
try {
|
|
1633
|
+
ctx.replyText(text);
|
|
1634
|
+
} catch (err) {
|
|
1635
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] failed to render slash command reply: ${err instanceof Error ? err.message : String(err)}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1465
1638
|
function createAssistantHandler(options) {
|
|
1466
1639
|
const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
|
|
1467
1640
|
const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
|
|
1468
1641
|
const hasSkills = skillLoader.hasSkills;
|
|
1469
1642
|
return async function assistantHandler(ctx, wake) {
|
|
1643
|
+
if (await tryHandleSlashCommand(ctx, wake)) return;
|
|
1470
1644
|
const loadedSkills = await skillLoader.load(ctx);
|
|
1471
1645
|
const readSet = new Set();
|
|
1472
1646
|
const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
|
|
@@ -1484,7 +1658,7 @@ function createAssistantHandler(options) {
|
|
|
1484
1658
|
...loadedSkills.tools,
|
|
1485
1659
|
...mcp.tools()
|
|
1486
1660
|
];
|
|
1487
|
-
const
|
|
1661
|
+
const hasWebhookSourceTools = tools.some((tool) => getToolName(tool) === `list_webhook_sources`);
|
|
1488
1662
|
const hasScheduleTools = tools.some((tool) => getToolName(tool) === `upsert_cron_schedule`);
|
|
1489
1663
|
const titlePromise = !ctx.tags.title ? (async () => {
|
|
1490
1664
|
const firstUserMessage = await extractFirstUserMessage(ctx);
|
|
@@ -1559,6 +1733,26 @@ function createAssistantHandler(options) {
|
|
|
1559
1733
|
}
|
|
1560
1734
|
}
|
|
1561
1735
|
});
|
|
1736
|
+
const goal = ctx.getGoal();
|
|
1737
|
+
const enforcedGoal = goal && goal.status === `active` ? goal : void 0;
|
|
1738
|
+
const activeGoalPromptInfo = enforcedGoal ? {
|
|
1739
|
+
objective: enforcedGoal.objective,
|
|
1740
|
+
tokenBudget: enforcedGoal.tokenBudget,
|
|
1741
|
+
tokensUsed: enforcedGoal.tokensUsed
|
|
1742
|
+
} : void 0;
|
|
1743
|
+
const budgetAbort = new AbortController();
|
|
1744
|
+
let runTokensUsed = enforcedGoal?.tokensUsed ?? 0;
|
|
1745
|
+
let budgetTripped = false;
|
|
1746
|
+
const onStepEnd = enforcedGoal ? (stats) => {
|
|
1747
|
+
if (budgetTripped) return;
|
|
1748
|
+
runTokensUsed += stats.uncachedInput + stats.output;
|
|
1749
|
+
ctx.updateGoalUsage(runTokensUsed);
|
|
1750
|
+
if (enforcedGoal.tokenBudget !== null && runTokensUsed >= enforcedGoal.tokenBudget) {
|
|
1751
|
+
budgetTripped = true;
|
|
1752
|
+
serverLog.info(`[horton ${ctx.entityUrl}] goal budget exhausted (${runTokensUsed} tokens) — aborting run`);
|
|
1753
|
+
budgetAbort.abort();
|
|
1754
|
+
}
|
|
1755
|
+
} : void 0;
|
|
1562
1756
|
ctx.useAgent({
|
|
1563
1757
|
systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
|
|
1564
1758
|
hasDocsSupport: Boolean(docsSupport),
|
|
@@ -1566,14 +1760,27 @@ function createAssistantHandler(options) {
|
|
|
1566
1760
|
docsUrl,
|
|
1567
1761
|
modelProvider: modelConfig.provider,
|
|
1568
1762
|
modelId: String(modelConfig.model),
|
|
1569
|
-
|
|
1570
|
-
hasScheduleTools
|
|
1763
|
+
hasWebhookSourceTools,
|
|
1764
|
+
hasScheduleTools,
|
|
1765
|
+
...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
|
|
1571
1766
|
}),
|
|
1572
1767
|
...modelConfig,
|
|
1573
1768
|
tools,
|
|
1574
|
-
...streamFn && { streamFn }
|
|
1769
|
+
...streamFn && { streamFn },
|
|
1770
|
+
...onStepEnd && { onStepEnd }
|
|
1575
1771
|
});
|
|
1576
|
-
|
|
1772
|
+
try {
|
|
1773
|
+
await ctx.agent.run(void 0, budgetAbort.signal);
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
if (!budgetTripped) throw err;
|
|
1776
|
+
serverLog.info(`[horton ${ctx.entityUrl}] agent.run aborted by budget enforcement`);
|
|
1777
|
+
}
|
|
1778
|
+
if (enforcedGoal) ctx.updateGoalUsage(runTokensUsed, budgetTripped ? { status: `budget_limited` } : void 0);
|
|
1779
|
+
if (budgetTripped && enforcedGoal && enforcedGoal.tokenBudget !== null) {
|
|
1780
|
+
const budget = enforcedGoal.tokenBudget;
|
|
1781
|
+
const suggestedNext = Math.max(budget * 2, budget + 1e4);
|
|
1782
|
+
writeSlashCommandReply(ctx, `⚠️ Stopped — goal hit the token budget (${formatTokenCount(runTokensUsed)} / ${formatTokenCount(budget)} tokens used). Raise the budget with \`/goal set "..." --tokens ${formatTokenCount(suggestedNext)}\`, or call \`/goal complete\` to finalize.`);
|
|
1783
|
+
}
|
|
1577
1784
|
await titlePromise;
|
|
1578
1785
|
};
|
|
1579
1786
|
}
|
|
@@ -1613,7 +1820,8 @@ function registerHorton(registry, options) {
|
|
|
1613
1820
|
subject_value: `user`,
|
|
1614
1821
|
permission: `manage`
|
|
1615
1822
|
}],
|
|
1616
|
-
|
|
1823
|
+
state: { comments: commentsCollection },
|
|
1824
|
+
slashCommands: [GOAL_SLASH_COMMAND, ...buildSkillSlashCommands(skillsRegistry)],
|
|
1617
1825
|
handler: assistantHandler
|
|
1618
1826
|
});
|
|
1619
1827
|
return [`horton`];
|
|
@@ -1797,6 +2005,7 @@ function registerWorker(registry, options) {
|
|
|
1797
2005
|
subject_value: `user`,
|
|
1798
2006
|
permission: `manage`
|
|
1799
2007
|
}],
|
|
2008
|
+
state: { comments: commentsCollection },
|
|
1800
2009
|
async handler(ctx) {
|
|
1801
2010
|
const args = parseWorkerArgs(ctx.args);
|
|
1802
2011
|
const readSet = new Set();
|
|
@@ -1839,7 +2048,7 @@ function dedupeToolsByName(tools) {
|
|
|
1839
2048
|
}
|
|
1840
2049
|
function createBuiltinElectricTools(custom) {
|
|
1841
2050
|
return async (context) => {
|
|
1842
|
-
const builtinTools = [...
|
|
2051
|
+
const builtinTools = [...createWebhookSourceTools(context), ...createScheduleTools({
|
|
1843
2052
|
...context,
|
|
1844
2053
|
db: context.db
|
|
1845
2054
|
})];
|
|
@@ -1848,7 +2057,7 @@ function createBuiltinElectricTools(custom) {
|
|
|
1848
2057
|
};
|
|
1849
2058
|
}
|
|
1850
2059
|
async function createBuiltinAgentHandler(options) {
|
|
1851
|
-
const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType } = options;
|
|
2060
|
+
const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType, dockerSandbox: dockerSandboxOpts } = options;
|
|
1852
2061
|
const modelCatalog = await createBuiltinModelCatalog({
|
|
1853
2062
|
allowMockFallback: Boolean(streamFn),
|
|
1854
2063
|
enabledModelValues
|
|
@@ -1884,7 +2093,7 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1884
2093
|
modelCatalog
|
|
1885
2094
|
});
|
|
1886
2095
|
typeNames.push(`worker`);
|
|
1887
|
-
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
|
|
2096
|
+
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
|
|
1888
2097
|
const runtime = createRuntimeHandler({
|
|
1889
2098
|
baseUrl: agentServerUrl,
|
|
1890
2099
|
serveEndpoint,
|
|
@@ -1904,7 +2113,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1904
2113
|
registry,
|
|
1905
2114
|
typeNames,
|
|
1906
2115
|
skillsRegistry,
|
|
1907
|
-
shutdownSandboxes
|
|
2116
|
+
shutdownSandboxes,
|
|
2117
|
+
modelCatalog
|
|
1908
2118
|
};
|
|
1909
2119
|
}
|
|
1910
2120
|
async function registerBuiltinAgentTypes(bootstrap) {
|
|
@@ -1923,6 +2133,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
|
1923
2133
|
return dockerBootSweep;
|
|
1924
2134
|
}
|
|
1925
2135
|
/**
|
|
2136
|
+
* Merge the profile's working-directory mount with embedder docker options
|
|
2137
|
+
* into the option fragment spread into `dockerSandbox()`. An internal helper:
|
|
2138
|
+
* exported from this module so the unit test can import it, but intentionally
|
|
2139
|
+
* not re-exported from `index.ts` (not part of the package's public API).
|
|
2140
|
+
*/
|
|
2141
|
+
function resolveDockerSandboxOpts(cwdMount, custom) {
|
|
2142
|
+
const extraMounts = [...cwdMount ? [cwdMount] : [], ...custom?.extraMounts ?? []];
|
|
2143
|
+
return {
|
|
2144
|
+
...custom?.image !== void 0 && { image: custom.image },
|
|
2145
|
+
...custom?.allowFloatingTag !== void 0 && { allowFloatingTag: custom.allowFloatingTag },
|
|
2146
|
+
...custom?.env !== void 0 && { env: custom.env },
|
|
2147
|
+
...extraMounts.length > 0 && { extraMounts }
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
1926
2151
|
* Built-in sandbox profiles. `local` is always available. `docker` is
|
|
1927
2152
|
* gated on Docker being reachable so a user without Docker installed
|
|
1928
2153
|
* sees only what works — the UI never offers a non-functional choice.
|
|
@@ -1932,7 +2157,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
|
1932
2157
|
* server must run on shutdown (the providers' debounced idle teardowns die
|
|
1933
2158
|
* with the process).
|
|
1934
2159
|
*/
|
|
1935
|
-
async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
2160
|
+
async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
|
|
1936
2161
|
const profiles = [{
|
|
1937
2162
|
name: `local`,
|
|
1938
2163
|
label: `Local`,
|
|
@@ -1957,11 +2182,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1957
2182
|
workingDirectory: `/work`,
|
|
1958
2183
|
factory: () => dockerSandbox({
|
|
1959
2184
|
initialNetworkPolicy: { mode: `allow-all` },
|
|
1960
|
-
|
|
2185
|
+
...resolveDockerSandboxOpts(cwd ? {
|
|
1961
2186
|
hostPath: cwd,
|
|
1962
2187
|
containerPath: `/work`,
|
|
1963
2188
|
readOnly: false
|
|
1964
|
-
}
|
|
2189
|
+
} : void 0, dockerOpts),
|
|
1965
2190
|
sandboxKey,
|
|
1966
2191
|
persistent,
|
|
1967
2192
|
owner,
|