@electric-ax/agents 0.4.17 → 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, pgSync, 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";
@@ -1087,25 +1087,66 @@ function filterChoicesByEnabledModels(choices, values) {
1087
1087
  const filtered = choices.filter((choice) => enabled.has(choice.value));
1088
1088
  return filtered.length > 0 ? filtered : choices;
1089
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
+ };
1090
1108
  function withProviderPayloadDefaults(config, choice, reasoningEffort) {
1091
- if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
1092
- const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1093
- const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1094
- return {
1095
- ...config,
1096
- onPayload: (payload) => {
1097
- if (typeof payload !== `object` || payload === null) return void 0;
1098
- const body = payload;
1099
- const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1100
- return {
1101
- ...body,
1102
- reasoning: {
1103
- ...existingReasoning,
1104
- effort
1105
- }
1106
- };
1107
- }
1108
- };
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;
1109
1150
  }
1110
1151
  function parseReasoningEffort(value) {
1111
1152
  return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
@@ -1369,7 +1410,18 @@ Workflow when forking yourself for parallel exploration:
1369
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.
1370
1411
 
1371
1412
  Working directory: ${workingDirectory}
1372
- 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.`;
1373
1425
  }
1374
1426
  function getToolName(tool) {
1375
1427
  if (typeof tool !== `object` || tool === null) return null;
@@ -1394,6 +1446,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1394
1446
  createObservePgSyncTool(ctx),
1395
1447
  createSetTitleTool(ctx),
1396
1448
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1449
+ ...ctx.getGoal()?.status === `active` ? [createMarkGoalCompleteTool(ctx)] : [],
1397
1450
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1398
1451
  ];
1399
1452
  }
@@ -1462,11 +1515,58 @@ async function readAgentsMd(sandbox) {
1462
1515
  return null;
1463
1516
  }
1464
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
+ }
1465
1564
  function createAssistantHandler(options) {
1466
1565
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1467
1566
  const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1468
1567
  const hasSkills = skillLoader.hasSkills;
1469
1568
  return async function assistantHandler(ctx, wake) {
1569
+ if (await tryHandleSlashCommand(ctx, wake)) return;
1470
1570
  const loadedSkills = await skillLoader.load(ctx);
1471
1571
  const readSet = new Set();
1472
1572
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
@@ -1559,6 +1659,26 @@ function createAssistantHandler(options) {
1559
1659
  }
1560
1660
  }
1561
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;
1562
1682
  ctx.useAgent({
1563
1683
  systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
1564
1684
  hasDocsSupport: Boolean(docsSupport),
@@ -1567,13 +1687,26 @@ function createAssistantHandler(options) {
1567
1687
  modelProvider: modelConfig.provider,
1568
1688
  modelId: String(modelConfig.model),
1569
1689
  hasEventSourceTools,
1570
- hasScheduleTools
1690
+ hasScheduleTools,
1691
+ ...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
1571
1692
  }),
1572
1693
  ...modelConfig,
1573
1694
  tools,
1574
- ...streamFn && { streamFn }
1695
+ ...streamFn && { streamFn },
1696
+ ...onStepEnd && { onStepEnd }
1575
1697
  });
1576
- 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
+ }
1577
1710
  await titlePromise;
1578
1711
  };
1579
1712
  }
@@ -1613,7 +1746,8 @@ function registerHorton(registry, options) {
1613
1746
  subject_value: `user`,
1614
1747
  permission: `manage`
1615
1748
  }],
1616
- slashCommands: buildSkillSlashCommands(skillsRegistry),
1749
+ state: { comments: commentsCollection },
1750
+ slashCommands: [GOAL_SLASH_COMMAND, ...buildSkillSlashCommands(skillsRegistry)],
1617
1751
  handler: assistantHandler
1618
1752
  });
1619
1753
  return [`horton`];
@@ -1797,6 +1931,7 @@ function registerWorker(registry, options) {
1797
1931
  subject_value: `user`,
1798
1932
  permission: `manage`
1799
1933
  }],
1934
+ state: { comments: commentsCollection },
1800
1935
  async handler(ctx) {
1801
1936
  const args = parseWorkerArgs(ctx.args);
1802
1937
  const readSet = new Set();
@@ -1848,7 +1983,7 @@ function createBuiltinElectricTools(custom) {
1848
1983
  };
1849
1984
  }
1850
1985
  async function createBuiltinAgentHandler(options) {
1851
- 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;
1852
1987
  const modelCatalog = await createBuiltinModelCatalog({
1853
1988
  allowMockFallback: Boolean(streamFn),
1854
1989
  enabledModelValues
@@ -1884,7 +2019,7 @@ async function createBuiltinAgentHandler(options) {
1884
2019
  modelCatalog
1885
2020
  });
1886
2021
  typeNames.push(`worker`);
1887
- const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
2022
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
1888
2023
  const runtime = createRuntimeHandler({
1889
2024
  baseUrl: agentServerUrl,
1890
2025
  serveEndpoint,
@@ -1904,7 +2039,8 @@ async function createBuiltinAgentHandler(options) {
1904
2039
  registry,
1905
2040
  typeNames,
1906
2041
  skillsRegistry,
1907
- shutdownSandboxes
2042
+ shutdownSandboxes,
2043
+ modelCatalog
1908
2044
  };
1909
2045
  }
1910
2046
  async function registerBuiltinAgentTypes(bootstrap) {
@@ -1923,6 +2059,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1923
2059
  return dockerBootSweep;
1924
2060
  }
1925
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
+ /**
1926
2077
  * Built-in sandbox profiles. `local` is always available. `docker` is
1927
2078
  * gated on Docker being reachable so a user without Docker installed
1928
2079
  * sees only what works — the UI never offers a non-functional choice.
@@ -1932,7 +2083,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1932
2083
  * server must run on shutdown (the providers' debounced idle teardowns die
1933
2084
  * with the process).
1934
2085
  */
1935
- async function buildBuiltinSandboxProfiles(workingDirectory) {
2086
+ async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
1936
2087
  const profiles = [{
1937
2088
  name: `local`,
1938
2089
  label: `Local`,
@@ -1957,11 +2108,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1957
2108
  workingDirectory: `/work`,
1958
2109
  factory: () => dockerSandbox({
1959
2110
  initialNetworkPolicy: { mode: `allow-all` },
1960
- extraMounts: cwd ? [{
2111
+ ...resolveDockerSandboxOpts(cwd ? {
1961
2112
  hostPath: cwd,
1962
2113
  containerPath: `/work`,
1963
2114
  readOnly: false
1964
- }] : void 0,
2115
+ } : void 0, dockerOpts),
1965
2116
  sandboxKey,
1966
2117
  persistent,
1967
2118
  owner,
package/dist/index.cjs CHANGED
@@ -1093,25 +1093,66 @@ function filterChoicesByEnabledModels(choices, values) {
1093
1093
  const filtered = choices.filter((choice) => enabled.has(choice.value));
1094
1094
  return filtered.length > 0 ? filtered : choices;
1095
1095
  }
1096
+ /**
1097
+ * Anthropic-specific budget mapping for `reasoningEffort`.
1098
+ *
1099
+ * Anthropic's `thinking.budget_tokens` is a hard cap on tokens spent
1100
+ * inside the thinking block before the model must commit to its
1101
+ * answer. Docs require ≥ 1024; we scale from there. Numbers tuned so
1102
+ * `medium` is the spot most "show your work" requests land, and
1103
+ * `high` covers tougher reasoning without uncapped spend.
1104
+ *
1105
+ * Keep in sync with provider doc updates — Anthropic has shifted the
1106
+ * minimum once already (older models capped lower).
1107
+ */
1108
+ const ANTHROPIC_THINKING_BUDGET_BY_EFFORT = {
1109
+ minimal: 1024,
1110
+ low: 2048,
1111
+ medium: 8192,
1112
+ high: 24576
1113
+ };
1096
1114
  function withProviderPayloadDefaults(config, choice, reasoningEffort) {
1097
- if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
1098
- const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1099
- const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1100
- return {
1101
- ...config,
1102
- onPayload: (payload) => {
1103
- if (typeof payload !== `object` || payload === null) return void 0;
1104
- const body = payload;
1105
- const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1106
- return {
1107
- ...body,
1108
- reasoning: {
1109
- ...existingReasoning,
1110
- effort
1111
- }
1112
- };
1113
- }
1114
- };
1115
+ if (!choice.reasoning) return config;
1116
+ if (choice.provider === `openai` || choice.provider === `openai-codex`) {
1117
+ const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1118
+ const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1119
+ return {
1120
+ ...config,
1121
+ onPayload: (payload) => {
1122
+ if (typeof payload !== `object` || payload === null) return void 0;
1123
+ const body = payload;
1124
+ const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1125
+ return {
1126
+ ...body,
1127
+ reasoning: {
1128
+ ...existingReasoning,
1129
+ effort
1130
+ }
1131
+ };
1132
+ }
1133
+ };
1134
+ }
1135
+ if (choice.provider === `anthropic`) {
1136
+ const effectiveEffort = reasoningEffort ?? `minimal`;
1137
+ const budgetTokens = ANTHROPIC_THINKING_BUDGET_BY_EFFORT[effectiveEffort];
1138
+ return {
1139
+ ...config,
1140
+ onPayload: (payload) => {
1141
+ if (typeof payload !== `object` || payload === null) return void 0;
1142
+ const body = payload;
1143
+ const existingThinking = typeof body.thinking === `object` && body.thinking !== null ? body.thinking : {};
1144
+ return {
1145
+ ...body,
1146
+ thinking: {
1147
+ ...existingThinking,
1148
+ type: `enabled`,
1149
+ budget_tokens: budgetTokens
1150
+ }
1151
+ };
1152
+ }
1153
+ };
1154
+ }
1155
+ return config;
1115
1156
  }
1116
1157
  function parseReasoningEffort(value) {
1117
1158
  return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
@@ -1376,7 +1417,18 @@ Workflow when forking yourself for parallel exploration:
1376
1417
  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.
1377
1418
 
1378
1419
  Working directory: ${workingDirectory}
1379
- The current year is ${new Date().getFullYear()}.`;
1420
+ The current year is ${new Date().getFullYear()}.${buildGoalGuidance(opts.activeGoal)}`;
1421
+ }
1422
+ function buildGoalGuidance(goal) {
1423
+ if (!goal) return ``;
1424
+ const budgetLine = goal.tokenBudget === null ? `unlimited` : `${goal.tokensUsed} / ${goal.tokenBudget} tokens used`;
1425
+ return `
1426
+
1427
+ # Active goal
1428
+ - Objective: ${goal.objective}
1429
+ - Token budget: ${budgetLine}
1430
+
1431
+ 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.`;
1380
1432
  }
1381
1433
  function getToolName(tool) {
1382
1434
  if (typeof tool !== `object` || tool === null) return null;
@@ -1401,6 +1453,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1401
1453
  createObservePgSyncTool(ctx),
1402
1454
  createSetTitleTool(ctx),
1403
1455
  (0, __electric_ax_agents_runtime_tools.createSendTool)(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1456
+ ...ctx.getGoal()?.status === `active` ? [(0, __electric_ax_agents_runtime_tools.createMarkGoalCompleteTool)(ctx)] : [],
1404
1457
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1405
1458
  ];
1406
1459
  }
@@ -1469,11 +1522,58 @@ async function readAgentsMd(sandbox) {
1469
1522
  return null;
1470
1523
  }
1471
1524
  }
1525
+ function extractWakeText(wake) {
1526
+ if (wake.type !== `inbox`) return null;
1527
+ const payload = wake.payload;
1528
+ if (typeof payload === `string`) return payload;
1529
+ if (payload && typeof payload === `object`) {
1530
+ const record = payload;
1531
+ if (typeof record.text === `string`) return record.text;
1532
+ if (typeof record.source === `string`) return record.source;
1533
+ }
1534
+ return null;
1535
+ }
1536
+ async function tryHandleSlashCommand(ctx, wake) {
1537
+ const text = extractWakeText(wake);
1538
+ if (text === null) return false;
1539
+ if ((0, __electric_ax_agents_runtime.isGoalCommandText)(text)) {
1540
+ const command = (0, __electric_ax_agents_runtime.parseGoalCommand)(text);
1541
+ const result = (0, __electric_ax_agents_runtime.dispatchGoalCommand)(ctx, command);
1542
+ if (result.message) {
1543
+ serverLog.info(`[horton ${ctx.entityUrl}] ${result.message}`);
1544
+ writeSlashCommandReply(ctx, result.message);
1545
+ }
1546
+ if (command.kind === `set`) await kickoffGoalRun(ctx);
1547
+ return result.handled;
1548
+ }
1549
+ return false;
1550
+ }
1551
+ const GOAL_KICKOFF_TEXT = `Start working toward the active goal now. Call \`mark_goal_complete\` when you believe it is done.`;
1552
+ async function kickoffGoalRun(ctx) {
1553
+ const goal = ctx.getGoal();
1554
+ if (!goal || goal.status !== `active`) return;
1555
+ try {
1556
+ await ctx.send(ctx.entityUrl, {
1557
+ kind: `goal_kickoff`,
1558
+ text: GOAL_KICKOFF_TEXT
1559
+ }, { type: `inbox` });
1560
+ } catch (err) {
1561
+ serverLog.warn(`[horton ${ctx.entityUrl}] failed to enqueue goal kickoff: ${err instanceof Error ? err.message : String(err)}`);
1562
+ }
1563
+ }
1564
+ function writeSlashCommandReply(ctx, text) {
1565
+ try {
1566
+ ctx.replyText(text);
1567
+ } catch (err) {
1568
+ serverLog.warn(`[horton ${ctx.entityUrl}] failed to render slash command reply: ${err instanceof Error ? err.message : String(err)}`);
1569
+ }
1570
+ }
1472
1571
  function createAssistantHandler(options) {
1473
1572
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1474
1573
  const skillLoader = (0, __electric_ax_agents_runtime.createContextSkillLoader)(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1475
1574
  const hasSkills = skillLoader.hasSkills;
1476
1575
  return async function assistantHandler(ctx, wake) {
1576
+ if (await tryHandleSlashCommand(ctx, wake)) return;
1477
1577
  const loadedSkills = await skillLoader.load(ctx);
1478
1578
  const readSet = new Set();
1479
1579
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
@@ -1566,6 +1666,26 @@ function createAssistantHandler(options) {
1566
1666
  }
1567
1667
  }
1568
1668
  });
1669
+ const goal = ctx.getGoal();
1670
+ const enforcedGoal = goal && goal.status === `active` ? goal : void 0;
1671
+ const activeGoalPromptInfo = enforcedGoal ? {
1672
+ objective: enforcedGoal.objective,
1673
+ tokenBudget: enforcedGoal.tokenBudget,
1674
+ tokensUsed: enforcedGoal.tokensUsed
1675
+ } : void 0;
1676
+ const budgetAbort = new AbortController();
1677
+ let runTokensUsed = enforcedGoal?.tokensUsed ?? 0;
1678
+ let budgetTripped = false;
1679
+ const onStepEnd = enforcedGoal ? (stats) => {
1680
+ if (budgetTripped) return;
1681
+ runTokensUsed += stats.uncachedInput + stats.output;
1682
+ ctx.updateGoalUsage(runTokensUsed);
1683
+ if (enforcedGoal.tokenBudget !== null && runTokensUsed >= enforcedGoal.tokenBudget) {
1684
+ budgetTripped = true;
1685
+ serverLog.info(`[horton ${ctx.entityUrl}] goal budget exhausted (${runTokensUsed} tokens) — aborting run`);
1686
+ budgetAbort.abort();
1687
+ }
1688
+ } : void 0;
1569
1689
  ctx.useAgent({
1570
1690
  systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
1571
1691
  hasDocsSupport: Boolean(docsSupport),
@@ -1574,13 +1694,26 @@ function createAssistantHandler(options) {
1574
1694
  modelProvider: modelConfig.provider,
1575
1695
  modelId: String(modelConfig.model),
1576
1696
  hasEventSourceTools,
1577
- hasScheduleTools
1697
+ hasScheduleTools,
1698
+ ...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
1578
1699
  }),
1579
1700
  ...modelConfig,
1580
1701
  tools,
1581
- ...streamFn && { streamFn }
1702
+ ...streamFn && { streamFn },
1703
+ ...onStepEnd && { onStepEnd }
1582
1704
  });
1583
- await ctx.agent.run();
1705
+ try {
1706
+ await ctx.agent.run(void 0, budgetAbort.signal);
1707
+ } catch (err) {
1708
+ if (!budgetTripped) throw err;
1709
+ serverLog.info(`[horton ${ctx.entityUrl}] agent.run aborted by budget enforcement`);
1710
+ }
1711
+ if (enforcedGoal) ctx.updateGoalUsage(runTokensUsed, budgetTripped ? { status: `budget_limited` } : void 0);
1712
+ if (budgetTripped && enforcedGoal && enforcedGoal.tokenBudget !== null) {
1713
+ const budget = enforcedGoal.tokenBudget;
1714
+ const suggestedNext = Math.max(budget * 2, budget + 1e4);
1715
+ writeSlashCommandReply(ctx, `⚠️ Stopped — goal hit the token budget (${(0, __electric_ax_agents_runtime.formatTokenCount)(runTokensUsed)} / ${(0, __electric_ax_agents_runtime.formatTokenCount)(budget)} tokens used). Raise the budget with \`/goal set "..." --tokens ${(0, __electric_ax_agents_runtime.formatTokenCount)(suggestedNext)}\`, or call \`/goal complete\` to finalize.`);
1716
+ }
1584
1717
  await titlePromise;
1585
1718
  };
1586
1719
  }
@@ -1620,7 +1753,8 @@ function registerHorton(registry, options) {
1620
1753
  subject_value: `user`,
1621
1754
  permission: `manage`
1622
1755
  }],
1623
- slashCommands: (0, __electric_ax_agents_runtime.buildSkillSlashCommands)(skillsRegistry),
1756
+ state: { comments: __electric_ax_agents_runtime.commentsCollection },
1757
+ slashCommands: [__electric_ax_agents_runtime.GOAL_SLASH_COMMAND, ...(0, __electric_ax_agents_runtime.buildSkillSlashCommands)(skillsRegistry)],
1624
1758
  handler: assistantHandler
1625
1759
  });
1626
1760
  return [`horton`];
@@ -1804,6 +1938,7 @@ function registerWorker(registry, options) {
1804
1938
  subject_value: `user`,
1805
1939
  permission: `manage`
1806
1940
  }],
1941
+ state: { comments: __electric_ax_agents_runtime.commentsCollection },
1807
1942
  async handler(ctx) {
1808
1943
  const args = parseWorkerArgs(ctx.args);
1809
1944
  const readSet = new Set();
@@ -1856,7 +1991,7 @@ function createBuiltinElectricTools(custom) {
1856
1991
  };
1857
1992
  }
1858
1993
  async function createBuiltinAgentHandler(options) {
1859
- const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType } = options;
1994
+ const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType, dockerSandbox: dockerSandboxOpts } = options;
1860
1995
  const modelCatalog = await createBuiltinModelCatalog({
1861
1996
  allowMockFallback: Boolean(streamFn),
1862
1997
  enabledModelValues
@@ -1892,7 +2027,7 @@ async function createBuiltinAgentHandler(options) {
1892
2027
  modelCatalog
1893
2028
  });
1894
2029
  typeNames.push(`worker`);
1895
- const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
2030
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
1896
2031
  const runtime = (0, __electric_ax_agents_runtime.createRuntimeHandler)({
1897
2032
  baseUrl: agentServerUrl,
1898
2033
  serveEndpoint,
@@ -1912,7 +2047,8 @@ async function createBuiltinAgentHandler(options) {
1912
2047
  registry,
1913
2048
  typeNames,
1914
2049
  skillsRegistry,
1915
- shutdownSandboxes
2050
+ shutdownSandboxes,
2051
+ modelCatalog
1916
2052
  };
1917
2053
  }
1918
2054
  async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
@@ -1941,6 +2077,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1941
2077
  return dockerBootSweep;
1942
2078
  }
1943
2079
  /**
2080
+ * Merge the profile's working-directory mount with embedder docker options
2081
+ * into the option fragment spread into `dockerSandbox()`. An internal helper:
2082
+ * exported from this module so the unit test can import it, but intentionally
2083
+ * not re-exported from `index.ts` (not part of the package's public API).
2084
+ */
2085
+ function resolveDockerSandboxOpts(cwdMount, custom) {
2086
+ const extraMounts = [...cwdMount ? [cwdMount] : [], ...custom?.extraMounts ?? []];
2087
+ return {
2088
+ ...custom?.image !== void 0 && { image: custom.image },
2089
+ ...custom?.allowFloatingTag !== void 0 && { allowFloatingTag: custom.allowFloatingTag },
2090
+ ...custom?.env !== void 0 && { env: custom.env },
2091
+ ...extraMounts.length > 0 && { extraMounts }
2092
+ };
2093
+ }
2094
+ /**
1944
2095
  * Built-in sandbox profiles. `local` is always available. `docker` is
1945
2096
  * gated on Docker being reachable so a user without Docker installed
1946
2097
  * sees only what works — the UI never offers a non-functional choice.
@@ -1950,7 +2101,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1950
2101
  * server must run on shutdown (the providers' debounced idle teardowns die
1951
2102
  * with the process).
1952
2103
  */
1953
- async function buildBuiltinSandboxProfiles(workingDirectory) {
2104
+ async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
1954
2105
  const profiles = [{
1955
2106
  name: `local`,
1956
2107
  label: `Local`,
@@ -1975,11 +2126,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1975
2126
  workingDirectory: `/work`,
1976
2127
  factory: () => dockerSandbox({
1977
2128
  initialNetworkPolicy: { mode: `allow-all` },
1978
- extraMounts: cwd ? [{
2129
+ ...resolveDockerSandboxOpts(cwd ? {
1979
2130
  hostPath: cwd,
1980
2131
  containerPath: `/work`,
1981
2132
  readOnly: false
1982
- }] : void 0,
2133
+ } : void 0, dockerOpts),
1983
2134
  sandboxKey,
1984
2135
  persistent,
1985
2136
  owner,
@@ -2391,4 +2542,5 @@ exports.registerBuiltinAgentTypes = registerBuiltinAgentTypes
2391
2542
  exports.registerHorton = registerHorton
2392
2543
  exports.registerWorker = registerWorker
2393
2544
  exports.resolveBuiltinAgentsEntrypointOptions = resolveBuiltinAgentsEntrypointOptions
2545
+ exports.resolveBuiltinModelConfig = resolveBuiltinModelConfig
2394
2546
  exports.runBuiltinAgentsEntrypoint = runBuiltinAgentsEntrypoint
package/dist/index.d.cts CHANGED
@@ -6,6 +6,36 @@ import { Sandbox } from "@electric-ax/agents-runtime/sandbox";
6
6
  import { ChangeEvent } from "@durable-streams/state";
7
7
  import { braveSearchTool } from "@electric-ax/agents-runtime/tools";
8
8
 
9
+ //#region src/model-catalog.d.ts
10
+ type BuiltinModelProvider = AvailableProvider;
11
+ type BuiltinModelInput = `text` | `image`;
12
+ interface BuiltinModelChoice {
13
+ provider: BuiltinModelProvider;
14
+ id: string;
15
+ label: string;
16
+ value: string;
17
+ reasoning: boolean;
18
+ input: Array<BuiltinModelInput>;
19
+ }
20
+ interface BuiltinModelCatalog {
21
+ choices: Array<BuiltinModelChoice>;
22
+ defaultChoice: BuiltinModelChoice;
23
+ }
24
+ interface BuiltinModelCatalogOptions {
25
+ allowMockFallback?: boolean;
26
+ enabledModelValues?: ReadonlyArray<string> | null;
27
+ }
28
+ declare const REASONING_EFFORT_VALUES: readonly ["auto", "minimal", "low", "medium", "high"];
29
+ type BuiltinReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
30
+ type ExplicitReasoningEffort = Exclude<BuiltinReasoningEffort, `auto`>;
31
+ type BuiltinAgentModelConfig = Pick<AgentConfig, `model` | `provider` | `onPayload` | `getApiKey`> & {
32
+ reasoningEffort?: ExplicitReasoningEffort;
33
+ };
34
+ declare function builtinModelProviderLabel(provider: BuiltinModelProvider): string;
35
+ declare function listBuiltinModelChoices(providers: ReadonlyArray<BuiltinModelProvider>): Array<BuiltinModelChoice>;
36
+ declare function resolveBuiltinModelConfig(catalog: BuiltinModelCatalog, args: Readonly<Record<string, unknown>>): BuiltinAgentModelConfig;
37
+
38
+ //#endregion
9
39
  //#region src/bootstrap.d.ts
10
40
  declare const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = "/_electric/builtin-agent-handler";
11
41
  interface AgentHandlerResult {
@@ -21,8 +51,38 @@ interface AgentHandlerResult {
21
51
  * die with the process, which would leave containers running.
22
52
  */
23
53
  shutdownSandboxes: (() => Promise<void>) | null;
54
+ /**
55
+ * Model catalog the built-in agents resolve `model` args against — lets
56
+ * embedders register sibling agent types with the same model resolution.
57
+ */
58
+ modelCatalog: BuiltinModelCatalog;
24
59
  }
25
60
  type BuiltinElectricToolsFactory = NonNullable<ProcessWakeConfig[`createElectricTools`]>;
61
+ /** Mount spec mirroring `DockerSandboxOpts['extraMounts']` items. */
62
+ interface BuiltinDockerSandboxMount {
63
+ hostPath: string;
64
+ containerPath: string;
65
+ readOnly?: boolean;
66
+ }
67
+ /**
68
+ * Embedder customization for the built-in `docker` sandbox profile.
69
+ * Threads straight into `dockerSandbox()` (which already supports these);
70
+ * custom `extraMounts` are appended after the working-directory mount.
71
+ * These are embedder/operator-trust inputs: `extraMounts` is subject to the
72
+ * runtime's docker-socket guard, and `env` is passed verbatim into the
73
+ * container.
74
+ *
75
+ * Note: custom `extraMounts` must not target the working-directory container
76
+ * path (`/work`) — it collides with the cwd mount and fails at container-create
77
+ * time with an opaque docker error.
78
+ */
79
+ interface BuiltinDockerSandboxOptions {
80
+ /** Digest-pinned image unless `allowFloatingTag` is set. */
81
+ image?: string;
82
+ allowFloatingTag?: boolean;
83
+ env?: Record<string, string>;
84
+ extraMounts?: Array<BuiltinDockerSandboxMount>;
85
+ }
26
86
  interface BuiltinAgentHandlerOptions {
27
87
  agentServerUrl: string;
28
88
  serveEndpoint?: string;
@@ -36,6 +96,8 @@ interface BuiltinAgentHandlerOptions {
36
96
  serverHeaders?: HeadersProvider;
37
97
  defaultDispatchPolicyForType?: (typeName: string) => DispatchPolicy | undefined;
38
98
  createElectricTools?: BuiltinElectricToolsFactory;
99
+ /** Customize the built-in `docker` sandbox profile (image, env, mounts). */
100
+ dockerSandbox?: BuiltinDockerSandboxOptions;
39
101
  }
40
102
  declare function createBuiltinElectricTools(custom?: BuiltinElectricToolsFactory): BuiltinElectricToolsFactory;
41
103
  declare function createBuiltinAgentHandler(options: BuiltinAgentHandlerOptions): Promise<AgentHandlerResult | null>;
@@ -45,6 +107,12 @@ declare const registerAgentTypes: typeof registerBuiltinAgentTypes;
45
107
 
46
108
  //#endregion
47
109
  //#region src/durable-streams-cache.d.ts
110
+ /**
111
+ * Merge the profile's working-directory mount with embedder docker options
112
+ * into the option fragment spread into `dockerSandbox()`. An internal helper:
113
+ * exported from this module so the unit test can import it, but intentionally
114
+ * not re-exported from `index.ts` (not part of the package's public API).
115
+ */
48
116
  type DurableStreamsFetchCacheOptions = false | {
49
117
  store?: `memory` | `sqlite`;
50
118
  sqliteLocation?: string;
@@ -160,40 +228,15 @@ declare function runBuiltinAgentsEntrypoint({
160
228
  url: string;
161
229
  }>;
162
230
 
163
- //#endregion
164
- //#region src/model-catalog.d.ts
165
- type BuiltinModelProvider = AvailableProvider;
166
- type BuiltinModelInput = `text` | `image`;
167
- interface BuiltinModelChoice {
168
- provider: BuiltinModelProvider;
169
- id: string;
170
- label: string;
171
- value: string;
172
- reasoning: boolean;
173
- input: Array<BuiltinModelInput>;
174
- }
175
- interface BuiltinModelCatalog {
176
- choices: Array<BuiltinModelChoice>;
177
- defaultChoice: BuiltinModelChoice;
178
- }
179
- interface BuiltinModelCatalogOptions {
180
- allowMockFallback?: boolean;
181
- enabledModelValues?: ReadonlyArray<string> | null;
182
- }
183
- declare const REASONING_EFFORT_VALUES: readonly ["auto", "minimal", "low", "medium", "high"];
184
- type BuiltinReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
185
- type ExplicitReasoningEffort = Exclude<BuiltinReasoningEffort, `auto`>;
186
- type BuiltinAgentModelConfig = Pick<AgentConfig, `model` | `provider` | `onPayload` | `getApiKey`> & {
187
- reasoningEffort?: ExplicitReasoningEffort;
188
- };
189
- declare function builtinModelProviderLabel(provider: BuiltinModelProvider): string;
190
- declare function listBuiltinModelChoices(providers: ReadonlyArray<BuiltinModelProvider>): Array<BuiltinModelChoice>;
191
- declare function resolveBuiltinModelConfig(catalog: BuiltinModelCatalog, args: Readonly<Record<string, unknown>>): BuiltinAgentModelConfig;
192
-
193
231
  //#endregion
194
232
  //#region src/agents/horton.d.ts
195
233
  declare const HORTON_MODEL = "claude-sonnet-4-6";
196
234
  declare function generateTitle(userMessage: string, llmCall: (prompt: string) => Promise<string>, onFallback?: (reason: string) => void): Promise<string>;
235
+ interface ActiveGoalPromptInfo {
236
+ objective: string;
237
+ tokenBudget: number | null;
238
+ tokensUsed: number;
239
+ }
197
240
  declare function buildHortonSystemPrompt(workingDirectory: string, opts?: {
198
241
  hasDocsSupport?: boolean;
199
242
  hasEventSourceTools?: boolean;
@@ -202,6 +245,7 @@ declare function buildHortonSystemPrompt(workingDirectory: string, opts?: {
202
245
  docsUrl?: string;
203
246
  modelProvider?: string;
204
247
  modelId?: string;
248
+ activeGoal?: ActiveGoalPromptInfo;
205
249
  }): string;
206
250
  declare function createHortonTools(sandbox: Sandbox, ctx: HandlerContext, readSet: Set<string>, opts?: {
207
251
  docsSearchTool?: AgentTool$1;
@@ -254,4 +298,4 @@ declare function createHortonDocsSupport(workingDirectory: string, opts?: {
254
298
  }): HortonDocsSupport | null;
255
299
 
256
300
  //#endregion
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 };
301
+ export { AgentHandlerResult, BuiltinAgentHandlerOptions, BuiltinAgentModelConfig, BuiltinAgentsEntrypointOptions, BuiltinAgentsEntrypointServer, BuiltinAgentsServer, BuiltinAgentsServerOptions, BuiltinDockerSandboxMount, BuiltinDockerSandboxOptions, BuiltinElectricToolsFactory, BuiltinModelCatalog, 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, resolveBuiltinModelConfig, runBuiltinAgentsEntrypoint };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,36 @@ import { AgentTool as AgentTool$1, StreamFn } from "@mariozechner/pi-agent-core"
6
6
  import { IncomingMessage, ServerResponse } from "node:http";
7
7
  import { ChangeEvent } from "@durable-streams/state";
8
8
 
9
+ //#region src/model-catalog.d.ts
10
+ type BuiltinModelProvider = AvailableProvider;
11
+ type BuiltinModelInput = `text` | `image`;
12
+ interface BuiltinModelChoice {
13
+ provider: BuiltinModelProvider;
14
+ id: string;
15
+ label: string;
16
+ value: string;
17
+ reasoning: boolean;
18
+ input: Array<BuiltinModelInput>;
19
+ }
20
+ interface BuiltinModelCatalog {
21
+ choices: Array<BuiltinModelChoice>;
22
+ defaultChoice: BuiltinModelChoice;
23
+ }
24
+ interface BuiltinModelCatalogOptions {
25
+ allowMockFallback?: boolean;
26
+ enabledModelValues?: ReadonlyArray<string> | null;
27
+ }
28
+ declare const REASONING_EFFORT_VALUES: readonly ["auto", "minimal", "low", "medium", "high"];
29
+ type BuiltinReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
30
+ type ExplicitReasoningEffort = Exclude<BuiltinReasoningEffort, `auto`>;
31
+ type BuiltinAgentModelConfig = Pick<AgentConfig, `model` | `provider` | `onPayload` | `getApiKey`> & {
32
+ reasoningEffort?: ExplicitReasoningEffort;
33
+ };
34
+ declare function builtinModelProviderLabel(provider: BuiltinModelProvider): string;
35
+ declare function listBuiltinModelChoices(providers: ReadonlyArray<BuiltinModelProvider>): Array<BuiltinModelChoice>;
36
+ declare function resolveBuiltinModelConfig(catalog: BuiltinModelCatalog, args: Readonly<Record<string, unknown>>): BuiltinAgentModelConfig;
37
+
38
+ //#endregion
9
39
  //#region src/bootstrap.d.ts
10
40
  declare const DEFAULT_BUILTIN_AGENT_HANDLER_PATH = "/_electric/builtin-agent-handler";
11
41
  interface AgentHandlerResult {
@@ -21,8 +51,38 @@ interface AgentHandlerResult {
21
51
  * die with the process, which would leave containers running.
22
52
  */
23
53
  shutdownSandboxes: (() => Promise<void>) | null;
54
+ /**
55
+ * Model catalog the built-in agents resolve `model` args against — lets
56
+ * embedders register sibling agent types with the same model resolution.
57
+ */
58
+ modelCatalog: BuiltinModelCatalog;
24
59
  }
25
60
  type BuiltinElectricToolsFactory = NonNullable<ProcessWakeConfig[`createElectricTools`]>;
61
+ /** Mount spec mirroring `DockerSandboxOpts['extraMounts']` items. */
62
+ interface BuiltinDockerSandboxMount {
63
+ hostPath: string;
64
+ containerPath: string;
65
+ readOnly?: boolean;
66
+ }
67
+ /**
68
+ * Embedder customization for the built-in `docker` sandbox profile.
69
+ * Threads straight into `dockerSandbox()` (which already supports these);
70
+ * custom `extraMounts` are appended after the working-directory mount.
71
+ * These are embedder/operator-trust inputs: `extraMounts` is subject to the
72
+ * runtime's docker-socket guard, and `env` is passed verbatim into the
73
+ * container.
74
+ *
75
+ * Note: custom `extraMounts` must not target the working-directory container
76
+ * path (`/work`) — it collides with the cwd mount and fails at container-create
77
+ * time with an opaque docker error.
78
+ */
79
+ interface BuiltinDockerSandboxOptions {
80
+ /** Digest-pinned image unless `allowFloatingTag` is set. */
81
+ image?: string;
82
+ allowFloatingTag?: boolean;
83
+ env?: Record<string, string>;
84
+ extraMounts?: Array<BuiltinDockerSandboxMount>;
85
+ }
26
86
  interface BuiltinAgentHandlerOptions {
27
87
  agentServerUrl: string;
28
88
  serveEndpoint?: string;
@@ -36,6 +96,8 @@ interface BuiltinAgentHandlerOptions {
36
96
  serverHeaders?: HeadersProvider;
37
97
  defaultDispatchPolicyForType?: (typeName: string) => DispatchPolicy | undefined;
38
98
  createElectricTools?: BuiltinElectricToolsFactory;
99
+ /** Customize the built-in `docker` sandbox profile (image, env, mounts). */
100
+ dockerSandbox?: BuiltinDockerSandboxOptions;
39
101
  }
40
102
  declare function createBuiltinElectricTools(custom?: BuiltinElectricToolsFactory): BuiltinElectricToolsFactory;
41
103
  declare function createBuiltinAgentHandler(options: BuiltinAgentHandlerOptions): Promise<AgentHandlerResult | null>;
@@ -45,6 +107,12 @@ declare const registerAgentTypes: typeof registerBuiltinAgentTypes;
45
107
 
46
108
  //#endregion
47
109
  //#region src/durable-streams-cache.d.ts
110
+ /**
111
+ * Merge the profile's working-directory mount with embedder docker options
112
+ * into the option fragment spread into `dockerSandbox()`. An internal helper:
113
+ * exported from this module so the unit test can import it, but intentionally
114
+ * not re-exported from `index.ts` (not part of the package's public API).
115
+ */
48
116
  type DurableStreamsFetchCacheOptions = false | {
49
117
  store?: `memory` | `sqlite`;
50
118
  sqliteLocation?: string;
@@ -160,40 +228,15 @@ declare function runBuiltinAgentsEntrypoint({
160
228
  url: string;
161
229
  }>;
162
230
 
163
- //#endregion
164
- //#region src/model-catalog.d.ts
165
- type BuiltinModelProvider = AvailableProvider;
166
- type BuiltinModelInput = `text` | `image`;
167
- interface BuiltinModelChoice {
168
- provider: BuiltinModelProvider;
169
- id: string;
170
- label: string;
171
- value: string;
172
- reasoning: boolean;
173
- input: Array<BuiltinModelInput>;
174
- }
175
- interface BuiltinModelCatalog {
176
- choices: Array<BuiltinModelChoice>;
177
- defaultChoice: BuiltinModelChoice;
178
- }
179
- interface BuiltinModelCatalogOptions {
180
- allowMockFallback?: boolean;
181
- enabledModelValues?: ReadonlyArray<string> | null;
182
- }
183
- declare const REASONING_EFFORT_VALUES: readonly ["auto", "minimal", "low", "medium", "high"];
184
- type BuiltinReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
185
- type ExplicitReasoningEffort = Exclude<BuiltinReasoningEffort, `auto`>;
186
- type BuiltinAgentModelConfig = Pick<AgentConfig, `model` | `provider` | `onPayload` | `getApiKey`> & {
187
- reasoningEffort?: ExplicitReasoningEffort;
188
- };
189
- declare function builtinModelProviderLabel(provider: BuiltinModelProvider): string;
190
- declare function listBuiltinModelChoices(providers: ReadonlyArray<BuiltinModelProvider>): Array<BuiltinModelChoice>;
191
- declare function resolveBuiltinModelConfig(catalog: BuiltinModelCatalog, args: Readonly<Record<string, unknown>>): BuiltinAgentModelConfig;
192
-
193
231
  //#endregion
194
232
  //#region src/agents/horton.d.ts
195
233
  declare const HORTON_MODEL = "claude-sonnet-4-6";
196
234
  declare function generateTitle(userMessage: string, llmCall: (prompt: string) => Promise<string>, onFallback?: (reason: string) => void): Promise<string>;
235
+ interface ActiveGoalPromptInfo {
236
+ objective: string;
237
+ tokenBudget: number | null;
238
+ tokensUsed: number;
239
+ }
197
240
  declare function buildHortonSystemPrompt(workingDirectory: string, opts?: {
198
241
  hasDocsSupport?: boolean;
199
242
  hasEventSourceTools?: boolean;
@@ -202,6 +245,7 @@ declare function buildHortonSystemPrompt(workingDirectory: string, opts?: {
202
245
  docsUrl?: string;
203
246
  modelProvider?: string;
204
247
  modelId?: string;
248
+ activeGoal?: ActiveGoalPromptInfo;
205
249
  }): string;
206
250
  declare function createHortonTools(sandbox: Sandbox, ctx: HandlerContext, readSet: Set<string>, opts?: {
207
251
  docsSearchTool?: AgentTool$1;
@@ -254,4 +298,4 @@ declare function createHortonDocsSupport(workingDirectory: string, opts?: {
254
298
  }): HortonDocsSupport | null;
255
299
 
256
300
  //#endregion
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 };
301
+ export { AgentHandlerResult, BuiltinAgentHandlerOptions, BuiltinAgentModelConfig, BuiltinAgentsEntrypointOptions, BuiltinAgentsEntrypointServer, BuiltinAgentsServer, BuiltinAgentsServerOptions, BuiltinDockerSandboxMount, BuiltinDockerSandboxOptions, BuiltinElectricToolsFactory, BuiltinModelCatalog, 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, resolveBuiltinModelConfig, runBuiltinAgentsEntrypoint };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
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, buildSkillSlashCommands, completeWithLowCostModel, createContextSkillLoader, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, pgSync, 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";
4
+ 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";
5
+ import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createMarkGoalCompleteTool, createReadFileTool, createScheduleTools, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
6
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";
@@ -1069,25 +1069,66 @@ function filterChoicesByEnabledModels(choices, values) {
1069
1069
  const filtered = choices.filter((choice) => enabled.has(choice.value));
1070
1070
  return filtered.length > 0 ? filtered : choices;
1071
1071
  }
1072
+ /**
1073
+ * Anthropic-specific budget mapping for `reasoningEffort`.
1074
+ *
1075
+ * Anthropic's `thinking.budget_tokens` is a hard cap on tokens spent
1076
+ * inside the thinking block before the model must commit to its
1077
+ * answer. Docs require ≥ 1024; we scale from there. Numbers tuned so
1078
+ * `medium` is the spot most "show your work" requests land, and
1079
+ * `high` covers tougher reasoning without uncapped spend.
1080
+ *
1081
+ * Keep in sync with provider doc updates — Anthropic has shifted the
1082
+ * minimum once already (older models capped lower).
1083
+ */
1084
+ const ANTHROPIC_THINKING_BUDGET_BY_EFFORT = {
1085
+ minimal: 1024,
1086
+ low: 2048,
1087
+ medium: 8192,
1088
+ high: 24576
1089
+ };
1072
1090
  function withProviderPayloadDefaults(config, choice, reasoningEffort) {
1073
- if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
1074
- const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1075
- const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1076
- return {
1077
- ...config,
1078
- onPayload: (payload) => {
1079
- if (typeof payload !== `object` || payload === null) return void 0;
1080
- const body = payload;
1081
- const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1082
- return {
1083
- ...body,
1084
- reasoning: {
1085
- ...existingReasoning,
1086
- effort
1087
- }
1088
- };
1089
- }
1090
- };
1091
+ if (!choice.reasoning) return config;
1092
+ if (choice.provider === `openai` || choice.provider === `openai-codex`) {
1093
+ const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
1094
+ const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
1095
+ return {
1096
+ ...config,
1097
+ onPayload: (payload) => {
1098
+ if (typeof payload !== `object` || payload === null) return void 0;
1099
+ const body = payload;
1100
+ const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1101
+ return {
1102
+ ...body,
1103
+ reasoning: {
1104
+ ...existingReasoning,
1105
+ effort
1106
+ }
1107
+ };
1108
+ }
1109
+ };
1110
+ }
1111
+ if (choice.provider === `anthropic`) {
1112
+ const effectiveEffort = reasoningEffort ?? `minimal`;
1113
+ const budgetTokens = ANTHROPIC_THINKING_BUDGET_BY_EFFORT[effectiveEffort];
1114
+ return {
1115
+ ...config,
1116
+ onPayload: (payload) => {
1117
+ if (typeof payload !== `object` || payload === null) return void 0;
1118
+ const body = payload;
1119
+ const existingThinking = typeof body.thinking === `object` && body.thinking !== null ? body.thinking : {};
1120
+ return {
1121
+ ...body,
1122
+ thinking: {
1123
+ ...existingThinking,
1124
+ type: `enabled`,
1125
+ budget_tokens: budgetTokens
1126
+ }
1127
+ };
1128
+ }
1129
+ };
1130
+ }
1131
+ return config;
1091
1132
  }
1092
1133
  function parseReasoningEffort(value) {
1093
1134
  return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
@@ -1352,7 +1393,18 @@ Workflow when forking yourself for parallel exploration:
1352
1393
  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.
1353
1394
 
1354
1395
  Working directory: ${workingDirectory}
1355
- The current year is ${new Date().getFullYear()}.`;
1396
+ The current year is ${new Date().getFullYear()}.${buildGoalGuidance(opts.activeGoal)}`;
1397
+ }
1398
+ function buildGoalGuidance(goal) {
1399
+ if (!goal) return ``;
1400
+ const budgetLine = goal.tokenBudget === null ? `unlimited` : `${goal.tokensUsed} / ${goal.tokenBudget} tokens used`;
1401
+ return `
1402
+
1403
+ # Active goal
1404
+ - Objective: ${goal.objective}
1405
+ - Token budget: ${budgetLine}
1406
+
1407
+ 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.`;
1356
1408
  }
1357
1409
  function getToolName(tool) {
1358
1410
  if (typeof tool !== `object` || tool === null) return null;
@@ -1377,6 +1429,7 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1377
1429
  createObservePgSyncTool(ctx),
1378
1430
  createSetTitleTool(ctx),
1379
1431
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1432
+ ...ctx.getGoal()?.status === `active` ? [createMarkGoalCompleteTool(ctx)] : [],
1380
1433
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1381
1434
  ];
1382
1435
  }
@@ -1445,11 +1498,58 @@ async function readAgentsMd(sandbox) {
1445
1498
  return null;
1446
1499
  }
1447
1500
  }
1501
+ function extractWakeText(wake) {
1502
+ if (wake.type !== `inbox`) return null;
1503
+ const payload = wake.payload;
1504
+ if (typeof payload === `string`) return payload;
1505
+ if (payload && typeof payload === `object`) {
1506
+ const record = payload;
1507
+ if (typeof record.text === `string`) return record.text;
1508
+ if (typeof record.source === `string`) return record.source;
1509
+ }
1510
+ return null;
1511
+ }
1512
+ async function tryHandleSlashCommand(ctx, wake) {
1513
+ const text = extractWakeText(wake);
1514
+ if (text === null) return false;
1515
+ if (isGoalCommandText(text)) {
1516
+ const command = parseGoalCommand(text);
1517
+ const result = dispatchGoalCommand(ctx, command);
1518
+ if (result.message) {
1519
+ serverLog.info(`[horton ${ctx.entityUrl}] ${result.message}`);
1520
+ writeSlashCommandReply(ctx, result.message);
1521
+ }
1522
+ if (command.kind === `set`) await kickoffGoalRun(ctx);
1523
+ return result.handled;
1524
+ }
1525
+ return false;
1526
+ }
1527
+ const GOAL_KICKOFF_TEXT = `Start working toward the active goal now. Call \`mark_goal_complete\` when you believe it is done.`;
1528
+ async function kickoffGoalRun(ctx) {
1529
+ const goal = ctx.getGoal();
1530
+ if (!goal || goal.status !== `active`) return;
1531
+ try {
1532
+ await ctx.send(ctx.entityUrl, {
1533
+ kind: `goal_kickoff`,
1534
+ text: GOAL_KICKOFF_TEXT
1535
+ }, { type: `inbox` });
1536
+ } catch (err) {
1537
+ serverLog.warn(`[horton ${ctx.entityUrl}] failed to enqueue goal kickoff: ${err instanceof Error ? err.message : String(err)}`);
1538
+ }
1539
+ }
1540
+ function writeSlashCommandReply(ctx, text) {
1541
+ try {
1542
+ ctx.replyText(text);
1543
+ } catch (err) {
1544
+ serverLog.warn(`[horton ${ctx.entityUrl}] failed to render slash command reply: ${err instanceof Error ? err.message : String(err)}`);
1545
+ }
1546
+ }
1448
1547
  function createAssistantHandler(options) {
1449
1548
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1450
1549
  const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1451
1550
  const hasSkills = skillLoader.hasSkills;
1452
1551
  return async function assistantHandler(ctx, wake) {
1552
+ if (await tryHandleSlashCommand(ctx, wake)) return;
1453
1553
  const loadedSkills = await skillLoader.load(ctx);
1454
1554
  const readSet = new Set();
1455
1555
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
@@ -1542,6 +1642,26 @@ function createAssistantHandler(options) {
1542
1642
  }
1543
1643
  }
1544
1644
  });
1645
+ const goal = ctx.getGoal();
1646
+ const enforcedGoal = goal && goal.status === `active` ? goal : void 0;
1647
+ const activeGoalPromptInfo = enforcedGoal ? {
1648
+ objective: enforcedGoal.objective,
1649
+ tokenBudget: enforcedGoal.tokenBudget,
1650
+ tokensUsed: enforcedGoal.tokensUsed
1651
+ } : void 0;
1652
+ const budgetAbort = new AbortController();
1653
+ let runTokensUsed = enforcedGoal?.tokensUsed ?? 0;
1654
+ let budgetTripped = false;
1655
+ const onStepEnd = enforcedGoal ? (stats) => {
1656
+ if (budgetTripped) return;
1657
+ runTokensUsed += stats.uncachedInput + stats.output;
1658
+ ctx.updateGoalUsage(runTokensUsed);
1659
+ if (enforcedGoal.tokenBudget !== null && runTokensUsed >= enforcedGoal.tokenBudget) {
1660
+ budgetTripped = true;
1661
+ serverLog.info(`[horton ${ctx.entityUrl}] goal budget exhausted (${runTokensUsed} tokens) — aborting run`);
1662
+ budgetAbort.abort();
1663
+ }
1664
+ } : void 0;
1545
1665
  ctx.useAgent({
1546
1666
  systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
1547
1667
  hasDocsSupport: Boolean(docsSupport),
@@ -1550,13 +1670,26 @@ function createAssistantHandler(options) {
1550
1670
  modelProvider: modelConfig.provider,
1551
1671
  modelId: String(modelConfig.model),
1552
1672
  hasEventSourceTools,
1553
- hasScheduleTools
1673
+ hasScheduleTools,
1674
+ ...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
1554
1675
  }),
1555
1676
  ...modelConfig,
1556
1677
  tools,
1557
- ...streamFn && { streamFn }
1678
+ ...streamFn && { streamFn },
1679
+ ...onStepEnd && { onStepEnd }
1558
1680
  });
1559
- await ctx.agent.run();
1681
+ try {
1682
+ await ctx.agent.run(void 0, budgetAbort.signal);
1683
+ } catch (err) {
1684
+ if (!budgetTripped) throw err;
1685
+ serverLog.info(`[horton ${ctx.entityUrl}] agent.run aborted by budget enforcement`);
1686
+ }
1687
+ if (enforcedGoal) ctx.updateGoalUsage(runTokensUsed, budgetTripped ? { status: `budget_limited` } : void 0);
1688
+ if (budgetTripped && enforcedGoal && enforcedGoal.tokenBudget !== null) {
1689
+ const budget = enforcedGoal.tokenBudget;
1690
+ const suggestedNext = Math.max(budget * 2, budget + 1e4);
1691
+ 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.`);
1692
+ }
1560
1693
  await titlePromise;
1561
1694
  };
1562
1695
  }
@@ -1596,7 +1729,8 @@ function registerHorton(registry, options) {
1596
1729
  subject_value: `user`,
1597
1730
  permission: `manage`
1598
1731
  }],
1599
- slashCommands: buildSkillSlashCommands(skillsRegistry),
1732
+ state: { comments: commentsCollection },
1733
+ slashCommands: [GOAL_SLASH_COMMAND, ...buildSkillSlashCommands(skillsRegistry)],
1600
1734
  handler: assistantHandler
1601
1735
  });
1602
1736
  return [`horton`];
@@ -1780,6 +1914,7 @@ function registerWorker(registry, options) {
1780
1914
  subject_value: `user`,
1781
1915
  permission: `manage`
1782
1916
  }],
1917
+ state: { comments: commentsCollection },
1783
1918
  async handler(ctx) {
1784
1919
  const args = parseWorkerArgs(ctx.args);
1785
1920
  const readSet = new Set();
@@ -1832,7 +1967,7 @@ function createBuiltinElectricTools(custom) {
1832
1967
  };
1833
1968
  }
1834
1969
  async function createBuiltinAgentHandler(options) {
1835
- const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType } = options;
1970
+ const { agentServerUrl, serveEndpoint, workingDirectory, streamFn, enabledModelValues, createElectricTools, publicUrl, runtimeName, baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType, dockerSandbox: dockerSandboxOpts } = options;
1836
1971
  const modelCatalog = await createBuiltinModelCatalog({
1837
1972
  allowMockFallback: Boolean(streamFn),
1838
1973
  enabledModelValues
@@ -1868,7 +2003,7 @@ async function createBuiltinAgentHandler(options) {
1868
2003
  modelCatalog
1869
2004
  });
1870
2005
  typeNames.push(`worker`);
1871
- const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
2006
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
1872
2007
  const runtime = createRuntimeHandler({
1873
2008
  baseUrl: agentServerUrl,
1874
2009
  serveEndpoint,
@@ -1888,7 +2023,8 @@ async function createBuiltinAgentHandler(options) {
1888
2023
  registry,
1889
2024
  typeNames,
1890
2025
  skillsRegistry,
1891
- shutdownSandboxes
2026
+ shutdownSandboxes,
2027
+ modelCatalog
1892
2028
  };
1893
2029
  }
1894
2030
  async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
@@ -1917,6 +2053,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1917
2053
  return dockerBootSweep;
1918
2054
  }
1919
2055
  /**
2056
+ * Merge the profile's working-directory mount with embedder docker options
2057
+ * into the option fragment spread into `dockerSandbox()`. An internal helper:
2058
+ * exported from this module so the unit test can import it, but intentionally
2059
+ * not re-exported from `index.ts` (not part of the package's public API).
2060
+ */
2061
+ function resolveDockerSandboxOpts(cwdMount, custom) {
2062
+ const extraMounts = [...cwdMount ? [cwdMount] : [], ...custom?.extraMounts ?? []];
2063
+ return {
2064
+ ...custom?.image !== void 0 && { image: custom.image },
2065
+ ...custom?.allowFloatingTag !== void 0 && { allowFloatingTag: custom.allowFloatingTag },
2066
+ ...custom?.env !== void 0 && { env: custom.env },
2067
+ ...extraMounts.length > 0 && { extraMounts }
2068
+ };
2069
+ }
2070
+ /**
1920
2071
  * Built-in sandbox profiles. `local` is always available. `docker` is
1921
2072
  * gated on Docker being reachable so a user without Docker installed
1922
2073
  * sees only what works — the UI never offers a non-functional choice.
@@ -1926,7 +2077,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1926
2077
  * server must run on shutdown (the providers' debounced idle teardowns die
1927
2078
  * with the process).
1928
2079
  */
1929
- async function buildBuiltinSandboxProfiles(workingDirectory) {
2080
+ async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
1930
2081
  const profiles = [{
1931
2082
  name: `local`,
1932
2083
  label: `Local`,
@@ -1951,11 +2102,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1951
2102
  workingDirectory: `/work`,
1952
2103
  factory: () => dockerSandbox({
1953
2104
  initialNetworkPolicy: { mode: `allow-all` },
1954
- extraMounts: cwd ? [{
2105
+ ...resolveDockerSandboxOpts(cwd ? {
1955
2106
  hostPath: cwd,
1956
2107
  containerPath: `/work`,
1957
2108
  readOnly: false
1958
- }] : void 0,
2109
+ } : void 0, dockerOpts),
1959
2110
  sandboxKey,
1960
2111
  persistent,
1961
2112
  owner,
@@ -2341,4 +2492,4 @@ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd
2341
2492
  }
2342
2493
 
2343
2494
  //#endregion
2344
- 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 };
2495
+ 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, resolveBuiltinModelConfig, runBuiltinAgentsEntrypoint };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
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.3",
53
- "@electric-ax/agents-runtime": "0.3.13"
53
+ "@electric-ax/agents-runtime": "0.4.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/better-sqlite3": "^7.6.13",