@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.
@@ -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 (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
980
- const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
981
- const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
982
- return {
983
- ...config,
984
- onPayload: (payload) => {
985
- if (typeof payload !== `object` || payload === null) return void 0;
986
- const body = payload;
987
- const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
988
- return {
989
- ...body,
990
- reasoning: {
991
- ...existingReasoning,
992
- effort
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 titles in 3-5 words. Respond with only the title, no quotes, no punctuation, no preamble.";
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
- await ctx.agent.run();
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
- slashCommands: buildSkillSlashCommands(skillsRegistry),
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
- extraMounts: cwd ? [{
2111
+ ...resolveDockerSandboxOpts(cwd ? {
1841
2112
  hostPath: cwd,
1842
2113
  containerPath: `/work`,
1843
2114
  readOnly: false
1844
- }] : void 0,
2115
+ } : void 0, dockerOpts),
1845
2116
  sandboxKey,
1846
2117
  persistent,
1847
2118
  owner,