@electric-ax/agents 0.4.16 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -818,6 +818,69 @@ function createSpawnWorkerTool(ctx, modelConfig) {
818
818
  };
819
819
  }
820
820
 
821
+ //#endregion
822
+ //#region src/tools/observe-pg-sync.ts
823
+ function asToolResult(value) {
824
+ return {
825
+ content: [{
826
+ type: `text`,
827
+ text: typeof value === `string` ? value : JSON.stringify(value, null, 2)
828
+ }],
829
+ details: {}
830
+ };
831
+ }
832
+ const PgSyncOperation = __sinclair_typebox.Type.Union([
833
+ __sinclair_typebox.Type.Literal(`insert`),
834
+ __sinclair_typebox.Type.Literal(`update`),
835
+ __sinclair_typebox.Type.Literal(`delete`)
836
+ ]);
837
+ function createObservePgSyncTool(ctx) {
838
+ return {
839
+ name: `observe_pg_sync`,
840
+ label: `Observe Postgres Sync`,
841
+ description: `Observe an Electric Postgres shape stream and wake this agent when matching row changes arrive.`,
842
+ parameters: __sinclair_typebox.Type.Object({
843
+ url: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String({ description: `Optional Electric shape endpoint URL. Defaults to the server-configured pg-sync URL.` })),
844
+ table: __sinclair_typebox.Type.String({
845
+ minLength: 1,
846
+ pattern: `\\S`,
847
+ description: `Postgres table name to observe.`
848
+ }),
849
+ columns: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String(), { description: `Optional list of columns to include in the shape.` })),
850
+ where: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String({ description: `Optional Electric shape WHERE clause.` })),
851
+ params: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Array(__sinclair_typebox.Type.String()), __sinclair_typebox.Type.Record(__sinclair_typebox.Type.String(), __sinclair_typebox.Type.String())])),
852
+ replica: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Union([__sinclair_typebox.Type.Literal(`default`), __sinclair_typebox.Type.Literal(`full`)])),
853
+ wake: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
854
+ ops: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Array(PgSyncOperation)),
855
+ debounceMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number())
856
+ }, { additionalProperties: false }))
857
+ }),
858
+ execute: async (_toolCallId, params) => {
859
+ const args = params;
860
+ if (typeof args.table !== `string` || args.table.trim().length === 0) throw new Error(`table is required`);
861
+ const source = (0, __electric_ax_agents_runtime.pgSync)({
862
+ url: args.url,
863
+ table: args.table,
864
+ columns: args.columns,
865
+ where: args.where,
866
+ params: args.params,
867
+ replica: args.replica
868
+ });
869
+ const wake = {
870
+ on: `change`,
871
+ ...args.wake?.ops ? { ops: args.wake.ops } : {},
872
+ ...args.wake?.debounceMs !== void 0 ? { debounceMs: args.wake.debounceMs } : {}
873
+ };
874
+ await ctx.observe(source, { wake });
875
+ return asToolResult({
876
+ sourceRef: source.sourceRef,
877
+ streamUrl: source.streamUrl,
878
+ wake
879
+ });
880
+ }
881
+ };
882
+ }
883
+
821
884
  //#endregion
822
885
  //#region src/tools/fork.ts
823
886
  function createForkTool(ctx) {
@@ -872,6 +935,49 @@ Omit 'entityUrl' to fork your own session. Pass a different session's URL to for
872
935
  };
873
936
  }
874
937
 
938
+ //#endregion
939
+ //#region src/tools/set-title.ts
940
+ function createSetTitleTool(ctx) {
941
+ return {
942
+ name: `set_title`,
943
+ label: `Set Title`,
944
+ 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.`,
945
+ parameters: __sinclair_typebox.Type.Object({ title: __sinclair_typebox.Type.String({ description: `New session title. Whitespace is trimmed and the title must not be empty.` }) }),
946
+ execute: async (_toolCallId, params) => {
947
+ const { title } = params;
948
+ const trimmedTitle = typeof title === `string` ? title.trim() : ``;
949
+ if (trimmedTitle.length === 0) return {
950
+ content: [{
951
+ type: `text`,
952
+ text: `Error: title must be a non-empty string.`
953
+ }],
954
+ details: { updated: false }
955
+ };
956
+ try {
957
+ await ctx.setTag(`title`, trimmedTitle);
958
+ return {
959
+ content: [{
960
+ type: `text`,
961
+ text: `Session title set to “${trimmedTitle}”.`
962
+ }],
963
+ details: {
964
+ updated: true,
965
+ title: trimmedTitle
966
+ }
967
+ };
968
+ } catch (err) {
969
+ return {
970
+ content: [{
971
+ type: `text`,
972
+ text: `Error setting session title: ${err instanceof Error ? err.message : `Unknown error`}`
973
+ }],
974
+ details: { updated: false }
975
+ };
976
+ }
977
+ }
978
+ };
979
+ }
980
+
875
981
  //#endregion
876
982
  //#region src/model-catalog.ts
877
983
  const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
@@ -987,25 +1093,66 @@ function filterChoicesByEnabledModels(choices, values) {
987
1093
  const filtered = choices.filter((choice) => enabled.has(choice.value));
988
1094
  return filtered.length > 0 ? filtered : choices;
989
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
+ };
990
1114
  function withProviderPayloadDefaults(config, choice, reasoningEffort) {
991
- if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
992
- const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
993
- const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
994
- return {
995
- ...config,
996
- onPayload: (payload) => {
997
- if (typeof payload !== `object` || payload === null) return void 0;
998
- const body = payload;
999
- const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
1000
- return {
1001
- ...body,
1002
- reasoning: {
1003
- ...existingReasoning,
1004
- effort
1005
- }
1006
- };
1007
- }
1008
- };
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;
1009
1156
  }
1010
1157
  function parseReasoningEffort(value) {
1011
1158
  return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
@@ -1054,7 +1201,7 @@ function modelInputSchemaDefs(catalog) {
1054
1201
  //#endregion
1055
1202
  //#region src/agents/horton.ts
1056
1203
  const HORTON_MODEL = `claude-sonnet-4-6`;
1057
- const TITLE_SYSTEM_PROMPT = "You generate concise chat session titles in 3-5 words. Respond with only the title, no quotes, no punctuation, no preamble.";
1204
+ 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.";
1058
1205
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1059
1206
  const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1060
1207
  const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
@@ -1148,12 +1295,16 @@ function withTimeout(promise, ms, description) {
1148
1295
  if (timeout) clearTimeout(timeout);
1149
1296
  });
1150
1297
  }
1298
+ function looksLikeNonTitle(title) {
1299
+ if (title.split(/\s+/).filter(Boolean).length > 8) return true;
1300
+ return /[!?,]/.test(title);
1301
+ }
1151
1302
  async function generateTitle(userMessage, llmCall, onFallback) {
1152
1303
  try {
1153
1304
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1154
1305
  const title = raw.trim();
1155
- if (title.length > 0) return title;
1156
- onFallback?.(`empty LLM title response`);
1306
+ if (title.length > 0 && !looksLikeNonTitle(title)) return title;
1307
+ onFallback?.(title.length === 0 ? `empty LLM title response` : `non-title LLM response`);
1157
1308
  return buildFallbackTitle(userMessage);
1158
1309
  } catch (err) {
1159
1310
  onFallback?.(err instanceof Error ? err.message : String(err));
@@ -1163,6 +1314,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1163
1314
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1164
1315
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1165
1316
  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` : ``;
1317
+ const titleTool = `\n- set_title: set or rename this chat session's UI title`;
1166
1318
  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` : ``;
1167
1319
  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` : ``;
1168
1320
  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.` : ``;
@@ -1218,8 +1370,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1218
1370
  - fetch_url: fetch and convert a URL to markdown
1219
1371
  - spawn_worker: dispatch a subagent for an isolated task
1220
1372
  - 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.
1373
+ - observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
1221
1374
  - send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
1222
- ${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
1375
+ ${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
1223
1376
 
1224
1377
  # Working with files
1225
1378
  - Prefer edit over write when modifying existing files.
@@ -1264,7 +1417,18 @@ Workflow when forking yourself for parallel exploration:
1264
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.
1265
1418
 
1266
1419
  Working directory: ${workingDirectory}
1267
- 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.`;
1268
1432
  }
1269
1433
  function getToolName(tool) {
1270
1434
  if (typeof tool !== `object` || tool === null) return null;
@@ -1286,7 +1450,10 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1286
1450
  })] : [(0, __electric_ax_agents_runtime_tools.createFetchUrlTool)(sandbox)],
1287
1451
  createSpawnWorkerTool(ctx, opts.modelConfig),
1288
1452
  createForkTool(ctx),
1453
+ createObservePgSyncTool(ctx),
1454
+ createSetTitleTool(ctx),
1289
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)] : [],
1290
1457
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1291
1458
  ];
1292
1459
  }
@@ -1355,11 +1522,58 @@ async function readAgentsMd(sandbox) {
1355
1522
  return null;
1356
1523
  }
1357
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
+ }
1358
1571
  function createAssistantHandler(options) {
1359
1572
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1360
1573
  const skillLoader = (0, __electric_ax_agents_runtime.createContextSkillLoader)(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1361
1574
  const hasSkills = skillLoader.hasSkills;
1362
1575
  return async function assistantHandler(ctx, wake) {
1576
+ if (await tryHandleSlashCommand(ctx, wake)) return;
1363
1577
  const loadedSkills = await skillLoader.load(ctx);
1364
1578
  const readSet = new Set();
1365
1579
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
@@ -1452,6 +1666,26 @@ function createAssistantHandler(options) {
1452
1666
  }
1453
1667
  }
1454
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;
1455
1689
  ctx.useAgent({
1456
1690
  systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
1457
1691
  hasDocsSupport: Boolean(docsSupport),
@@ -1460,13 +1694,26 @@ function createAssistantHandler(options) {
1460
1694
  modelProvider: modelConfig.provider,
1461
1695
  modelId: String(modelConfig.model),
1462
1696
  hasEventSourceTools,
1463
- hasScheduleTools
1697
+ hasScheduleTools,
1698
+ ...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
1464
1699
  }),
1465
1700
  ...modelConfig,
1466
1701
  tools,
1467
- ...streamFn && { streamFn }
1702
+ ...streamFn && { streamFn },
1703
+ ...onStepEnd && { onStepEnd }
1468
1704
  });
1469
- 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
+ }
1470
1717
  await titlePromise;
1471
1718
  };
1472
1719
  }
@@ -1506,7 +1753,8 @@ function registerHorton(registry, options) {
1506
1753
  subject_value: `user`,
1507
1754
  permission: `manage`
1508
1755
  }],
1509
- 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)],
1510
1758
  handler: assistantHandler
1511
1759
  });
1512
1760
  return [`horton`];
@@ -1690,6 +1938,7 @@ function registerWorker(registry, options) {
1690
1938
  subject_value: `user`,
1691
1939
  permission: `manage`
1692
1940
  }],
1941
+ state: { comments: __electric_ax_agents_runtime.commentsCollection },
1693
1942
  async handler(ctx) {
1694
1943
  const args = parseWorkerArgs(ctx.args);
1695
1944
  const readSet = new Set();
@@ -1742,7 +1991,7 @@ function createBuiltinElectricTools(custom) {
1742
1991
  };
1743
1992
  }
1744
1993
  async function createBuiltinAgentHandler(options) {
1745
- 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;
1746
1995
  const modelCatalog = await createBuiltinModelCatalog({
1747
1996
  allowMockFallback: Boolean(streamFn),
1748
1997
  enabledModelValues
@@ -1778,7 +2027,7 @@ async function createBuiltinAgentHandler(options) {
1778
2027
  modelCatalog
1779
2028
  });
1780
2029
  typeNames.push(`worker`);
1781
- const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
2030
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
1782
2031
  const runtime = (0, __electric_ax_agents_runtime.createRuntimeHandler)({
1783
2032
  baseUrl: agentServerUrl,
1784
2033
  serveEndpoint,
@@ -1798,7 +2047,8 @@ async function createBuiltinAgentHandler(options) {
1798
2047
  registry,
1799
2048
  typeNames,
1800
2049
  skillsRegistry,
1801
- shutdownSandboxes
2050
+ shutdownSandboxes,
2051
+ modelCatalog
1802
2052
  };
1803
2053
  }
1804
2054
  async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
@@ -1827,6 +2077,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1827
2077
  return dockerBootSweep;
1828
2078
  }
1829
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
+ /**
1830
2095
  * Built-in sandbox profiles. `local` is always available. `docker` is
1831
2096
  * gated on Docker being reachable so a user without Docker installed
1832
2097
  * sees only what works — the UI never offers a non-functional choice.
@@ -1836,7 +2101,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1836
2101
  * server must run on shutdown (the providers' debounced idle teardowns die
1837
2102
  * with the process).
1838
2103
  */
1839
- async function buildBuiltinSandboxProfiles(workingDirectory) {
2104
+ async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
1840
2105
  const profiles = [{
1841
2106
  name: `local`,
1842
2107
  label: `Local`,
@@ -1861,11 +2126,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1861
2126
  workingDirectory: `/work`,
1862
2127
  factory: () => dockerSandbox({
1863
2128
  initialNetworkPolicy: { mode: `allow-all` },
1864
- extraMounts: cwd ? [{
2129
+ ...resolveDockerSandboxOpts(cwd ? {
1865
2130
  hostPath: cwd,
1866
2131
  containerPath: `/work`,
1867
2132
  readOnly: false
1868
- }] : void 0,
2133
+ } : void 0, dockerOpts),
1869
2134
  sandboxKey,
1870
2135
  persistent,
1871
2136
  owner,
@@ -1912,13 +2177,19 @@ function resolveCwd(args, fallback) {
1912
2177
  //#endregion
1913
2178
  //#region src/durable-streams-cache.ts
1914
2179
  const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
2180
+ let installed = false;
1915
2181
  function installDurableStreamsFetchCache(options = {}) {
1916
2182
  if (options === false) return;
2183
+ if (installed) {
2184
+ console.warn(`[agents] installDurableStreamsFetchCache called more than once; ignoring`);
2185
+ return;
2186
+ }
1917
2187
  const store = options.store === `sqlite` || options.sqliteLocation ? new undici.cacheStores.SqliteCacheStore({
1918
2188
  location: options.sqliteLocation,
1919
2189
  maxCount: options.maxCount
1920
2190
  }) : new undici.cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1921
2191
  (0, undici.setGlobalDispatcher)((0, undici.getGlobalDispatcher)().compose(undici.interceptors.cache({ store })));
2192
+ installed = true;
1922
2193
  }
1923
2194
 
1924
2195
  //#endregion
@@ -2271,4 +2542,5 @@ exports.registerBuiltinAgentTypes = registerBuiltinAgentTypes
2271
2542
  exports.registerHorton = registerHorton
2272
2543
  exports.registerWorker = registerWorker
2273
2544
  exports.resolveBuiltinAgentsEntrypointOptions = resolveBuiltinAgentsEntrypointOptions
2545
+ exports.resolveBuiltinModelConfig = resolveBuiltinModelConfig
2274
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 };