manychat-ai-agent 0.17.1 → 0.18.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.18.1](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.18.0...v0.18.1) (2026-10-07)
4
+
5
+
6
+ ### Specs and Docs
7
+
8
+ * add a getting-started guide for npm create, and point to it from the top of the README ([893e8da](https://github.com/pedronastasi/manychat-ai-agent/commit/893e8dac5a668d6e20ae2439822894a4c3aaa71d))
9
+ * derive a top bar, collapse the long sidebar groups, and fix badge and heading styling on the site ([2faca35](https://github.com/pedronastasi/manychat-ai-agent/commit/2faca35920a98ecbe7e8d3e4ec1e5a45372068b5))
10
+ * derive nav bar from sections, collapse long groups, add getting-started guide ([31a3fde](https://github.com/pedronastasi/manychat-ai-agent/commit/31a3fde37dcee03b7847f21523114335623bc036))
11
+
12
+ ## [0.18.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.17.1...v0.18.0) (2026-10-06)
13
+
14
+
15
+ ### Features
16
+
17
+ * finish plugin tools, and move plugin channels to spec 038 (spec 036) ([712c860](https://github.com/pedronastasi/manychat-ai-agent/commit/712c8603a291da1b0af3a980d30d2b0958e84d7d))
18
+ * finish plugin tools, and move plugin channels to spec 038 (spec 036) ([1908609](https://github.com/pedronastasi/manychat-ai-agent/commit/190860916fffaea797571226647672a2a2b5751d))
19
+
20
+
21
+ ### Specs and Docs
22
+
23
+ * record PR 182 in spec 036 ([2ab0918](https://github.com/pedronastasi/manychat-ai-agent/commit/2ab0918a8bd5beadcf2498ac1065e8b042a9a819))
24
+
3
25
  ## [0.17.1](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.17.0...v0.17.1) (2026-10-06)
4
26
 
5
27
 
package/README.md CHANGED
@@ -29,6 +29,17 @@ and an embedded [PGlite](https://pglite.dev/) Postgres. In another terminal,
29
29
 
30
30
  ![pnpm simulate answering a price question from the catalog and escalating a request for a person](docs/assets/demo.svg)
31
31
 
32
+ That clone is for trying the agent and working on it. To run one for your own
33
+ business, generate a project of your own instead, which depends on the published
34
+ package rather than a copy of this repository:
35
+
36
+ ```bash
37
+ npm create manychat-ai-agent@latest my-agent
38
+ ```
39
+
40
+ [Starting a new agent](docs/guides/getting-started.md) walks through it,
41
+ from the offline first run to a deployment.
42
+
32
43
  ---
33
44
 
34
45
  ## The problem this solves
@@ -366,7 +377,8 @@ It writes `config/`, an offline `.env`, an eval suite, CI, Compose and
366
377
  Renovate, depending on the agent and its image at the scaffolder's version.
367
378
  Replace `config/` with your own and keep the repository private: the generated
368
379
  CI fails when it is public
369
- ([specs/035](specs/035-create-scaffolds-a-tenant-project.md)).
380
+ ([specs/035](specs/035-create-scaffolds-a-tenant-project.md)). The steps are in
381
+ [Starting a new agent](docs/guides/getting-started.md).
370
382
 
371
383
  Point a ManyChat **Dynamic Block** (Dev Tools, requires a Pro plan) at
372
384
  `POST /v1/channels/manychat/message` and add an `Authorization: Bearer <secret>`
@@ -24,7 +24,11 @@ export interface SystemPromptParts {
24
24
  */
25
25
  export declare function buildSystemPrompt(persona: string, catalog: Catalog, rules: Rules,
26
26
  /** This tenant's `tools.json` (specs/012); its funnel adds the SALES rules (specs/023). */
27
- tools?: Tools): SystemPromptParts;
27
+ tools?: Tools,
28
+ /** Whether the deployment's plugins add tools of their own (specs/036). */
29
+ options?: {
30
+ pluginTools?: boolean;
31
+ }): SystemPromptParts;
28
32
  declare const NUDGE_NOTE_OPEN = "[no reply from the contact since";
29
33
  /**
30
34
  * The trigger of a nudge turn, in place of a contact message (specs/025 § A
@@ -58,8 +58,10 @@ function renderCatalog(catalog) {
58
58
  */
59
59
  export function buildSystemPrompt(persona, catalog, rules,
60
60
  /** This tenant's `tools.json` (specs/012); its funnel adds the SALES rules (specs/023). */
61
- tools = NO_TOOLS) {
62
- const withTools = offersTools(tools);
61
+ tools = NO_TOOLS,
62
+ /** Whether the deployment's plugins add tools of their own (specs/036). */
63
+ options = {}) {
64
+ const withTools = offersTools(tools) || options.pluginTools === true;
63
65
  const staticPrefix = [
64
66
  persona.trim(),
65
67
  '',
@@ -112,6 +114,7 @@ tools = NO_TOOLS) {
112
114
  'someone who has declined twice, or a turn whose opening flow asks it.',
113
115
  'Null is a decision, not a way to skip the field.',
114
116
  ...(withTools ? ACTIONS_SECTION : []),
117
+ ...(options.pluginTools ? PLUGIN_TOOLS_LINES : []),
115
118
  ...intentSection(tools),
116
119
  ...salesSection(tools),
117
120
  ...coursesSection(tools),
@@ -141,6 +144,14 @@ const ACTIONS_SECTION = [
141
144
  'writes that line, not you: it lists what reached ManyChat on that turn. Do not',
142
145
  'repeat those actions unless the contact asks, and never write such a line.',
143
146
  ];
147
+ /**
148
+ * Only when a plugin adds a tool, so a deployment without one gets the prompt
149
+ * it had before (specs/036).
150
+ */
151
+ const PLUGIN_TOOLS_LINES = [
152
+ 'This deployment adds tools of its own. They are staged like the others: performed',
153
+ 'after your reply, and not at all if you escalate. Their description says when to use them.',
154
+ ];
144
155
  const NUDGE_NOTE_OPEN = '[no reply from the contact since';
145
156
  /**
146
157
  * How the model schedules and writes a follow-up (specs/025). Only present
@@ -7,6 +7,7 @@ import type { ContactReads } from './contact.ts';
7
7
  import type { FlowSends } from './flows.ts';
8
8
  import { ActionStage } from './tools.ts';
9
9
  import type { ContactActions } from './tools.ts';
10
+ import type { Plugins } from '../plugins/plugins.ts';
10
11
  export interface AgentUsage {
11
12
  inputTokens: number | undefined;
12
13
  outputTokens: number | undefined;
@@ -125,6 +126,8 @@ export interface RunnerOptions {
125
126
  * what Constitution C5 forbids. Enable only against a trusted collector.
126
127
  */
127
128
  recordPromptsInTraces?: boolean;
129
+ /** The tenant's plugin tools, loaded once at boot (specs/036). */
130
+ plugins?: Plugins | undefined;
128
131
  }
129
132
  export declare class GenerateTextRunner implements AgentRunner {
130
133
  private readonly opts;
@@ -91,11 +91,12 @@ export class GenerateTextRunner {
91
91
  const config = this.opts.config();
92
92
  if (this.cached?.config !== config) {
93
93
  const tools = config.tools ?? NO_TOOLS;
94
- const withTools = offersTools(tools);
94
+ const pluginTools = this.opts.plugins?.hasTools ?? false;
95
+ const withTools = offersTools(tools) || pluginTools;
95
96
  this.cached = {
96
97
  config,
97
98
  withTools,
98
- ...buildSystemPrompt(config.persona, config.catalog, config.rules, tools),
99
+ ...buildSystemPrompt(config.persona, config.catalog, config.rules, tools, { pluginTools }),
99
100
  };
100
101
  }
101
102
  return this.cached;
@@ -110,6 +111,7 @@ export class GenerateTextRunner {
110
111
  nudgeTurn: nudge !== undefined,
111
112
  flows,
112
113
  beforeFlow,
114
+ plugins: this.opts.plugins,
113
115
  })
114
116
  : undefined;
115
117
  // The opening is no longer known before the loop runs: the prospect write
@@ -3,6 +3,7 @@ import type { Tools, INTENT_VALUES } from '../contracts/config.ts';
3
3
  import type { ActionRecord, ActionStatus, PerformableAction, StagedAction } from '../contracts/agent.ts';
4
4
  import type { ContactReads } from './contact.ts';
5
5
  import type { FlowSends, SendFlowAction } from './flows.ts';
6
+ import type { Plugins } from '../plugins/plugins.ts';
6
7
  /**
7
8
  * Steps one to three may call tools; step four offers none, so it must
8
9
  * produce the reply. Four fit read, act, read again and reply (specs/024
@@ -235,4 +236,6 @@ options?: {
235
236
  * (specs/032 § The opening flow is the server's, not the model's).
236
237
  */
237
238
  beforeFlow?: (() => Promise<void>) | undefined;
239
+ /** The tenant's plugin tools, staged as the built-in ones are (specs/036). */
240
+ plugins?: Plugins | undefined;
238
241
  }): ToolSet | undefined;
@@ -685,5 +685,8 @@ options = {}) {
685
685
  },
686
686
  });
687
687
  }
688
+ // Added last, and a built-in name was refused when the plugin loaded, so a
689
+ // plugin can neither replace nor shadow one of the tools above.
690
+ options.plugins?.addTools(tools, stage, closed, description => `${description}\n${STAGED}`);
688
691
  return Object.keys(tools).length > 0 ? tools : undefined;
689
692
  }
@@ -118,6 +118,9 @@ export class ManyChatHttpClient {
118
118
  // A row in `nudges`, written by NudgingPerformer (specs/025). Reaching
119
119
  // ManyChat with one means the wrapper was left out.
120
120
  throw new Error('schedule_nudge is not a ManyChat action');
121
+ case 'plugin':
122
+ // Performed by its plugin (specs/036); the same mistake as above.
123
+ throw new Error('a plugin tool is not a ManyChat action');
121
124
  }
122
125
  }
123
126
  /**
package/dist/cli/run.js CHANGED
@@ -90,7 +90,22 @@ export async function run(argv) {
90
90
  return undefined;
91
91
  }
92
92
  case 'config check': {
93
- console.log(`config check passed (${configDir})`);
93
+ // Plugins are checked as `agent serve` loads them, so a check that
94
+ // passes is a boot that does not stop on one (specs/036).
95
+ const { loadPlugins } = await import("../plugins/loader.js");
96
+ let names;
97
+ try {
98
+ names = (await loadPlugins(configDir)).names;
99
+ }
100
+ catch (error) {
101
+ if (error instanceof ConfigError) {
102
+ console.error(`invalid plugins (${configDir}): ${error.message}`);
103
+ return 1;
104
+ }
105
+ throw error;
106
+ }
107
+ const loaded = names.length > 0 ? `; plugins: ${names.join(', ')}` : '';
108
+ console.log(`config check passed (${configDir}${loaded})`);
94
109
  return 0;
95
110
  }
96
111
  case 'upgrade': {
@@ -119,9 +134,11 @@ async function startWorkers(configDir) {
119
134
  const { resolveModel } = await import("../agent/registry.js");
120
135
  const { ConfigStore } = await import("../config/loader.js");
121
136
  const { createLogger } = await import("../observability/logger.js");
137
+ const { loadPlugins } = await import("../plugins/loader.js");
122
138
  const env = loadEnv();
123
139
  const logger = createLogger(env.LOG_LEVEL);
124
140
  const configStore = new ConfigStore(configDir, reservedNames(env));
141
+ const plugins = await loadPlugins(configDir);
125
142
  let db;
126
143
  if (dbMod.isEmbedded(env.DATABASE_URL)) {
127
144
  const embedded = await dbMod.createEmbeddedDatabase(env.DATABASE_URL);
@@ -141,8 +158,9 @@ async function startWorkers(configDir) {
141
158
  maxOutputTokens: env.AGENT_MAX_OUTPUT_TOKENS,
142
159
  temperature: env.AGENT_TEMPERATURE,
143
160
  reasoningEffort: env.AGENT_REASONING_EFFORT,
161
+ plugins,
144
162
  });
145
- const stopOutbox = new OutboxWorker({ db, client: manychat, logger }).start();
163
+ const stopOutbox = new OutboxWorker({ db, client: manychat, logger, plugins }).start();
146
164
  const stopNudges = new NudgeWorker({
147
165
  db,
148
166
  runner,
@@ -146,7 +146,8 @@ export type ToolName = z.infer<typeof ToolName>;
146
146
  * What a turn's `actions` entry may record: a tool the model called, or a
147
147
  * `send_event`, which no model is ever offered. The server performs it as a
148
148
  * follow-on of a funnel write (specs/027 § Every event is recorded beside the
149
- * write that caused it).
149
+ * write that caused it). A plugin tool is recorded as `plugin`, its name as
150
+ * the id (specs/036).
150
151
  */
151
152
  export declare const ActionKind: z.ZodEnum<{
152
153
  send_flow: "send_flow";
@@ -156,6 +157,7 @@ export declare const ActionKind: z.ZodEnum<{
156
157
  write_note: "write_note";
157
158
  schedule_nudge: "schedule_nudge";
158
159
  send_event: "send_event";
160
+ plugin: "plugin";
159
161
  }>;
160
162
  export type ActionKind = z.infer<typeof ActionKind>;
161
163
  /**
@@ -234,6 +236,12 @@ export declare const StagedAction: z.ZodDiscriminatedUnion<[z.ZodObject<{
234
236
  tool: z.ZodLiteral<"schedule_nudge">;
235
237
  id: z.ZodString;
236
238
  minutes: z.ZodNumber;
239
+ }, z.core.$strip>, z.ZodObject<{
240
+ tool: z.ZodLiteral<"plugin">;
241
+ id: z.ZodString;
242
+ plugin: z.ZodString;
243
+ params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
244
+ notes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
237
245
  }, z.core.$strip>], "tool">;
238
246
  export type StagedAction = z.infer<typeof StagedAction>;
239
247
  /** Everything that may reach ManyChat as an action: a staged one, or a follow-on event (specs/027). */
@@ -263,6 +271,7 @@ export declare const ActionRecord: z.ZodObject<{
263
271
  write_note: "write_note";
264
272
  schedule_nudge: "schedule_nudge";
265
273
  send_event: "send_event";
274
+ plugin: "plugin";
266
275
  }>;
267
276
  id: z.ZodString;
268
277
  value: z.ZodOptional<z.ZodString>;
@@ -146,9 +146,10 @@ export const ToolName = z.enum([
146
146
  * What a turn's `actions` entry may record: a tool the model called, or a
147
147
  * `send_event`, which no model is ever offered. The server performs it as a
148
148
  * follow-on of a funnel write (specs/027 § Every event is recorded beside the
149
- * write that caused it).
149
+ * write that caused it). A plugin tool is recorded as `plugin`, its name as
150
+ * the id (specs/036).
150
151
  */
151
- export const ActionKind = z.enum([...ToolName.options, 'send_event']);
152
+ export const ActionKind = z.enum([...ToolName.options, 'send_event', 'plugin']);
152
153
  /**
153
154
  * An action the model staged, resolved against `tools.json` at the moment it
154
155
  * was staged. Carries the ManyChat names it will be performed with, so it
@@ -224,6 +225,19 @@ export const StagedAction = z.discriminatedUnion('tool', [
224
225
  * `nudges`, never as a ManyChat request (specs/025).
225
226
  */
226
227
  z.object({ tool: z.literal('schedule_nudge'), id: z.string(), minutes: z.number() }),
228
+ /**
229
+ * A tool a tenant's plugin declares, `id` being its name. Its parameters
230
+ * were validated against the declaration when it was staged, and its notes
231
+ * cleaned and kept apart so they can be kept out of the logs as a
232
+ * `write_note`'s text is. The plugin performs it, never ManyChat (specs/036).
233
+ */
234
+ z.object({
235
+ tool: z.literal('plugin'),
236
+ id: z.string(),
237
+ plugin: z.string(),
238
+ params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
239
+ notes: z.record(z.string(), z.string()).optional(),
240
+ }),
227
241
  ]);
228
242
  /** What became of a staged action (specs/012 § Every staged action is recorded on its turn). */
229
243
  export const ActionStatus = z.enum([
@@ -10,7 +10,13 @@ function reasonFor(error, subscriberId, action) {
10
10
  // A ManyChat error's message already names the endpoint, the status and
11
11
  // ManyChat's own message.
12
12
  const raw = error instanceof Error ? error.message : String(error);
13
- const unquoted = action.tool === 'write_note' ? raw.split(action.text).join('[note]') : raw;
13
+ // A plugin's own error can quote anything it was given (specs/036).
14
+ const quoted = action.tool === 'write_note'
15
+ ? [action.text]
16
+ : action.tool === 'plugin'
17
+ ? Object.values(action.notes ?? {})
18
+ : [];
19
+ const unquoted = quoted.reduce((text, value) => (value.length > 0 ? text.split(value).join('[note]') : text), raw);
14
20
  return redactText(unquoted.split(subscriberId).join('[subscriber]')).slice(0, 200);
15
21
  }
16
22
  /**
@@ -83,7 +83,7 @@ export declare class ConversationStore {
83
83
  actionHistory(conversationId: string): Promise<{
84
84
  createdAt: Date;
85
85
  actions: {
86
- tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event";
86
+ tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event" | "plugin";
87
87
  id: string;
88
88
  status: "staged" | "performed" | "failed" | "discarded" | "dropped_over_cap" | "dead_lettered";
89
89
  value?: string | undefined;
@@ -109,7 +109,7 @@ export declare class ConversationStore {
109
109
  role: "user" | "agent";
110
110
  text: string;
111
111
  actions: {
112
- tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event";
112
+ tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event" | "plugin";
113
113
  id: string;
114
114
  status: "staged" | "performed" | "failed" | "discarded" | "dropped_over_cap" | "dead_lettered";
115
115
  value?: string | undefined;
@@ -427,7 +427,7 @@ export declare const turns: import("drizzle-orm/pg-core").PgTableWithColumns<{
427
427
  dataType: "json";
428
428
  columnType: "PgJsonb";
429
429
  data: {
430
- tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event";
430
+ tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event" | "plugin";
431
431
  id: string;
432
432
  status: "staged" | "performed" | "failed" | "discarded" | "dropped_over_cap" | "dead_lettered";
433
433
  value?: string | undefined;
@@ -448,7 +448,7 @@ export declare const turns: import("drizzle-orm/pg-core").PgTableWithColumns<{
448
448
  generated: undefined;
449
449
  }, {}, {
450
450
  $type: {
451
- tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event";
451
+ tool: "send_flow" | "add_tag" | "remove_tag" | "set_field" | "write_note" | "schedule_nudge" | "send_event" | "plugin";
452
452
  id: string;
453
453
  status: "staged" | "performed" | "failed" | "discarded" | "dropped_over_cap" | "dead_lettered";
454
454
  value?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { loadEnv, loadTenantConfig } from "../config/loader.js";
2
2
  import { resolveModel } from "../agent/registry.js";
3
3
  import { GenerateTextRunner } from "../agent/runner.js";
4
+ import { loadPlugins } from "../plugins/loader.js";
4
5
  import { ActionStage, describeAction } from "../agent/tools.js";
5
6
  import { checkCase, classify, evalDir, loadCases } from "./cases.js";
6
7
  /** When a nudge case's contact went quiet (specs/025). Invented, and fixed. */
@@ -20,6 +21,9 @@ export async function runEval() {
20
21
  const configDir = process.env.CONFIG_DIR ?? 'config';
21
22
  const suiteDir = evalDir();
22
23
  const tenant = loadTenantConfig(configDir);
24
+ // Offered as in production, so a suite can assert a plugin tool is chosen.
25
+ // Nothing staged here is performed (specs/036).
26
+ const plugins = await loadPlugins(configDir);
23
27
  const cases = loadCases(suiteDir);
24
28
  // Defaults to the race deadline, so a suite that sets nothing behaves as
25
29
  // before. A tenant evaluating a reasoning model raises this rather than
@@ -32,6 +36,7 @@ export async function runEval() {
32
36
  maxOutputTokens: env.AGENT_MAX_OUTPUT_TOKENS,
33
37
  temperature: env.AGENT_TEMPERATURE,
34
38
  reasoningEffort: env.AGENT_REASONING_EFFORT,
39
+ plugins,
35
40
  });
36
41
  console.log(`\n model: ${env.AGENT_MODEL} suite: ${suiteDir} cases: ${cases.length}\n`);
37
42
  const outcomes = [];
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The bare `manychat-ai-agent` entry point: the plugin API (specs/036). The
3
+ * agent itself is run through its CLI or image, never imported.
4
+ */
5
+ export { definePlugin, defineTool, PLUGIN_API_VERSION } from './plugins/api.ts';
6
+ export type { ParamsOf, Plugin, PluginCall, PluginLogger, PluginParameter, PluginParameters, PluginTool, } from './plugins/api.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The bare `manychat-ai-agent` entry point: the plugin API (specs/036). The
3
+ * agent itself is run through its CLI or image, never imported.
4
+ */
5
+ export { definePlugin, defineTool, PLUGIN_API_VERSION } from "./plugins/api.js";
package/dist/main.js CHANGED
@@ -14,11 +14,15 @@ import { manychatClientFor } from "./channels/manychat/client.js";
14
14
  import { OutboxWorker } from "./outbox/worker.js";
15
15
  import { NudgeWorker } from "./nudge/worker.js";
16
16
  import { buildServer } from "./server.js";
17
+ import { loadPlugins } from "./plugins/loader.js";
17
18
  export async function main() {
18
19
  const env = loadEnv();
19
20
  // A tool aimed at the reply flow or field fails the boot, and a reload that
20
21
  // introduces one is refused (specs/012).
21
- const configStore = new ConfigStore(process.env.CONFIG_DIR ?? 'config', reservedNames(env));
22
+ const configDir = process.env.CONFIG_DIR ?? 'config';
23
+ const configStore = new ConfigStore(configDir, reservedNames(env));
24
+ // Code, loaded once: a plugin that does not load stops the boot (specs/036).
25
+ const plugins = await loadPlugins(configDir);
22
26
  let db;
23
27
  if (isEmbedded(env.DATABASE_URL)) {
24
28
  const embedded = await createEmbeddedDatabase(env.DATABASE_URL);
@@ -36,6 +40,7 @@ export async function main() {
36
40
  configStore,
37
41
  manychat,
38
42
  contacts: manychat,
43
+ plugins,
39
44
  });
40
45
  const migrated = await runMigrations(db);
41
46
  if (migrated.length > 0)
@@ -44,6 +49,7 @@ export async function main() {
44
49
  db,
45
50
  client: manychat,
46
51
  logger: app.log,
52
+ plugins,
47
53
  }).start();
48
54
  // Follow-ups the agent scheduled (specs/025). Idle for a tenant without a
49
55
  // `nudge` section: nothing schedules one.
@@ -1,5 +1,6 @@
1
1
  import type { Database } from '../db/client.ts';
2
2
  import type { ManyChatClient } from '../channels/manychat/client.ts';
3
+ import type { Plugins } from '../plugins/plugins.ts';
3
4
  export interface WorkerLogger {
4
5
  info: (obj: object, msg: string) => void;
5
6
  warn: (obj: object, msg: string) => void;
@@ -9,6 +10,8 @@ export interface WorkerOptions {
9
10
  db: Database;
10
11
  client: ManyChatClient;
11
12
  logger: WorkerLogger;
13
+ /** Performs the plugin actions deferred with a reply (specs/036). */
14
+ plugins?: Plugins | undefined;
12
15
  batchSize?: number;
13
16
  pollIntervalMs?: number;
14
17
  }
@@ -26,6 +29,7 @@ export declare class OutboxWorker {
26
29
  private readonly tokens;
27
30
  private readonly store;
28
31
  private readonly nudges;
32
+ private readonly actions;
29
33
  private running;
30
34
  /**
31
35
  * Set by the stop function. A contact's rows after the one being sent go
@@ -12,6 +12,7 @@ export class OutboxWorker {
12
12
  tokens;
13
13
  store;
14
14
  nudges;
15
+ actions;
15
16
  running = false;
16
17
  /**
17
18
  * Set by the stop function. A contact's rows after the one being sent go
@@ -26,6 +27,7 @@ export class OutboxWorker {
26
27
  this.tokens = new ContactTokens(opts.db, opts.client);
27
28
  this.store = new ConversationStore(opts.db);
28
29
  this.nudges = new NudgeStore(opts.db);
30
+ this.actions = opts.plugins ? opts.plugins.performer(opts.client, opts.logger) : opts.client;
29
31
  }
30
32
  /**
31
33
  * The actions deferred with a reply, performed once, after its first
@@ -39,8 +41,8 @@ export class OutboxWorker {
39
41
  return;
40
42
  try {
41
43
  const performer = row.conversationId
42
- ? new NudgingPerformer(this.opts.client, this.nudges, row.conversationId)
43
- : this.opts.client;
44
+ ? new NudgingPerformer(this.actions, this.nudges, row.conversationId)
45
+ : this.actions;
44
46
  const outcomes = await performActions(performer, row.subscriberId, actions, this.opts.logger);
45
47
  if (turnId)
46
48
  await this.store.resolveStaged(turnId, outcomes);
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The plugin API: what a tenant project's plugin package exports, imported
3
+ * from the bare `manychat-ai-agent` entry point (specs/036).
4
+ *
5
+ * A plugin adds tools. Each is staged when the model calls it and performed
6
+ * after the reply, exactly as the built-in tools of specs/012 are, so a plugin
7
+ * gets no more power than one of them has.
8
+ */
9
+ /** The plugin API this agent implements. A plugin naming another is refused at startup. */
10
+ export declare const PLUGIN_API_VERSION = 1;
11
+ /**
12
+ * One parameter of a plugin tool. There is no free-text string: text is a
13
+ * `note`, bounded and cleaned as a `tools.json` note is (specs/012 § Free-text
14
+ * field values are refused, ADR-0017).
15
+ */
16
+ export type PluginParameter = {
17
+ type: 'enum';
18
+ values: readonly [string, ...string[]];
19
+ description?: string;
20
+ optional?: boolean;
21
+ } | {
22
+ type: 'number';
23
+ integer?: boolean;
24
+ min?: number;
25
+ max?: number;
26
+ description?: string;
27
+ optional?: boolean;
28
+ } | {
29
+ type: 'boolean';
30
+ description?: string;
31
+ optional?: boolean;
32
+ } | {
33
+ type: 'note';
34
+ /** At most `MAX_NOTE_LENGTH` (500), as a `tools.json` note. */
35
+ maxLength: number;
36
+ description?: string;
37
+ optional?: boolean;
38
+ };
39
+ export type PluginParameters = Readonly<Record<string, PluginParameter>>;
40
+ type ValueOf<P extends PluginParameter> = P extends {
41
+ type: 'enum';
42
+ values: readonly (infer V)[];
43
+ } ? V : P extends {
44
+ type: 'number';
45
+ } ? number : P extends {
46
+ type: 'boolean';
47
+ } ? boolean : string;
48
+ /** The validated values `perform` receives, typed from the declared parameters. */
49
+ export type ParamsOf<S extends PluginParameters> = {
50
+ [K in keyof S as S[K] extends {
51
+ optional: true;
52
+ } ? never : K]: ValueOf<S[K]>;
53
+ } & {
54
+ [K in keyof S as S[K] extends {
55
+ optional: true;
56
+ } ? K : never]?: ValueOf<S[K]>;
57
+ };
58
+ /**
59
+ * The agent's own redacting logger, scoped to the plugin. Message and string
60
+ * fields are redacted before they are written (C5).
61
+ */
62
+ export interface PluginLogger {
63
+ info(message: string, fields?: Record<string, unknown>): void;
64
+ warn(message: string, fields?: Record<string, unknown>): void;
65
+ error(message: string, fields?: Record<string, unknown>): void;
66
+ }
67
+ /**
68
+ * Everything `perform` is given: the turn's subscriber, never one the model
69
+ * named; the validated parameters; the logger; and a signal that aborts when
70
+ * the agent stops waiting. No model, prompt, catalog or database.
71
+ */
72
+ export interface PluginCall<P> {
73
+ subscriberId: string;
74
+ params: P;
75
+ logger: PluginLogger;
76
+ signal: AbortSignal;
77
+ }
78
+ export interface PluginTool<S extends PluginParameters = PluginParameters> {
79
+ /** Lowercase snake case, unique, and not a built-in tool's name. */
80
+ name: string;
81
+ /** What the model reads to decide when to call it. */
82
+ description: string;
83
+ parameters: S;
84
+ /**
85
+ * Runs after the reply is sent, once. A failure is logged and recorded on
86
+ * the turn, never retried (specs/012 § A failed action is logged, never retried).
87
+ */
88
+ perform(call: PluginCall<ParamsOf<S>>): Promise<void> | void;
89
+ }
90
+ export interface Plugin {
91
+ name: string;
92
+ /** The plugin API it was written against: `PLUGIN_API_VERSION`. */
93
+ apiVersion: number;
94
+ tools?: readonly PluginTool<any>[];
95
+ }
96
+ /**
97
+ * A plugin package's default export. Returns the plugin unchanged: the agent
98
+ * checks it when it loads, so a plain object passes or fails the same way.
99
+ */
100
+ export declare function definePlugin<const P extends Plugin>(plugin: P): P;
101
+ /** Types a tool's `perform` from its parameters. Returns the tool unchanged. */
102
+ export declare function defineTool<const S extends PluginParameters>(tool: PluginTool<S>): PluginTool<S>;
103
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The plugin API: what a tenant project's plugin package exports, imported
3
+ * from the bare `manychat-ai-agent` entry point (specs/036).
4
+ *
5
+ * A plugin adds tools. Each is staged when the model calls it and performed
6
+ * after the reply, exactly as the built-in tools of specs/012 are, so a plugin
7
+ * gets no more power than one of them has.
8
+ */
9
+ /** The plugin API this agent implements. A plugin naming another is refused at startup. */
10
+ export const PLUGIN_API_VERSION = 1;
11
+ /**
12
+ * A plugin package's default export. Returns the plugin unchanged: the agent
13
+ * checks it when it loads, so a plain object passes or fails the same way.
14
+ */
15
+ export function definePlugin(plugin) {
16
+ return plugin;
17
+ }
18
+ /** Types a tool's `perform` from its parameters. Returns the tool unchanged. */
19
+ export function defineTool(tool) {
20
+ return tool;
21
+ }
@@ -0,0 +1,20 @@
1
+ import { ConfigError } from '../config/loader.ts';
2
+ import { Plugins } from './plugins.ts';
3
+ /** The optional tenant file that lists the plugin packages to load (specs/036). */
4
+ export declare const PLUGINS_FILE = "plugins.json";
5
+ /** A plugin that cannot be loaded. A `ConfigError`, so every command reports it as one. */
6
+ export declare class PluginError extends ConfigError {
7
+ constructor(message: string);
8
+ }
9
+ /** The packages `plugins.json` lists, or none when the file is absent. */
10
+ export declare function listedPlugins(configDir: string): string[];
11
+ /**
12
+ * Loads the plugins `config/plugins.json` lists, once, at boot (specs/036).
13
+ * Each is resolved from the tenant project's `node_modules`: by default the
14
+ * directory that holds `configDir`. Any that does not load is a startup error.
15
+ *
16
+ * `SIGHUP` does not come here: it reloads configuration, not code.
17
+ */
18
+ export declare function loadPlugins(configDir: string, options?: {
19
+ from?: string;
20
+ }): Promise<Plugins>;
@@ -0,0 +1,267 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { z } from 'zod';
13
+ import { ConfigError } from "../config/loader.js";
14
+ import { MAX_NOTE_LENGTH } from "../contracts/config.js";
15
+ import { ToolName } from "../contracts/agent.js";
16
+ import { PLUGIN_API_VERSION } from "./api.js";
17
+ import { Plugins } from "./plugins.js";
18
+ /** The optional tenant file that lists the plugin packages to load (specs/036). */
19
+ export const PLUGINS_FILE = 'plugins.json';
20
+ const PluginsFile = z.object({ plugins: z.array(z.string().min(1)) }).strict();
21
+ /** Names the model already knows: the six of specs/012 and the read of specs/024. */
22
+ const BUILT_IN = new Set([...ToolName.options, 'get_contact']);
23
+ const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
24
+ const PLUGIN_KEYS = new Set(['name', 'apiVersion', 'tools']);
25
+ const TOOL_KEYS = new Set(['name', 'description', 'parameters', 'perform']);
26
+ const PARAMETER_KEYS = {
27
+ enum: new Set(['type', 'values', 'description', 'optional']),
28
+ number: new Set(['type', 'integer', 'min', 'max', 'description', 'optional']),
29
+ boolean: new Set(['type', 'description', 'optional']),
30
+ note: new Set(['type', 'maxLength', 'description', 'optional']),
31
+ };
32
+ /** A plugin that cannot be loaded. A `ConfigError`, so every command reports it as one. */
33
+ export class PluginError extends ConfigError {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = 'PluginError';
37
+ }
38
+ }
39
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
40
+ function unknownKeys(value, allowed) {
41
+ return Object.keys(value).filter(key => !allowed.has(key));
42
+ }
43
+ /** The directory of `name` in the first `node_modules` above `from`, as Node finds a package. */
44
+ function packageDir(name, from) {
45
+ let dir = resolve(from);
46
+ for (;;) {
47
+ const candidate = join(dir, 'node_modules', name);
48
+ if (existsSync(join(candidate, 'package.json')))
49
+ return candidate;
50
+ const parent = dirname(dir);
51
+ if (parent === dir)
52
+ return undefined;
53
+ dir = parent;
54
+ }
55
+ }
56
+ /** The ESM entry an `exports` value points the bare import at. */
57
+ function exported(target) {
58
+ if (typeof target === 'string')
59
+ return target;
60
+ if (Array.isArray(target)) {
61
+ for (const option of target) {
62
+ const found = exported(option);
63
+ if (found)
64
+ return found;
65
+ }
66
+ return undefined;
67
+ }
68
+ if (!isObject(target))
69
+ return undefined;
70
+ if ('.' in target)
71
+ return exported(target['.']);
72
+ for (const condition of ['import', 'node', 'default']) {
73
+ const found = exported(target[condition]);
74
+ if (found)
75
+ return found;
76
+ }
77
+ return undefined;
78
+ }
79
+ function entryOf(dir) {
80
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
81
+ const pkg = isObject(manifest) ? manifest : {};
82
+ const target = exported(pkg.exports) ?? (typeof pkg.main === 'string' ? pkg.main : undefined) ?? 'index.js';
83
+ return join(dir, target);
84
+ }
85
+ /**
86
+ * Checks one parameter against what a built-in tool may take. A string outside
87
+ * a note is refused, as `tools.json` refuses one (specs/012 § Free-text field
88
+ * values are refused).
89
+ */
90
+ function checkParameter(where, key, parameter) {
91
+ const at = `${where}, parameter "${key}"`;
92
+ if (!isObject(parameter))
93
+ throw new PluginError(`${at}: is not a parameter declaration`);
94
+ const type = parameter.type;
95
+ if (type === 'string' || type === 'text') {
96
+ throw new PluginError(`${at}: free text is refused; declare it as a "note" with a maxLength (specs/012)`);
97
+ }
98
+ if (typeof type !== 'string' || !(type in PARAMETER_KEYS)) {
99
+ throw new PluginError(`${at}: type must be one of enum, number, boolean, note`);
100
+ }
101
+ const extra = unknownKeys(parameter, PARAMETER_KEYS[type]);
102
+ if (extra.length > 0)
103
+ throw new PluginError(`${at}: unknown keys ${extra.join(', ')}`);
104
+ if (parameter.description !== undefined && typeof parameter.description !== 'string') {
105
+ throw new PluginError(`${at}: description must be a string`);
106
+ }
107
+ if (parameter.optional !== undefined && typeof parameter.optional !== 'boolean') {
108
+ throw new PluginError(`${at}: optional must be a boolean`);
109
+ }
110
+ if (type === 'enum') {
111
+ const values = parameter.values;
112
+ if (!Array.isArray(values) ||
113
+ values.length === 0 ||
114
+ values.some(value => typeof value !== 'string' || value.length === 0) ||
115
+ new Set(values).size !== values.length) {
116
+ throw new PluginError(`${at}: values must be distinct, non-empty strings`);
117
+ }
118
+ }
119
+ if (type === 'number') {
120
+ const { min, max, integer } = parameter;
121
+ for (const [name, bound] of [
122
+ ['min', min],
123
+ ['max', max],
124
+ ]) {
125
+ if (bound !== undefined && (typeof bound !== 'number' || !Number.isFinite(bound))) {
126
+ throw new PluginError(`${at}: ${name} must be a finite number`);
127
+ }
128
+ }
129
+ if (typeof min === 'number' && typeof max === 'number' && min > max) {
130
+ throw new PluginError(`${at}: min is greater than max`);
131
+ }
132
+ if (integer !== undefined && typeof integer !== 'boolean') {
133
+ throw new PluginError(`${at}: integer must be a boolean`);
134
+ }
135
+ }
136
+ if (type === 'note') {
137
+ const { maxLength } = parameter;
138
+ if (typeof maxLength !== 'number' ||
139
+ !Number.isInteger(maxLength) ||
140
+ maxLength < 1 ||
141
+ maxLength > MAX_NOTE_LENGTH) {
142
+ throw new PluginError(`${at}: maxLength must be a whole number from 1 to ${MAX_NOTE_LENGTH} (ADR-0017)`);
143
+ }
144
+ }
145
+ }
146
+ function checkTool(plugin, value, taken) {
147
+ if (!isObject(value))
148
+ throw new PluginError(`plugin ${plugin}: a tool is not an object`);
149
+ const name = value.name;
150
+ if (typeof name !== 'string' || !TOOL_NAME.test(name)) {
151
+ throw new PluginError(`plugin ${plugin}: tool name ${JSON.stringify(name)} must be lowercase snake case`);
152
+ }
153
+ const where = `plugin ${plugin}, tool ${name}`;
154
+ if (BUILT_IN.has(name))
155
+ throw new PluginError(`${where}: takes the name of a built-in tool`);
156
+ const owner = taken.get(name);
157
+ if (owner !== undefined)
158
+ throw new PluginError(`${where}: plugin ${owner} already defines it`);
159
+ const extra = unknownKeys(value, TOOL_KEYS);
160
+ if (extra.length > 0)
161
+ throw new PluginError(`${where}: unknown keys ${extra.join(', ')}`);
162
+ if (typeof value.description !== 'string' || value.description.trim().length === 0) {
163
+ throw new PluginError(`${where}: description must be a non-empty string`);
164
+ }
165
+ if (!isObject(value.parameters))
166
+ throw new PluginError(`${where}: parameters must be an object`);
167
+ for (const [key, parameter] of Object.entries(value.parameters)) {
168
+ checkParameter(where, key, parameter);
169
+ }
170
+ if (typeof value.perform !== 'function') {
171
+ throw new PluginError(`${where}: perform must be a function`);
172
+ }
173
+ taken.set(name, plugin);
174
+ return value;
175
+ }
176
+ /**
177
+ * Checks a plugin's default export. Everything that would leave the prompt
178
+ * promising an action nothing performs fails here, at startup (C6).
179
+ */
180
+ function checkPlugin(spec, value, taken) {
181
+ if (!isObject(value)) {
182
+ throw new PluginError(`plugin ${spec}: its default export is not a plugin (use definePlugin)`);
183
+ }
184
+ if (typeof value.name !== 'string' || value.name.length === 0) {
185
+ throw new PluginError(`plugin ${spec}: name must be a non-empty string`);
186
+ }
187
+ const name = value.name;
188
+ if (value.apiVersion !== PLUGIN_API_VERSION) {
189
+ throw new PluginError(`plugin ${name}: apiVersion ${JSON.stringify(value.apiVersion)} is not supported; ` +
190
+ `this agent supports ${PLUGIN_API_VERSION}`);
191
+ }
192
+ // Refused rather than ignored: a channel that silently never mounts is a
193
+ // contact nobody answers (specs/038).
194
+ if ('channels' in value) {
195
+ throw new PluginError(`plugin ${name}: channels are not supported yet (specs/038)`);
196
+ }
197
+ const extra = unknownKeys(value, PLUGIN_KEYS);
198
+ if (extra.length > 0)
199
+ throw new PluginError(`plugin ${name}: unknown keys ${extra.join(', ')}`);
200
+ if (value.tools !== undefined && !Array.isArray(value.tools)) {
201
+ throw new PluginError(`plugin ${name}: tools must be an array`);
202
+ }
203
+ const tools = (value.tools ?? []).map(tool => checkTool(name, tool, taken));
204
+ return { name, tools };
205
+ }
206
+ /** The packages `plugins.json` lists, or none when the file is absent. */
207
+ export function listedPlugins(configDir) {
208
+ const path = join(configDir, PLUGINS_FILE);
209
+ if (!existsSync(path))
210
+ return [];
211
+ let raw;
212
+ try {
213
+ raw = JSON.parse(readFileSync(path, 'utf8'));
214
+ }
215
+ catch (error) {
216
+ throw new PluginError(`${PLUGINS_FILE}: ${error.message}`);
217
+ }
218
+ const parsed = PluginsFile.safeParse(raw);
219
+ if (!parsed.success) {
220
+ throw new PluginError(`${PLUGINS_FILE}: ${z.prettifyError(parsed.error)}`);
221
+ }
222
+ const packages = parsed.data.plugins;
223
+ const repeated = packages.find((name, index) => packages.indexOf(name) !== index);
224
+ if (repeated !== undefined) {
225
+ throw new PluginError(`${PLUGINS_FILE}: ${repeated} is listed twice`);
226
+ }
227
+ return packages;
228
+ }
229
+ /**
230
+ * Loads the plugins `config/plugins.json` lists, once, at boot (specs/036).
231
+ * Each is resolved from the tenant project's `node_modules`: by default the
232
+ * directory that holds `configDir`. Any that does not load is a startup error.
233
+ *
234
+ * `SIGHUP` does not come here: it reloads configuration, not code.
235
+ */
236
+ export async function loadPlugins(configDir, options = {}) {
237
+ const packages = listedPlugins(configDir);
238
+ if (packages.length === 0)
239
+ return Plugins.NONE;
240
+ const from = options.from ?? dirname(resolve(configDir));
241
+ const taken = new Map();
242
+ const names = new Set();
243
+ const loaded = [];
244
+ for (const spec of packages) {
245
+ if (spec.startsWith('.') || spec.startsWith('/') || spec.includes('\\')) {
246
+ throw new PluginError(`plugin ${spec}: list a package name, not a path`);
247
+ }
248
+ const dir = packageDir(spec, from);
249
+ if (!dir) {
250
+ throw new PluginError(`plugin ${spec} is not installed (no node_modules/${spec} from ${from})`);
251
+ }
252
+ let module;
253
+ try {
254
+ module = (await import(__rewriteRelativeImportExtension(pathToFileURL(entryOf(dir)).href)));
255
+ }
256
+ catch (error) {
257
+ throw new PluginError(`plugin ${spec} failed to load: ${error.message}`);
258
+ }
259
+ const plugin = checkPlugin(spec, module.default, taken);
260
+ if (names.has(plugin.name)) {
261
+ throw new PluginError(`plugin ${spec}: another plugin is already named ${plugin.name}`);
262
+ }
263
+ names.add(plugin.name);
264
+ loaded.push(...plugin.tools.map(tool => ({ plugin: plugin.name, tool })));
265
+ }
266
+ return new Plugins(loaded, [...names]);
267
+ }
@@ -0,0 +1,49 @@
1
+ import { type ToolSet } from 'ai';
2
+ import type { ActionPerformer } from '../channels/manychat/client.ts';
3
+ import type { ActionStage } from '../agent/tools.ts';
4
+ import type { PluginTool } from './api.ts';
5
+ /**
6
+ * How long the agent waits for a plugin's `perform`. Past it the signal
7
+ * aborts and the action is recorded as failed, so a hung plugin cannot hold
8
+ * up the rest of the turn's actions or the outbox (specs/036). Chosen, not
9
+ * measured: the ManyChat client's own request timeout is shorter.
10
+ */
11
+ export declare const PLUGIN_PERFORM_TIMEOUT_MS = 10000;
12
+ /** The parameter values a staged plugin action carries, already validated and cleaned. */
13
+ export type PluginParams = Record<string, string | number | boolean>;
14
+ /** The logger the agent writes through; the process's redacting pino logger in production. */
15
+ export interface HostLogger {
16
+ info: (obj: object, msg: string) => void;
17
+ warn: (obj: object, msg: string) => void;
18
+ error: (obj: object, msg: string) => void;
19
+ }
20
+ export interface LoadedTool {
21
+ /** The plugin that declared it. */
22
+ plugin: string;
23
+ tool: PluginTool;
24
+ }
25
+ /**
26
+ * The plugins a process loaded at boot (specs/036). Each tool is offered to
27
+ * the model beside the built-in ones and performed after the reply, as a
28
+ * built-in tool is.
29
+ */
30
+ export declare class Plugins {
31
+ static readonly NONE: Plugins;
32
+ private readonly byName;
33
+ /** The loaded plugins' names, in the order `plugins.json` lists them. */
34
+ readonly names: readonly string[];
35
+ constructor(tools: readonly LoadedTool[], names?: readonly string[]);
36
+ get hasTools(): boolean;
37
+ /**
38
+ * Adds each plugin tool to the turn's tools. A call stages the action and
39
+ * nothing else; `closed` refuses it as the built-in writes are refused
40
+ * before a contact is a prospect (specs/034).
41
+ */
42
+ addTools(tools: ToolSet, stage: ActionStage, closed: () => boolean, describe: (description: string) => string): void;
43
+ /**
44
+ * Wraps the process's performer: a plugin action goes to its plugin, every
45
+ * other action where it went before.
46
+ */
47
+ performer(inner: ActionPerformer, logger: HostLogger): ActionPerformer;
48
+ private perform;
49
+ }
@@ -0,0 +1,157 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { cleanNote } from "../agent/contact.js";
4
+ import { redactText } from "../observability/redact.js";
5
+ /**
6
+ * How long the agent waits for a plugin's `perform`. Past it the signal
7
+ * aborts and the action is recorded as failed, so a hung plugin cannot hold
8
+ * up the rest of the turn's actions or the outbox (specs/036). Chosen, not
9
+ * measured: the ManyChat client's own request timeout is shorter.
10
+ */
11
+ export const PLUGIN_PERFORM_TIMEOUT_MS = 10_000;
12
+ /** The model-facing schema, built here from the declaration, never taken from the plugin. */
13
+ function schemaOf(parameter) {
14
+ let schema;
15
+ switch (parameter.type) {
16
+ case 'enum':
17
+ schema = z.enum(parameter.values);
18
+ break;
19
+ case 'number': {
20
+ let number = parameter.integer ? z.number().int() : z.number();
21
+ if (parameter.min !== undefined)
22
+ number = number.min(parameter.min);
23
+ if (parameter.max !== undefined)
24
+ number = number.max(parameter.max);
25
+ schema = number;
26
+ break;
27
+ }
28
+ case 'boolean':
29
+ schema = z.boolean();
30
+ break;
31
+ case 'note':
32
+ schema = z.string();
33
+ break;
34
+ }
35
+ if (parameter.description !== undefined)
36
+ schema = schema.describe(parameter.description);
37
+ return parameter.optional ? schema.optional() : schema;
38
+ }
39
+ const NOTE_RULE = 'A note parameter is for the people who follow up: never put a name, phone number, email or link in one; they are removed.';
40
+ /**
41
+ * A logger that redacts what a plugin writes and names the plugin on every
42
+ * line: the agent's logger, never one the plugin brings (C5).
43
+ */
44
+ function pluginLogger(host, plugin, subscriberId) {
45
+ const clean = (text) => redactText(text.split(subscriberId).join('[subscriber]'));
46
+ const write = (level) => (message, fields = {}) => {
47
+ const redacted = Object.fromEntries(Object.entries(fields).map(([key, value]) => [
48
+ key,
49
+ typeof value === 'string' ? clean(value) : value,
50
+ ]));
51
+ host[level]({ ...redacted, plugin }, clean(message));
52
+ };
53
+ return { info: write('info'), warn: write('warn'), error: write('error') };
54
+ }
55
+ /**
56
+ * The plugins a process loaded at boot (specs/036). Each tool is offered to
57
+ * the model beside the built-in ones and performed after the reply, as a
58
+ * built-in tool is.
59
+ */
60
+ export class Plugins {
61
+ static NONE = new Plugins([]);
62
+ byName;
63
+ /** The loaded plugins' names, in the order `plugins.json` lists them. */
64
+ names;
65
+ constructor(tools, names = []) {
66
+ this.byName = new Map(tools.map(entry => [entry.tool.name, entry]));
67
+ this.names = names;
68
+ }
69
+ get hasTools() {
70
+ return this.byName.size > 0;
71
+ }
72
+ /**
73
+ * Adds each plugin tool to the turn's tools. A call stages the action and
74
+ * nothing else; `closed` refuses it as the built-in writes are refused
75
+ * before a contact is a prospect (specs/034).
76
+ */
77
+ addTools(tools, stage, closed, describe) {
78
+ for (const { plugin, tool: declared } of this.byName.values()) {
79
+ const parameters = Object.entries(declared.parameters);
80
+ const notes = parameters.filter(([, parameter]) => parameter.type === 'note');
81
+ tools[declared.name] = tool({
82
+ description: describe(notes.length > 0 ? `${declared.description}\n${NOTE_RULE}` : declared.description),
83
+ inputSchema: z.object(Object.fromEntries(parameters.map(([key, parameter]) => [key, schemaOf(parameter)]))),
84
+ execute: (input) => {
85
+ if (closed())
86
+ return { staged: false, reason: 'not_prospect' };
87
+ const params = {};
88
+ const notes = {};
89
+ for (const [key, parameter] of parameters) {
90
+ const value = input[key];
91
+ if (value === undefined)
92
+ continue;
93
+ if (parameter.type !== 'note') {
94
+ params[key] = value;
95
+ continue;
96
+ }
97
+ // Cleaned when staged, as a `tools.json` note is, so the outbox
98
+ // never holds what the plugin will not be given (specs/024).
99
+ const cleaned = cleanNote(value, parameter.maxLength);
100
+ if (cleaned.length > 0)
101
+ notes[key] = cleaned;
102
+ else if (!parameter.optional)
103
+ return { staged: false };
104
+ }
105
+ return {
106
+ staged: stage.stage({
107
+ tool: 'plugin',
108
+ id: declared.name,
109
+ plugin,
110
+ params,
111
+ ...(Object.keys(notes).length > 0 ? { notes } : {}),
112
+ }),
113
+ };
114
+ },
115
+ });
116
+ }
117
+ }
118
+ /**
119
+ * Wraps the process's performer: a plugin action goes to its plugin, every
120
+ * other action where it went before.
121
+ */
122
+ performer(inner, logger) {
123
+ return {
124
+ performAction: (subscriberId, action) => action.tool === 'plugin'
125
+ ? this.perform(subscriberId, action.id, { ...action.params, ...action.notes }, logger)
126
+ : inner.performAction(subscriberId, action),
127
+ };
128
+ }
129
+ async perform(subscriberId, name, params, logger) {
130
+ // A row queued before a restart that dropped the plugin.
131
+ const entry = this.byName.get(name);
132
+ if (!entry)
133
+ throw new Error(`plugin tool ${name} is not loaded`);
134
+ const controller = new AbortController();
135
+ let timer;
136
+ const timedOut = new Promise((_resolve, reject) => {
137
+ timer = setTimeout(() => {
138
+ controller.abort();
139
+ reject(new Error(`plugin tool ${name} timed out after ${PLUGIN_PERFORM_TIMEOUT_MS} ms`));
140
+ }, PLUGIN_PERFORM_TIMEOUT_MS);
141
+ });
142
+ try {
143
+ await Promise.race([
144
+ Promise.resolve().then(() => entry.tool.perform({
145
+ subscriberId,
146
+ params,
147
+ logger: pluginLogger(logger, entry.plugin, subscriberId),
148
+ signal: controller.signal,
149
+ })),
150
+ timedOut,
151
+ ]);
152
+ }
153
+ finally {
154
+ clearTimeout(timer);
155
+ }
156
+ }
157
+ }
package/dist/server.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { AgentRunner } from './agent/runner.ts';
7
7
  import type { ContactReader, ManyChatClient } from './channels/manychat/client.ts';
8
8
  import type { ConfigStore } from './config/loader.ts';
9
9
  import { type FfmpegPaths } from './media/ffmpeg.ts';
10
+ import { Plugins } from './plugins/plugins.ts';
10
11
  export interface BuildOptions {
11
12
  env: Env;
12
13
  db: Database;
@@ -34,6 +35,8 @@ export interface BuildOptions {
34
35
  transcriptionModel?: TranscriptionModel;
35
36
  /** Injected by tests to stand in for a server without ffmpeg. */
36
37
  ffmpegPaths?: FfmpegPaths;
38
+ /** The tenant's plugins, loaded once at boot (specs/036). None when absent. */
39
+ plugins?: Plugins;
37
40
  }
38
41
  /**
39
42
  * The whole production composition. Plugins are registered before any route is
package/dist/server.js CHANGED
@@ -20,6 +20,7 @@ import { bearerToken, createSharedSecretGuard, isAuthenticated } from "./routes/
20
20
  import { escalationReply } from "./agent/guardrails.js";
21
21
  import { mediaScrubbingStream, redactText } from "./observability/redact.js";
22
22
  import { loggerOptions } from "./observability/logger.js";
23
+ import { Plugins } from "./plugins/plugins.js";
23
24
  const MESSAGE_ROUTE = '/v1/channels/manychat/message';
24
25
  /**
25
26
  * The whole production composition. Plugins are registered before any route is
@@ -52,6 +53,7 @@ export async function buildServer(opts) {
52
53
  const manychatClient = opts.manychat ?? built;
53
54
  const contacts = opts.contacts ?? built;
54
55
  const adapter = new ManyChatAdapter(manychatClient);
56
+ const plugins = opts.plugins ?? Plugins.NONE;
55
57
  const runner = opts.runner ??
56
58
  new GenerateTextRunner({
57
59
  model: resolveModel(env.AGENT_MODEL),
@@ -62,6 +64,7 @@ export async function buildServer(opts) {
62
64
  maxOutputTokens: env.AGENT_MAX_OUTPUT_TOKENS,
63
65
  temperature: env.AGENT_TEMPERATURE,
64
66
  reasoningEffort: env.AGENT_REASONING_EFFORT,
67
+ plugins,
65
68
  });
66
69
  // Resolved once, like the answering model: a TRANSCRIPTION_MODEL typo fails
67
70
  // the deploy, and a missing ffmpeg is found at boot rather than per video.
@@ -210,7 +213,8 @@ export async function buildServer(opts) {
210
213
  logger: request.log,
211
214
  tokenWriter: manychatClient,
212
215
  tokensEnforced: env.CONTACT_TOKENS_ENFORCED,
213
- actions: manychatClient,
216
+ // A plugin action goes to its plugin, the rest to ManyChat (specs/036).
217
+ actions: plugins.performer(manychatClient, request.log),
214
218
  contacts,
215
219
  media,
216
220
  lanes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "manychat-ai-agent",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "A provider-agnostic conversational AI agent gateway that bridges no-code chat platforms to any LLM.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,6 +12,10 @@
12
12
  "agent": "dist/cli.js"
13
13
  },
14
14
  "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
15
19
  "./config": {
16
20
  "types": "./dist/config/index.d.ts",
17
21
  "default": "./dist/config/index.js"