@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.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, 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";
@@ -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`;
@@ -963,25 +1069,66 @@ function filterChoicesByEnabledModels(choices, values) {
963
1069
  const filtered = choices.filter((choice) => enabled.has(choice.value));
964
1070
  return filtered.length > 0 ? filtered : choices;
965
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
+ };
966
1090
  function withProviderPayloadDefaults(config, choice, reasoningEffort) {
967
- if (choice.provider !== `openai` && choice.provider !== `openai-codex` || !choice.reasoning) return config;
968
- const defaultEffort = choice.provider === `openai-codex` ? `low` : `minimal`;
969
- const effort = reasoningEffort === `minimal` && choice.provider === `openai-codex` ? `low` : reasoningEffort ?? defaultEffort;
970
- return {
971
- ...config,
972
- onPayload: (payload) => {
973
- if (typeof payload !== `object` || payload === null) return void 0;
974
- const body = payload;
975
- const existingReasoning = typeof body.reasoning === `object` && body.reasoning !== null ? body.reasoning : {};
976
- return {
977
- ...body,
978
- reasoning: {
979
- ...existingReasoning,
980
- effort
981
- }
982
- };
983
- }
984
- };
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;
985
1132
  }
986
1133
  function parseReasoningEffort(value) {
987
1134
  return value === `minimal` || value === `low` || value === `medium` || value === `high` ? value : null;
@@ -1030,7 +1177,7 @@ function modelInputSchemaDefs(catalog) {
1030
1177
  //#endregion
1031
1178
  //#region src/agents/horton.ts
1032
1179
  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.";
1180
+ 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
1181
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
1035
1182
  const TITLE_GENERATION_TIMEOUT_MS = 8e3;
1036
1183
  const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
@@ -1124,12 +1271,16 @@ function withTimeout(promise, ms, description) {
1124
1271
  if (timeout) clearTimeout(timeout);
1125
1272
  });
1126
1273
  }
1274
+ function looksLikeNonTitle(title) {
1275
+ if (title.split(/\s+/).filter(Boolean).length > 8) return true;
1276
+ return /[!?,]/.test(title);
1277
+ }
1127
1278
  async function generateTitle(userMessage, llmCall, onFallback) {
1128
1279
  try {
1129
1280
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
1130
1281
  const title = raw.trim();
1131
- if (title.length > 0) return title;
1132
- onFallback?.(`empty LLM title response`);
1282
+ if (title.length > 0 && !looksLikeNonTitle(title)) return title;
1283
+ onFallback?.(title.length === 0 ? `empty LLM title response` : `non-title LLM response`);
1133
1284
  return buildFallbackTitle(userMessage);
1134
1285
  } catch (err) {
1135
1286
  onFallback?.(err instanceof Error ? err.message : String(err));
@@ -1139,6 +1290,7 @@ async function generateTitle(userMessage, llmCall, onFallback) {
1139
1290
  function buildHortonSystemPrompt(workingDirectory, opts = {}) {
1140
1291
  const docsTools = opts.hasDocsSupport ? `\n- search_electric_agents_docs: hybrid search over the built-in Electric Agents docs index` : ``;
1141
1292
  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` : ``;
1293
+ const titleTool = `\n- set_title: set or rename this chat session's UI title`;
1142
1294
  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
1295
  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
1296
  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 +1346,9 @@ When a user opens with a greeting ("hi", "hello", "hey", etc.) or a broad statem
1194
1346
  - fetch_url: fetch and convert a URL to markdown
1195
1347
  - spawn_worker: dispatch a subagent for an isolated task
1196
1348
  - 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.
1349
+ - observe_pg_sync: observe an Electric Postgres sync stream and wake on matching changes
1197
1350
  - 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}
1351
+ ${eventSourceTools}${titleTool}${scheduleTools}${docsTools}${skillsTools}
1199
1352
 
1200
1353
  # Working with files
1201
1354
  - Prefer edit over write when modifying existing files.
@@ -1240,7 +1393,18 @@ Workflow when forking yourself for parallel exploration:
1240
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.
1241
1394
 
1242
1395
  Working directory: ${workingDirectory}
1243
- 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.`;
1244
1408
  }
1245
1409
  function getToolName(tool) {
1246
1410
  if (typeof tool !== `object` || tool === null) return null;
@@ -1262,7 +1426,10 @@ function createHortonTools(sandbox, ctx, readSet, opts = {}) {
1262
1426
  })] : [createFetchUrlTool(sandbox)],
1263
1427
  createSpawnWorkerTool(ctx, opts.modelConfig),
1264
1428
  createForkTool(ctx),
1429
+ createObservePgSyncTool(ctx),
1430
+ createSetTitleTool(ctx),
1265
1431
  createSendTool(ctx.send, { selfEntityUrl: ctx.entityUrl }),
1432
+ ...ctx.getGoal()?.status === `active` ? [createMarkGoalCompleteTool(ctx)] : [],
1266
1433
  ...opts.docsSearchTool ? [opts.docsSearchTool] : []
1267
1434
  ];
1268
1435
  }
@@ -1331,11 +1498,58 @@ async function readAgentsMd(sandbox) {
1331
1498
  return null;
1332
1499
  }
1333
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
+ }
1334
1547
  function createAssistantHandler(options) {
1335
1548
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1336
1549
  const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1337
1550
  const hasSkills = skillLoader.hasSkills;
1338
1551
  return async function assistantHandler(ctx, wake) {
1552
+ if (await tryHandleSlashCommand(ctx, wake)) return;
1339
1553
  const loadedSkills = await skillLoader.load(ctx);
1340
1554
  const readSet = new Set();
1341
1555
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
@@ -1428,6 +1642,26 @@ function createAssistantHandler(options) {
1428
1642
  }
1429
1643
  }
1430
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;
1431
1665
  ctx.useAgent({
1432
1666
  systemPrompt: buildHortonSystemPrompt(sandboxCwd, {
1433
1667
  hasDocsSupport: Boolean(docsSupport),
@@ -1436,13 +1670,26 @@ function createAssistantHandler(options) {
1436
1670
  modelProvider: modelConfig.provider,
1437
1671
  modelId: String(modelConfig.model),
1438
1672
  hasEventSourceTools,
1439
- hasScheduleTools
1673
+ hasScheduleTools,
1674
+ ...activeGoalPromptInfo && { activeGoal: activeGoalPromptInfo }
1440
1675
  }),
1441
1676
  ...modelConfig,
1442
1677
  tools,
1443
- ...streamFn && { streamFn }
1678
+ ...streamFn && { streamFn },
1679
+ ...onStepEnd && { onStepEnd }
1444
1680
  });
1445
- 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
+ }
1446
1693
  await titlePromise;
1447
1694
  };
1448
1695
  }
@@ -1482,7 +1729,8 @@ function registerHorton(registry, options) {
1482
1729
  subject_value: `user`,
1483
1730
  permission: `manage`
1484
1731
  }],
1485
- slashCommands: buildSkillSlashCommands(skillsRegistry),
1732
+ state: { comments: commentsCollection },
1733
+ slashCommands: [GOAL_SLASH_COMMAND, ...buildSkillSlashCommands(skillsRegistry)],
1486
1734
  handler: assistantHandler
1487
1735
  });
1488
1736
  return [`horton`];
@@ -1666,6 +1914,7 @@ function registerWorker(registry, options) {
1666
1914
  subject_value: `user`,
1667
1915
  permission: `manage`
1668
1916
  }],
1917
+ state: { comments: commentsCollection },
1669
1918
  async handler(ctx) {
1670
1919
  const args = parseWorkerArgs(ctx.args);
1671
1920
  const readSet = new Set();
@@ -1718,7 +1967,7 @@ function createBuiltinElectricTools(custom) {
1718
1967
  };
1719
1968
  }
1720
1969
  async function createBuiltinAgentHandler(options) {
1721
- 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;
1722
1971
  const modelCatalog = await createBuiltinModelCatalog({
1723
1972
  allowMockFallback: Boolean(streamFn),
1724
1973
  enabledModelValues
@@ -1754,7 +2003,7 @@ async function createBuiltinAgentHandler(options) {
1754
2003
  modelCatalog
1755
2004
  });
1756
2005
  typeNames.push(`worker`);
1757
- const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd);
2006
+ const { profiles: sandboxProfiles, shutdownSandboxes } = await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts);
1758
2007
  const runtime = createRuntimeHandler({
1759
2008
  baseUrl: agentServerUrl,
1760
2009
  serveEndpoint,
@@ -1774,7 +2023,8 @@ async function createBuiltinAgentHandler(options) {
1774
2023
  registry,
1775
2024
  typeNames,
1776
2025
  skillsRegistry,
1777
- shutdownSandboxes
2026
+ shutdownSandboxes,
2027
+ modelCatalog
1778
2028
  };
1779
2029
  }
1780
2030
  async function createAgentHandler(agentServerUrl, workingDirectory, streamFn, createElectricTools, serveEndpoint) {
@@ -1803,6 +2053,21 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1803
2053
  return dockerBootSweep;
1804
2054
  }
1805
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
+ /**
1806
2071
  * Built-in sandbox profiles. `local` is always available. `docker` is
1807
2072
  * gated on Docker being reachable so a user without Docker installed
1808
2073
  * sees only what works — the UI never offers a non-functional choice.
@@ -1812,7 +2077,7 @@ function sweepOrphanedDockerSandboxesOnce(sweep) {
1812
2077
  * server must run on shutdown (the providers' debounced idle teardowns die
1813
2078
  * with the process).
1814
2079
  */
1815
- async function buildBuiltinSandboxProfiles(workingDirectory) {
2080
+ async function buildBuiltinSandboxProfiles(workingDirectory, dockerOpts) {
1816
2081
  const profiles = [{
1817
2082
  name: `local`,
1818
2083
  label: `Local`,
@@ -1837,11 +2102,11 @@ async function buildBuiltinSandboxProfiles(workingDirectory) {
1837
2102
  workingDirectory: `/work`,
1838
2103
  factory: () => dockerSandbox({
1839
2104
  initialNetworkPolicy: { mode: `allow-all` },
1840
- extraMounts: cwd ? [{
2105
+ ...resolveDockerSandboxOpts(cwd ? {
1841
2106
  hostPath: cwd,
1842
2107
  containerPath: `/work`,
1843
2108
  readOnly: false
1844
- }] : void 0,
2109
+ } : void 0, dockerOpts),
1845
2110
  sandboxKey,
1846
2111
  persistent,
1847
2112
  owner,
@@ -1888,13 +2153,19 @@ function resolveCwd(args, fallback) {
1888
2153
  //#endregion
1889
2154
  //#region src/durable-streams-cache.ts
1890
2155
  const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
2156
+ let installed = false;
1891
2157
  function installDurableStreamsFetchCache(options = {}) {
1892
2158
  if (options === false) return;
2159
+ if (installed) {
2160
+ console.warn(`[agents] installDurableStreamsFetchCache called more than once; ignoring`);
2161
+ return;
2162
+ }
1893
2163
  const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
1894
2164
  location: options.sqliteLocation,
1895
2165
  maxCount: options.maxCount
1896
2166
  }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1897
2167
  setGlobalDispatcher(getGlobalDispatcher().compose(interceptors.cache({ store })));
2168
+ installed = true;
1898
2169
  }
1899
2170
 
1900
2171
  //#endregion
@@ -2221,4 +2492,4 @@ async function runBuiltinAgentsEntrypoint({ env = process.env, cwd = process.cwd
2221
2492
  }
2222
2493
 
2223
2494
  //#endregion
2224
- 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 };