@electric-ax/agents 0.4.15 → 0.4.17

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,7 +4,7 @@ import { cacheStores, getGlobalDispatcher, interceptors, setGlobalDispatcher } f
4
4
  import fs from "node:fs";
5
5
  import pino from "pino";
6
6
  import { fileURLToPath } from "node:url";
7
- import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, buildSkillSlashCommands, completeWithLowCostModel, createContextSkillLoader, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
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
8
  import { braveSearchTool, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, 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";
@@ -19,13 +19,19 @@ import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, ke
19
19
 
20
20
  //#region src/durable-streams-cache.ts
21
21
  const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
22
+ let installed = false;
22
23
  function installDurableStreamsFetchCache(options = {}) {
23
24
  if (options === false) return;
25
+ if (installed) {
26
+ console.warn(`[agents] installDurableStreamsFetchCache called more than once; ignoring`);
27
+ return;
28
+ }
24
29
  const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
25
30
  location: options.sqliteLocation,
26
31
  maxCount: options.maxCount
27
32
  }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
28
33
  setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
34
+ installed = true;
29
35
  }
30
36
 
31
37
  //#endregion
@@ -806,6 +812,69 @@ function createSpawnWorkerTool(ctx, modelConfig) {
806
812
  };
807
813
  }
808
814
 
815
+ //#endregion
816
+ //#region src/tools/observe-pg-sync.ts
817
+ function asToolResult(value) {
818
+ return {
819
+ content: [{
820
+ type: `text`,
821
+ text: typeof value === `string` ? value : JSON.stringify(value, null, 2)
822
+ }],
823
+ details: {}
824
+ };
825
+ }
826
+ const PgSyncOperation = Type.Union([
827
+ Type.Literal(`insert`),
828
+ Type.Literal(`update`),
829
+ Type.Literal(`delete`)
830
+ ]);
831
+ function createObservePgSyncTool(ctx) {
832
+ return {
833
+ name: `observe_pg_sync`,
834
+ label: `Observe Postgres Sync`,
835
+ description: `Observe an Electric Postgres shape stream and wake this agent when matching row changes arrive.`,
836
+ parameters: Type.Object({
837
+ url: Type.Optional(Type.String({ description: `Optional Electric shape endpoint URL. Defaults to the server-configured pg-sync URL.` })),
838
+ table: Type.String({
839
+ minLength: 1,
840
+ pattern: `\\S`,
841
+ description: `Postgres table name to observe.`
842
+ }),
843
+ columns: Type.Optional(Type.Array(Type.String(), { description: `Optional list of columns to include in the shape.` })),
844
+ where: Type.Optional(Type.String({ description: `Optional Electric shape WHERE clause.` })),
845
+ params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
846
+ replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)])),
847
+ wake: Type.Optional(Type.Object({
848
+ ops: Type.Optional(Type.Array(PgSyncOperation)),
849
+ debounceMs: Type.Optional(Type.Number())
850
+ }, { additionalProperties: false }))
851
+ }),
852
+ execute: async (_toolCallId, params) => {
853
+ const args = params;
854
+ if (typeof args.table !== `string` || args.table.trim().length === 0) throw new Error(`table is required`);
855
+ const source = pgSync({
856
+ url: args.url,
857
+ table: args.table,
858
+ columns: args.columns,
859
+ where: args.where,
860
+ params: args.params,
861
+ replica: args.replica
862
+ });
863
+ const wake = {
864
+ on: `change`,
865
+ ...args.wake?.ops ? { ops: args.wake.ops } : {},
866
+ ...args.wake?.debounceMs !== void 0 ? { debounceMs: args.wake.debounceMs } : {}
867
+ };
868
+ await ctx.observe(source, { wake });
869
+ return asToolResult({
870
+ sourceRef: source.sourceRef,
871
+ streamUrl: source.streamUrl,
872
+ wake
873
+ });
874
+ }
875
+ };
876
+ }
877
+
809
878
  //#endregion
810
879
  //#region src/tools/fork.ts
811
880
  function createForkTool(ctx) {
@@ -860,6 +929,49 @@ Omit 'entityUrl' to fork your own session. Pass a different session's URL to for
860
929
  };
861
930
  }
862
931
 
932
+ //#endregion
933
+ //#region src/tools/set-title.ts
934
+ function createSetTitleTool(ctx) {
935
+ return {
936
+ name: `set_title`,
937
+ label: `Set Title`,
938
+ description: `Set the chat session title shown in the UI. Use this when the current title is missing, stale, misleading, or the user asks to rename the session. Provide a concise, human-readable title.`,
939
+ parameters: Type.Object({ title: Type.String({ description: `New session title. Whitespace is trimmed and the title must not be empty.` }) }),
940
+ execute: async (_toolCallId, params) => {
941
+ const { title } = params;
942
+ const trimmedTitle = typeof title === `string` ? title.trim() : ``;
943
+ if (trimmedTitle.length === 0) return {
944
+ content: [{
945
+ type: `text`,
946
+ text: `Error: title must be a non-empty string.`
947
+ }],
948
+ details: { updated: false }
949
+ };
950
+ try {
951
+ await ctx.setTag(`title`, trimmedTitle);
952
+ return {
953
+ content: [{
954
+ type: `text`,
955
+ text: `Session title set to “${trimmedTitle}”.`
956
+ }],
957
+ details: {
958
+ updated: true,
959
+ title: trimmedTitle
960
+ }
961
+ };
962
+ } catch (err) {
963
+ return {
964
+ content: [{
965
+ type: `text`,
966
+ text: `Error setting session title: ${err instanceof Error ? err.message : `Unknown error`}`
967
+ }],
968
+ details: { updated: false }
969
+ };
970
+ }
971
+ }
972
+ };
973
+ }
974
+
863
975
  //#endregion
864
976
  //#region src/model-catalog.ts
865
977
  const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
@@ -1041,7 +1153,7 @@ function modelInputSchemaDefs(catalog) {
1041
1153
 
1042
1154
  //#endregion
1043
1155
  //#region src/agents/horton.ts
1044
- const TITLE_SYSTEM_PROMPT = "You generate concise chat session titles in 3-5 words. Respond with only the title, no quotes, no punctuation, no preamble.";
1156
+ const TITLE_SYSTEM_PROMPT = "You generate a concise 3-5 word chat session title from the user's first message. Respond with only the title no quotes, punctuation, preamble, or explanation. The user may reference images, files, or attachments you cannot see; infer a title from their intent anyway. Never apologize or say anything is missing — always output a short title.";
1045
1157
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1046
1158
  const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1047
1159
  const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
@@ -1135,12 +1247,16 @@ function withTimeout(promise, ms, description) {
1135
1247
  if (timeout) clearTimeout(timeout);
1136
1248
  });
1137
1249
  }
1250
+ function looksLikeNonTitle(title) {
1251
+ if (title.split(/\s+/).filter(Boolean).length > 8) return true;
1252
+ return /[!?,]/.test(title);
1253
+ }
1138
1254
  async function generateTitle(userMessage, llmCall, onFallback) {
1139
1255
  try {
1140
1256
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1141
1257
  const title = raw.trim();
1142
- if (title.length > 0) return title;
1143
- onFallback?.(`empty LLM title response`);
1258
+ if (title.length > 0 && !looksLikeNonTitle(title)) return title;
1259
+ onFallback?.(title.length === 0 ? `empty LLM title response` : `non-title LLM response`);
1144
1260
  return buildFallbackTitle(userMessage);
1145
1261
  } catch (err) {
1146
1262
  onFallback?.(err instanceof Error ? err.message : String(err));
@@ -1150,6 +1266,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1150
1266
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1151
1267
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1152
1268
  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` : ``;
1269
+ const titleTool = `\n- set_title: set or rename this chat session's UI title`;
1153
1270
  const scheduleTools = opts.hasScheduleTools ? `\n- upsert_cron_schedule: create or update a recurring cron wake for yourself. Always include payload with the concrete instruction/message you should receive when the cron fires.\n- delete_schedule: delete one of your cron or future-send schedules by stable id\n- list_schedules: list your manifest-backed cron and future-send schedules` : ``;
1154
1271
  const skillsTools = opts.hasSkills ? `\n- use_skill: load a skill (knowledge, instructions, or a tutorial) into your context to help with the user's request\n- remove_skill: unload a skill from context when you're done with it` : ``;
1155
1272
  const docsGuidance = opts.hasDocsSupport ? `\n- For ANY question about Electric Agents or this framework, ALWAYS use search_electric_agents_docs FIRST. Do not use web_search or fetch_url for Electric Agents topics unless the docs search returns no useful results.\n- The search tool returns chunk content directly — you do not need to read the source files.\n- Use repo read/bash tools only for non-doc files or when you need to inspect exact implementation code in the workspace.` : ``;
@@ -1205,8 +1322,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1205
1322
  - fetch_url: fetch and convert a URL to markdown
1206
1323
  - spawn_worker: dispatch a subagent for an isolated task
1207
1324
  - fork: spawn a child session that inherits this conversation's history up to the latest completed response. Same parent-ownership model as spawn_worker — when the fork's next run finishes, you'll wake with its response.
1325
+ - observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
1208
1326
  - send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
1209
- ${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
1327
+ ${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
1210
1328
 
1211
1329
  # Working with files
1212
1330
  - Prefer edit over write when modifying existing files.
@@ -1273,6 +1391,8 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1273
1391
  })] : [createFetchUrlTool(sandbox)],
1274
1392
  createSpawnWorkerTool(ctx, opts.modelConfig),
1275
1393
  createForkTool(ctx),
1394
+ createObservePgSyncTool(ctx),
1395
+ createSetTitleTool(ctx),
1276
1396
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1277
1397
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1278
1398
  ];
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`;
@@ -1054,7 +1160,7 @@ function modelInputSchemaDefs(catalog) {
1054
1160
  //#endregion
1055
1161
  //#region src/agents/horton.ts
1056
1162
  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.";
1163
+ 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
1164
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1059
1165
  const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1060
1166
  const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
@@ -1148,12 +1254,16 @@ function withTimeout(promise, ms, description) {
1148
1254
  if (timeout) clearTimeout(timeout);
1149
1255
  });
1150
1256
  }
1257
+ function looksLikeNonTitle(title) {
1258
+ if (title.split(/\s+/).filter(Boolean).length > 8) return true;
1259
+ return /[!?,]/.test(title);
1260
+ }
1151
1261
  async function generateTitle(userMessage, llmCall, onFallback) {
1152
1262
  try {
1153
1263
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1154
1264
  const title = raw.trim();
1155
- if (title.length > 0) return title;
1156
- onFallback?.(`empty LLM title response`);
1265
+ if (title.length > 0 && !looksLikeNonTitle(title)) return title;
1266
+ onFallback?.(title.length === 0 ? `empty LLM title response` : `non-title LLM response`);
1157
1267
  return buildFallbackTitle(userMessage);
1158
1268
  } catch (err) {
1159
1269
  onFallback?.(err instanceof Error ? err.message : String(err));
@@ -1163,6 +1273,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1163
1273
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1164
1274
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1165
1275
  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` : ``;
1276
+ const titleTool = `\n- set_title: set or rename this chat session's UI title`;
1166
1277
  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
1278
  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
1279
  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 +1329,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1218
1329
  - fetch_url: fetch and convert a URL to markdown
1219
1330
  - spawn_worker: dispatch a subagent for an isolated task
1220
1331
  - 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.
1332
+ - observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
1221
1333
  - 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}
1334
+ ${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
1223
1335
 
1224
1336
  # Working with files
1225
1337
  - Prefer edit over write when modifying existing files.
@@ -1286,6 +1398,8 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1286
1398
  })] : [(0, __electric_ax_agents_runtime_tools.createFetchUrlTool)(sandbox)],
1287
1399
  createSpawnWorkerTool(ctx, opts.modelConfig),
1288
1400
  createForkTool(ctx),
1401
+ createObservePgSyncTool(ctx),
1402
+ createSetTitleTool(ctx),
1289
1403
  (0, __electric_ax_agents_runtime_tools.createSendTool)(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1290
1404
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1291
1405
  ];
@@ -1912,13 +2026,19 @@ function resolveCwd(args, fallback) {
1912
2026
  //#endregion
1913
2027
  //#region src/durable-streams-cache.ts
1914
2028
  const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
2029
+ let installed = false;
1915
2030
  function installDurableStreamsFetchCache(options = {}) {
1916
2031
  if (options === false) return;
2032
+ if (installed) {
2033
+ console.warn(`[agents] installDurableStreamsFetchCache called more than once; ignoring`);
2034
+ return;
2035
+ }
1917
2036
  const store = options.store === `sqlite` || options.sqliteLocation ? new undici.cacheStores.SqliteCacheStore({
1918
2037
  location: options.sqliteLocation,
1919
2038
  maxCount: options.maxCount
1920
2039
  }) : new undici.cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1921
2040
  (0, undici.setGlobalDispatcher)((0, undici.getGlobalDispatcher)().compose(undici.interceptors.cache({ store })));
2041
+ installed = true;
1922
2042
  }
1923
2043
 
1924
2044
  //#endregion
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
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, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
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
5
  import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, 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";
@@ -794,6 +794,69 @@ function createSpawnWorkerTool(ctx, modelConfig) {
794
794
  };
795
795
  }
796
796
 
797
+ //#endregion
798
+ //#region src/tools/observe-pg-sync.ts
799
+ function asToolResult(value) {
800
+ return {
801
+ content: [{
802
+ type: `text`,
803
+ text: typeof value === `string` ? value : JSON.stringify(value, null, 2)
804
+ }],
805
+ details: {}
806
+ };
807
+ }
808
+ const PgSyncOperation = Type.Union([
809
+ Type.Literal(`insert`),
810
+ Type.Literal(`update`),
811
+ Type.Literal(`delete`)
812
+ ]);
813
+ function createObservePgSyncTool(ctx) {
814
+ return {
815
+ name: `observe_pg_sync`,
816
+ label: `Observe Postgres Sync`,
817
+ description: `Observe an Electric Postgres shape stream and wake this agent when matching row changes arrive.`,
818
+ parameters: Type.Object({
819
+ url: Type.Optional(Type.String({ description: `Optional Electric shape endpoint URL. Defaults to the server-configured pg-sync URL.` })),
820
+ table: Type.String({
821
+ minLength: 1,
822
+ pattern: `\\S`,
823
+ description: `Postgres table name to observe.`
824
+ }),
825
+ columns: Type.Optional(Type.Array(Type.String(), { description: `Optional list of columns to include in the shape.` })),
826
+ where: Type.Optional(Type.String({ description: `Optional Electric shape WHERE clause.` })),
827
+ params: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Record(Type.String(), Type.String())])),
828
+ replica: Type.Optional(Type.Union([Type.Literal(`default`), Type.Literal(`full`)])),
829
+ wake: Type.Optional(Type.Object({
830
+ ops: Type.Optional(Type.Array(PgSyncOperation)),
831
+ debounceMs: Type.Optional(Type.Number())
832
+ }, { additionalProperties: false }))
833
+ }),
834
+ execute: async (_toolCallId, params) => {
835
+ const args = params;
836
+ if (typeof args.table !== `string` || args.table.trim().length === 0) throw new Error(`table is required`);
837
+ const source = pgSync({
838
+ url: args.url,
839
+ table: args.table,
840
+ columns: args.columns,
841
+ where: args.where,
842
+ params: args.params,
843
+ replica: args.replica
844
+ });
845
+ const wake = {
846
+ on: `change`,
847
+ ...args.wake?.ops ? { ops: args.wake.ops } : {},
848
+ ...args.wake?.debounceMs !== void 0 ? { debounceMs: args.wake.debounceMs } : {}
849
+ };
850
+ await ctx.observe(source, { wake });
851
+ return asToolResult({
852
+ sourceRef: source.sourceRef,
853
+ streamUrl: source.streamUrl,
854
+ wake
855
+ });
856
+ }
857
+ };
858
+ }
859
+
797
860
  //#endregion
798
861
  //#region src/tools/fork.ts
799
862
  function createForkTool(ctx) {
@@ -848,6 +911,49 @@ Omit 'entityUrl' to fork your own session. Pass a different session's URL to for
848
911
  };
849
912
  }
850
913
 
914
+ //#endregion
915
+ //#region src/tools/set-title.ts
916
+ function createSetTitleTool(ctx) {
917
+ return {
918
+ name: `set_title`,
919
+ label: `Set Title`,
920
+ 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.`,
921
+ parameters: Type.Object({ title: Type.String({ description: `New session title. Whitespace is trimmed and the title must not be empty.` }) }),
922
+ execute: async (_toolCallId, params) => {
923
+ const { title } = params;
924
+ const trimmedTitle = typeof title === `string` ? title.trim() : ``;
925
+ if (trimmedTitle.length === 0) return {
926
+ content: [{
927
+ type: `text`,
928
+ text: `Error: title must be a non-empty string.`
929
+ }],
930
+ details: { updated: false }
931
+ };
932
+ try {
933
+ await ctx.setTag(`title`, trimmedTitle);
934
+ return {
935
+ content: [{
936
+ type: `text`,
937
+ text: `Session title set to “${trimmedTitle}”.`
938
+ }],
939
+ details: {
940
+ updated: true,
941
+ title: trimmedTitle
942
+ }
943
+ };
944
+ } catch (err) {
945
+ return {
946
+ content: [{
947
+ type: `text`,
948
+ text: `Error setting session title: ${err instanceof Error ? err.message : `Unknown error`}`
949
+ }],
950
+ details: { updated: false }
951
+ };
952
+ }
953
+ }
954
+ };
955
+ }
956
+
851
957
  //#endregion
852
958
  //#region src/model-catalog.ts
853
959
  const MODEL_INPUTS_SCHEMA_DEF = `electricModelInputs`;
@@ -1030,7 +1136,7 @@ function modelInputSchemaDefs(catalog) {
1030
1136
  //#endregion
1031
1137
  //#region src/agents/horton.ts
1032
1138
  const HORTON_MODEL = `claude-sonnet-4-6`;
1033
- 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.";
1139
+ 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.";
1034
1140
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1035
1141
  const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1036
1142
  const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
@@ -1124,12 +1230,16 @@ function withTimeout(promise, ms, description) {
1124
1230
  if (timeout) clearTimeout(timeout);
1125
1231
  });
1126
1232
  }
1233
+ function looksLikeNonTitle(title) {
1234
+ if (title.split(/\s+/).filter(Boolean).length > 8) return true;
1235
+ return /[!?,]/.test(title);
1236
+ }
1127
1237
  async function generateTitle(userMessage, llmCall, onFallback) {
1128
1238
  try {
1129
1239
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1130
1240
  const title = raw.trim();
1131
- if (title.length > 0) return title;
1132
- onFallback?.(`empty LLM title response`);
1241
+ if (title.length > 0 && !looksLikeNonTitle(title)) return title;
1242
+ onFallback?.(title.length === 0 ? `empty LLM title response` : `non-title LLM response`);
1133
1243
  return buildFallbackTitle(userMessage);
1134
1244
  } catch (err) {
1135
1245
  onFallback?.(err instanceof Error ? err.message : String(err));
@@ -1139,6 +1249,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1139
1249
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1140
1250
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1141
1251
  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` : ``;
1252
+ const titleTool = `\n- set_title: set or rename this chat session's UI title`;
1142
1253
  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` : ``;
1143
1254
  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` : ``;
1144
1255
  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.` : ``;
@@ -1194,8 +1305,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1194
1305
  - fetch_url: fetch and convert a URL to markdown
1195
1306
  - spawn_worker: dispatch a subagent for an isolated task
1196
1307
  - 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.
1308
+ - observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
1197
1309
  - send: send a message to an Electric Agent/entity. To schedule future work for yourself, call send with self: true and afterMs.
1198
- ${eventSourceTools}${scheduleTools}${docsTools}${skillsTools}
1310
+ ${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
1199
1311
 
1200
1312
  # Working with files
1201
1313
  - Prefer edit over write when modifying existing files.
@@ -1262,6 +1374,8 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1262
1374
  })] : [createFetchUrlTool(sandbox)],
1263
1375
  createSpawnWorkerTool(ctx, opts.modelConfig),
1264
1376
  createForkTool(ctx),
1377
+ createObservePgSyncTool(ctx),
1378
+ createSetTitleTool(ctx),
1265
1379
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1266
1380
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1267
1381
  ];
@@ -1888,13 +2002,19 @@ function resolveCwd(args, fallback) {
1888
2002
  //#endregion
1889
2003
  //#region src/durable-streams-cache.ts
1890
2004
  const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
2005
+ let installed = false;
1891
2006
  function installDurableStreamsFetchCache(options = {}) {
1892
2007
  if (options === false) return;
2008
+ if (installed) {
2009
+ console.warn(`[agents] installDurableStreamsFetchCache called more than once; ignoring`);
2010
+ return;
2011
+ }
1893
2012
  const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
1894
2013
  location: options.sqliteLocation,
1895
2014
  maxCount: options.maxCount
1896
2015
  }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1897
2016
  setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
2017
+ installed = true;
1898
2018
  }
1899
2019
 
1900
2020
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Built-in Electric Agents runtimes such as Horton and worker",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "sqlite-vec": "^0.1.9",
50
50
  "undici": "^7.24.7",
51
51
  "zod": "^4.3.6",
52
- "@electric-ax/agents-mcp": "0.2.2",
53
- "@electric-ax/agents-runtime": "0.3.11"
52
+ "@electric-ax/agents-mcp": "0.2.3",
53
+ "@electric-ax/agents-runtime": "0.3.13"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/better-sqlite3": "^7.6.13",