@electric-ax/agents 0.4.16 → 0.4.18
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 +306 -35
- package/dist/index.cjs +305 -33
- package/dist/index.d.cts +75 -31
- package/dist/index.d.ts +75 -31
- package/dist/index.js +307 -36
- 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, 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";
|
|
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, createEventSourceTools, createFetchUrlTool, createMarkGoalCompleteTool, createReadFileTool, createScheduleTools, createSendTool, 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";
|
|
@@ -19,13 +19,19 @@ import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, ke
|
|
|
19
19
|
|
|
20
20
|
//#region src/durable-streams-cache.ts
|
|
21
21
|
const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
|
|
22
|
+
let installed = false;
|
|
22
23
|
function installDurableStreamsFetchCache(options = {}) {
|
|
23
24
|
if (options === false) return;
|
|
25
|
+
if (installed) {
|
|
26
|
+
console.warn(`[agents] installDurableStreamsFetchCache called more than once; ignoring`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
24
29
|
const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
|
|
25
30
|
location: options.sqliteLocation,
|
|
26
31
|
maxCount: options.maxCount
|
|
27
32
|
}) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
|
|
28
33
|
setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
|
|
34
|
+
installed = true;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
//#endregion
|
|
@@ -806,6 +812,69 @@ function createSpawnWorkerTool(ctx, modelConfig) {
|
|
|
806
812
|
};
|
|
807
813
|
}
|
|
808
814
|
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/tools/observe-pg-sync.ts
|
|
817
|
+
function asToolResult(value) {
|
|
818
|
+
return {
|
|
819
|
+
content: [{
|
|
820
|
+
type: `text`,
|
|
821
|
+
text: typeof value === `string` ? value : JSON.stringify(value, null, 2)
|
|
822
|
+
}],
|
|
823
|
+
details: {}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const PgSyncOperation = Type.Union([
|
|
827
|
+
Type.Literal(`insert`),
|
|
828
|
+
Type.Literal(`update`),
|
|
829
|
+
Type.Literal(`delete`)
|
|
830
|
+
]);
|
|
831
|
+
function createObservePgSyncTool(ctx) {
|
|
832
|
+
return {
|
|
833
|
+
name: `observe_pg_sync`,
|
|
834
|
+
label: `Observe Postgres Sync`,
|
|
835
|
+
description: `Observe an Electric Postgres shape stream and wake this agent when matching row changes arrive.`,
|
|
836
|
+
parameters: Type.Object({
|
|
837
|
+
url: Type.Optional(Type.String({ description: `Optional Electric shape endpoint URL. Defaults to the server-configured pg-sync URL.` })),
|
|
838
|
+
table: Type.String({
|
|
839
|
+
minLength: 1,
|
|
840
|
+
pattern: `\\S`,
|
|
841
|
+
description: `Postgres table name to observe.`
|
|
842
|
+
}),
|
|
843
|
+
columns: Type.Optional(Type.Array(Type.String(), { description: `Optional list of columns to include in the shape.` })),
|
|
844
|
+
where: Type.Optional(Type.String({ description: `Optional Electric shape WHERE clause.` })),
|
|
845
|
+
params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
|
|
846
|
+
replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)])),
|
|
847
|
+
wake: Type.Optional(Type.Object({
|
|
848
|
+
ops: Type.Optional(Type.Array(PgSyncOperation)),
|
|
849
|
+
debounceMs: Type.Optional(Type.Number())
|
|
850
|
+
}, { additionalProperties: false }))
|
|
851
|
+
}),
|
|
852
|
+
execute: async (_toolCallId, params) => {
|
|
853
|
+
const args = params;
|
|
854
|
+
if (typeof args.table !== `string` || args.table.trim().length === 0) throw new Error(`table is required`);
|
|
855
|
+
const source = pgSync({
|
|
856
|
+
url: args.url,
|
|
857
|
+
table: args.table,
|
|
858
|
+
columns: args.columns,
|
|
859
|
+
where: args.where,
|
|
860
|
+
params: args.params,
|
|
861
|
+
replica: args.replica
|
|
862
|
+
});
|
|
863
|
+
const wake = {
|
|
864
|
+
on: `change`,
|
|
865
|
+
...args.wake?.ops ? { ops: args.wake.ops } : {},
|
|
866
|
+
...args.wake?.debounceMs !== void 0 ? { debounceMs: args.wake.debounceMs } : {}
|
|
867
|
+
};
|
|
868
|
+
await ctx.observe(source, { wake });
|
|
869
|
+
return asToolResult({
|
|
870
|
+
sourceRef: source.sourceRef,
|
|
871
|
+
streamUrl: source.streamUrl,
|
|
872
|
+
wake
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
809
878
|
//#endregion
|
|
810
879
|
//#region src/tools/fork.ts
|
|
811
880
|
function createForkTool(ctx) {
|
|
@@ -860,6 +929,49 @@ Omit 'entityUrl' to fork your own session. Pass a different session's URL to for
|
|
|
860
929
|
};
|
|
861
930
|
}
|
|
862
931
|
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/tools/set-title.ts
|
|
934
|
+
function createSetTitleTool(ctx) {
|
|
935
|
+
return {
|
|
936
|
+
name: `set_title`,
|
|
937
|
+
label: `Set Title`,
|
|
938
|
+
description: `Set the chat session title shown in the UI. Use this when the current title is missing, stale, misleading, or the user asks to rename the session. Provide a concise, human-readable title.`,
|
|
939
|
+
parameters: Type.Object({ title: Type.String({ description: `New session title. Whitespace is trimmed and the title must not be empty.` }) }),
|
|
940
|
+
execute: async (_toolCallId, params) => {
|
|
941
|
+
const { title } = params;
|
|
942
|
+
const trimmedTitle = typeof title === `string` ? title.trim() : ``;
|
|
943
|
+
if (trimmedTitle.length === 0) return {
|
|
944
|
+
content: [{
|
|
945
|
+
type: `text`,
|
|
946
|
+
text: `Error: title must be a non-empty string.`
|
|
947
|
+
}],
|
|
948
|
+
details: { updated: false }
|
|
949
|
+
};
|
|
950
|
+
try {
|
|
951
|
+
await ctx.setTag(`title`, trimmedTitle);
|
|
952
|
+
return {
|
|
953
|
+
content: [{
|
|
954
|
+
type: `text`,
|
|
955
|
+
text: `Session title set to “${trimmedTitle}”.`
|
|
956
|
+
}],
|
|
957
|
+
details: {
|
|
958
|
+
updated: true,
|
|
959
|
+
title: trimmedTitle
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
} catch (err) {
|
|
963
|
+
return {
|
|
964
|
+
content: [{
|
|
965
|
+
type: `text`,
|
|
966
|
+
text: `Error setting session title: ${err instanceof Error ? err.message : `Unknown error`}`
|
|
967
|
+
}],
|
|
968
|
+
details: { updated: false }
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
863
975
|
//#endregion
|
|
864
976
|
//#region src/model-catalog.ts
|
|
865
977
|
const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
|
|
@@ -975,25 +1087,66 @@ function filterChoicesByEnabledModels(choices, values) {
|
|
|
975
1087
|
const filtered = choices.filter((choice) => enabled.has(choice.value));
|
|
976
1088
|
return filtered.length > 0 ? filtered : choices;
|
|
977
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Anthropic-specific budget mapping for `reasoningEffort`.
|
|
1092
|
+
*
|
|
1093
|
+
* Anthropic's `thinking.budget_tokens` is a hard cap on tokens spent
|
|
1094
|
+
* inside the thinking block before the model must commit to its
|
|
1095
|
+
* answer. Docs require ≥ 1024; we scale from there. Numbers tuned so
|
|
1096
|
+
* `medium` is the spot most "show your work" requests land, and
|
|
1097
|
+
* `high` covers tougher reasoning without uncapped spend.
|
|
1098
|
+
*
|
|
1099
|
+
* Keep in sync with provider doc updates — Anthropic has shifted the
|
|
1100
|
+
* minimum once already (older models capped lower).
|
|
1101
|
+
*/
|
|
1102
|
+
const ANTHROPIC_THINKING_BUDGET_BY_EFFORT = {
|
|
1103
|
+
minimal: 1024,
|
|
1104
|
+
low: 2048,
|
|
1105
|
+
medium: 8192,
|
|
1106
|
+
high: 24576
|
|
1107
|
+
};
|
|
978
1108
|
function withProviderPayloadDefaults(config, choice, reasoningEffort) {
|
|
979
|
-
if (
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1109
|
+
if (!choice.reasoning) return config;
|
|
1110
|
+
if (choice.provider === `openai` || choice.provider === `openai-codex`) {
|
|
1111
|
+
const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
|
|
1112
|
+
const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
|
|
1113
|
+
return {
|
|
1114
|
+
...config,
|
|
1115
|
+
onPayload: (payload) => {
|
|
1116
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1117
|
+
const body = payload;
|
|
1118
|
+
const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
|
|
1119
|
+
return {
|
|
1120
|
+
...body,
|
|
1121
|
+
reasoning: {
|
|
1122
|
+
...existingReasoning,
|
|
1123
|
+
effort
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
if (choice.provider === `anthropic`) {
|
|
1130
|
+
const effectiveEffort = reasoningEffort ?? `minimal`;
|
|
1131
|
+
const budgetTokens = ANTHROPIC_THINKING_BUDGET_BY_EFFORT[effectiveEffort];
|
|
1132
|
+
return {
|
|
1133
|
+
...config,
|
|
1134
|
+
onPayload: (payload) => {
|
|
1135
|
+
if (typeof payload !== `object` || payload === null) return void 0;
|
|
1136
|
+
const body = payload;
|
|
1137
|
+
const existingThinking = typeof body.thinking === `object` && body.thinking !== null ? body.thinking : {};
|
|
1138
|
+
return {
|
|
1139
|
+
...body,
|
|
1140
|
+
thinking: {
|
|
1141
|
+
...existingThinking,
|
|
1142
|
+
type: `enabled`,
|
|
1143
|
+
budget_tokens: budgetTokens
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
return config;
|
|
997
1150
|
}
|
|
998
1151
|
function parseReasoningEffort(value) {
|
|
999
1152
|
return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
|
|
@@ -1041,7 +1194,7 @@ function modelInputSchemaDefs(catalog) {
|
|
|
1041
1194
|
|
|
1042
1195
|
//#endregion
|
|
1043
1196
|
//#region src/agents/horton.ts
|
|
1044
|
-
const TITLE_SYSTEM_PROMPT = "You generate concise chat session
|
|
1197
|
+
const TITLE_SYSTEM_PROMPT = "You generate a concise 3-5 word chat session title from the user's first message. Respond with only the title — no quotes, punctuation, preamble, or explanation. The user may reference images, files, or attachments you cannot see; infer a title from their intent anyway. Never apologize or say anything is missing — always output a short title.";
|
|
1045
1198
|
const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
|
|
1046
1199
|
const TITLE_GENERATION_TIMEOUT_MS = 8e3;
|
|
1047
1200
|
const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
|
|
@@ -1135,12 +1288,16 @@ function withTimeout(promise, ms, description) {
|
|
|
1135
1288
|
if (timeout) clearTimeout(timeout);
|
|
1136
1289
|
});
|
|
1137
1290
|
}
|
|
1291
|
+
function looksLikeNonTitle(title) {
|
|
1292
|
+
if (title.split(/\s+/).filter(Boolean).length > 8) return true;
|
|
1293
|
+
return /[!?,]/.test(title);
|
|
1294
|
+
}
|
|
1138
1295
|
async function generateTitle(userMessage, llmCall, onFallback) {
|
|
1139
1296
|
try {
|
|
1140
1297
|
const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
|
|
1141
1298
|
const title = raw.trim();
|
|
1142
|
-
if (title.length > 0) return title;
|
|
1143
|
-
onFallback?.(`empty LLM title response`);
|
|
1299
|
+
if (title.length > 0 && !looksLikeNonTitle(title)) return title;
|
|
1300
|
+
onFallback?.(title.length === 0 ? `empty LLM title response` : `non-title LLM response`);
|
|
1144
1301
|
return buildFallbackTitle(userMessage);
|
|
1145
1302
|
} catch (err) {
|
|
1146
1303
|
onFallback?.(err instanceof Error ? err.message : String(err));
|
|
@@ -1150,6 +1307,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
|
|
|
1150
1307
|
function buildHortonSystemPrompt(workingDirectory, opts = {}) {
|
|
1151
1308
|
const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
|
|
1152
1309
|
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` : ``;
|
|
1310
|
+
const titleTool = `\n- set_title: set or rename this chat session's UI title`;
|
|
1153
1311
|
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` : ``;
|
|
1154
1312
|
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` : ``;
|
|
1155
1313
|
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.` : ``;
|
|
@@ -1205,8 +1363,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
|
|
|
1205
1363
|
- fetch_url: fetch and convert a URL to markdown
|
|
1206
1364
|
- spawn_worker: dispatch a subagent for an isolated task
|
|
1207
1365
|
- 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.
|
|
1366
|
+
- observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
|
|
1208
1367
|
- send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
|
|
1209
|
-
${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
|
|
1368
|
+
${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
|
|
1210
1369
|
|
|
1211
1370
|
# Working with files
|
|
1212
1371
|
- Prefer edit over write when modifying existing files.
|
|
@@ -1251,7 +1410,18 @@ Workflow when forking yourself for parallel exploration:
|
|
|
1251
1410
|
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.
|
|
1252
1411
|
|
|
1253
1412
|
Working directory: ${workingDirectory}
|
|
1254
|
-
The current year is ${new Date().getFullYear()}
|
|
1413
|
+
The current year is ${new Date().getFullYear()}.${buildGoalGuidance(opts.activeGoal)}`;
|
|
1414
|
+
}
|
|
1415
|
+
function buildGoalGuidance(goal) {
|
|
1416
|
+
if (!goal) return ``;
|
|
1417
|
+
const budgetLine = goal.tokenBudget === null ? `unlimited` : `${goal.tokensUsed} / ${goal.tokenBudget} tokens used`;
|
|
1418
|
+
return `
|
|
1419
|
+
|
|
1420
|
+
# Active goal
|
|
1421
|
+
- Objective: ${goal.objective}
|
|
1422
|
+
- Token budget: ${budgetLine}
|
|
1423
|
+
|
|
1424
|
+
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.`;
|
|
1255
1425
|
}
|
|
1256
1426
|
function getToolName(tool) {
|
|
1257
1427
|
if (typeof tool !== `object` || tool === null) return null;
|
|
@@ -1273,7 +1443,10 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
|
|
|
1273
1443
|
})] : [createFetchUrlTool(sandbox)],
|
|
1274
1444
|
createSpawnWorkerTool(ctx, opts.modelConfig),
|
|
1275
1445
|
createForkTool(ctx),
|
|
1446
|
+
createObservePgSyncTool(ctx),
|
|
1447
|
+
createSetTitleTool(ctx),
|
|
1276
1448
|
createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
|
|
1449
|
+
...ctx.getGoal()?.status === `active` ? [createMarkGoalCompleteTool(ctx)] : [],
|
|
1277
1450
|
...opts.docsSearchTool ? [opts.docsSearchTool] : []
|
|
1278
1451
|
];
|
|
1279
1452
|
}
|
|
@@ -1342,11 +1515,58 @@ async function readAgentsMd(sandbox) {
|
|
|
1342
1515
|
return null;
|
|
1343
1516
|
}
|
|
1344
1517
|
}
|
|
1518
|
+
function extractWakeText(wake) {
|
|
1519
|
+
if (wake.type !== `inbox`) return null;
|
|
1520
|
+
const payload = wake.payload;
|
|
1521
|
+
if (typeof payload === `string`) return payload;
|
|
1522
|
+
if (payload && typeof payload === `object`) {
|
|
1523
|
+
const record = payload;
|
|
1524
|
+
if (typeof record.text === `string`) return record.text;
|
|
1525
|
+
if (typeof record.source === `string`) return record.source;
|
|
1526
|
+
}
|
|
1527
|
+
return null;
|
|
1528
|
+
}
|
|
1529
|
+
async function tryHandleSlashCommand(ctx, wake) {
|
|
1530
|
+
const text = extractWakeText(wake);
|
|
1531
|
+
if (text === null) return false;
|
|
1532
|
+
if (isGoalCommandText(text)) {
|
|
1533
|
+
const command = parseGoalCommand(text);
|
|
1534
|
+
const result = dispatchGoalCommand(ctx, command);
|
|
1535
|
+
if (result.message) {
|
|
1536
|
+
serverLog.info(`[horton ${ctx.entityUrl}] ${result.message}`);
|
|
1537
|
+
writeSlashCommandReply(ctx, result.message);
|
|
1538
|
+
}
|
|
1539
|
+
if (command.kind === `set`) await kickoffGoalRun(ctx);
|
|
1540
|
+
return result.handled;
|
|
1541
|
+
}
|
|
1542
|
+
return false;
|
|
1543
|
+
}
|
|
1544
|
+
const GOAL_KICKOFF_TEXT = `Start working toward the active goal now. Call \`mark_goal_complete\` when you believe it is done.`;
|
|
1545
|
+
async function kickoffGoalRun(ctx) {
|
|
1546
|
+
const goal = ctx.getGoal();
|
|
1547
|
+
if (!goal || goal.status !== `active`) return;
|
|
1548
|
+
try {
|
|
1549
|
+
await ctx.send(ctx.entityUrl, {
|
|
1550
|
+
kind: `goal_kickoff`,
|
|
1551
|
+
text: GOAL_KICKOFF_TEXT
|
|
1552
|
+
}, { type: `inbox` });
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] failed to enqueue goal kickoff: ${err instanceof Error ? err.message : String(err)}`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function writeSlashCommandReply(ctx, text) {
|
|
1558
|
+
try {
|
|
1559
|
+
ctx.replyText(text);
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
serverLog.warn(`[horton ${ctx.entityUrl}] failed to render slash command reply: ${err instanceof Error ? err.message : String(err)}`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1345
1564
|
function createAssistantHandler(options) {
|
|
1346
1565
|
const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
|
|
1347
1566
|
const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
|
|
1348
1567
|
const hasSkills = skillLoader.hasSkills;
|
|
1349
1568
|
return async function assistantHandler(ctx, wake) {
|
|
1569
|
+
if (await tryHandleSlashCommand(ctx, wake)) return;
|
|
1350
1570
|
const loadedSkills = await skillLoader.load(ctx);
|
|
1351
1571
|
const readSet = new Set();
|
|
1352
1572
|
const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
|
|
@@ -1439,6 +1659,26 @@ function createAssistantHandler(options) {
|
|
|
1439
1659
|
}
|
|
1440
1660
|
}
|
|
1441
1661
|
});
|
|
1662
|
+
const goal = ctx.getGoal();
|
|
1663
|
+
const enforcedGoal = goal && goal.status === `active` ? goal : void 0;
|
|
1664
|
+
const activeGoalPromptInfo = enforcedGoal ? {
|
|
1665
|
+
objective: enforcedGoal.objective,
|
|
1666
|
+
tokenBudget: enforcedGoal.tokenBudget,
|
|
1667
|
+
tokensUsed: enforcedGoal.tokensUsed
|
|
1668
|
+
} : void 0;
|
|
1669
|
+
const budgetAbort = new AbortController();
|
|
1670
|
+
let runTokensUsed = enforcedGoal?.tokensUsed ?? 0;
|
|
1671
|
+
let budgetTripped = false;
|
|
1672
|
+
const onStepEnd = enforcedGoal ? (stats) => {
|
|
1673
|
+
if (budgetTripped) return;
|
|
1674
|
+
runTokensUsed += stats.uncachedInput + stats.output;
|
|
1675
|
+
ctx.updateGoalUsage(runTokensUsed);
|
|
1676
|
+
if (enforcedGoal.tokenBudget !== null && runTokensUsed >= enforcedGoal.tokenBudget) {
|
|
1677
|
+
budgetTripped = true;
|
|
1678
|
+
serverLog.info(`[horton ${ctx.entityUrl}] goal budget exhausted (${runTokensUsed} tokens) — aborting run`);
|
|
1679
|
+
budgetAbort.abort();
|
|
1680
|
+
}
|
|
1681
|
+
} : void 0;
|
|
1442
1682
|
ctx.useAgent({
|
|
1443
1683
|
systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
|
|
1444
1684
|
hasDocsSupport: Boolean(docsSupport),
|
|
@@ -1447,13 +1687,26 @@ function createAssistantHandler(options) {
|
|
|
1447
1687
|
modelProvider: modelConfig.provider,
|
|
1448
1688
|
modelId: String(modelConfig.model),
|
|
1449
1689
|
hasEventSourceTools,
|
|
1450
|
-
hasScheduleTools
|
|
1690
|
+
hasScheduleTools,
|
|
1691
|
+
...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
|
|
1451
1692
|
}),
|
|
1452
1693
|
...modelConfig,
|
|
1453
1694
|
tools,
|
|
1454
|
-
...streamFn && { streamFn }
|
|
1695
|
+
...streamFn && { streamFn },
|
|
1696
|
+
...onStepEnd && { onStepEnd }
|
|
1455
1697
|
});
|
|
1456
|
-
|
|
1698
|
+
try {
|
|
1699
|
+
await ctx.agent.run(void 0, budgetAbort.signal);
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
if (!budgetTripped) throw err;
|
|
1702
|
+
serverLog.info(`[horton ${ctx.entityUrl}] agent.run aborted by budget enforcement`);
|
|
1703
|
+
}
|
|
1704
|
+
if (enforcedGoal) ctx.updateGoalUsage(runTokensUsed, budgetTripped ? { status: `budget_limited` } : void 0);
|
|
1705
|
+
if (budgetTripped && enforcedGoal && enforcedGoal.tokenBudget !== null) {
|
|
1706
|
+
const budget = enforcedGoal.tokenBudget;
|
|
1707
|
+
const suggestedNext = Math.max(budget * 2, budget + 1e4);
|
|
1708
|
+
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.`);
|
|
1709
|
+
}
|
|
1457
1710
|
await titlePromise;
|
|
1458
1711
|
};
|
|
1459
1712
|
}
|
|
@@ -1493,7 +1746,8 @@ function registerHorton(registry, options) {
|
|
|
1493
1746
|
subject_value: `user`,
|
|
1494
1747
|
permission: `manage`
|
|
1495
1748
|
}],
|
|
1496
|
-
|
|
1749
|
+
state: { comments: commentsCollection },
|
|
1750
|
+
slashCommands: [GOAL_SLASH_COMMAND, ...buildSkillSlashCommands(skillsRegistry)],
|
|
1497
1751
|
handler: assistantHandler
|
|
1498
1752
|
});
|
|
1499
1753
|
return [`horton`];
|
|
@@ -1677,6 +1931,7 @@ function registerWorker(registry, options) {
|
|
|
1677
1931
|
subject_value: `user`,
|
|
1678
1932
|
permission: `manage`
|
|
1679
1933
|
}],
|
|
1934
|
+
state: { comments: commentsCollection },
|
|
1680
1935
|
async handler(ctx) {
|
|
1681
1936
|
const args = parseWorkerArgs(ctx.args);
|
|
1682
1937
|
const readSet = new Set();
|
|
@@ -1728,7 +1983,7 @@ function createBuiltinElectricTools(custom) {
|
|
|
1728
1983
|
};
|
|
1729
1984
|
}
|
|
1730
1985
|
async function createBuiltinAgentHandler(options) {
|
|
1731
|
-
const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType } = options;
|
|
1986
|
+
const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType, dockerSandbox: dockerSandboxOpts } = options;
|
|
1732
1987
|
const modelCatalog = await createBuiltinModelCatalog({
|
|
1733
1988
|
allowMockFallback: Boolean(streamFn),
|
|
1734
1989
|
enabledModelValues
|
|
@@ -1764,7 +2019,7 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1764
2019
|
modelCatalog
|
|
1765
2020
|
});
|
|
1766
2021
|
typeNames.push(`worker`);
|
|
1767
|
-
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
|
|
2022
|
+
const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
|
|
1768
2023
|
const runtime = createRuntimeHandler({
|
|
1769
2024
|
baseUrl: agentServerUrl,
|
|
1770
2025
|
serveEndpoint,
|
|
@@ -1784,7 +2039,8 @@ async function createBuiltinAgentHandler(options) {
|
|
|
1784
2039
|
registry,
|
|
1785
2040
|
typeNames,
|
|
1786
2041
|
skillsRegistry,
|
|
1787
|
-
shutdownSandboxes
|
|
2042
|
+
shutdownSandboxes,
|
|
2043
|
+
modelCatalog
|
|
1788
2044
|
};
|
|
1789
2045
|
}
|
|
1790
2046
|
async function registerBuiltinAgentTypes(bootstrap) {
|
|
@@ -1803,6 +2059,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
|
1803
2059
|
return dockerBootSweep;
|
|
1804
2060
|
}
|
|
1805
2061
|
/**
|
|
2062
|
+
* Merge the profile's working-directory mount with embedder docker options
|
|
2063
|
+
* into the option fragment spread into `dockerSandbox()`. An internal helper:
|
|
2064
|
+
* exported from this module so the unit test can import it, but intentionally
|
|
2065
|
+
* not re-exported from `index.ts` (not part of the package's public API).
|
|
2066
|
+
*/
|
|
2067
|
+
function resolveDockerSandboxOpts(cwdMount, custom) {
|
|
2068
|
+
const extraMounts = [...cwdMount ? [cwdMount] : [], ...custom?.extraMounts ?? []];
|
|
2069
|
+
return {
|
|
2070
|
+
...custom?.image !== void 0 && { image: custom.image },
|
|
2071
|
+
...custom?.allowFloatingTag !== void 0 && { allowFloatingTag: custom.allowFloatingTag },
|
|
2072
|
+
...custom?.env !== void 0 && { env: custom.env },
|
|
2073
|
+
...extraMounts.length > 0 && { extraMounts }
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
1806
2077
|
* Built-in sandbox profiles. `local` is always available. `docker` is
|
|
1807
2078
|
* gated on Docker being reachable so a user without Docker installed
|
|
1808
2079
|
* sees only what works — the UI never offers a non-functional choice.
|
|
@@ -1812,7 +2083,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
|
|
|
1812
2083
|
* server must run on shutdown (the providers' debounced idle teardowns die
|
|
1813
2084
|
* with the process).
|
|
1814
2085
|
*/
|
|
1815
|
-
async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
2086
|
+
async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
|
|
1816
2087
|
const profiles = [{
|
|
1817
2088
|
name: `local`,
|
|
1818
2089
|
label: `Local`,
|
|
@@ -1837,11 +2108,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
|
|
|
1837
2108
|
workingDirectory: `/work`,
|
|
1838
2109
|
factory: () => dockerSandbox({
|
|
1839
2110
|
initialNetworkPolicy: { mode: `allow-all` },
|
|
1840
|
-
|
|
2111
|
+
...resolveDockerSandboxOpts(cwd ? {
|
|
1841
2112
|
hostPath: cwd,
|
|
1842
2113
|
containerPath: `/work`,
|
|
1843
2114
|
readOnly: false
|
|
1844
|
-
}
|
|
2115
|
+
} : void 0, dockerOpts),
|
|
1845
2116
|
sandboxKey,
|
|
1846
2117
|
persistent,
|
|
1847
2118
|
owner,
|