manychat-ai-agent 0.22.0 → 0.23.0
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 +13 -0
- package/dist/agent/runner.d.ts +9 -2
- package/dist/agent/runner.js +39 -39
- package/dist/channels/plugin/adapter.d.ts +47 -0
- package/dist/channels/plugin/adapter.js +108 -0
- package/dist/cli/run.js +4 -2
- package/dist/contracts/agent.d.ts +11 -0
- package/dist/contracts/agent.js +12 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/outbox/queue.d.ts +6 -0
- package/dist/outbox/queue.js +5 -0
- package/dist/outbox/worker.d.ts +9 -0
- package/dist/outbox/worker.js +29 -2
- package/dist/plugins/api.d.ts +81 -2
- package/dist/plugins/api.js +13 -1
- package/dist/plugins/loader.d.ts +7 -2
- package/dist/plugins/loader.js +55 -11
- package/dist/plugins/plugins.d.ts +11 -3
- package/dist/plugins/plugins.js +8 -2
- package/dist/routes/turn.d.ts +14 -0
- package/dist/routes/turn.js +20 -7
- package/dist/server.js +109 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.23.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.22.0...v0.23.0) (2026-10-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* mount plugin channels beside ManyChat's route (spec 038) ([366c646](https://github.com/pedronastasi/manychat-ai-agent/commit/366c646a7223d959603b0f6f5159725d368bf33c))
|
|
9
|
+
* mount plugin channels beside ManyChat's route (spec 038) ([e53d5ed](https://github.com/pedronastasi/manychat-ai-agent/commit/e53d5ed9ea667246d4788faa087226049350ab96))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Specs and Docs
|
|
13
|
+
|
|
14
|
+
* record PR 206 in spec 038 ([f61a13e](https://github.com/pedronastasi/manychat-ai-agent/commit/f61a13ea2448920db388d842da52bfb7a655a698))
|
|
15
|
+
|
|
3
16
|
## [0.22.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.21.0...v0.22.0) (2026-10-08)
|
|
4
17
|
|
|
5
18
|
|
package/dist/agent/runner.d.ts
CHANGED
|
@@ -109,6 +109,12 @@ export interface AgentTurnInput {
|
|
|
109
109
|
nudge?: {
|
|
110
110
|
since: Date;
|
|
111
111
|
} | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* False on a plugin channel's turn: `tools.json`'s tools write to ManyChat,
|
|
114
|
+
* so neither they nor their prompt lines are offered there. Plugin tools
|
|
115
|
+
* still are (specs/038).
|
|
116
|
+
*/
|
|
117
|
+
builtInTools?: boolean | undefined;
|
|
112
118
|
}
|
|
113
119
|
/**
|
|
114
120
|
* The port every caller depends on. Implemented with `generateText`: a single
|
|
@@ -150,7 +156,8 @@ export interface RunnerOptions {
|
|
|
150
156
|
}
|
|
151
157
|
export declare class GenerateTextRunner implements AgentRunner {
|
|
152
158
|
private readonly opts;
|
|
153
|
-
|
|
159
|
+
/** One per kind of turn: with `tools.json`'s tools, and without them (specs/038). */
|
|
160
|
+
private readonly cached;
|
|
154
161
|
constructor(opts: RunnerOptions);
|
|
155
162
|
/**
|
|
156
163
|
* Rebuilds only when ConfigStore swaps in a new object, so the prefix stays
|
|
@@ -159,5 +166,5 @@ export declare class GenerateTextRunner implements AgentRunner {
|
|
|
159
166
|
* An activated playbook version rebuilds it once, not on every turn.
|
|
160
167
|
*/
|
|
161
168
|
private current;
|
|
162
|
-
run({ text, history, signal, media, stage, contact, reads, pluginReads, flows, beforeFlow, nudge, }: AgentTurnInput): Promise<AgentResult>;
|
|
169
|
+
run({ text, history, signal, media, stage, contact, reads, pluginReads, flows, beforeFlow, nudge, builtInTools, }: AgentTurnInput): Promise<AgentResult>;
|
|
163
170
|
}
|
package/dist/agent/runner.js
CHANGED
|
@@ -79,7 +79,8 @@ function nudgeMessage(since, contactNotices) {
|
|
|
79
79
|
}
|
|
80
80
|
export class GenerateTextRunner {
|
|
81
81
|
opts;
|
|
82
|
-
|
|
82
|
+
/** One per kind of turn: with `tools.json`'s tools, and without them (specs/038). */
|
|
83
|
+
cached = new Map();
|
|
83
84
|
constructor(opts) {
|
|
84
85
|
this.opts = opts;
|
|
85
86
|
}
|
|
@@ -89,43 +90,46 @@ export class GenerateTextRunner {
|
|
|
89
90
|
* failed reload keeps the previous object, so it correctly rebuilds nothing.
|
|
90
91
|
* An activated playbook version rebuilds it once, not on every turn.
|
|
91
92
|
*/
|
|
92
|
-
current() {
|
|
93
|
+
current(builtInTools) {
|
|
93
94
|
const config = this.opts.config();
|
|
94
95
|
const playbook = this.opts.playbook?.current();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
96
|
+
const cached = this.cached.get(builtInTools);
|
|
97
|
+
if (cached?.config === config && cached.loadedPlaybook === playbook?.id)
|
|
98
|
+
return cached;
|
|
99
|
+
const tools = builtInTools ? (config.tools ?? NO_TOOLS) : NO_TOOLS;
|
|
100
|
+
// The staged-tool lines follow write tools only: a read is not staged
|
|
101
|
+
// (specs/039 § The prompt changes only when a plugin adds a read tool).
|
|
102
|
+
const pluginTools = this.opts.plugins?.hasWriteTools ?? false;
|
|
103
|
+
const pluginReadTools = this.opts.plugins?.hasReadTools ?? false;
|
|
104
|
+
const withTools = offersTools(tools) || pluginTools || pluginReadTools;
|
|
105
|
+
const prompt = buildSystemPrompt(config.persona, config.catalog, config.rules, tools, {
|
|
106
|
+
pluginTools,
|
|
107
|
+
pluginReadTools,
|
|
108
|
+
});
|
|
109
|
+
// After the catalog and before the cache breakpoint, so a version is
|
|
110
|
+
// cached with the rest of the prefix (specs/031). Without `learning`
|
|
111
|
+
// the prefix is what it was before that spec.
|
|
112
|
+
const rendered = config.rules.learning ? renderPlaybook(playbook) : undefined;
|
|
113
|
+
const built = {
|
|
114
|
+
config,
|
|
115
|
+
loadedPlaybook: playbook?.id,
|
|
116
|
+
playbook: rendered ? playbook : undefined,
|
|
117
|
+
withTools,
|
|
118
|
+
tools,
|
|
119
|
+
staticPrefix: prompt.staticPrefix,
|
|
120
|
+
catalogBlock: rendered ? `${prompt.catalogBlock}\n\n${rendered}` : prompt.catalogBlock,
|
|
121
|
+
};
|
|
122
|
+
this.cached.set(builtInTools, built);
|
|
123
|
+
return built;
|
|
120
124
|
}
|
|
121
|
-
async run({ text, history, signal, media, stage = new ActionStage(), contact, reads, pluginReads, flows, beforeFlow, nudge, }) {
|
|
125
|
+
async run({ text, history, signal, media, stage = new ActionStage(), contact, reads, pluginReads, flows, beforeFlow, nudge, builtInTools = true, }) {
|
|
122
126
|
const started = Date.now();
|
|
123
127
|
// Resolved once per turn: a reload landing mid-turn must not produce a
|
|
124
128
|
// reply built from one config and guarded by another.
|
|
125
|
-
const { config, playbook, staticPrefix, catalogBlock, withTools } = this.current();
|
|
129
|
+
const { config, playbook, staticPrefix, catalogBlock, withTools, tools: tenantTools, } = this.current(builtInTools);
|
|
126
130
|
const playbookVersion = playbook?.id ?? null;
|
|
127
131
|
const tools = withTools
|
|
128
|
-
? buildTools(
|
|
132
|
+
? buildTools(tenantTools, stage, contact, reads, {
|
|
129
133
|
nudgeTurn: nudge !== undefined,
|
|
130
134
|
flows,
|
|
131
135
|
beforeFlow,
|
|
@@ -135,13 +139,11 @@ export class GenerateTextRunner {
|
|
|
135
139
|
: undefined;
|
|
136
140
|
// The opening is no longer known before the loop runs: the prospect write
|
|
137
141
|
// that queues it says so, and the reply step is told below (specs/034).
|
|
138
|
-
const opening = contact?.openingDue ? openingFlow(
|
|
142
|
+
const opening = contact?.openingDue ? openingFlow(tenantTools) : undefined;
|
|
139
143
|
const contactNotices = [
|
|
140
|
-
...(intentField(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
...(funnelField(config.tools ?? NO_TOOLS) ? [funnelNotice(contact?.funnelStage)] : []),
|
|
144
|
-
...(offeringField(config.tools ?? NO_TOOLS)
|
|
144
|
+
...(intentField(tenantTools) ? [intentNotice(contact?.intent, contact?.advertOffering)] : []),
|
|
145
|
+
...(funnelField(tenantTools) ? [funnelNotice(contact?.funnelStage)] : []),
|
|
146
|
+
...(offeringField(tenantTools)
|
|
145
147
|
? [offeringNotice(contact?.offering, contact?.offeringChangedFrom)]
|
|
146
148
|
: []),
|
|
147
149
|
];
|
|
@@ -167,9 +169,7 @@ export class GenerateTextRunner {
|
|
|
167
169
|
...messages,
|
|
168
170
|
{
|
|
169
171
|
role: 'user',
|
|
170
|
-
content: stagedNotice(stage, reads?.latest, opening && stagesProspect(stage,
|
|
171
|
-
? opening
|
|
172
|
-
: undefined, pluginReads?.latest),
|
|
172
|
+
content: stagedNotice(stage, reads?.latest, opening && stagesProspect(stage, tenantTools) ? opening : undefined, pluginReads?.latest),
|
|
173
173
|
},
|
|
174
174
|
],
|
|
175
175
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type AgentReply, type InboundMessage } from '../../contracts/agent.ts';
|
|
2
|
+
import type { ChannelReply, PluginChannel } from '../../plugins/api.ts';
|
|
3
|
+
import { type HostLogger, type Plugins } from '../../plugins/plugins.ts';
|
|
4
|
+
import type { ChannelAdapter, ParseContext } from '../port.ts';
|
|
5
|
+
/**
|
|
6
|
+
* How long the outbox waits for a channel's `push`. Past it the signal aborts
|
|
7
|
+
* and the delivery is retried, as a ManyChat send that timed out is. The same
|
|
8
|
+
* bound as a plugin's `perform` (specs/036); chosen, not measured.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CHANNEL_PUSH_TIMEOUT_MS = 10000;
|
|
11
|
+
/** A request that fails the channel's own schema: refused with a 400 before any turn (specs/038). */
|
|
12
|
+
export declare class ChannelInputError extends Error {
|
|
13
|
+
constructor(channel: string);
|
|
14
|
+
}
|
|
15
|
+
/** The route a channel is mounted at, beside ManyChat's (specs/038). */
|
|
16
|
+
export declare const channelRoute: (name: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* A plugin's channel behind the `ChannelAdapter` port (specs/038, ADR-0005).
|
|
19
|
+
* The plugin translates; everything it does not translate is decided here,
|
|
20
|
+
* the same way for every plugin channel: the tenant, the channel, and that
|
|
21
|
+
* the request carries no contact token and no offering.
|
|
22
|
+
*
|
|
23
|
+
* The contact's ID is prefixed with the channel's name, so a contact on one
|
|
24
|
+
* channel never shares a conversation, an order or an outbox lane with one on
|
|
25
|
+
* another whose platform happens to use the same ID.
|
|
26
|
+
*/
|
|
27
|
+
export declare class PluginChannelAdapter implements ChannelAdapter<unknown, unknown> {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly plugin: string;
|
|
30
|
+
readonly route: string;
|
|
31
|
+
private readonly channel;
|
|
32
|
+
private readonly logger;
|
|
33
|
+
private readonly prefix;
|
|
34
|
+
constructor(plugin: string, channel: PluginChannel, logger: HostLogger);
|
|
35
|
+
/** Whether the request passes the channel's schema; checked before anything reads it (C3). */
|
|
36
|
+
accepts(raw: unknown): boolean;
|
|
37
|
+
parse(raw: unknown, ctx: ParseContext): InboundMessage;
|
|
38
|
+
render(reply: AgentReply): unknown;
|
|
39
|
+
/** Delivers a lost race's reply (specs/038 § Outbound delivery is the adapter's `push`). */
|
|
40
|
+
push(to: {
|
|
41
|
+
subscriberId: string;
|
|
42
|
+
}, reply: AgentReply): Promise<void>;
|
|
43
|
+
/** What `push` sends, from what the outbox keeps of a reply. */
|
|
44
|
+
deliver(to: string, reply: ChannelReply): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/** An adapter for each channel the plugins add, by name, logging through `logger`. */
|
|
47
|
+
export declare function channelAdapters(plugins: Plugins, logger: HostLogger): ReadonlyMap<string, PluginChannelAdapter>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { ChannelMessage } from "../../contracts/agent.js";
|
|
2
|
+
import { pluginLogger } from "../../plugins/plugins.js";
|
|
3
|
+
/**
|
|
4
|
+
* How long the outbox waits for a channel's `push`. Past it the signal aborts
|
|
5
|
+
* and the delivery is retried, as a ManyChat send that timed out is. The same
|
|
6
|
+
* bound as a plugin's `perform` (specs/036); chosen, not measured.
|
|
7
|
+
*/
|
|
8
|
+
export const CHANNEL_PUSH_TIMEOUT_MS = 10_000;
|
|
9
|
+
/** A request that fails the channel's own schema: refused with a 400 before any turn (specs/038). */
|
|
10
|
+
export class ChannelInputError extends Error {
|
|
11
|
+
constructor(channel) {
|
|
12
|
+
super(`request does not match channel ${channel}'s schema`);
|
|
13
|
+
this.name = 'ChannelInputError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The route a channel is mounted at, beside ManyChat's (specs/038). */
|
|
17
|
+
export const channelRoute = (name) => `/v1/channels/${name}/message`;
|
|
18
|
+
/**
|
|
19
|
+
* A plugin's channel behind the `ChannelAdapter` port (specs/038, ADR-0005).
|
|
20
|
+
* The plugin translates; everything it does not translate is decided here,
|
|
21
|
+
* the same way for every plugin channel: the tenant, the channel, and that
|
|
22
|
+
* the request carries no contact token and no offering.
|
|
23
|
+
*
|
|
24
|
+
* The contact's ID is prefixed with the channel's name, so a contact on one
|
|
25
|
+
* channel never shares a conversation, an order or an outbox lane with one on
|
|
26
|
+
* another whose platform happens to use the same ID.
|
|
27
|
+
*/
|
|
28
|
+
export class PluginChannelAdapter {
|
|
29
|
+
name;
|
|
30
|
+
plugin;
|
|
31
|
+
route;
|
|
32
|
+
channel;
|
|
33
|
+
logger;
|
|
34
|
+
prefix;
|
|
35
|
+
constructor(plugin, channel, logger) {
|
|
36
|
+
this.plugin = plugin;
|
|
37
|
+
this.channel = channel;
|
|
38
|
+
this.logger = logger;
|
|
39
|
+
this.name = channel.name;
|
|
40
|
+
this.route = channelRoute(channel.name);
|
|
41
|
+
this.prefix = `${channel.name}:`;
|
|
42
|
+
}
|
|
43
|
+
/** Whether the request passes the channel's schema; checked before anything reads it (C3). */
|
|
44
|
+
accepts(raw) {
|
|
45
|
+
return this.channel.inbound.safeParse(raw).success;
|
|
46
|
+
}
|
|
47
|
+
parse(raw, ctx) {
|
|
48
|
+
const checked = this.channel.inbound.safeParse(raw);
|
|
49
|
+
if (!checked.success)
|
|
50
|
+
throw new ChannelInputError(this.name);
|
|
51
|
+
// What the plugin returns is an external boundary too (C3).
|
|
52
|
+
const message = ChannelMessage.parse(this.channel.parse(checked.data));
|
|
53
|
+
return {
|
|
54
|
+
tenantId: ctx.tenantId,
|
|
55
|
+
subscriberId: `${this.prefix}${message.subscriberId}`,
|
|
56
|
+
text: message.text,
|
|
57
|
+
channel: this.name,
|
|
58
|
+
contactName: message.contactName ?? null,
|
|
59
|
+
locale: message.locale ?? null,
|
|
60
|
+
// The channel's own auth binds its contacts: there is no token to
|
|
61
|
+
// present, and none is issued (specs/038 § A plugin channel carries no
|
|
62
|
+
// contact token).
|
|
63
|
+
contactToken: null,
|
|
64
|
+
offering: null,
|
|
65
|
+
receivedAt: new Date(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
render(reply) {
|
|
69
|
+
return this.channel.render({ messages: reply.messages, escalate: reply.escalate });
|
|
70
|
+
}
|
|
71
|
+
/** Delivers a lost race's reply (specs/038 § Outbound delivery is the adapter's `push`). */
|
|
72
|
+
async push(to, reply) {
|
|
73
|
+
await this.deliver(to.subscriberId, { messages: reply.messages, escalate: reply.escalate });
|
|
74
|
+
}
|
|
75
|
+
/** What `push` sends, from what the outbox keeps of a reply. */
|
|
76
|
+
async deliver(to, reply) {
|
|
77
|
+
const subscriberId = to.startsWith(this.prefix) ? to.slice(this.prefix.length) : to;
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
let timer;
|
|
80
|
+
const timedOut = new Promise((_resolve, reject) => {
|
|
81
|
+
timer = setTimeout(() => {
|
|
82
|
+
controller.abort();
|
|
83
|
+
reject(new Error(`channel ${this.name} push timed out after ${CHANNEL_PUSH_TIMEOUT_MS} ms`));
|
|
84
|
+
}, CHANNEL_PUSH_TIMEOUT_MS);
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await Promise.race([
|
|
88
|
+
Promise.resolve().then(() => this.channel.push({
|
|
89
|
+
subscriberId,
|
|
90
|
+
reply,
|
|
91
|
+
logger: pluginLogger(this.logger, this.plugin, subscriberId),
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
})),
|
|
94
|
+
timedOut,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** An adapter for each channel the plugins add, by name, logging through `logger`. */
|
|
103
|
+
export function channelAdapters(plugins, logger) {
|
|
104
|
+
return new Map(plugins.channels.map(({ plugin, channel }) => [
|
|
105
|
+
channel.name,
|
|
106
|
+
new PluginChannelAdapter(plugin, channel, logger),
|
|
107
|
+
]));
|
|
108
|
+
}
|
package/dist/cli/run.js
CHANGED
|
@@ -112,11 +112,13 @@ export async function run(argv) {
|
|
|
112
112
|
}
|
|
113
113
|
throw error;
|
|
114
114
|
}
|
|
115
|
-
// Each plugin's read tools beside its write tools (specs/039).
|
|
115
|
+
// Each plugin's read tools beside its write tools (specs/039), and its channels.
|
|
116
116
|
const tools = (names) => (names.length > 0 ? names.join(', ') : 'none');
|
|
117
117
|
const loaded = summary.length > 0
|
|
118
118
|
? `; plugins: ${summary
|
|
119
|
-
.map(entry => `${entry.plugin} (writes: ${tools(entry.writes)}; reads: ${tools(entry.reads)}
|
|
119
|
+
.map(entry => `${entry.plugin} (writes: ${tools(entry.writes)}; reads: ${tools(entry.reads)}` +
|
|
120
|
+
// Only a plugin that adds one says so (specs/038).
|
|
121
|
+
(entry.channels.length > 0 ? `; channels: ${entry.channels.join(', ')})` : ')'))
|
|
120
122
|
.join(', ')}`
|
|
121
123
|
: '';
|
|
122
124
|
console.log(`config check passed (${configDir}${loaded})`);
|
|
@@ -114,6 +114,17 @@ export declare const InboundMessage: z.ZodObject<{
|
|
|
114
114
|
receivedAt: z.ZodDate;
|
|
115
115
|
}, z.core.$strip>;
|
|
116
116
|
export type InboundMessage = z.infer<typeof InboundMessage>;
|
|
117
|
+
/**
|
|
118
|
+
* What a plugin channel's `parse` returns, checked before the turn reads it
|
|
119
|
+
* (specs/038, C3). The bounds are ManyChat's inbound ones.
|
|
120
|
+
*/
|
|
121
|
+
export declare const ChannelMessage: z.ZodObject<{
|
|
122
|
+
subscriberId: z.ZodString;
|
|
123
|
+
text: z.ZodString;
|
|
124
|
+
contactName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
125
|
+
locale: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
126
|
+
}, z.core.$strict>;
|
|
127
|
+
export type ChannelMessage = z.infer<typeof ChannelMessage>;
|
|
117
128
|
/** Why a turn ended the way it did — recorded per turn for observability. */
|
|
118
129
|
export declare const TurnOutcome: z.ZodEnum<{
|
|
119
130
|
error: "error";
|
package/dist/contracts/agent.js
CHANGED
|
@@ -116,6 +116,18 @@ export const InboundMessage = z.object({
|
|
|
116
116
|
offering: z.string().nullish(),
|
|
117
117
|
receivedAt: z.date(),
|
|
118
118
|
});
|
|
119
|
+
/**
|
|
120
|
+
* What a plugin channel's `parse` returns, checked before the turn reads it
|
|
121
|
+
* (specs/038, C3). The bounds are ManyChat's inbound ones.
|
|
122
|
+
*/
|
|
123
|
+
export const ChannelMessage = z
|
|
124
|
+
.object({
|
|
125
|
+
subscriberId: z.string().min(1).max(256),
|
|
126
|
+
text: z.string().max(4096),
|
|
127
|
+
contactName: z.string().max(256).nullish(),
|
|
128
|
+
locale: z.string().max(64).nullish(),
|
|
129
|
+
})
|
|
130
|
+
.strict();
|
|
119
131
|
/** Why a turn ended the way it did — recorded per turn for observability. */
|
|
120
132
|
export const TurnOutcome = z.enum([
|
|
121
133
|
'answered_inline', // model won the race
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bare `manychat-ai-agent` entry point: the plugin API (specs/036, with
|
|
3
|
-
* read tools from specs/039). The agent itself is run through its CLI or
|
|
3
|
+
* read tools from specs/039 and provisional channels from specs/038). The agent itself is run through its CLI or
|
|
4
4
|
* image, never imported.
|
|
5
5
|
*/
|
|
6
|
-
export { definePlugin, defineReadTool, defineTool, PLUGIN_API_VERSION, SUPPORTED_PLUGIN_API_VERSIONS, } from './plugins/api.ts';
|
|
7
|
-
export type { ParamsOf, Plugin, PluginCall, PluginLogger, PluginParameter, PluginParameters, PluginReadTool, PluginTool, ReadParameter, ReadParameters, ResultField, ResultFields, ResultOf, } from './plugins/api.ts';
|
|
6
|
+
export { CHANNEL_API_VERSION, defineChannel, definePlugin, defineReadTool, defineTool, PLUGIN_API_VERSION, SUPPORTED_CHANNEL_API_VERSIONS, SUPPORTED_PLUGIN_API_VERSIONS, } from './plugins/api.ts';
|
|
7
|
+
export type { ChannelMessage, ChannelPush, ChannelReply, InboundSchema, ParamsOf, Plugin, PluginCall, PluginChannel, PluginLogger, PluginParameter, PluginParameters, PluginReadTool, PluginTool, ReadParameter, ReadParameters, ResultField, ResultFields, ResultOf, } from './plugins/api.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bare `manychat-ai-agent` entry point: the plugin API (specs/036, with
|
|
3
|
-
* read tools from specs/039). The agent itself is run through its CLI or
|
|
3
|
+
* read tools from specs/039 and provisional channels from specs/038). The agent itself is run through its CLI or
|
|
4
4
|
* image, never imported.
|
|
5
5
|
*/
|
|
6
|
-
export { definePlugin, defineReadTool, defineTool, PLUGIN_API_VERSION, SUPPORTED_PLUGIN_API_VERSIONS, } from "./plugins/api.js";
|
|
6
|
+
export { CHANNEL_API_VERSION, defineChannel, definePlugin, defineReadTool, defineTool, PLUGIN_API_VERSION, SUPPORTED_CHANNEL_API_VERSIONS, SUPPORTED_PLUGIN_API_VERSIONS, } from "./plugins/api.js";
|
package/dist/outbox/queue.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface ReplyPayload {
|
|
|
15
15
|
messages: string[];
|
|
16
16
|
actions?: StagedAction[];
|
|
17
17
|
turnId?: string;
|
|
18
|
+
/** The plugin channel that delivers it, through its adapter's `push`; ManyChat when absent (specs/038). */
|
|
19
|
+
channel?: string;
|
|
20
|
+
/** Set on a handoff queued for a plugin channel, which is told so (specs/038). */
|
|
21
|
+
escalate?: true;
|
|
18
22
|
}
|
|
19
23
|
/**
|
|
20
24
|
* A deferred reply, or the retry of a contact token's write (specs/019). The
|
|
@@ -45,6 +49,8 @@ export declare class OutboxQueue {
|
|
|
45
49
|
} | undefined;
|
|
46
50
|
/** Held until then: a flow sent this turn is still playing (specs/030). */
|
|
47
51
|
notBefore?: Date | undefined;
|
|
52
|
+
/** A plugin channel's name; ManyChat when absent (specs/038). */
|
|
53
|
+
channel?: string | undefined;
|
|
48
54
|
}): Promise<string>;
|
|
49
55
|
/**
|
|
50
56
|
* Whether a reply to the contact is still queued: pending, or being
|
package/dist/outbox/queue.js
CHANGED
|
@@ -8,6 +8,11 @@ export class OutboxQueue {
|
|
|
8
8
|
}
|
|
9
9
|
async enqueue(input) {
|
|
10
10
|
const payload = { messages: input.reply.messages };
|
|
11
|
+
if (input.channel !== undefined) {
|
|
12
|
+
payload.channel = input.channel;
|
|
13
|
+
if (input.reply.escalate)
|
|
14
|
+
payload.escalate = true;
|
|
15
|
+
}
|
|
11
16
|
if (input.actions && input.actions.staged.length > 0) {
|
|
12
17
|
payload.actions = [...input.actions.staged];
|
|
13
18
|
payload.turnId = input.actions.turnId;
|
package/dist/outbox/worker.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { Database } from '../db/client.ts';
|
|
2
2
|
import type { ManyChatClient } from '../channels/manychat/client.ts';
|
|
3
3
|
import type { Plugins } from '../plugins/plugins.ts';
|
|
4
|
+
/**
|
|
5
|
+
* A reply queued for a channel no plugin adds any more, because the process
|
|
6
|
+
* restarted without it. Never retried and never sent to ManyChat (specs/038).
|
|
7
|
+
*/
|
|
8
|
+
export declare class ChannelNotLoadedError extends Error {
|
|
9
|
+
constructor(channel: string);
|
|
10
|
+
}
|
|
4
11
|
export interface WorkerLogger {
|
|
5
12
|
info: (obj: object, msg: string) => void;
|
|
6
13
|
warn: (obj: object, msg: string) => void;
|
|
@@ -30,6 +37,8 @@ export declare class OutboxWorker {
|
|
|
30
37
|
private readonly store;
|
|
31
38
|
private readonly nudges;
|
|
32
39
|
private readonly actions;
|
|
40
|
+
/** The plugins' channels, which deliver their own replies (specs/038). */
|
|
41
|
+
private readonly channels;
|
|
33
42
|
private running;
|
|
34
43
|
/**
|
|
35
44
|
* Set by the stop function. A contact's rows after the one being sent go
|
package/dist/outbox/worker.js
CHANGED
|
@@ -6,6 +6,17 @@ import { performActions, performedOffering } from "../conversation/actions.js";
|
|
|
6
6
|
import { recordOf } from "../agent/tools.js";
|
|
7
7
|
import { NudgeStore } from "../nudge/store.js";
|
|
8
8
|
import { NudgingPerformer } from "../nudge/performer.js";
|
|
9
|
+
import { channelAdapters } from "../channels/plugin/adapter.js";
|
|
10
|
+
/**
|
|
11
|
+
* A reply queued for a channel no plugin adds any more, because the process
|
|
12
|
+
* restarted without it. Never retried and never sent to ManyChat (specs/038).
|
|
13
|
+
*/
|
|
14
|
+
export class ChannelNotLoadedError extends Error {
|
|
15
|
+
constructor(channel) {
|
|
16
|
+
super(`channel ${channel} is not loaded`);
|
|
17
|
+
this.name = 'ChannelNotLoadedError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
9
20
|
export class OutboxWorker {
|
|
10
21
|
opts;
|
|
11
22
|
queue;
|
|
@@ -13,6 +24,8 @@ export class OutboxWorker {
|
|
|
13
24
|
store;
|
|
14
25
|
nudges;
|
|
15
26
|
actions;
|
|
27
|
+
/** The plugins' channels, which deliver their own replies (specs/038). */
|
|
28
|
+
channels;
|
|
16
29
|
running = false;
|
|
17
30
|
/**
|
|
18
31
|
* Set by the stop function. A contact's rows after the one being sent go
|
|
@@ -28,6 +41,7 @@ export class OutboxWorker {
|
|
|
28
41
|
this.store = new ConversationStore(opts.db);
|
|
29
42
|
this.nudges = new NudgeStore(opts.db);
|
|
30
43
|
this.actions = opts.plugins ? opts.plugins.performer(opts.client, opts.logger) : opts.client;
|
|
44
|
+
this.channels = opts.plugins ? channelAdapters(opts.plugins, opts.logger) : new Map();
|
|
31
45
|
}
|
|
32
46
|
/**
|
|
33
47
|
* The actions deferred with a reply, performed once, after its first
|
|
@@ -69,7 +83,17 @@ export class OutboxWorker {
|
|
|
69
83
|
}
|
|
70
84
|
async deliver(row) {
|
|
71
85
|
if (row.kind === 'reply') {
|
|
72
|
-
|
|
86
|
+
const { channel, messages, escalate } = row.payload;
|
|
87
|
+
if (channel === undefined) {
|
|
88
|
+
await this.opts.client.sendText(row.subscriberId, messages);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// A plugin channel's reply goes out through its own adapter, never
|
|
92
|
+
// through ManyChat (specs/038).
|
|
93
|
+
const adapter = this.channels.get(channel);
|
|
94
|
+
if (!adapter)
|
|
95
|
+
throw new ChannelNotLoadedError(channel);
|
|
96
|
+
await adapter.deliver(row.subscriberId, { messages, escalate: escalate === true });
|
|
73
97
|
return;
|
|
74
98
|
}
|
|
75
99
|
// The token that failed to land was never stored, so a fresh one replaces
|
|
@@ -140,7 +164,10 @@ export class OutboxWorker {
|
|
|
140
164
|
// ManyChat's own verdict when it is ManyChat's error, and a retry
|
|
141
165
|
// otherwise: an unknown failure, such as the database, must not
|
|
142
166
|
// discard a reply (specs/022 § Retries follow the SDK's retryable).
|
|
143
|
-
const retryable = error instanceof ManyChatError
|
|
167
|
+
const retryable = error instanceof ManyChatError
|
|
168
|
+
? error.retryable
|
|
169
|
+
: // A channel that is gone will not come back by retrying (specs/038).
|
|
170
|
+
!(error instanceof ChannelNotLoadedError);
|
|
144
171
|
// A token write's error is kept to its status: ManyChat's answer to it
|
|
145
172
|
// could quote the value it was sent (specs/019).
|
|
146
173
|
const message = row.kind === 'contact_token'
|
package/dist/plugins/api.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The plugin API: what a tenant project's plugin package exports, imported
|
|
3
3
|
* from the bare `manychat-ai-agent` entry point (specs/036).
|
|
4
4
|
*
|
|
5
|
-
* A plugin adds tools. A write tool is staged when the model calls it and
|
|
5
|
+
* A plugin adds tools, and channels (specs/038). A write tool is staged when the model calls it and
|
|
6
6
|
* performed after the reply, exactly as the built-in tools of specs/012 are.
|
|
7
7
|
* A read tool is performed when called, as `get_contact` is, and the model is
|
|
8
8
|
* shown only the result it declares (specs/039). Neither gets more power than
|
|
@@ -186,11 +186,88 @@ export interface PluginReadTool<S extends ReadParameters = ReadParameters, R ext
|
|
|
186
186
|
*/
|
|
187
187
|
read(call: PluginCall<ParamsOf<S>>): Promise<ResultOf<R>> | ResultOf<R>;
|
|
188
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* The newest channel API this agent implements (specs/038). Provisional: it
|
|
191
|
+
* may break in any minor release until a second adapter has been built
|
|
192
|
+
* against it, which is why it is `0` and versioned apart from the tool API.
|
|
193
|
+
*/
|
|
194
|
+
export declare const CHANNEL_API_VERSION = 0;
|
|
195
|
+
/** Every channel `apiVersion` this agent mounts. A channel naming another is refused at startup. */
|
|
196
|
+
export declare const SUPPORTED_CHANNEL_API_VERSIONS: readonly number[];
|
|
197
|
+
/**
|
|
198
|
+
* The schema a channel validates its inbound request with (C3): a Zod
|
|
199
|
+
* schema, or anything with the same `safeParse`.
|
|
200
|
+
*/
|
|
201
|
+
export interface InboundSchema<T> {
|
|
202
|
+
safeParse(raw: unknown): {
|
|
203
|
+
success: true;
|
|
204
|
+
data: T;
|
|
205
|
+
} | {
|
|
206
|
+
success: false;
|
|
207
|
+
error: unknown;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* What a channel's `parse` translates its request into. The agent fills in the
|
|
212
|
+
* rest: the tenant, the channel, and that the request carries no contact token
|
|
213
|
+
* and no offering (specs/038 § The adapter translates, and decides nothing).
|
|
214
|
+
*/
|
|
215
|
+
export interface ChannelMessage {
|
|
216
|
+
/** The contact's ID on the platform. The agent prefixes it with the channel's name. */
|
|
217
|
+
subscriberId: string;
|
|
218
|
+
text: string;
|
|
219
|
+
contactName?: string | null | undefined;
|
|
220
|
+
locale?: string | null | undefined;
|
|
221
|
+
}
|
|
222
|
+
/** What a channel renders or pushes: the reply's messages, and whether it hands off to a person. */
|
|
223
|
+
export interface ChannelReply {
|
|
224
|
+
/**
|
|
225
|
+
* Empty when rendered for a response that says nothing yet: the contact's
|
|
226
|
+
* previous reply is still queued, and this one follows through `push`
|
|
227
|
+
* (specs/037).
|
|
228
|
+
*/
|
|
229
|
+
messages: readonly string[];
|
|
230
|
+
escalate: boolean;
|
|
231
|
+
}
|
|
232
|
+
/** Everything `push` is given: the contact's platform ID, the reply, the logger and a signal. */
|
|
233
|
+
export interface ChannelPush {
|
|
234
|
+
/** The ID `parse` returned, without the channel's prefix. */
|
|
235
|
+
subscriberId: string;
|
|
236
|
+
reply: ChannelReply;
|
|
237
|
+
logger: PluginLogger;
|
|
238
|
+
signal: AbortSignal;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A channel other than ManyChat (specs/038): the `ChannelAdapter` port, as a
|
|
242
|
+
* plugin implements it. Mounted at `/v1/channels/<name>/message` behind the
|
|
243
|
+
* same auth, budget, race, order and outbox as ManyChat's route.
|
|
244
|
+
*
|
|
245
|
+
* Provisional: see `CHANNEL_API_VERSION`.
|
|
246
|
+
*/
|
|
247
|
+
export interface PluginChannel<TInbound = unknown, TOutbound = unknown> {
|
|
248
|
+
/** Lowercase, digits and dashes; the route's path segment. Not `manychat`. */
|
|
249
|
+
name: string;
|
|
250
|
+
/** The channel API it was written against: `0`, provisional. */
|
|
251
|
+
apiVersion: number;
|
|
252
|
+
/** Validates the request before anything reads it; one that fails is refused with a 400. */
|
|
253
|
+
inbound: InboundSchema<TInbound>;
|
|
254
|
+
/** Translates the validated request. Throwing hands the contact to a person. */
|
|
255
|
+
parse(request: TInbound): ChannelMessage;
|
|
256
|
+
/** Renders the reply as the platform's response body. */
|
|
257
|
+
render(reply: ChannelReply): TOutbound;
|
|
258
|
+
/**
|
|
259
|
+
* Delivers a reply outside the request, for a lost race. Throwing retries
|
|
260
|
+
* it, as the outbox retries ManyChat; past 10 seconds it counts as failed.
|
|
261
|
+
*/
|
|
262
|
+
push(call: ChannelPush): Promise<void> | void;
|
|
263
|
+
}
|
|
189
264
|
export interface Plugin {
|
|
190
265
|
name: string;
|
|
191
|
-
/** The plugin API
|
|
266
|
+
/** The plugin API its tools were written against; a read tool needs `2`. */
|
|
192
267
|
apiVersion: number;
|
|
193
268
|
tools?: readonly (PluginTool<any> | PluginReadTool<any, any>)[];
|
|
269
|
+
/** Channels, each with its own provisional `apiVersion` (specs/038). */
|
|
270
|
+
channels?: readonly PluginChannel<any, any>[];
|
|
194
271
|
}
|
|
195
272
|
/**
|
|
196
273
|
* A plugin package's default export. Returns the plugin unchanged: the agent
|
|
@@ -199,6 +276,8 @@ export interface Plugin {
|
|
|
199
276
|
export declare function definePlugin<const P extends Plugin>(plugin: P): P;
|
|
200
277
|
/** Types a tool's `perform` from its parameters. Returns the tool unchanged. */
|
|
201
278
|
export declare function defineTool<const S extends PluginParameters>(tool: PluginTool<S>): PluginTool<S>;
|
|
279
|
+
/** Types a channel's `parse` from its inbound schema. Returns the channel unchanged. */
|
|
280
|
+
export declare function defineChannel<TInbound, TOutbound>(channel: PluginChannel<TInbound, TOutbound>): PluginChannel<TInbound, TOutbound>;
|
|
202
281
|
/** Types a read tool's `read` from its parameters and result. Returns the tool unchanged. */
|
|
203
282
|
export declare function defineReadTool<const S extends ReadParameters, const R extends ResultFields>(tool: PluginReadTool<S, R>): PluginReadTool<S, R>;
|
|
204
283
|
export {};
|
package/dist/plugins/api.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The plugin API: what a tenant project's plugin package exports, imported
|
|
3
3
|
* from the bare `manychat-ai-agent` entry point (specs/036).
|
|
4
4
|
*
|
|
5
|
-
* A plugin adds tools. A write tool is staged when the model calls it and
|
|
5
|
+
* A plugin adds tools, and channels (specs/038). A write tool is staged when the model calls it and
|
|
6
6
|
* performed after the reply, exactly as the built-in tools of specs/012 are.
|
|
7
7
|
* A read tool is performed when called, as `get_contact` is, and the model is
|
|
8
8
|
* shown only the result it declares (specs/039). Neither gets more power than
|
|
@@ -20,6 +20,14 @@ export const MAX_RESULT_TEXT_LENGTH = 1000;
|
|
|
20
20
|
export const MAX_RESULT_ITEMS = 5;
|
|
21
21
|
/** The longest a read result may be once serialised. Chosen, not measured. */
|
|
22
22
|
export const MAX_RESULT_LENGTH = 2000;
|
|
23
|
+
/**
|
|
24
|
+
* The newest channel API this agent implements (specs/038). Provisional: it
|
|
25
|
+
* may break in any minor release until a second adapter has been built
|
|
26
|
+
* against it, which is why it is `0` and versioned apart from the tool API.
|
|
27
|
+
*/
|
|
28
|
+
export const CHANNEL_API_VERSION = 0;
|
|
29
|
+
/** Every channel `apiVersion` this agent mounts. A channel naming another is refused at startup. */
|
|
30
|
+
export const SUPPORTED_CHANNEL_API_VERSIONS = [0];
|
|
23
31
|
/**
|
|
24
32
|
* A plugin package's default export. Returns the plugin unchanged: the agent
|
|
25
33
|
* checks it when it loads, so a plain object passes or fails the same way.
|
|
@@ -31,6 +39,10 @@ export function definePlugin(plugin) {
|
|
|
31
39
|
export function defineTool(tool) {
|
|
32
40
|
return tool;
|
|
33
41
|
}
|
|
42
|
+
/** Types a channel's `parse` from its inbound schema. Returns the channel unchanged. */
|
|
43
|
+
export function defineChannel(channel) {
|
|
44
|
+
return channel;
|
|
45
|
+
}
|
|
34
46
|
/** Types a read tool's `read` from its parameters and result. Returns the tool unchanged. */
|
|
35
47
|
export function defineReadTool(tool) {
|
|
36
48
|
return tool;
|
package/dist/plugins/loader.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConfigError } from '../config/loader.ts';
|
|
2
|
-
import type { PluginReadTool, PluginTool } from './api.ts';
|
|
2
|
+
import type { PluginChannel, PluginReadTool, PluginTool } from './api.ts';
|
|
3
3
|
import { Plugins } from './plugins.ts';
|
|
4
4
|
/** The optional tenant file that lists the plugin packages to load (specs/036). */
|
|
5
5
|
export declare const PLUGINS_FILE = "plugins.json";
|
|
@@ -7,6 +7,10 @@ export declare const PLUGINS_FILE = "plugins.json";
|
|
|
7
7
|
export declare const BUILT_IN_TOOLS: ReadonlySet<string>;
|
|
8
8
|
/** A tool name the loader accepts: lowercase snake case, at most 64 characters. */
|
|
9
9
|
export declare const TOOL_NAME: RegExp;
|
|
10
|
+
/** A channel name the loader accepts: the path segment of its route (specs/038). */
|
|
11
|
+
export declare const CHANNEL_NAME: RegExp;
|
|
12
|
+
/** Channels the agent mounts itself. */
|
|
13
|
+
export declare const BUILT_IN_CHANNELS: ReadonlySet<string>;
|
|
10
14
|
/** A plugin that cannot be loaded. A `ConfigError`, so every command reports it as one. */
|
|
11
15
|
export declare class PluginError extends ConfigError {
|
|
12
16
|
constructor(message: string);
|
|
@@ -21,9 +25,10 @@ type CheckedTool = PluginTool | PluginReadTool;
|
|
|
21
25
|
* promising an action nothing performs fails here, at startup (C6).
|
|
22
26
|
* `agent plugin new` runs it on what it generates (specs/041).
|
|
23
27
|
*/
|
|
24
|
-
export declare function checkPlugin(spec: string, value: unknown, taken: Map<string, string>): {
|
|
28
|
+
export declare function checkPlugin(spec: string, value: unknown, taken: Map<string, string>, channelsTaken?: Map<string, string>): {
|
|
25
29
|
name: string;
|
|
26
30
|
tools: CheckedTool[];
|
|
31
|
+
channels: PluginChannel<unknown, unknown>[];
|
|
27
32
|
};
|
|
28
33
|
/** The packages `plugins.json` lists, or none when the file is absent. */
|
|
29
34
|
export declare function listedPlugins(configDir: string): string[];
|
package/dist/plugins/loader.js
CHANGED
|
@@ -13,7 +13,7 @@ import { z } from 'zod';
|
|
|
13
13
|
import { ConfigError } from "../config/loader.js";
|
|
14
14
|
import { MAX_NOTE_LENGTH } from "../contracts/config.js";
|
|
15
15
|
import { ToolName } from "../contracts/agent.js";
|
|
16
|
-
import { MAX_QUERY_LENGTH, MAX_RESULT_ITEMS, MAX_RESULT_TEXT_LENGTH, SUPPORTED_PLUGIN_API_VERSIONS, } from "./api.js";
|
|
16
|
+
import { MAX_QUERY_LENGTH, SUPPORTED_CHANNEL_API_VERSIONS, MAX_RESULT_ITEMS, MAX_RESULT_TEXT_LENGTH, SUPPORTED_PLUGIN_API_VERSIONS, } from "./api.js";
|
|
17
17
|
import { Plugins } from "./plugins.js";
|
|
18
18
|
/** The optional tenant file that lists the plugin packages to load (specs/036). */
|
|
19
19
|
export const PLUGINS_FILE = 'plugins.json';
|
|
@@ -25,7 +25,12 @@ export const BUILT_IN_TOOLS = new Set([
|
|
|
25
25
|
]);
|
|
26
26
|
/** A tool name the loader accepts: lowercase snake case, at most 64 characters. */
|
|
27
27
|
export const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
28
|
-
|
|
28
|
+
/** A channel name the loader accepts: the path segment of its route (specs/038). */
|
|
29
|
+
export const CHANNEL_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
30
|
+
/** Channels the agent mounts itself. */
|
|
31
|
+
export const BUILT_IN_CHANNELS = new Set(['manychat']);
|
|
32
|
+
const PLUGIN_KEYS = new Set(['name', 'apiVersion', 'tools', 'channels']);
|
|
33
|
+
const CHANNEL_KEYS = new Set(['name', 'apiVersion', 'inbound', 'parse', 'render', 'push']);
|
|
29
34
|
const TOOL_KEYS = new Set(['name', 'description', 'parameters', 'perform', 'read', 'result']);
|
|
30
35
|
const ENUM_KEYS = new Set(['type', 'values', 'description', 'optional']);
|
|
31
36
|
const NUMBER_KEYS = new Set(['type', 'integer', 'min', 'max', 'description', 'optional']);
|
|
@@ -270,12 +275,49 @@ function checkTool(plugin, apiVersion, value, taken) {
|
|
|
270
275
|
taken.set(name, plugin);
|
|
271
276
|
return value;
|
|
272
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Checks one channel (specs/038). Its `apiVersion` is its own, provisional,
|
|
280
|
+
* and apart from the plugin's tool API.
|
|
281
|
+
*/
|
|
282
|
+
function checkChannel(plugin, value, taken) {
|
|
283
|
+
if (!isObject(value))
|
|
284
|
+
throw new PluginError(`plugin ${plugin}: a channel is not an object`);
|
|
285
|
+
const name = value.name;
|
|
286
|
+
if (typeof name !== 'string' || !CHANNEL_NAME.test(name)) {
|
|
287
|
+
throw new PluginError(`plugin ${plugin}: channel name ${JSON.stringify(name)} must be lowercase letters, digits and dashes`);
|
|
288
|
+
}
|
|
289
|
+
const where = `plugin ${plugin}, channel ${name}`;
|
|
290
|
+
if (BUILT_IN_CHANNELS.has(name))
|
|
291
|
+
throw new PluginError(`${where}: takes a built-in channel's name`);
|
|
292
|
+
const owner = taken.get(name);
|
|
293
|
+
if (owner !== undefined)
|
|
294
|
+
throw new PluginError(`${where}: plugin ${owner} already defines it`);
|
|
295
|
+
const extra = unknownKeys(value, CHANNEL_KEYS);
|
|
296
|
+
if (extra.length > 0)
|
|
297
|
+
throw new PluginError(`${where}: unknown keys ${extra.join(', ')}`);
|
|
298
|
+
const apiVersion = value.apiVersion;
|
|
299
|
+
if (typeof apiVersion !== 'number' || !SUPPORTED_CHANNEL_API_VERSIONS.includes(apiVersion)) {
|
|
300
|
+
throw new PluginError(`${where}: apiVersion ${JSON.stringify(apiVersion)} is not supported; ` +
|
|
301
|
+
`this agent supports channel API ${SUPPORTED_CHANNEL_API_VERSIONS.join(' and ')} (specs/038)`);
|
|
302
|
+
}
|
|
303
|
+
// The schema the request is validated with before anything reads it (C3).
|
|
304
|
+
const inbound = value.inbound;
|
|
305
|
+
if (!isObject(inbound) || typeof inbound.safeParse !== 'function') {
|
|
306
|
+
throw new PluginError(`${where}: inbound must be a Zod schema`);
|
|
307
|
+
}
|
|
308
|
+
for (const key of ['parse', 'render', 'push']) {
|
|
309
|
+
if (typeof value[key] !== 'function')
|
|
310
|
+
throw new PluginError(`${where}: ${key} must be a function`);
|
|
311
|
+
}
|
|
312
|
+
taken.set(name, plugin);
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
273
315
|
/**
|
|
274
316
|
* Checks a plugin's default export. Everything that would leave the prompt
|
|
275
317
|
* promising an action nothing performs fails here, at startup (C6).
|
|
276
318
|
* `agent plugin new` runs it on what it generates (specs/041).
|
|
277
319
|
*/
|
|
278
|
-
export function checkPlugin(spec, value, taken) {
|
|
320
|
+
export function checkPlugin(spec, value, taken, channelsTaken = new Map()) {
|
|
279
321
|
if (!isObject(value)) {
|
|
280
322
|
throw new PluginError(`plugin ${spec}: its default export is not a plugin (use definePlugin)`);
|
|
281
323
|
}
|
|
@@ -288,19 +330,18 @@ export function checkPlugin(spec, value, taken) {
|
|
|
288
330
|
throw new PluginError(`plugin ${name}: apiVersion ${JSON.stringify(apiVersion)} is not supported; ` +
|
|
289
331
|
`this agent supports ${SUPPORTED_PLUGIN_API_VERSIONS.join(' and ')}`);
|
|
290
332
|
}
|
|
291
|
-
// Refused rather than ignored: a channel that silently never mounts is a
|
|
292
|
-
// contact nobody answers (specs/038).
|
|
293
|
-
if ('channels' in value) {
|
|
294
|
-
throw new PluginError(`plugin ${name}: channels are not supported yet (specs/038)`);
|
|
295
|
-
}
|
|
296
333
|
const extra = unknownKeys(value, PLUGIN_KEYS);
|
|
297
334
|
if (extra.length > 0)
|
|
298
335
|
throw new PluginError(`plugin ${name}: unknown keys ${extra.join(', ')}`);
|
|
299
336
|
if (value.tools !== undefined && !Array.isArray(value.tools)) {
|
|
300
337
|
throw new PluginError(`plugin ${name}: tools must be an array`);
|
|
301
338
|
}
|
|
339
|
+
if (value.channels !== undefined && !Array.isArray(value.channels)) {
|
|
340
|
+
throw new PluginError(`plugin ${name}: channels must be an array`);
|
|
341
|
+
}
|
|
302
342
|
const tools = (value.tools ?? []).map(tool => checkTool(name, apiVersion, tool, taken));
|
|
303
|
-
|
|
343
|
+
const channels = (value.channels ?? []).map(channel => checkChannel(name, channel, channelsTaken));
|
|
344
|
+
return { name, tools, channels };
|
|
304
345
|
}
|
|
305
346
|
/** The packages `plugins.json` lists, or none when the file is absent. */
|
|
306
347
|
export function listedPlugins(configDir) {
|
|
@@ -338,8 +379,10 @@ export async function loadPlugins(configDir, options = {}) {
|
|
|
338
379
|
return Plugins.NONE;
|
|
339
380
|
const from = options.from ?? dirname(resolve(configDir));
|
|
340
381
|
const taken = new Map();
|
|
382
|
+
const channelsTaken = new Map();
|
|
341
383
|
const names = new Set();
|
|
342
384
|
const loaded = [];
|
|
385
|
+
const channels = [];
|
|
343
386
|
for (const spec of packages) {
|
|
344
387
|
if (spec.startsWith('.') || spec.startsWith('/') || spec.includes('\\')) {
|
|
345
388
|
throw new PluginError(`plugin ${spec}: list a package name, not a path`);
|
|
@@ -355,12 +398,13 @@ export async function loadPlugins(configDir, options = {}) {
|
|
|
355
398
|
catch (error) {
|
|
356
399
|
throw new PluginError(`plugin ${spec} failed to load: ${error.message}`);
|
|
357
400
|
}
|
|
358
|
-
const plugin = checkPlugin(spec, module.default, taken);
|
|
401
|
+
const plugin = checkPlugin(spec, module.default, taken, channelsTaken);
|
|
359
402
|
if (names.has(plugin.name)) {
|
|
360
403
|
throw new PluginError(`plugin ${spec}: another plugin is already named ${plugin.name}`);
|
|
361
404
|
}
|
|
362
405
|
names.add(plugin.name);
|
|
363
406
|
loaded.push(...plugin.tools.map(tool => ({ plugin: plugin.name, tool })));
|
|
407
|
+
channels.push(...plugin.channels.map(channel => ({ plugin: plugin.name, channel })));
|
|
364
408
|
}
|
|
365
|
-
return new Plugins(loaded, [...names]);
|
|
409
|
+
return new Plugins(loaded, [...names], channels);
|
|
366
410
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ToolSet } from 'ai';
|
|
2
2
|
import type { ActionPerformer } from '../channels/manychat/client.ts';
|
|
3
3
|
import type { ActionStage } from '../agent/tools.ts';
|
|
4
|
-
import type { PluginLogger, PluginReadTool, PluginTool } from './api.ts';
|
|
4
|
+
import type { PluginChannel, PluginLogger, PluginReadTool, PluginTool } from './api.ts';
|
|
5
5
|
import { type PluginReads } from './reads.ts';
|
|
6
6
|
/**
|
|
7
7
|
* How long the agent waits for a plugin's `perform`. Past it the signal
|
|
@@ -24,6 +24,11 @@ export interface LoadedTool {
|
|
|
24
24
|
/** A write tool, which declares `perform`, or a read tool, which declares `read` (specs/039). */
|
|
25
25
|
tool: PluginTool | PluginReadTool;
|
|
26
26
|
}
|
|
27
|
+
export interface LoadedChannel {
|
|
28
|
+
/** The plugin that declared it. */
|
|
29
|
+
plugin: string;
|
|
30
|
+
channel: PluginChannel;
|
|
31
|
+
}
|
|
27
32
|
/**
|
|
28
33
|
* A logger that redacts what a plugin writes and names the plugin on every
|
|
29
34
|
* line: the agent's logger, never one the plugin brings (C5).
|
|
@@ -40,18 +45,21 @@ export declare class Plugins {
|
|
|
40
45
|
private readonly readsByName;
|
|
41
46
|
/** The loaded plugins' names, in the order `plugins.json` lists them. */
|
|
42
47
|
readonly names: readonly string[];
|
|
43
|
-
|
|
48
|
+
/** The channels the plugins add, each mounted beside ManyChat's (specs/038). */
|
|
49
|
+
readonly channels: readonly LoadedChannel[];
|
|
50
|
+
constructor(tools: readonly LoadedTool[], names?: readonly string[], channels?: readonly LoadedChannel[]);
|
|
44
51
|
/** Whether any plugin adds a tool, of either kind. */
|
|
45
52
|
get hasTools(): boolean;
|
|
46
53
|
/** Whether any plugin adds a write tool: the prompt's staged-tool lines follow this (specs/039). */
|
|
47
54
|
get hasWriteTools(): boolean;
|
|
48
55
|
/** Whether any plugin adds a read tool: the prompt's read lines follow this (specs/039). */
|
|
49
56
|
get hasReadTools(): boolean;
|
|
50
|
-
/** Each loaded plugin's tools by kind, as `agent config check` reports them. */
|
|
57
|
+
/** Each loaded plugin's tools by kind and its channels, as `agent config check` reports them. */
|
|
51
58
|
summary(): {
|
|
52
59
|
plugin: string;
|
|
53
60
|
writes: string[];
|
|
54
61
|
reads: string[];
|
|
62
|
+
channels: string[];
|
|
55
63
|
}[];
|
|
56
64
|
/**
|
|
57
65
|
* Adds each read tool to the turn's tools (specs/039). A call is performed
|
package/dist/plugins/plugins.js
CHANGED
|
@@ -69,7 +69,9 @@ export class Plugins {
|
|
|
69
69
|
readsByName;
|
|
70
70
|
/** The loaded plugins' names, in the order `plugins.json` lists them. */
|
|
71
71
|
names;
|
|
72
|
-
|
|
72
|
+
/** The channels the plugins add, each mounted beside ManyChat's (specs/038). */
|
|
73
|
+
channels;
|
|
74
|
+
constructor(tools, names = [], channels = []) {
|
|
73
75
|
const writes = new Map();
|
|
74
76
|
const reads = new Map();
|
|
75
77
|
for (const { plugin, tool } of tools) {
|
|
@@ -81,6 +83,7 @@ export class Plugins {
|
|
|
81
83
|
this.byName = writes;
|
|
82
84
|
this.readsByName = reads;
|
|
83
85
|
this.names = names;
|
|
86
|
+
this.channels = channels;
|
|
84
87
|
}
|
|
85
88
|
/** Whether any plugin adds a tool, of either kind. */
|
|
86
89
|
get hasTools() {
|
|
@@ -94,9 +97,12 @@ export class Plugins {
|
|
|
94
97
|
get hasReadTools() {
|
|
95
98
|
return this.readsByName.size > 0;
|
|
96
99
|
}
|
|
97
|
-
/** Each loaded plugin's tools by kind, as `agent config check` reports them. */
|
|
100
|
+
/** Each loaded plugin's tools by kind and its channels, as `agent config check` reports them. */
|
|
98
101
|
summary() {
|
|
99
102
|
return this.names.map(plugin => ({
|
|
103
|
+
channels: this.channels
|
|
104
|
+
.filter(entry => entry.plugin === plugin)
|
|
105
|
+
.map(entry => entry.channel.name),
|
|
100
106
|
plugin,
|
|
101
107
|
writes: [...this.byName.values()]
|
|
102
108
|
.filter(entry => entry.plugin === plugin)
|
package/dist/routes/turn.d.ts
CHANGED
|
@@ -38,6 +38,13 @@ export interface TurnDeps {
|
|
|
38
38
|
* request; without it, turns run side by side.
|
|
39
39
|
*/
|
|
40
40
|
lanes?: TurnLanes | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The plugin channel the turn arrived on (specs/038); ManyChat when absent.
|
|
43
|
+
* Its contacts are bound by the channel's own auth and carry no token, its
|
|
44
|
+
* turns are offered no `tools.json` tool, and its queued replies go out
|
|
45
|
+
* through its adapter.
|
|
46
|
+
*/
|
|
47
|
+
channel?: string | undefined;
|
|
41
48
|
}
|
|
42
49
|
export interface TurnResult {
|
|
43
50
|
reply: AgentReply;
|
|
@@ -72,6 +79,13 @@ export declare class TurnHandler {
|
|
|
72
79
|
private readonly tokens;
|
|
73
80
|
private readonly nudges;
|
|
74
81
|
constructor(deps: TurnDeps);
|
|
82
|
+
/** Queues a reply for the turn's channel: ManyChat, or a plugin's (specs/038). */
|
|
83
|
+
private enqueue;
|
|
84
|
+
/**
|
|
85
|
+
* A plugin channel's contact is bound by the channel's own auth: it has no
|
|
86
|
+
* token to present, and none is issued (specs/038).
|
|
87
|
+
*/
|
|
88
|
+
private bindingOf;
|
|
75
89
|
/**
|
|
76
90
|
* Runs the turn once the contact's previous one has settled (specs/037 §
|
|
77
91
|
* Turns that enter history run one at a time). A turn still waiting at its
|
package/dist/routes/turn.js
CHANGED
|
@@ -89,6 +89,17 @@ export class TurnHandler {
|
|
|
89
89
|
this.tokens = new ContactTokens(deps.db, deps.tokenWriter);
|
|
90
90
|
this.nudges = new NudgeStore(deps.db);
|
|
91
91
|
}
|
|
92
|
+
/** Queues a reply for the turn's channel: ManyChat, or a plugin's (specs/038). */
|
|
93
|
+
enqueue(input) {
|
|
94
|
+
return this.queue.enqueue({ ...input, channel: this.deps.channel });
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A plugin channel's contact is bound by the channel's own auth: it has no
|
|
98
|
+
* token to present, and none is issued (specs/038).
|
|
99
|
+
*/
|
|
100
|
+
bindingOf(known, inbound) {
|
|
101
|
+
return this.deps.channel !== undefined ? 'bound' : bindingFor(known, inbound.contactToken);
|
|
102
|
+
}
|
|
92
103
|
/**
|
|
93
104
|
* Runs the turn once the contact's previous one has settled (specs/037 §
|
|
94
105
|
* Turns that enter history run one at a time). A turn still waiting at its
|
|
@@ -113,7 +124,7 @@ export class TurnHandler {
|
|
|
113
124
|
slot.leave();
|
|
114
125
|
throw error;
|
|
115
126
|
});
|
|
116
|
-
const binding =
|
|
127
|
+
const binding = this.bindingOf(known, inbound);
|
|
117
128
|
if (binding === 'unbound' && tokensEnforced) {
|
|
118
129
|
slot.leave();
|
|
119
130
|
return this.run(inbound, { deadlineAt });
|
|
@@ -186,7 +197,7 @@ export class TurnHandler {
|
|
|
186
197
|
let conversation;
|
|
187
198
|
try {
|
|
188
199
|
conversation = await this.store.find(inbound.tenantId, inbound.subscriberId);
|
|
189
|
-
await this.
|
|
200
|
+
await this.enqueue({
|
|
190
201
|
tenantId: inbound.tenantId,
|
|
191
202
|
subscriberId: inbound.subscriberId,
|
|
192
203
|
conversationId: conversation?.id ?? null,
|
|
@@ -214,7 +225,7 @@ export class TurnHandler {
|
|
|
214
225
|
// (specs/019). Without it the request is answered from its own message
|
|
215
226
|
// alone, and neither reads nor extends the contact's history.
|
|
216
227
|
const known = await this.store.find(inbound.tenantId, inbound.subscriberId);
|
|
217
|
-
const binding =
|
|
228
|
+
const binding = this.bindingOf(known, inbound);
|
|
218
229
|
// During the rollout a request without the token is treated as it was
|
|
219
230
|
// before specs/019, so nobody loses context before their token lands.
|
|
220
231
|
const bound = binding !== 'unbound' || !tokensEnforced;
|
|
@@ -249,7 +260,7 @@ export class TurnHandler {
|
|
|
249
260
|
if (!(await mustQueue()))
|
|
250
261
|
return result;
|
|
251
262
|
try {
|
|
252
|
-
await this.
|
|
263
|
+
await this.enqueue({
|
|
253
264
|
tenantId: inbound.tenantId,
|
|
254
265
|
subscriberId: inbound.subscriberId,
|
|
255
266
|
conversationId: conversation.id,
|
|
@@ -502,6 +513,7 @@ export class TurnHandler {
|
|
|
502
513
|
pluginReads,
|
|
503
514
|
flows,
|
|
504
515
|
beforeFlow: sendOpening,
|
|
516
|
+
builtInTools: this.deps.channel === undefined,
|
|
505
517
|
})
|
|
506
518
|
.then(result => ({ kind: 'model', result }));
|
|
507
519
|
// Inside the race, as a flow the model calls is (specs/029): a send that
|
|
@@ -581,7 +593,7 @@ export class TurnHandler {
|
|
|
581
593
|
return result;
|
|
582
594
|
}
|
|
583
595
|
try {
|
|
584
|
-
await this.
|
|
596
|
+
await this.enqueue({
|
|
585
597
|
tenantId: inbound.tenantId,
|
|
586
598
|
subscriberId: inbound.subscriberId,
|
|
587
599
|
conversationId: conversation.id,
|
|
@@ -652,7 +664,7 @@ export class TurnHandler {
|
|
|
652
664
|
// A silent response gave the contact nothing yet, so the holding
|
|
653
665
|
// line it held back goes out now, after the flow (specs/030).
|
|
654
666
|
if (silent) {
|
|
655
|
-
await this.
|
|
667
|
+
await this.enqueue({
|
|
656
668
|
tenantId: inbound.tenantId,
|
|
657
669
|
subscriberId: inbound.subscriberId,
|
|
658
670
|
conversationId: conversation.id,
|
|
@@ -683,7 +695,7 @@ export class TurnHandler {
|
|
|
683
695
|
logger.info({ interventions: done.result.interventions, deferred: true }, 'guardrails intervened');
|
|
684
696
|
}
|
|
685
697
|
}
|
|
686
|
-
await this.
|
|
698
|
+
await this.enqueue({
|
|
687
699
|
tenantId: inbound.tenantId,
|
|
688
700
|
subscriberId: inbound.subscriberId,
|
|
689
701
|
conversationId: conversation.id,
|
|
@@ -854,6 +866,7 @@ export class TurnHandler {
|
|
|
854
866
|
pluginReads: ctx.pluginReads,
|
|
855
867
|
flows: ctx.flows,
|
|
856
868
|
beforeFlow: ctx.beforeFlow,
|
|
869
|
+
builtInTools: this.deps.channel === undefined,
|
|
857
870
|
media: {
|
|
858
871
|
// `unsupported` never resolves; it always takes the fallback above.
|
|
859
872
|
kind: media.kind,
|
package/dist/server.js
CHANGED
|
@@ -5,12 +5,13 @@ import underPressure from '@fastify/under-pressure';
|
|
|
5
5
|
import swagger from '@fastify/swagger';
|
|
6
6
|
import { serializerCompiler, validatorCompiler, jsonSchemaTransform, } from 'fastify-type-provider-zod';
|
|
7
7
|
import { ManyChatInbound, ManyChatResponse } from "./contracts/manychat.js";
|
|
8
|
-
import { capabilitiesFor } from "./contracts/config.js";
|
|
8
|
+
import { capabilitiesFor, NO_TOOLS } from "./contracts/config.js";
|
|
9
9
|
import { acceptsImages, resolveModel, resolveTranscriptionModel } from "./agent/registry.js";
|
|
10
10
|
import { GenerateTextRunner } from "./agent/runner.js";
|
|
11
11
|
import { SdkTranscriber } from "./agent/transcriber.js";
|
|
12
12
|
import { ManyChatAdapter } from "./channels/manychat/adapter.js";
|
|
13
13
|
import { manychatClientFor } from "./channels/manychat/client.js";
|
|
14
|
+
import { channelAdapters } from "./channels/plugin/adapter.js";
|
|
14
15
|
import { ManyChatMediaFetcher } from "./channels/manychat/media.js";
|
|
15
16
|
import { detectFfmpeg, FfmpegVideoSplitter } from "./media/ffmpeg.js";
|
|
16
17
|
import { MediaResolver } from "./media/resolver.js";
|
|
@@ -22,6 +23,21 @@ import { mediaScrubbingStream, redactText } from "./observability/redact.js";
|
|
|
22
23
|
import { loggerOptions } from "./observability/logger.js";
|
|
23
24
|
import { Plugins } from "./plugins/plugins.js";
|
|
24
25
|
const MESSAGE_ROUTE = '/v1/channels/manychat/message';
|
|
26
|
+
/**
|
|
27
|
+
* A plugin channel's turns issue no token, so nothing is ever written
|
|
28
|
+
* (specs/038). Refusing, should that change unnoticed.
|
|
29
|
+
*/
|
|
30
|
+
const NO_TOKEN_WRITER = {
|
|
31
|
+
writeToken: () => Promise.reject(new Error('a plugin channel writes no contact token')),
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* What a plugin channel's turn performs a built-in action through. None is
|
|
35
|
+
* offered there, so none is staged; one that were would fail, never reach
|
|
36
|
+
* ManyChat for a contact who is not on it (specs/038, C6).
|
|
37
|
+
*/
|
|
38
|
+
const MANYCHAT_UNAVAILABLE = {
|
|
39
|
+
performAction: () => Promise.reject(new Error('a plugin channel performs no ManyChat action')),
|
|
40
|
+
};
|
|
25
41
|
/**
|
|
26
42
|
* The whole production composition. Plugins are registered before any route is
|
|
27
43
|
* declared, inside this function, because `@fastify/rate-limit` and anything
|
|
@@ -127,6 +143,16 @@ export async function buildServer(opts) {
|
|
|
127
143
|
// And their offering, read per request so a reload reaches it (specs/028).
|
|
128
144
|
offeringField: tenant().tools?.fields.find(field => field.offering)?.field,
|
|
129
145
|
});
|
|
146
|
+
// The plugins' channels, each mounted beside ManyChat's route (specs/038).
|
|
147
|
+
const channels = channelAdapters(plugins, app.log);
|
|
148
|
+
/** How each message route renders a handoff, by route. */
|
|
149
|
+
const handoffs = new Map([
|
|
150
|
+
[
|
|
151
|
+
MESSAGE_ROUTE,
|
|
152
|
+
(handoff, secret) => adapter.render(handoff, { capabilities, ...callbackFor(secret) }),
|
|
153
|
+
],
|
|
154
|
+
...[...channels.values()].map(channel => [channel.route, (handoff) => channel.render(handoff)]),
|
|
155
|
+
]);
|
|
130
156
|
app.setErrorHandler((error, request, reply) => {
|
|
131
157
|
// Client errors (rate limit, oversized body, validation) keep Fastify's
|
|
132
158
|
// answer. Validation runs after authentication, so its detail reaches only
|
|
@@ -144,13 +170,16 @@ export async function buildServer(opts) {
|
|
|
144
170
|
// parameters, and those include the contact's text (C5).
|
|
145
171
|
request.log.error({ error: { name: error.name, code: error.code, message: redactText(error.message) } }, 'unhandled error');
|
|
146
172
|
}
|
|
147
|
-
// On
|
|
173
|
+
// On a message route a failure, overload included, is a handoff to a
|
|
148
174
|
// person, never a 500 carrying the error or a 503 the contact never sees
|
|
149
175
|
// (C6, specs/017 § An error on the message route is a handoff, not a 500).
|
|
150
|
-
|
|
151
|
-
|
|
176
|
+
// A plugin channel's route hands off the same way, through its own
|
|
177
|
+
// renderer (specs/038).
|
|
178
|
+
const route = request.routeOptions.url;
|
|
179
|
+
const render = route === undefined ? undefined : handoffs.get(route);
|
|
180
|
+
if (render && isAuthenticated(request, env.MANYCHAT_SHARED_SECRET)) {
|
|
152
181
|
try {
|
|
153
|
-
const handoff =
|
|
182
|
+
const handoff = render(escalationReply('low_confidence', tenant().rules.messages.escalation), bearerToken(request.headers.authorization));
|
|
154
183
|
// under-pressure sets it for its 503; on a 200 it means nothing.
|
|
155
184
|
reply.removeHeader('retry-after');
|
|
156
185
|
return reply.code(200).send(handoff);
|
|
@@ -180,46 +209,29 @@ export async function buildServer(opts) {
|
|
|
180
209
|
// One for the process, so every request for a contact shares the order
|
|
181
210
|
// (specs/037). Past this bound everything a turn runs has ended (C6).
|
|
182
211
|
const lanes = new TurnLanes(env.MODEL_ABORT_MS + env.RACE_DEADLINE_MS);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
schema: {
|
|
194
|
-
summary: 'ManyChat Dynamic Block webhook',
|
|
195
|
-
body: ManyChatInbound,
|
|
196
|
-
response: { 200: ManyChatResponse },
|
|
197
|
-
security: [{ sharedSecret: [] }],
|
|
198
|
-
},
|
|
199
|
-
}, async (request) => {
|
|
200
|
-
const inbound = adapter.parse(request.body, {
|
|
201
|
-
tenantId: env.TENANT_ID,
|
|
202
|
-
channel: env.CHANNEL,
|
|
203
|
-
logger: request.log,
|
|
204
|
-
});
|
|
212
|
+
// After the response is sent, never before: the text the actions follow
|
|
213
|
+
// has to leave first (specs/012 § Actions follow the text).
|
|
214
|
+
const performAfterResponse = async (request) => {
|
|
215
|
+
const perform = afterResponse.get(request);
|
|
216
|
+
afterResponse.delete(request);
|
|
217
|
+
if (perform)
|
|
218
|
+
await perform();
|
|
219
|
+
};
|
|
220
|
+
/** One turn, on any message route; what differs between channels is `deps`. */
|
|
221
|
+
const runTurn = async (request, inbound, deps) => {
|
|
205
222
|
// Constructed per request: `tenant()` re-reads config, which SIGHUP can
|
|
206
223
|
// have reloaded since the last turn.
|
|
207
224
|
const handler = new TurnHandler({
|
|
208
225
|
db,
|
|
209
226
|
runner,
|
|
210
227
|
rules: tenant().rules,
|
|
211
|
-
tools: tenant().tools,
|
|
212
228
|
raceDeadlineMs: env.RACE_DEADLINE_MS,
|
|
213
229
|
modelAbortMs: env.MODEL_ABORT_MS,
|
|
214
230
|
logger: request.log,
|
|
215
|
-
tokenWriter: manychatClient,
|
|
216
|
-
tokensEnforced: env.CONTACT_TOKENS_ENFORCED,
|
|
217
|
-
// A plugin action goes to its plugin, the rest to ManyChat (specs/036).
|
|
218
|
-
actions: plugins.performer(manychatClient, request.log),
|
|
219
|
-
contacts,
|
|
220
231
|
plugins,
|
|
221
232
|
media,
|
|
222
233
|
lanes,
|
|
234
|
+
...deps,
|
|
223
235
|
});
|
|
224
236
|
const turn = await handler.handle(inbound);
|
|
225
237
|
const { reply, outcome, conversationId, binding } = turn;
|
|
@@ -233,14 +245,77 @@ export async function buildServer(opts) {
|
|
|
233
245
|
outcome,
|
|
234
246
|
binding,
|
|
235
247
|
escalated: reply.escalate,
|
|
248
|
+
...(deps.channel ? { channel: deps.channel } : {}),
|
|
236
249
|
...(turn.silent ? { silent: true } : {}),
|
|
237
250
|
}, 'turn complete');
|
|
238
|
-
return
|
|
251
|
+
return turn;
|
|
252
|
+
};
|
|
253
|
+
app.post(MESSAGE_ROUTE, {
|
|
254
|
+
onRequest: createSharedSecretGuard(env.MANYCHAT_SHARED_SECRET),
|
|
255
|
+
onResponse: performAfterResponse,
|
|
256
|
+
schema: {
|
|
257
|
+
summary: 'ManyChat Dynamic Block webhook',
|
|
258
|
+
body: ManyChatInbound,
|
|
259
|
+
response: { 200: ManyChatResponse },
|
|
260
|
+
security: [{ sharedSecret: [] }],
|
|
261
|
+
},
|
|
262
|
+
}, async (request) => {
|
|
263
|
+
const inbound = adapter.parse(request.body, {
|
|
264
|
+
tenantId: env.TENANT_ID,
|
|
265
|
+
channel: env.CHANNEL,
|
|
266
|
+
logger: request.log,
|
|
267
|
+
});
|
|
268
|
+
const turn = await runTurn(request, inbound, {
|
|
269
|
+
tokenWriter: manychatClient,
|
|
270
|
+
tokensEnforced: env.CONTACT_TOKENS_ENFORCED,
|
|
271
|
+
// A plugin action goes to its plugin, the rest to ManyChat (specs/036).
|
|
272
|
+
actions: plugins.performer(manychatClient, request.log),
|
|
273
|
+
contacts,
|
|
274
|
+
tools: tenant().tools,
|
|
275
|
+
});
|
|
276
|
+
return adapter.render(turn.reply, {
|
|
239
277
|
capabilities,
|
|
240
278
|
silent: turn.silent,
|
|
241
279
|
// Re-registered every turn so the loop stays server-side (specs/002).
|
|
242
280
|
...callbackFor(bearerToken(request.headers.authorization)),
|
|
243
281
|
});
|
|
244
282
|
});
|
|
283
|
+
// Each plugin channel runs exactly as ManyChat's route does: the same auth,
|
|
284
|
+
// rate limit, budget, race, order and outbox. What it is not given is
|
|
285
|
+
// ManyChat: no contact token, no `tools.json` tool, no contact read
|
|
286
|
+
// (specs/038).
|
|
287
|
+
for (const channel of channels.values()) {
|
|
288
|
+
app.post(channel.route, {
|
|
289
|
+
onRequest: createSharedSecretGuard(env.MANYCHAT_SHARED_SECRET),
|
|
290
|
+
// Its own schema, before anything reads the body (C3). Its detail is
|
|
291
|
+
// not returned: the plugin's schema is the plugin's.
|
|
292
|
+
preValidation: async (request, reply) => {
|
|
293
|
+
if (!channel.accepts(request.body)) {
|
|
294
|
+
await reply.code(400).send({ error: 'invalid request' });
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
onResponse: performAfterResponse,
|
|
298
|
+
schema: {
|
|
299
|
+
summary: `Plugin channel ${channel.name} (provisional, specs/038)`,
|
|
300
|
+
security: [{ sharedSecret: [] }],
|
|
301
|
+
},
|
|
302
|
+
}, async (request) => {
|
|
303
|
+
const inbound = channel.parse(request.body, {
|
|
304
|
+
tenantId: env.TENANT_ID,
|
|
305
|
+
channel: channel.name,
|
|
306
|
+
});
|
|
307
|
+
const turn = await runTurn(request, inbound, {
|
|
308
|
+
tokenWriter: NO_TOKEN_WRITER,
|
|
309
|
+
tokensEnforced: false,
|
|
310
|
+
actions: plugins.performer(MANYCHAT_UNAVAILABLE, request.log),
|
|
311
|
+
contacts: undefined,
|
|
312
|
+
tools: NO_TOOLS,
|
|
313
|
+
channel: channel.name,
|
|
314
|
+
});
|
|
315
|
+
// A silent response carries no message: the reply follows from the
|
|
316
|
+
// outbox (specs/030, specs/037).
|
|
317
|
+
return channel.render(turn.silent ? { ...turn.reply, messages: [] } : turn.reply);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
245
320
|
return { app, runner };
|
|
246
321
|
}
|